mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
356 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6053636f66 | |||
| f2e2aee672 | |||
| 11cbb2bbf0 | |||
| 30bd19d6ce | |||
| d0b5c02062 | |||
| 771192e406 | |||
| 13f502bd76 | |||
| 11055b12ca | |||
| d0ea96eec0 | |||
| 02eafc03f1 | |||
| 33d07a0313 | |||
| 763b852148 | |||
| d5b0617fd7 | |||
| e3ce83674c | |||
| 5cc5df6909 | |||
| 11d8cf8996 | |||
| eae578970e | |||
| e076953c6a | |||
| 5c455591ea | |||
| 19b3dcd927 | |||
| 702e27edbf | |||
| 7c872de9af | |||
| 53b18143e7 | |||
| d812813aac | |||
| 9505b7fd7f | |||
| 9e07822598 | |||
| 6d6604a043 | |||
| 64d5071eb4 | |||
| 4a29ff0b19 | |||
| 6acab83ac5 | |||
| a3391b46ec | |||
| 9d021a0ea9 | |||
| 5b35435136 | |||
| 38b1cd1e4b | |||
| 50e4bf30f2 | |||
| 6f8d6462b9 | |||
| 596bb1ccc6 | |||
| 8023d4fd7e | |||
| 5808c5ae27 | |||
| 0945bc1e74 | |||
| c82ea978da | |||
| 9184e28691 | |||
| 59784af72c | |||
| 8feb1881b9 | |||
| 62747e07f1 | |||
| 934511a21f | |||
| e36b71db9c | |||
| 924cd7018f | |||
| d5e91f9ce7 | |||
| f1ad8cd55e | |||
| d177c73642 | |||
| 011bcf8ccd | |||
| 7d0e5809e1 | |||
| b369148057 | |||
| 2e0105b5ed | |||
| 3f24ade8e6 | |||
| 9cdaf26174 | |||
| 3b2c604615 | |||
| 223689316b | |||
| 6456a0de0c | |||
| 57458fb32f | |||
| b2521060cc | |||
| 13b6a1ba7e | |||
| ec81d802d5 | |||
| f6eca257d6 | |||
| e744b1afcd | |||
| bfcae972f7 | |||
| 360f886c37 | |||
| 305c278e1c | |||
| ccfcf6235f | |||
| 62f7d92bb2 | |||
| f8915141c8 | |||
| 7e1e4af19b | |||
| 439c462a9b | |||
| 95aa71abd6 | |||
| 3c829cbf97 | |||
| 29a31d5ca3 | |||
| 4a8bb24c0f | |||
| 6b6c3afe0c | |||
| fd41388584 | |||
| b418b69dc3 | |||
| e1e2962a02 | |||
| f1662e174f | |||
| a73c55fc1f | |||
| 0aa923a99d | |||
| b75160bb2b | |||
| 3d515102a1 | |||
| b857cc18d8 | |||
| 4737d962db | |||
| 7ba71078a8 | |||
| bee0fa8751 | |||
| 5916dfb08d | |||
| 9d13b03923 | |||
| 48e6385ac7 | |||
| cf664ff486 | |||
| 5ccf8ba128 | |||
| 3cd5917207 | |||
| e2dcca274f | |||
| 7369e898af | |||
| 1e2c12fddb | |||
| 4f7369b940 | |||
| f566049890 | |||
| fbc2da8b09 | |||
| af70b39515 | |||
| e080d72e8a | |||
| fd24e3676a | |||
| 942cd461f5 | |||
| 9567e1cbaa | |||
| 2d5d31268e | |||
| b376608709 | |||
| 551e502caf | |||
| 9fb7fcd22f | |||
| 1cda7d195b | |||
| 4a02923dda | |||
| a8a45631c2 | |||
| eaa755d4ce | |||
| 872396a90e | |||
| 6b3a2772cc | |||
| f378733abe | |||
| 0cf8f0439d | |||
| ab53087b3a | |||
| b50923a3b7 | |||
| 1b4a8da313 | |||
| 31684c2fee | |||
| fedd4b1fda | |||
| a41667f427 | |||
| 021fa3b313 | |||
| b7ca69aa0e | |||
| 1efcadbf46 | |||
| 598a6a8e0b | |||
| 1cd441a2f5 | |||
| 783dc55d02 | |||
| 88418d1ed0 | |||
| 6a74d1900f | |||
| 5566e29bcc | |||
| 1f49195251 | |||
| c5e15ece14 | |||
| 7ceb176d70 | |||
| b93bd1bd0b | |||
| 088133ec37 | |||
| 784766442d | |||
| e014a7c828 | |||
| 224e745a71 | |||
| 28ef74f1e9 | |||
| 70091ac39b | |||
| cc1011659d | |||
| 7eaa4a11bd | |||
| 495f25e2d4 | |||
| f2396000f2 | |||
| 77533aa385 | |||
| 01b2bf6fa3 | |||
| 6d7843805e | |||
| 0a593fb2c6 | |||
| 57f277f269 | |||
| 6924e02a17 | |||
| f4b645fd78 | |||
| fdb46d3072 | |||
| 858e9d3cb3 | |||
| 52a9dc7bec | |||
| 16fbd3b6a3 | |||
| aa09e60f5f | |||
| 3b2983831d | |||
| 16e69d0d0b | |||
| 548f52c770 | |||
| 8adac0d193 | |||
| c0d3c6c982 | |||
| c1516e7be0 | |||
| 8473894be2 | |||
| d5e2fbed0e | |||
| 2dfa78fbe0 | |||
| dff83c5ede | |||
| 483f483c4a | |||
| f780daf1b1 | |||
| 5e6e5bf017 | |||
| bfe2882ac3 | |||
| 0574be04f4 | |||
| 53b5da8cb8 | |||
| 5387b17c36 | |||
| 0e854b8772 | |||
| d23f247a8c | |||
| ce13c04e96 | |||
| e55fbe9ad0 | |||
| e5a11af6d2 | |||
| 76f21f8c96 | |||
| ea4d9afff2 | |||
| d884970a02 | |||
| ee11787dcc | |||
| 13e1ba977f | |||
| 7137ebdda2 | |||
| 9b71b08fe1 | |||
| 45a18edac0 | |||
| f1b1ca0a5f | |||
| 0c1718bf9c | |||
| a934ca548e | |||
| 2896bd7287 | |||
| 5ad103a8a2 | |||
| 16916db247 | |||
| 669eb1414f | |||
| 6ed8529d6a | |||
| bb36dbc4b9 | |||
| f9e38c7220 | |||
| 2b5a051a9e | |||
| 9793236941 | |||
| 497d1c9559 | |||
| 75c4fe5e67 | |||
| f4d3d3bd28 | |||
| 853aadaa0e | |||
| 8bf23e121c | |||
| cbffc3f6d5 | |||
| 52fc4ec5d8 | |||
| ab94579a30 | |||
| 43ddc79939 | |||
| 6938c6b8d0 | |||
| ba5d835248 | |||
| 510d58ba69 | |||
| c23b0770bf | |||
| cb4fdc56a5 | |||
| 3b28767212 | |||
| a1d6cb8cd0 | |||
| 0a094ef0b0 | |||
| 17c349af94 | |||
| 97f2a19227 | |||
| 6fc2415e5d | |||
| d68c131bbc | |||
| 0b6c217da6 | |||
| 432d78fc9d | |||
| 769ca1546a | |||
| 989684884c | |||
| a2b6554e73 | |||
| 28555445c9 | |||
| 20bddfe6c6 | |||
| 01243f7422 | |||
| 741edb499c | |||
| a3fd877a75 | |||
| 0c19c4c8ac | |||
| 9ad92c19a6 | |||
| c54185e6eb | |||
| 42fae2f915 | |||
| 48f4dd8573 | |||
| 396e3a4625 | |||
| 348915c420 | |||
| 157dadcae6 | |||
| 89b39b4bec | |||
| c42625c8aa | |||
| ac0e7ad738 | |||
| bdd92e69fc | |||
| f65178c08a | |||
| 3df40f18f8 | |||
| af007699ce | |||
| b8537bc4e7 | |||
| a4c3e57899 | |||
| 065069426b | |||
| 0d1cd45813 | |||
| 090032dccd | |||
| 987458ed63 | |||
| 32bc46c46b | |||
| f3a39ad5d2 | |||
| 98c95bf436 | |||
| a687ddd2a0 | |||
| 2bce8dc31e | |||
| 2c3597f0dd | |||
| 3570d9e9cf | |||
| cb8fab47af | |||
| 58cfff3912 | |||
| d2cdf36186 | |||
| 9237fbaad5 | |||
| c6a20c475f | |||
| 6e0bb6260e | |||
| 321c52351e | |||
| 13d91508c9 | |||
| 7a770659f3 | |||
| b734087d85 | |||
| ae354434e2 | |||
| ae16b03c7f | |||
| a1ac12761d | |||
| 1aabdd4394 | |||
| 0548f6b863 | |||
| 65fc40796b | |||
| 48af71d5f1 | |||
| cafcafb582 | |||
| 29da5383ed | |||
| ba50ff3b90 | |||
| 63a7dd1ce9 | |||
| 552ffb4257 | |||
| 87461fb73e | |||
| 22fac6f3c1 | |||
| 2932e8e89d | |||
| b7ea52701a | |||
| 6d8aa3e989 | |||
| 5a158155c5 | |||
| a30ec5d023 | |||
| eff9be3c99 | |||
| 5a17801c8e | |||
| 76651be12c | |||
| 5c93af6cdc | |||
| 3dbd5158c0 | |||
| 233d92b69d | |||
| bc6bf3195e | |||
| a71588777a | |||
| 8c9445d800 | |||
| 3cecf7c0b5 | |||
| e1128fa38f | |||
| 140412cb8b | |||
| 882b8629d7 | |||
| 7056866841 | |||
| cc6afa9190 | |||
| 93f075c270 | |||
| c2f991678c | |||
| b8c2f1b73b | |||
| 9b939c9a05 | |||
| 150c295370 | |||
| c5f23ce7ca | |||
| f7254fe8f6 | |||
| 32e3a4e547 | |||
| 3622155881 | |||
| 77cc8272c5 | |||
| acff0d0ef5 | |||
| 47cf6fe688 | |||
| 2b937719dd | |||
| 551faa47db | |||
| 692c1ebfda | |||
| 72c6f616f9 | |||
| 1da4b3653e | |||
| 8ef6d1667e | |||
| 961c0b581e | |||
| 3118228a68 | |||
| a47b9b0a55 | |||
| ae0b39c9c0 | |||
| 2d90947cb9 | |||
| 78c1c3bece | |||
| bbce30b24f | |||
| 92009ed6b4 | |||
| 54db3746c7 | |||
| 58c7012314 | |||
| baf0ca2682 | |||
| 191144b010 | |||
| 65d085d169 | |||
| a39e90003e | |||
| 013186a1ad | |||
| 6dd6fb0ce8 | |||
| db590a2b76 | |||
| e58088de24 | |||
| 88dfa60238 | |||
| 03fca4b9f8 | |||
| c5f7b8e0d2 | |||
| 1d18c56810 | |||
| e59e8780b6 | |||
| ea196bb22f | |||
| 47c2d19a70 | |||
| a11ab7cd7c | |||
| b7b25688ac | |||
| c77a6bb670 | |||
| 89f3e6f955 | |||
| e79b429c5e | |||
| c240f6bd5b | |||
| 33312e0fd9 |
@@ -0,0 +1,38 @@
|
||||
# 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
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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']
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
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?
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
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)
|
||||
@@ -7,11 +7,15 @@
|
||||
# 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 a Docker image
|
||||
name: Create and publish Docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
branches:
|
||||
- main
|
||||
- main-stable # Trigger on pushes to the main-stable branch
|
||||
tags:
|
||||
- 'v*' # Trigger on version tags (e.g., v1.7.0)
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@@ -26,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
@@ -40,11 +44,17 @@ 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 }}
|
||||
+40
-26
@@ -1,42 +1,56 @@
|
||||
# Test
|
||||
FROM node:18-alpine as test-target
|
||||
ENV NODE_ENV=development
|
||||
ENV PATH $PATH:/usr/src/app/node_modules/.bin
|
||||
# Base
|
||||
FROM node:18-alpine AS base
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
# Dependencies
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json prisma/ ./
|
||||
# Dependency files
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma
|
||||
|
||||
# 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
|
||||
# Install dependencies, including dev (release builds should use npm ci)
|
||||
ENV NODE_ENV development
|
||||
RUN npm ci
|
||||
|
||||
# Builder
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy development deps and source
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
FROM test-target as build-target
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Use build tools, installed as development packages, to produce a release build.
|
||||
# Build the application
|
||||
ENV NODE_ENV production
|
||||
RUN npm run build
|
||||
|
||||
# Reduce installed packages to production-only.
|
||||
# Reduce installed packages to production-only
|
||||
RUN npm prune --production
|
||||
|
||||
# Archive
|
||||
FROM node:18-alpine as archive-target
|
||||
ENV NODE_ENV=production
|
||||
ENV PATH $PATH:/usr/src/app/node_modules/.bin
|
||||
# Runner
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
# As user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 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
|
||||
# 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
|
||||
|
||||
# Expose port 3000 for the application to listen on
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["next", "start"]
|
||||
# Start the application
|
||||
CMD ["next", "start"]
|
||||
@@ -1,27 +1,77 @@
|
||||
# `BIG-AGI` 🤖💬
|
||||
# BIG-AGI 🧠✨
|
||||
|
||||
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` 🚀
|
||||
Welcome to big-AGI 👋, the GPT application for professionals that need form, function,
|
||||
simplicity, and speed. Powered by the latest models from 7 vendors, including
|
||||
open-source, `big-AGI` offers best-in-class Voice and Chat with AI Personas,
|
||||
visualizations, coding, drawing, calling, and quite more -- all in a polished UX.
|
||||
|
||||
[](https://big-agi.com)
|
||||
Pros use big-AGI. 🚀 Developers love big-AGI. 🤖
|
||||
|
||||
[](https://big-agi.com)
|
||||
|
||||
Or fork & run on Vercel
|
||||
|
||||
[](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.2 · Dec 12, 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]: Updated OpenRouter models (incl. Mixtral 8x7B)
|
||||
|
||||
### 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 👊
|
||||
|
||||

|
||||
[More](docs/pixels/big-AGI-compo2b.png), [screenshots](docs/pixels).
|
||||
|
||||
- **AI Personas**
|
||||
- **Polished UI**: installable web app, mobile-friendly, token counters, etc.
|
||||
- **Fast UX**: Microphone, Camera OCR, Drag files, Voice Synthesis
|
||||
- **Models**: [OpenAI](https://platform.openai.com/overview), [Anthropic](https://www.anthropic.com/product), [Azure](https://oai.azure.com/), [OpenRouter](https://openrouter.ai/), [Local models](https://github.com/oobabooga/text-generation-webui), and more
|
||||
- **Private**: use your own API keys and self-host if you like
|
||||
- **Advanced**: PDF import & Summarization, code execution
|
||||
- **Integrations**: ElevenLabs, Helicone, Paste.gg, Prodia and more
|
||||
- **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
|
||||
|
||||
## 💖 Support
|
||||
|
||||
@@ -39,101 +89,14 @@ Or fork & run on Vercel
|
||||
|
||||
<br/>
|
||||
|
||||
## 🧠 Latest Drops
|
||||
|
||||
#### Next
|
||||
|
||||
- **Cloudflare API Gateway** support
|
||||
- **Helicone for Anthropic** support
|
||||
- **Text Tools** - incl. highlight differences
|
||||
|
||||
#### 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 experiment 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.
|
||||
|
||||

|
||||
|
||||
## Develop 🧩
|
||||
## 🧩 Develop
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
Clone this repo, install the dependencies, and run the development server:
|
||||
Clone this repo, install the dependencies (all locally), and run the development server (which auto-watches the
|
||||
files for changes):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/enricoros/big-agi.git
|
||||
@@ -142,50 +105,57 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Now the app should be running on `http://localhost:3000`
|
||||
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.
|
||||
|
||||
### Integrations:
|
||||
## 🌐 Deploy manually
|
||||
|
||||
* [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
|
||||
The _production_ build of the application is optimized for performance and is performed by the `npm run build` command,
|
||||
after installing the required dependencies.
|
||||
|
||||
## Deploy with Docker 🐳
|
||||
```bash
|
||||
# .. repeat the steps above up to `npm install`, then:
|
||||
npm run build
|
||||
npm run start --port 3000
|
||||
```
|
||||
|
||||
The app will be running on the specified port, e.g. `http://localhost:3000`.
|
||||
|
||||
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).
|
||||
|
||||
### 🔧 Locally built image
|
||||
|
||||
> Firstly, write all your API keys and env vars to an `.env` file, and make sure the env file is using *both build and run*.
|
||||
> See [docs/environment-variables.md](docs/environment-variables.md) for a list of all environment variables.
|
||||
|
||||
```bash
|
||||
Build and run:
|
||||
|
||||
```bash
|
||||
docker build -t big-agi .
|
||||
docker run --detach 'big-agi'
|
||||
docker run -d -p 3000:3000 big-agi
|
||||
```
|
||||
|
||||
### Pre-built image
|
||||
Or run the official container:
|
||||
|
||||
> Warning: the UI will still be asking for keys, as the image was built without the API keys
|
||||
- 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
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## Deploy with Cloudflare Pages ☁️
|
||||
## ☁️ Deploy on Cloudflare Pages
|
||||
|
||||
Please refer to the [Cloudflare deployment documentation](docs/deploy-cloudflare.md).
|
||||
|
||||
## Deploy with Vercel 🚀
|
||||
## 🚀 Deploy on Vercel
|
||||
|
||||
Create your GitHub fork, create a Vercel project over that fork, and deploy it. Or press the button below for convenience.
|
||||
|
||||
[](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/>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
|
||||
import { appRouterEdge } from '~/server/api/trpc.router';
|
||||
import { appRouterEdge } from '~/server/api/trpc.router-edge';
|
||||
import { createTRPCFetchContext } from '~/server/api/trpc.server';
|
||||
|
||||
const handlerEdgeRoutes = (req: Request) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
|
||||
import { appRouterNode } from '~/server/api/trpc.router';
|
||||
import { appRouterNode } from '~/server/api/trpc.router-node';
|
||||
import { createTRPCFetchContext } from '~/server/api/trpc.server';
|
||||
|
||||
const handlerNodeRoutes = (req: Request) =>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# 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:
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
## 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.2 · Dec 11, 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]: Updated OpenRouter models (incl. Mixtral 8x7B)
|
||||
|
||||
### 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 ⛶
|
||||
@@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
|
||||
[](https://discord.gg/MkH4qj2Jp9)
|
||||
|
||||
---
|
||||
|
||||
Enjoy the enhanced browsing experience within `big-AGI` and explore the web without ever leaving your chat!
|
||||
@@ -4,7 +4,7 @@ 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 Nov 7, 2023_
|
||||
_Last updated on Dec 7, 2023_
|
||||
|
||||
### Components
|
||||
|
||||
@@ -20,26 +20,31 @@ This guide assumes that **big-AGI** is already installed on your system. Note th
|
||||
|
||||
### Text-web-ui Installation & Configuration:
|
||||
|
||||
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 minutes
|
||||
- Close it afterwards as we need to modify the startup flags
|
||||
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 --extensions openai` is present and uncommented
|
||||
- Make sure that `--listen --api` is present and uncommented
|
||||
3. Restart text-generation-webui
|
||||
- Double-click on "start"
|
||||
- You should see something like:
|
||||
```
|
||||
2023-11-07 21:24:26 INFO:Loading the extension "openai"...
|
||||
2023-11-07 21:24:27 INFO:OpenAI compatible API URL:
|
||||
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/v1
|
||||
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
|
||||
```
|
||||
- The OpenAI API is now running on port 5000, on both localhost (127.0.0.1) and your network IP address
|
||||
- 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:gptq-4bit-32g-actorder_True` - 4.3 GB
|
||||
- Download, for instance, `TheBloke/Llama-2-7B-Chat-GPTQ`
|
||||
- Select the model once it's loaded
|
||||
|
||||
### Integrating text-web-ui with big-AGI:
|
||||
@@ -51,4 +56,6 @@ This guide assumes that **big-AGI** is already installed on your system. Note th
|
||||
- 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
|
||||
|
||||

|
||||
|
||||
Enjoy the privacy and flexibility of local LLMs with `big-AGI` and `text-generation-webui`!
|
||||
+10
-5
@@ -5,15 +5,20 @@ This guide helps you connect [Ollama](https://ollama.ai) [models](https://ollama
|
||||
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_
|
||||
|
||||

|
||||
|
||||
## Quick Integration Guide
|
||||
|
||||
1. **Ensure Ollama API Server is Running**: Before starting, make sure your Ollama API server is up and running.
|
||||
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.
|
||||
5. **Start Using AI Personas**: Select an Ollama model and begin interacting with AI personas tailored to your needs.
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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.)
|
||||
+26
-13
@@ -3,35 +3,48 @@
|
||||
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.
|
||||
|
||||
## 🔧 Local Build & Deployment
|
||||
## Build and run your container 🔧
|
||||
|
||||
1. **Clone big-AGI**
|
||||
2. **Build the Docker Image**: Build a local docker image from the provided Dockerfile. The command is typically `docker build -t big-agi .`
|
||||
3. **Run the Docker Container**: Start a Docker container using the built image with the command `docker run -d -p 3000:3000 big-agi`
|
||||
|
||||
> Note: If the Docker container is built without setting environment variables,
|
||||
> the frontend UI will be unaware of them, despite the backend being able to use them at runtime.
|
||||
> Therefore, ensure all necessary environment variables are set during the build process.
|
||||
```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)
|
||||
|
||||
## Documentation
|
||||
|
||||
The big-AGI repository includes a Dockerfile and a GitHub Actions workflow for building and publishing a
|
||||
Docker image of the application.
|
||||
|
||||
### Dockerfile: Containers
|
||||
### Dockerfile
|
||||
|
||||
> A local build is recommended, as the 'ghcr' container is built without environment variables.
|
||||
|
||||
The [`Dockerfile`](../Dockerfile) is used to create a Docker image. It establishes a Node.js environment,
|
||||
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.
|
||||
|
||||
### GitHub Actions workflow
|
||||
### Official container images
|
||||
|
||||
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.
|
||||
|
||||
### Docker Compose
|
||||
Official pre-built containers: [ghcr.io/enricoros/big-agi](https://github.com/enricoros/big-agi/pkgs/container/big-agi)
|
||||
|
||||
Run official pre-built containers:
|
||||
```bash
|
||||
docker run -d -p 3000:3000 ghcr.io/enricoros/big-agi
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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
|
||||
@@ -3,16 +3,12 @@
|
||||
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/common/types/env.d.ts`](../src/common/types/env.d.ts).
|
||||
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.
|
||||
|
||||
> For Docker deployment, ensure all necessary environment variables are set **both during build and run**.
|
||||
> If the Docker container is built without setting environment variables, the frontend UI will be unaware
|
||||
> of them, despite the backend being able to use them at runtime.
|
||||
|
||||
The following is an example `.env` for copy-paste convenience:
|
||||
|
||||
```bash
|
||||
@@ -38,11 +34,20 @@ HELICONE_API_KEY=
|
||||
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=
|
||||
# Text-To-Image
|
||||
PRODIA_API_KEY=
|
||||
# Browse
|
||||
PUPPETEER_WSS_ENDPOINT=
|
||||
|
||||
# Backend Analytics
|
||||
BACKEND_ANALYTICS=
|
||||
|
||||
# Backend HTTP Basic Authentication
|
||||
HTTP_BASIC_AUTH_USERNAME=
|
||||
HTTP_BASIC_AUTH_PASSWORD=
|
||||
```
|
||||
|
||||
## Variables Documentation
|
||||
@@ -93,17 +98,23 @@ It is currently supported for:
|
||||
|
||||
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 ...' |
|
||||
| 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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 730 KiB |
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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..
|
||||
],
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
let nextConfig = {
|
||||
reactStrictMode: true,
|
||||
modularizeImports: {
|
||||
'@mui/icons-material': {
|
||||
transform: '@mui/icons-material/{{member}}',
|
||||
},
|
||||
},
|
||||
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;
|
||||
},
|
||||
// NOTE: the following shall be replaced by runtime config
|
||||
env: {
|
||||
HAS_SERVER_DB_PRISMA: !!process.env.POSTGRES_PRISMA_URL && !!process.env.POSTGRES_URL_NON_POOLING,
|
||||
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_AZURE_OPENAI: !!process.env.AZURE_OPENAI_API_KEY && !!process.env.AZURE_OPENAI_API_ENDPOINT,
|
||||
HAS_SERVER_KEY_ELEVENLABS: !!process.env.ELEVENLABS_API_KEY,
|
||||
HAS_SERVER_HOST_OLLAMA: !!process.env.OLLAMA_API_HOST,
|
||||
HAS_SERVER_KEY_OPENAI: !!process.env.OPENAI_API_KEY,
|
||||
HAS_SERVER_KEY_OPENROUTER: !!process.env.OPENROUTER_API_KEY,
|
||||
HAS_SERVER_KEY_PRODIA: !!process.env.PRODIA_API_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
// conditionally enable the nextjs bundle analyzer
|
||||
if (process.env.ANALYZE_BUNDLE)
|
||||
nextConfig = require('@next/bundle-analyzer')()(nextConfig);
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,41 @@
|
||||
/** @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;
|
||||
Generated
+478
-341
File diff suppressed because it is too large
Load Diff
+23
-21
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "big-agi",
|
||||
"version": "1.4.0",
|
||||
"version": "1.7.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@@ -18,28 +18,29 @@
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.14.16",
|
||||
"@mui/joy": "^5.0.0-beta.14",
|
||||
"@next/bundle-analyzer": "~14.0.2",
|
||||
"@prisma/client": "^5.5.2",
|
||||
"@mui/icons-material": "^5.14.18",
|
||||
"@mui/joy": "^5.0.0-beta.15",
|
||||
"@next/bundle-analyzer": "^14.0.3",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@sanity/diff-match-patch": "^3.1.1",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@trpc/client": "^10.43.3",
|
||||
"@trpc/next": "^10.43.3",
|
||||
"@trpc/react-query": "^10.43.3",
|
||||
"@trpc/server": "^10.43.3",
|
||||
"@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.2",
|
||||
"pdfjs-dist": "3.11.174",
|
||||
"next": "^14.0.3",
|
||||
"pdfjs-dist": "4.0.189",
|
||||
"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.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-timeago": "^7.2.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"superjson": "^2.2.1",
|
||||
@@ -49,19 +50,20 @@
|
||||
"zustand": "~4.3.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@cloudflare/puppeteer": "^0.0.5",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/plantuml-encoder": "^1.4.2",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react-katex": "^3.0.3",
|
||||
"@types/react-timeago": "^4.1.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-next": "~14.0.2",
|
||||
"prettier": "^3.0.3",
|
||||
"prisma": "^5.5.2",
|
||||
"typescript": "^5.2.2"
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-config-next": "^14.0.3",
|
||||
"prettier": "^3.1.0",
|
||||
"prisma": "^5.6.0",
|
||||
"typescript": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^18.0.0"
|
||||
|
||||
+27
-41
@@ -1,56 +1,42 @@
|
||||
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 'katex/dist/katex.min.css';
|
||||
import '~/common/styles/CodePrism.css'
|
||||
import '~/common/styles/CodePrism.css';
|
||||
import '~/common/styles/GithubMarkdown.css';
|
||||
import { Brand } from '~/common/brand';
|
||||
import { createEmotionCache, theme } from '~/common/theme';
|
||||
|
||||
import { ProviderBackend } from '~/common/state/ProviderBackend';
|
||||
import { ProviderSnacks } from '~/common/state/ProviderSnacks';
|
||||
import { ProviderTRPCQueryClient } from '~/common/state/ProviderTRPCQueryClient';
|
||||
import { ProviderTheming } from '~/common/state/ProviderTheming';
|
||||
|
||||
|
||||
// Client-side cache, shared for the whole session of the user in the browser.
|
||||
const clientSideEmotionCache = createEmotionCache();
|
||||
const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
|
||||
<>
|
||||
|
||||
export interface MyAppProps extends AppProps {
|
||||
emotionCache?: 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>
|
||||
|
||||
<ProviderTheming emotionCache={emotionCache}>
|
||||
<ProviderTRPCQueryClient>
|
||||
<ProviderSnacks>
|
||||
<ProviderBackend>
|
||||
<Component {...pageProps} />
|
||||
</ProviderBackend>
|
||||
</ProviderSnacks>
|
||||
</ProviderTRPCQueryClient>
|
||||
</ProviderTheming>
|
||||
|
||||
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);
|
||||
+3
-5
@@ -1,13 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import { AppType } from 'next/app';
|
||||
import { AppType, MyAppProps } 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/brand';
|
||||
import { bodyFontClassName, createEmotionCache } from '~/common/theme';
|
||||
|
||||
import { MyAppProps } from './_app';
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { bodyFontClassName, createEmotionCache } from '~/common/app.theme';
|
||||
|
||||
|
||||
interface MyDocumentProps extends DocumentProps {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,14 @@ 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 { 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';
|
||||
|
||||
|
||||
/**
|
||||
@@ -28,13 +31,13 @@ function AppShareTarget() {
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const { query, push: routerPush, replace: routerReplace } = useRouter();
|
||||
const { query } = useRouter();
|
||||
|
||||
|
||||
const queueComposerTextAndLaunchApp = React.useCallback((text: string) => {
|
||||
setComposerStartupText(text);
|
||||
void routerReplace('/');
|
||||
}, [routerReplace]);
|
||||
void navigateToIndex(true);
|
||||
}, []);
|
||||
|
||||
|
||||
// Detect the share Intent from the query
|
||||
@@ -71,18 +74,15 @@ function AppShareTarget() {
|
||||
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');
|
||||
callBrowseFetchPage(intentURL)
|
||||
.then(page => {
|
||||
if (page.stopReason !== 'error')
|
||||
queueComposerTextAndLaunchApp('\n\n```' + intentURL + '\n' + page.content + '\n```\n');
|
||||
else
|
||||
setErrorMessage('Could not read any data');
|
||||
setErrorMessage('Could not read any data' + page.error ? ': ' + page.error : '');
|
||||
})
|
||||
.catch(error => setErrorMessage(error?.message || error || 'Unknown error'))
|
||||
.finally(() => setIsDownloading(false));*/
|
||||
.finally(() => setIsDownloading(false));
|
||||
}
|
||||
}, [intentURL, queueComposerTextAndLaunchApp]);
|
||||
|
||||
@@ -110,7 +110,7 @@ function AppShareTarget() {
|
||||
</Alert>
|
||||
<Button
|
||||
variant='solid' color='danger'
|
||||
onClick={() => routerPush('/')}
|
||||
onClick={() => navigateToIndex()}
|
||||
endDecorator={<ArrowBackIcon />}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
@@ -130,7 +130,7 @@ function AppShareTarget() {
|
||||
|
||||
/**
|
||||
* This page will be invoked on mobile when sharing Text/URLs/Files from other APPs
|
||||
* Example URL: https://get.big-agi.com/launch?title=This+Title&text=https%3A%2F%2Fexample.com%2Fapp%2Fpath
|
||||
* Example URL: https://localhost:3000/link/share_target?title=This+Title&text=https%3A%2F%2Fexample.com%2Fapp%2Fpath
|
||||
*/
|
||||
export default function LaunchPage() {
|
||||
return (
|
||||
@@ -25,7 +25,7 @@
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/launch",
|
||||
"action": "/link/share_target",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Vendored
+1
-2
File diff suppressed because one or more lines are too long
@@ -3,16 +3,13 @@ import { useRouter } from 'next/router';
|
||||
|
||||
import { Container, Sheet } from '@mui/joy';
|
||||
|
||||
import { AppCallQueryParams } from '~/common/routes';
|
||||
import { AppCallQueryParams } from '~/common/app.routes';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
|
||||
import { CallUI } from './CallUI';
|
||||
import { CallWizard } from './CallWizard';
|
||||
|
||||
|
||||
export const APP_CALL_ENABLED = false;
|
||||
|
||||
|
||||
export function AppCall() {
|
||||
// external state
|
||||
const { query } = useRouter();
|
||||
|
||||
@@ -17,7 +17,7 @@ import { EXPERIMENTAL_speakTextStream } from '~/modules/elevenlabs/elevenlabs.cl
|
||||
import { SystemPurposeId, SystemPurposes } from '../../data';
|
||||
import { VChatMessageIn } from '~/modules/llms/transports/chatGenerate';
|
||||
import { streamChat } from '~/modules/llms/transports/streamChat';
|
||||
import { useVoiceDropdown } from '~/modules/elevenlabs/useVoiceDropdown';
|
||||
import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVoiceDropdown';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
|
||||
@@ -39,7 +39,7 @@ function CallMenuItems(props: {
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const { voicesDropdown } = useVoiceDropdown(false, !props.override);
|
||||
const { voicesDropdown } = useElevenLabsVoiceDropdown(false, !props.override);
|
||||
|
||||
const handlePushToTalkToggle = () => props.setPushToTalk(!props.pushToTalk);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ 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/routes';
|
||||
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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Chip, ColorPaletteProp, VariantProp } from '@mui/joy';
|
||||
import { SxProps } from '@mui/system';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import { VChatMessageIn } from '~/modules/llms/transports/chatGenerate';
|
||||
|
||||
|
||||
+328
-123
@@ -1,63 +1,123 @@
|
||||
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 { useModelsStore } from '~/modules/llms/store-llms';
|
||||
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
|
||||
import { useChatLLM, useModelsStore } from '~/modules/llms/store-llms';
|
||||
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { useLayoutPluggable } from '~/common/layout/store-applayout';
|
||||
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 { ChatDrawerItems } from './components/applayout/ChatDrawerItems';
|
||||
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
|
||||
import { ChatDrawerItemsMemo } from './components/applayout/ChatDrawerItems';
|
||||
import { ChatDropdowns } from './components/applayout/ChatDropdowns';
|
||||
import { ChatMenuItems } from './components/applayout/ChatMenuItems';
|
||||
import { ChatMessageList } from './components/ChatMessageList';
|
||||
import { ChatModeId } from './components/composer/store-composer';
|
||||
import { CmdAddRoleMessage, extractCommands } from './commands';
|
||||
import { CmdAddRoleMessage, CmdHelp, createCommandsHelpMessage, extractCommands } from './editors/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';
|
||||
|
||||
|
||||
const SPECIAL_ID_ALL_CHATS = 'all-chats';
|
||||
/**
|
||||
* Mode: how to treat the input from the Composer
|
||||
*/
|
||||
export type ChatModeId = 'immediate' | 'write-user' | 'react' | 'draw-imagine' | 'draw-imagine-plus';
|
||||
|
||||
|
||||
const SPECIAL_ID_WIPE_ALL: DConversationId = 'wipe-chats';
|
||||
|
||||
export function AppChat() {
|
||||
|
||||
// state
|
||||
const [isMessageSelectionMode, setIsMessageSelectionMode] = React.useState(false);
|
||||
const [diagramConfig, setDiagramConfig] = React.useState<DiagramConfig | null>(null);
|
||||
const [tradeConfig, setTradeConfig] = React.useState<TradeConfig | null>(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);
|
||||
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);
|
||||
|
||||
// external state
|
||||
const { activeConversationId, isConversationEmpty, hasAnyContent, duplicateConversation, deleteAllConversations, setMessages, systemPurposeId, setAutoTitle } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === state.activeConversationId);
|
||||
const isConversationEmpty = conversation ? !conversation.messages.length : true;
|
||||
const hasAnyContent = state.conversations.length > 1 || !isConversationEmpty;
|
||||
return {
|
||||
activeConversationId: state.activeConversationId,
|
||||
isConversationEmpty,
|
||||
hasAnyContent,
|
||||
duplicateConversation: state.duplicateConversation,
|
||||
deleteAllConversations: state.deleteAllConversations,
|
||||
setMessages: state.setMessages,
|
||||
systemPurposeId: conversation?.systemPurposeId ?? null,
|
||||
setAutoTitle: state.setAutoTitle,
|
||||
};
|
||||
}, shallow);
|
||||
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);
|
||||
|
||||
|
||||
const handleExecuteConversation = async (chatModeId: ChatModeId, conversationId: string, history: DMessage[]) => {
|
||||
// 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]);
|
||||
|
||||
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]);
|
||||
|
||||
|
||||
// Execution
|
||||
|
||||
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) => {
|
||||
const { chatLLMId } = useModelsStore.getState();
|
||||
if (!chatModeId || !conversationId || !chatLLMId) return;
|
||||
|
||||
@@ -75,21 +135,27 @@ 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 && systemPurposeId) {
|
||||
if (chatLLMId && focusedSystemPurposeId) {
|
||||
switch (chatModeId) {
|
||||
case 'immediate':
|
||||
case 'immediate-follow-up':
|
||||
return await runAssistantUpdatingState(conversationId, history, chatLLMId, systemPurposeId, true, chatModeId === 'immediate-follow-up');
|
||||
return await runAssistantUpdatingState(conversationId, history, chatLLMId, focusedSystemPurposeId);
|
||||
case 'write-user':
|
||||
return setMessages(conversationId, history);
|
||||
case 'react':
|
||||
@@ -115,127 +181,256 @@ export function AppChat() {
|
||||
// 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);
|
||||
setMessages(conversationId, history);
|
||||
};
|
||||
}, [focusedSystemPurposeId, setMessages]);
|
||||
|
||||
const _findConversation = (conversationId: string) =>
|
||||
conversationId ? useChatStore.getState().conversations.find(c => c.id === conversationId) ?? null : null;
|
||||
const handleComposerAction = (chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart): boolean => {
|
||||
|
||||
const handleExecuteChatHistory = async (conversationId: string, history: DMessage[]) =>
|
||||
await handleExecuteConversation('immediate', conversationId, history);
|
||||
|
||||
const handleImagineFromText = async (conversationId: string, messageText: string) => {
|
||||
const conversation = _findConversation(conversationId);
|
||||
if (conversation)
|
||||
return await handleExecuteConversation('draw-imagine-plus', conversationId, [...conversation.messages, createDMessage('user', messageText)]);
|
||||
};
|
||||
|
||||
const handleComposerNewMessage = async (chatModeId: ChatModeId, conversationId: string, userText: string) => {
|
||||
const conversation = _findConversation(conversationId);
|
||||
if (conversation)
|
||||
return await handleExecuteConversation(chatModeId, conversationId, [...conversation.messages, createDMessage('user', userText)]);
|
||||
};
|
||||
|
||||
const handleRegenerateAssistant = async () => {
|
||||
const conversation = activeConversationId ? _findConversation(activeConversationId) : null;
|
||||
if (conversation?.messages?.length) {
|
||||
const lastMessage = conversation.messages[conversation.messages.length - 1];
|
||||
if (lastMessage.role === 'assistant') {
|
||||
const newMessages = [...conversation.messages];
|
||||
newMessages.pop();
|
||||
return await handleExecuteConversation('immediate', conversation.id, newMessages);
|
||||
}
|
||||
// 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;
|
||||
};
|
||||
useGlobalShortcut('r', true, true, handleRegenerateAssistant);
|
||||
|
||||
const handleConversationExecuteHistory = async (conversationId: DConversationId, history: DMessage[]) =>
|
||||
await _handleExecute('immediate', conversationId, history);
|
||||
|
||||
const handleClearConversation = (conversationId: string) => setClearConfirmationId(conversationId);
|
||||
|
||||
const handleConfirmedClearConversation = () => {
|
||||
if (clearConfirmationId) {
|
||||
setMessages(clearConfirmationId, []);
|
||||
setAutoTitle(clearConfirmationId, '');
|
||||
setClearConfirmationId(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);
|
||||
if (conversation)
|
||||
return await _handleExecute('draw-imagine-plus', conversationId, [
|
||||
...conversation.messages,
|
||||
createDMessage('user', messageText),
|
||||
]);
|
||||
};
|
||||
|
||||
const handleDeleteAllConversations = () => setDeleteConfirmationId(SPECIAL_ID_ALL_CHATS);
|
||||
const handleTextSpeak = async (text: string) => {
|
||||
await speakText(text);
|
||||
};
|
||||
|
||||
|
||||
// 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);
|
||||
}
|
||||
}, [clearConversationId, setMessages]);
|
||||
|
||||
const handleConversationClear = (conversationId: DConversationId) => setClearConversationId(conversationId);
|
||||
|
||||
|
||||
const handleConfirmedDeleteConversation = () => {
|
||||
if (deleteConfirmationId) {
|
||||
if (deleteConfirmationId === SPECIAL_ID_ALL_CHATS) {
|
||||
deleteAllConversations();
|
||||
}// else
|
||||
// deleteConversation(deleteConfirmationId);
|
||||
setDeleteConfirmationId(null);
|
||||
if (deleteConversationId) {
|
||||
let nextConversationId: DConversationId | null;
|
||||
if (deleteConversationId === SPECIAL_ID_WIPE_ALL)
|
||||
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined);
|
||||
else
|
||||
nextConversationId = deleteConversation(deleteConversationId);
|
||||
setFocusedConversationId(nextConversationId);
|
||||
setDeleteConversationId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConversationsDeleteAll = () => setDeleteConversationId(SPECIAL_ID_WIPE_ALL);
|
||||
|
||||
const handleImportConversation = () => setTradeConfig({ dir: 'import' });
|
||||
const handleConversationDelete = React.useCallback((conversationId: DConversationId, bypassConfirmation: boolean) => {
|
||||
if (bypassConfirmation)
|
||||
setFocusedConversationId(deleteConversation(conversationId));
|
||||
else
|
||||
setDeleteConversationId(conversationId);
|
||||
}, [deleteConversation, setFocusedConversationId]);
|
||||
|
||||
const handleExportConversation = (conversationId: string | null) => setTradeConfig({ dir: 'export', conversationId });
|
||||
|
||||
const handleFlattenConversation = (conversationId: string) => setFlattenConversationId(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);
|
||||
|
||||
|
||||
// Pluggable ApplicationBar components
|
||||
|
||||
const centerItems = React.useMemo(() =>
|
||||
<ChatDropdowns conversationId={activeConversationId} />,
|
||||
[activeConversationId],
|
||||
<ChatDropdowns conversationId={focusedConversationId} />,
|
||||
[focusedConversationId],
|
||||
);
|
||||
|
||||
const drawerItems = React.useMemo(() =>
|
||||
<ChatDrawerItems
|
||||
conversationId={activeConversationId}
|
||||
onImportConversation={handleImportConversation}
|
||||
onDeleteAllConversations={handleDeleteAllConversations}
|
||||
<ChatDrawerItemsMemo
|
||||
activeConversationId={focusedConversationId}
|
||||
disableNewButton={isFocusedChatEmpty}
|
||||
onConversationActivate={setFocusedConversationId}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
onConversationImportDialog={handleConversationImportDialog}
|
||||
onConversationNew={handleConversationNew}
|
||||
onConversationsDeleteAll={handleConversationsDeleteAll}
|
||||
/>,
|
||||
[activeConversationId],
|
||||
[focusedConversationId, handleConversationDelete, handleConversationNew, isFocusedChatEmpty, setFocusedConversationId],
|
||||
);
|
||||
|
||||
const menuItems = React.useMemo(() =>
|
||||
<ChatMenuItems
|
||||
conversationId={activeConversationId} isConversationEmpty={isConversationEmpty} hasConversations={hasAnyContent}
|
||||
isMessageSelectionMode={isMessageSelectionMode} setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onClearConversation={handleClearConversation}
|
||||
onDuplicateConversation={duplicateConversation}
|
||||
onExportConversation={handleExportConversation}
|
||||
onFlattenConversation={handleFlattenConversation}
|
||||
conversationId={focusedConversationId}
|
||||
hasConversations={!areChatsEmpty}
|
||||
isConversationEmpty={isFocusedChatEmpty}
|
||||
isMessageSelectionMode={isMessageSelectionMode}
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationClear={handleConversationClear}
|
||||
onConversationExport={handleConversationExport}
|
||||
onConversationFlatten={handleConversationFlatten}
|
||||
/>,
|
||||
[activeConversationId, duplicateConversation, hasAnyContent, isConversationEmpty, isMessageSelectionMode],
|
||||
[areChatsEmpty, focusedConversationId, handleConversationBranch, isFocusedChatEmpty, isMessageSelectionMode],
|
||||
);
|
||||
|
||||
useLayoutPluggable(centerItems, drawerItems, menuItems);
|
||||
|
||||
return <>
|
||||
|
||||
<ChatMessageList
|
||||
conversationId={activeConversationId}
|
||||
isMessageSelectionMode={isMessageSelectionMode} setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onExecuteChatHistory={handleExecuteChatHistory}
|
||||
onImagineFromText={handleImagineFromText}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
backgroundColor: 'background.level1',
|
||||
overflowY: 'auto', // overflowY: 'hidden'
|
||||
minHeight: 96,
|
||||
}} />
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex', flexDirection: { xs: 'column', md: 'row' },
|
||||
overflow: 'clip',
|
||||
}}>
|
||||
|
||||
<Ephemerals
|
||||
conversationId={activeConversationId}
|
||||
sx={{
|
||||
// flexGrow: 0.1,
|
||||
flexShrink: 0.5,
|
||||
overflowY: 'auto',
|
||||
minHeight: 64,
|
||||
}} />
|
||||
{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>
|
||||
|
||||
<Composer
|
||||
conversationId={activeConversationId} messageId={null}
|
||||
isDeveloperMode={systemPurposeId === 'Developer'}
|
||||
onNewMessage={handleComposerNewMessage}
|
||||
chatLLM={chatLLM}
|
||||
composerTextAreaRef={composerTextAreaRef}
|
||||
conversationId={focusedConversationId}
|
||||
isDeveloperMode={focusedSystemPurposeId === 'Developer'}
|
||||
onAction={handleComposerAction}
|
||||
sx={{
|
||||
zIndex: 21, // position: 'sticky', bottom: 0,
|
||||
backgroundColor: 'background.surface',
|
||||
@@ -245,25 +440,35 @@ export function AppChat() {
|
||||
}} />
|
||||
|
||||
|
||||
{/* Import / Export */}
|
||||
{!!tradeConfig && <TradeModal config={tradeConfig} onClose={() => setTradeConfig(null)} />}
|
||||
{/* Diagrams */}
|
||||
{!!diagramConfig && <DiagramsModal config={diagramConfig} onClose={() => setDiagramConfig(null)} />}
|
||||
|
||||
{/* Flatten */}
|
||||
{!!flattenConversationId && <FlattenerModal conversationId={flattenConversationId} onClose={() => setFlattenConversationId(null)} />}
|
||||
{!!flattenConversationId && (
|
||||
<FlattenerModal
|
||||
conversationId={flattenConversationId}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onClose={() => setFlattenConversationId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Import / Export */}
|
||||
{!!tradeConfig && <TradeModal config={tradeConfig} onConversationActivate={setFocusedConversationId} onClose={() => setTradeConfig(null)} />}
|
||||
|
||||
|
||||
{/* [confirmation] Reset Conversation */}
|
||||
{!!clearConfirmationId && <ConfirmationModal
|
||||
open onClose={() => setClearConfirmationId(null)} onPositive={handleConfirmedClearConversation}
|
||||
confirmationText={'Are you sure you want to discard all the messages?'} positiveActionText={'Clear conversation'}
|
||||
{!!clearConversationId && <ConfirmationModal
|
||||
open onClose={() => setClearConversationId(null)} onPositive={handleConfirmedClearConversation}
|
||||
confirmationText={'Are you sure you want to discard all messages?'} positiveActionText={'Clear conversation'}
|
||||
/>}
|
||||
|
||||
{/* [confirmation] Delete All */}
|
||||
{!!deleteConfirmationId && <ConfirmationModal
|
||||
open onClose={() => setDeleteConfirmationId(null)} onPositive={handleConfirmedDeleteConversation}
|
||||
confirmationText={deleteConfirmationId === SPECIAL_ID_ALL_CHATS
|
||||
{!!deleteConversationId && <ConfirmationModal
|
||||
open onClose={() => setDeleteConversationId(null)} onPositive={handleConfirmedDeleteConversation}
|
||||
confirmationText={deleteConversationId === SPECIAL_ID_WIPE_ALL
|
||||
? '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={deleteConfirmationId === SPECIAL_ID_ALL_CHATS
|
||||
positiveActionText={deleteConversationId === SPECIAL_ID_WIPE_ALL
|
||||
? 'Yes, delete all'
|
||||
: 'Delete conversation'}
|
||||
/>}
|
||||
|
||||
@@ -1,168 +1,176 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, List, Sheet, Switch, Tooltip, Typography } from '@mui/joy';
|
||||
import { Box, List } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import { useChatLLM } from '~/modules/llms/store-llms';
|
||||
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
|
||||
|
||||
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
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 { ChatMessage } from './message/ChatMessage';
|
||||
import { ChatMessageMemo } from './message/ChatMessage';
|
||||
import { CleanerMessage, MessagesSelectionHeader } from './message/CleanerMessage';
|
||||
import { PersonaSelector } from './persona-selector/PersonaSelector';
|
||||
|
||||
|
||||
/**
|
||||
* [Experimental] A panel with tools for the chat
|
||||
*/
|
||||
function ToolsPanel(props: { showDiff: boolean, setShowDiff: (showDiff: boolean) => void }) {
|
||||
return (
|
||||
<Sheet
|
||||
variant='outlined' invertedColors
|
||||
sx={{
|
||||
position: 'fixed', top: 64, left: 8, zIndex: 101,
|
||||
boxShadow: 'md', borderRadius: '100px',
|
||||
p: 2,
|
||||
display: 'flex', flexFlow: 'row wrap', alignItems: 'center', justifyContent: 'space-between', gap: 2,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md'>
|
||||
🪛
|
||||
</Typography>
|
||||
<Tooltip title='Highlight differences'>
|
||||
<Switch
|
||||
checked={props.showDiff} onChange={() => props.setShowDiff(!props.showDiff)}
|
||||
startDecorator={<Typography level='title-md'>Diff</Typography>}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
import { useChatShowSystemMessages } from '../store-app-chat';
|
||||
|
||||
|
||||
/**
|
||||
* A list of ChatMessages
|
||||
*/
|
||||
export function ChatMessageList(props: {
|
||||
conversationId: string | null,
|
||||
showTools?: boolean,
|
||||
conversationId: DConversationId | null,
|
||||
chatLLMContextTokens?: number,
|
||||
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
|
||||
onExecuteChatHistory: (conversationId: string, history: DMessage[]) => void,
|
||||
onImagineFromText: (conversationId: string, userText: string) => Promise<any>,
|
||||
sx?: SxProps
|
||||
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,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [diffing, setDiffing] = React.useState<boolean>(false);
|
||||
const [isImagining, setIsImagining] = React.useState(false);
|
||||
const [isSpeaking, setIsSpeaking] = React.useState(false);
|
||||
const [selectedMessages, setSelectedMessages] = React.useState<Set<string>>(new Set());
|
||||
|
||||
// external state
|
||||
const { experimentalLabs, showSystemMessages } = useUIPreferencesStore(state => ({
|
||||
experimentalLabs: state.experimentalLabs,
|
||||
showSystemMessages: state.showSystemMessages,
|
||||
}));
|
||||
const { messages, editMessage, deleteMessage, historyTokenCount } = useChatStore(state => {
|
||||
const [showSystemMessages] = useChatShowSystemMessages();
|
||||
const { conversationMessages, historyTokenCount, editMessage, deleteMessage, setMessages } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return {
|
||||
messages: conversation ? conversation.messages : [],
|
||||
editMessage: state.editMessage, deleteMessage: state.deleteMessage,
|
||||
conversationMessages: conversation ? conversation.messages : [],
|
||||
historyTokenCount: conversation ? conversation.tokenCount : 0,
|
||||
deleteMessage: state.deleteMessage,
|
||||
editMessage: state.editMessage,
|
||||
setMessages: state.setMessages,
|
||||
};
|
||||
}, shallow);
|
||||
const { chatLLM } = useChatLLM();
|
||||
const { mayWork: isImaginable } = useCapabilityProdia();
|
||||
const { mayWork: isSpeakable } = useCapabilityElevenLabs();
|
||||
|
||||
const handleMessageDelete = (messageId: string) =>
|
||||
props.conversationId && deleteMessage(props.conversationId, messageId);
|
||||
// derived state
|
||||
const { conversationId, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props;
|
||||
|
||||
const handleMessageEdit = (messageId: string, newText: string) =>
|
||||
props.conversationId && editMessage(props.conversationId, messageId, { text: newText }, true);
|
||||
|
||||
const handleImagineFromText = (messageText: string): Promise<any> => {
|
||||
if (props.conversationId)
|
||||
return props.onImagineFromText(props.conversationId, messageText);
|
||||
else
|
||||
return Promise.reject('No conversation');
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
// text actions
|
||||
|
||||
const handleRunExample = (text: string) =>
|
||||
props.conversationId && props.onExecuteChatHistory(props.conversationId, [...messages, createDMessage('user', text)]);
|
||||
conversationId && onConversationExecuteHistory(conversationId, [...conversationMessages, 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();
|
||||
// message menu methods proxy
|
||||
|
||||
// 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 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]);
|
||||
|
||||
|
||||
const handleToggleSelected = (messageId: string, selected: boolean) => {
|
||||
// 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 handleSelectMessage = (messageId: string, selected: boolean) => {
|
||||
const newSelected = new Set(selectedMessages);
|
||||
selected ? newSelected.add(messageId) : newSelected.delete(messageId);
|
||||
setSelectedMessages(newSelected);
|
||||
};
|
||||
|
||||
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)
|
||||
const handleSelectionDelete = () => {
|
||||
if (conversationId)
|
||||
for (const selectedMessage of selectedMessages)
|
||||
deleteMessage(props.conversationId, selectedMessage);
|
||||
deleteMessage(conversationId, selectedMessage);
|
||||
setSelectedMessages(new Set());
|
||||
};
|
||||
|
||||
|
||||
// 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,
|
||||
// },
|
||||
// };
|
||||
useGlobalShortcut(props.isMessageSelectionMode && ShortcutKeyName.Esc, false, false, false, () => {
|
||||
props.setIsMessageSelectionMode(false);
|
||||
});
|
||||
|
||||
|
||||
// pass the diff text to most recent assistant message, once done
|
||||
const showTextTools = !!props.showTools || experimentalLabs;
|
||||
let diffMessage: DMessage | undefined;
|
||||
let diffText: string | undefined;
|
||||
if (diffing && showTextTools) {
|
||||
const [msgB, msgA] = filteredMessages.filter(m => m.role === 'assistant');
|
||||
if (!msgB.typing && msgB?.text && msgA?.text) {
|
||||
// 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 / 2 && lenB > lenA / 2) {
|
||||
diffMessage = msgB;
|
||||
diffText = textA;
|
||||
}
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<List sx={{
|
||||
@@ -170,34 +178,40 @@ 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={(chatLLM ? chatLLM.contextTokens : 0) - historyTokenCount}
|
||||
selected={selectedMessages.has(message.id)} onToggleSelected={handleToggleSelected}
|
||||
key={'sel-' + message.id}
|
||||
message={message}
|
||||
isBottom={idx === 0} remainingTokens={(props.chatLLMContextTokens || 0) - historyTokenCount}
|
||||
selected={selectedMessages.has(message.id)} onToggleSelected={handleSelectMessage}
|
||||
/>
|
||||
|
||||
) : (
|
||||
|
||||
<ChatMessage
|
||||
key={'msg-' + message.id} message={message} diffText={message === diffMessage ? diffText : undefined}
|
||||
<ChatMessageMemo
|
||||
key={'msg-' + message.id}
|
||||
message={message}
|
||||
diffPreviousText={message === diffMessage ? diffText : undefined}
|
||||
isBottom={idx === 0}
|
||||
onMessageDelete={() => handleMessageDelete(message.id)}
|
||||
onMessageEdit={newText => handleMessageEdit(message.id, newText)}
|
||||
onMessageRunFrom={(offset: number) => handleRestartFromMessage(message.id, offset)}
|
||||
onImagine={handleImagineFromText}
|
||||
isImagining={isImagining} isSpeaking={isSpeaking}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationRestartFrom={handleConversationRestartFrom}
|
||||
onConversationTruncate={handleConversationTruncate}
|
||||
onMessageDelete={handleMessageDelete}
|
||||
onMessageEdit={handleMessageEdit}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
/>
|
||||
|
||||
),
|
||||
)}
|
||||
|
||||
{showTextTools && <ToolsPanel showDiff={diffing} setShowDiff={setDiffing} />}
|
||||
|
||||
{/* Header at the bottom because of 'row-reverse' */}
|
||||
{props.isMessageSelectionMode && (
|
||||
<MessagesSelectionHeader
|
||||
@@ -205,8 +219,8 @@ export function ChatMessageList(props: {
|
||||
isBottom={filteredMessages.length === 0}
|
||||
sumTokens={historyTokenCount}
|
||||
onClose={() => props.setIsMessageSelectionMode(false)}
|
||||
onSelectAll={handleSelectAllMessages}
|
||||
onDeleteMessages={handleDeleteSelectedMessages}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeleteMessages={handleSelectionDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 { DEphemeral, useChatStore } from '~/common/state/store-chats';
|
||||
import { DConversationId, DEphemeral, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
|
||||
const StateLine = styled(Typography)(({ theme }) => ({
|
||||
@@ -32,8 +32,6 @@ 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>;
|
||||
}
|
||||
@@ -126,7 +124,7 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
|
||||
}
|
||||
|
||||
|
||||
export function Ephemerals(props: { conversationId: string | null, sx?: SxProps }) {
|
||||
export function Ephemerals(props: { conversationId: DConversationId | null, sx?: SxProps }) {
|
||||
// global state
|
||||
const theme = useTheme();
|
||||
const ephemerals = useChatStore(state => {
|
||||
|
||||
@@ -6,67 +6,64 @@ 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 { useChatStore } from '~/common/state/store-chats';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { ConversationItem } from './ConversationItem';
|
||||
import { ChatNavigationItemMemo } from './ChatNavigationItem';
|
||||
|
||||
|
||||
type ListGrouping = 'off' | 'persona';
|
||||
// type ListGrouping = 'off' | 'persona';
|
||||
|
||||
export function ChatDrawerItems(props: {
|
||||
conversationId: string | null
|
||||
onDeleteAllConversations: () => void,
|
||||
onImportConversation: () => void,
|
||||
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,
|
||||
}) {
|
||||
|
||||
// local state
|
||||
const [grouping] = React.useState<ListGrouping>('off');
|
||||
const { onConversationDelete, onConversationNew, onConversationActivate } = props;
|
||||
// const [grouping] = React.useState<ListGrouping>('off');
|
||||
|
||||
// external state
|
||||
const { conversationIDs, topNewConversationId, maxChatMessages, setActiveConversationId, createConversation, deleteConversation } = useChatStore(state => ({
|
||||
conversationIDs: state.conversations.map(conversation => conversation.id),
|
||||
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 conversations = useChatStore(state => state.conversations, shallow);
|
||||
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
|
||||
const labsEnhancedUI = useUXLabsStore(state => state.labsEnhancedUI);
|
||||
|
||||
|
||||
const totalConversations = conversationIDs.length;
|
||||
// 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 handleNew = () => {
|
||||
// if the first in the stack is a new conversation, just activate it
|
||||
if (topNewConversationId)
|
||||
setActiveConversationId(topNewConversationId);
|
||||
else
|
||||
createConversation();
|
||||
closeLayoutDrawer();
|
||||
};
|
||||
|
||||
const handleConversationActivate = React.useCallback((conversationId: string, closeMenu: boolean) => {
|
||||
setActiveConversationId(conversationId);
|
||||
const handleButtonNew = React.useCallback(() => {
|
||||
onConversationNew();
|
||||
closeLayoutDrawer();
|
||||
}, [onConversationNew]);
|
||||
|
||||
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
|
||||
onConversationActivate(conversationId);
|
||||
if (closeMenu)
|
||||
closeLayoutDrawer();
|
||||
}, [setActiveConversationId]);
|
||||
}, [onConversationActivate]);
|
||||
|
||||
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]);
|
||||
|
||||
// grouping
|
||||
let sortedIds = conversationIDs;
|
||||
/*let sortedIds = conversationIDs;
|
||||
if (grouping === 'persona') {
|
||||
const conversations = useChatStore.getState().conversations;
|
||||
|
||||
@@ -83,7 +80,7 @@ export function ChatDrawerItems(props: {
|
||||
|
||||
// flatten grouped conversations
|
||||
sortedIds = Object.values(groupedConversations).flat();
|
||||
}
|
||||
}*/
|
||||
|
||||
return <>
|
||||
|
||||
@@ -93,9 +90,12 @@ export function ChatDrawerItems(props: {
|
||||
{/* </Typography>*/}
|
||||
{/*</ListItem>*/}
|
||||
|
||||
<MenuItem disabled={!!topNewConversationId && topNewConversationId === props.conversationId} onClick={handleNew}>
|
||||
<MenuItem disabled={props.disableNewButton} onClick={handleButtonNew}>
|
||||
<ListItemDecorator><AddIcon /></ListItemDecorator>
|
||||
New
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
New
|
||||
{/*<KeyStroke combo='Ctrl + Alt + N' />*/}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
|
||||
<ListDivider sx={{ mb: 0 }} />
|
||||
@@ -115,22 +115,22 @@ export function ChatDrawerItems(props: {
|
||||
{/* </ToggleButtonGroup>*/}
|
||||
{/*</ListItem>*/}
|
||||
|
||||
{sortedIds.map(conversationId =>
|
||||
<ConversationItem
|
||||
key={'c-id-' + conversationId}
|
||||
conversationId={conversationId}
|
||||
isActive={conversationId === props.conversationId}
|
||||
isSingle={singleChat}
|
||||
{conversations.map(conversation =>
|
||||
<ChatNavigationItemMemo
|
||||
key={'nav-' + conversation.id}
|
||||
conversation={conversation}
|
||||
isActive={conversation.id === props.activeConversationId}
|
||||
isLonely={singleChat}
|
||||
maxChatMessages={(labsEnhancedUI || softMaxReached) ? maxChatMessages : 0}
|
||||
showSymbols={showSymbols}
|
||||
maxChatMessages={(experimentalLabs || softMaxReached) ? maxChatMessages : 0}
|
||||
conversationActivate={handleConversationActivate}
|
||||
conversationDelete={handleConversationDelete}
|
||||
onConversationActivate={handleConversationActivate}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
/>)}
|
||||
</Box>
|
||||
|
||||
<ListDivider sx={{ mt: 0 }} />
|
||||
|
||||
<MenuItem onClick={props.onImportConversation}>
|
||||
<MenuItem onClick={props.onConversationImportDialog}>
|
||||
<ListItemDecorator>
|
||||
<FileUploadIcon />
|
||||
</ListItemDecorator>
|
||||
@@ -138,24 +138,12 @@ export function ChatDrawerItems(props: {
|
||||
<OpenAIIcon sx={{ fontSize: 'xl', ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={!hasChats} onClick={props.onDeleteAllConversations}>
|
||||
<MenuItem disabled={!hasChats} onClick={props.onConversationsDeleteAll}>
|
||||
<ListItemDecorator><DeleteOutlineIcon /></ListItemDecorator>
|
||||
<Typography>
|
||||
Delete {totalConversations >= 2 ? `all ${totalConversations} chats` : 'chat'}
|
||||
</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>*/}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
|
||||
import { useChatLLMDropdown } from './useLLMDropdown';
|
||||
import { usePersonaIdDropdown } from './usePersonaDropdown';
|
||||
|
||||
|
||||
export function ChatDropdowns(props: {
|
||||
conversationId: string | null
|
||||
conversationId: DConversationId | null
|
||||
}) {
|
||||
|
||||
// state
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Badge, ListDivider, ListItemDecorator, MenuItem, Switch } from '@mui/joy';
|
||||
import { Box, ListDivider, ListItemDecorator, MenuItem, Switch } from '@mui/joy';
|
||||
import CheckBoxOutlineBlankOutlinedIcon from '@mui/icons-material/CheckBoxOutlineBlankOutlined';
|
||||
import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
@@ -10,59 +9,67 @@ import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { closeLayoutMenu } from '~/common/layout/store-applayout';
|
||||
import { useUICounter, useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUICounter } from '~/common/state/store-ui';
|
||||
|
||||
import { useChatShowSystemMessages } from '../../store-app-chat';
|
||||
|
||||
|
||||
export function ChatMenuItems(props: {
|
||||
conversationId: string | null, isConversationEmpty: boolean, hasConversations: boolean,
|
||||
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
|
||||
onClearConversation: (conversationId: string) => void,
|
||||
onDuplicateConversation: (conversationId: string) => void,
|
||||
onExportConversation: (conversationId: string | null) => void,
|
||||
onFlattenConversation: (conversationId: string) => void,
|
||||
conversationId: DConversationId | null,
|
||||
hasConversations: boolean,
|
||||
isConversationEmpty: boolean,
|
||||
isMessageSelectionMode: boolean,
|
||||
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
|
||||
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
|
||||
onConversationClear: (conversationId: DConversationId) => void,
|
||||
onConversationExport: (conversationId: DConversationId | null) => void,
|
||||
onConversationFlatten: (conversationId: DConversationId) => void,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const { novel: shareBadge, touch: shareTouch } = useUICounter('export-share');
|
||||
const { showSystemMessages, setShowSystemMessages } = useUIPreferencesStore(state => ({
|
||||
showSystemMessages: state.showSystemMessages, setShowSystemMessages: state.setShowSystemMessages,
|
||||
}), shallow);
|
||||
const { touch: shareTouch } = useUICounter('export-share');
|
||||
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
|
||||
|
||||
// derived state
|
||||
const disabled = !props.conversationId || props.isConversationEmpty;
|
||||
|
||||
const handleSystemMessagesToggle = () => setShowSystemMessages(!showSystemMessages);
|
||||
|
||||
const handleConversationExport = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
const closeMenu = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
closeLayoutMenu();
|
||||
props.onExportConversation(!disabled ? props.conversationId : null);
|
||||
};
|
||||
|
||||
const handleConversationClear = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.conversationId && props.onConversationClear(props.conversationId);
|
||||
};
|
||||
|
||||
const handleConversationBranch = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.conversationId && props.onConversationBranch(props.conversationId, null);
|
||||
};
|
||||
|
||||
const handleConversationExport = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.onConversationExport(!disabled ? props.conversationId : null);
|
||||
shareTouch();
|
||||
};
|
||||
|
||||
const handleConversationDuplicate = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
closeLayoutMenu();
|
||||
props.conversationId && props.onDuplicateConversation(props.conversationId);
|
||||
const handleConversationFlatten = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.conversationId && props.onConversationFlatten(props.conversationId);
|
||||
};
|
||||
|
||||
const handleConversationFlatten = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
closeLayoutMenu();
|
||||
props.conversationId && props.onFlattenConversation(props.conversationId);
|
||||
};
|
||||
|
||||
const handleToggleMessageSelectionMode = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
closeLayoutMenu();
|
||||
const handleToggleMessageSelectionMode = (event: React.MouseEvent) => {
|
||||
closeMenu(event);
|
||||
props.setIsMessageSelectionMode(!props.isMessageSelectionMode);
|
||||
};
|
||||
|
||||
const handleConversationClear = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
props.conversationId && props.onClearConversation(props.conversationId);
|
||||
};
|
||||
const handleToggleSystemMessages = () => setShowSystemMessages(!showSystemMessages);
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
@@ -72,29 +79,21 @@ export function ChatMenuItems(props: {
|
||||
{/* </Typography>*/}
|
||||
{/*</ListItem>*/}
|
||||
|
||||
<MenuItem onClick={handleSystemMessagesToggle}>
|
||||
<MenuItem onClick={handleToggleSystemMessages}>
|
||||
<ListItemDecorator><SettingsSuggestIcon /></ListItemDecorator>
|
||||
System message
|
||||
<Switch checked={showSystemMessages} onChange={handleSystemMessagesToggle} sx={{ ml: 'auto' }} />
|
||||
<Switch checked={showSystemMessages} onChange={handleToggleSystemMessages} sx={{ ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
|
||||
<ListDivider inset='startContent' />
|
||||
|
||||
<MenuItem disabled={disabled} onClick={handleConversationDuplicate}>
|
||||
<ListItemDecorator>
|
||||
{/*<Badge size='sm' color='success'>*/}
|
||||
<ForkRightIcon color='success' />
|
||||
{/*</Badge>*/}
|
||||
</ListItemDecorator>
|
||||
Duplicate
|
||||
<MenuItem disabled={disabled} onClick={handleConversationBranch}>
|
||||
<ListItemDecorator><ForkRightIcon /></ListItemDecorator>
|
||||
Branch
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={disabled} onClick={handleConversationFlatten}>
|
||||
<ListItemDecorator>
|
||||
{/*<Badge size='sm' color='success'>*/}
|
||||
<CompressIcon color='success' />
|
||||
{/*</Badge>*/}
|
||||
</ListItemDecorator>
|
||||
<ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>
|
||||
Flatten
|
||||
</MenuItem>
|
||||
|
||||
@@ -109,16 +108,17 @@ export function ChatMenuItems(props: {
|
||||
|
||||
<MenuItem disabled={!props.hasConversations} onClick={handleConversationExport}>
|
||||
<ListItemDecorator>
|
||||
<Badge color='danger' invisible={!shareBadge || !props.hasConversations}>
|
||||
<FileDownloadIcon />
|
||||
</Badge>
|
||||
<FileDownloadIcon />
|
||||
</ListItemDecorator>
|
||||
Share / Export ...
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={disabled} onClick={handleConversationClear}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Reset
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
Reset
|
||||
{!disabled && <KeyStroke combo='Ctrl + Alt + X' />}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
|
||||
</>;
|
||||
|
||||
+53
-50
@@ -1,5 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Avatar, Box, IconButton, ListItemDecorator, MenuItem, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
@@ -9,96 +8,100 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import { SystemPurposes } from '../../../../data';
|
||||
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { conversationTitle, useChatStore } from '~/common/state/store-chats';
|
||||
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
const DEBUG_CONVERSATION_IDs = false;
|
||||
|
||||
|
||||
export function ConversationItem(props: {
|
||||
conversationId: string,
|
||||
isActive: boolean, isSingle: boolean, showSymbols: boolean, maxChatMessages: number,
|
||||
conversationActivate: (conversationId: string, closeMenu: boolean) => void,
|
||||
conversationDelete: (conversationId: string) => void,
|
||||
export const ChatNavigationItemMemo = React.memo(ChatNavigationItem);
|
||||
|
||||
function ChatNavigationItem(props: {
|
||||
conversation: DConversation,
|
||||
isActive: boolean,
|
||||
isLonely: boolean,
|
||||
maxChatMessages: number,
|
||||
showSymbols: boolean,
|
||||
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
|
||||
onConversationDelete: (conversationId: DConversationId) => void,
|
||||
}) {
|
||||
|
||||
const { conversation, isActive } = props;
|
||||
|
||||
// state
|
||||
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||
const [deleteArmed, setDeleteArmed] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const doubleClickToEdit = useUIPreferencesStore(state => state.doubleClickToEdit);
|
||||
|
||||
// bind to conversation
|
||||
const cState = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return conversation && {
|
||||
isNew: conversation.messages.length === 0,
|
||||
messageCount: conversation.messages.length,
|
||||
assistantTyping: !!conversation.abortController,
|
||||
systemPurposeId: conversation.systemPurposeId,
|
||||
title: conversationTitle(conversation, 'new conversation'),
|
||||
setUserTitle: state.setUserTitle,
|
||||
};
|
||||
}, shallow);
|
||||
// derived state
|
||||
const { id: conversationId } = conversation;
|
||||
const isNew = conversation.messages.length === 0;
|
||||
const messageCount = conversation.messages.length;
|
||||
const assistantTyping = !!conversation.abortController;
|
||||
const systemPurposeId = conversation.systemPurposeId;
|
||||
const title = conversationTitle(conversation, 'new conversation');
|
||||
// const setUserTitle = state.setUserTitle;
|
||||
|
||||
// auto-close the arming menu when clicking away
|
||||
// NOTE: there currently is a bug (race condition) where the menu closes on a new item right after opening
|
||||
// because the isActive prop is not yet updated
|
||||
React.useEffect(() => {
|
||||
if (deleteArmed && !props.isActive)
|
||||
if (deleteArmed && !isActive)
|
||||
setDeleteArmed(false);
|
||||
}, [deleteArmed, props.isActive]);
|
||||
}, [deleteArmed, isActive]);
|
||||
|
||||
// sanity check: shouldn't happen, but just in case
|
||||
if (!cState) return null;
|
||||
const { isNew, messageCount, assistantTyping, setUserTitle, systemPurposeId, title } = cState;
|
||||
|
||||
const handleActivate = () => props.conversationActivate(props.conversationId, true);
|
||||
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
|
||||
|
||||
const handleEditBegin = () => setIsEditingTitle(true);
|
||||
const handleTitleEdit = () => setIsEditingTitle(true);
|
||||
|
||||
const handleEdited = (text: string) => {
|
||||
const handleTitleEdited = (text: string) => {
|
||||
setIsEditingTitle(false);
|
||||
setUserTitle(props.conversationId, text);
|
||||
useChatStore.getState().setUserTitle(conversationId, text);
|
||||
};
|
||||
|
||||
const handleDeleteBegin = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!props.isActive)
|
||||
props.conversationActivate(props.conversationId, false);
|
||||
const handleDeleteButtonShow = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!isActive)
|
||||
props.onConversationActivate(conversationId, false);
|
||||
else
|
||||
setDeleteArmed(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = (e: React.MouseEvent) => {
|
||||
const handleDeleteButtonHide = () => setDeleteArmed(false);
|
||||
|
||||
const handleConversationDelete = (event: React.MouseEvent) => {
|
||||
if (deleteArmed) {
|
||||
setDeleteArmed(false);
|
||||
e.stopPropagation();
|
||||
props.conversationDelete(props.conversationId);
|
||||
event.stopPropagation();
|
||||
props.onConversationDelete(conversationId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => setDeleteArmed(false);
|
||||
|
||||
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
|
||||
const buttonSx: SxProps = { ml: 1, ...(props.isActive ? { color: 'white' } : {}) };
|
||||
const buttonSx: SxProps = { ml: 1, ...(isActive ? { color: 'white' } : {}) };
|
||||
|
||||
const progress = props.maxChatMessages ? 100 * messageCount / props.maxChatMessages : 0;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
variant={props.isActive ? 'solid' : 'plain'} color='neutral'
|
||||
selected={props.isActive}
|
||||
onClick={handleActivate}
|
||||
variant={isActive ? 'solid' : 'plain'} color='neutral'
|
||||
selected={isActive}
|
||||
onClick={handleConversationActivate}
|
||||
sx={{
|
||||
// py: 0,
|
||||
position: 'relative',
|
||||
border: 'none', // note, there's a default border of 1px and invisible.. hmm
|
||||
'&:hover > button': { opacity: 1 },
|
||||
...(isActive ? { bgcolor: 'red' } : {}),
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Optional prgoress bar */}
|
||||
{/* Optional progress bar, underlay */}
|
||||
{progress > 0 && (
|
||||
<Box sx={{
|
||||
backgroundColor: 'neutral.softActiveBg',
|
||||
@@ -129,13 +132,13 @@ export function ConversationItem(props: {
|
||||
{/* Text */}
|
||||
{!isEditingTitle ? (
|
||||
|
||||
<Box onDoubleClick={() => doubleClickToEdit ? handleEditBegin() : null} sx={{ flexGrow: 1 }}>
|
||||
{DEBUG_CONVERSATION_IDs ? props.conversationId.slice(0, 10) : title}{assistantTyping && '...'}
|
||||
<Box onDoubleClick={() => doubleClickToEdit ? handleTitleEdit() : null} sx={{ flexGrow: 1 }}>
|
||||
{DEBUG_CONVERSATION_IDs ? conversationId.slice(0, 10) : title}{assistantTyping && '...'}
|
||||
</Box>
|
||||
|
||||
) : (
|
||||
|
||||
<InlineTextarea initialText={title} onEdit={handleEdited} sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }} />
|
||||
<InlineTextarea initialText={title} onEdit={handleTitleEdited} sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }} />
|
||||
|
||||
)}
|
||||
|
||||
@@ -151,21 +154,21 @@ export function ConversationItem(props: {
|
||||
{/*</IconButton>*/}
|
||||
|
||||
{/* Delete Arming */}
|
||||
{!props.isSingle && !deleteArmed && (
|
||||
{!props.isLonely && !deleteArmed && (
|
||||
<IconButton
|
||||
variant={props.isActive ? 'solid' : 'outlined'} color='neutral'
|
||||
variant={isActive ? 'solid' : 'outlined'} color='neutral'
|
||||
size='sm' sx={{ opacity: { xs: 1, sm: 0 }, transition: 'opacity 0.3s', ...buttonSx }}
|
||||
onClick={handleDeleteBegin}>
|
||||
onClick={handleDeleteButtonShow}>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Delete / Cancel buttons */}
|
||||
{!props.isSingle && deleteArmed && <>
|
||||
<IconButton size='sm' variant='solid' color='danger' sx={buttonSx} onClick={handleDeleteConfirm}>
|
||||
{!props.isLonely && deleteArmed && <>
|
||||
<IconButton size='sm' variant='solid' color='danger' sx={buttonSx} onClick={handleConversationDelete}>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
<IconButton size='sm' variant='solid' color='neutral' sx={buttonSx} onClick={handleDeleteCancel}>
|
||||
<IconButton size='sm' variant='solid' color='neutral' sx={buttonSx} onClick={handleDeleteButtonHide}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>}
|
||||
@@ -9,14 +9,13 @@ import { DLLM, DLLMId, DModelSourceId, useModelsStore } from '~/modules/llms/sto
|
||||
|
||||
import { AppBarDropdown, DropdownItems } from '~/common/layout/AppBarDropdown';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { hideOnMobile } from '~/common/theme';
|
||||
import { openLayoutLLMOptions, openLayoutModelsSetup } from '~/common/layout/store-applayout';
|
||||
|
||||
|
||||
function AppBarLLMDropdown(props: {
|
||||
llms: DLLM[],
|
||||
llmId: DLLMId | null,
|
||||
setLlmId: (llmId: DLLMId | null) => void,
|
||||
chatLlmId: DLLMId | null,
|
||||
setChatLlmId: (llmId: DLLMId | null) => void,
|
||||
placeholder?: string,
|
||||
}) {
|
||||
|
||||
@@ -24,7 +23,7 @@ function AppBarLLMDropdown(props: {
|
||||
const llmItems: DropdownItems = {};
|
||||
let prevSourceId: DModelSourceId | null = null;
|
||||
for (const llm of props.llms) {
|
||||
if (!llm.hidden || llm.id === props.llmId) {
|
||||
if (!llm.hidden || llm.id === props.chatLlmId) {
|
||||
if (!prevSourceId || llm.sId !== prevSourceId) {
|
||||
if (prevSourceId)
|
||||
llmItems[`sep-${llm.id}`] = { type: 'separator', title: llm.sId };
|
||||
@@ -34,22 +33,25 @@ function AppBarLLMDropdown(props: {
|
||||
}
|
||||
}
|
||||
|
||||
const handleChatLLMChange = (_event: any, value: DLLMId | null) => value && props.setLlmId(value);
|
||||
const handleChatLLMChange = (_event: any, value: DLLMId | null) => value && props.setChatLlmId(value);
|
||||
|
||||
const handleOpenLLMOptions = () => props.llmId && openLayoutLLMOptions(props.llmId);
|
||||
const handleOpenLLMOptions = () => props.chatLlmId && openLayoutLLMOptions(props.chatLlmId);
|
||||
|
||||
|
||||
return (
|
||||
<AppBarDropdown
|
||||
items={llmItems}
|
||||
value={props.llmId} onChange={handleChatLLMChange}
|
||||
value={props.chatLlmId} onChange={handleChatLLMChange}
|
||||
placeholder={props.placeholder || 'Models …'}
|
||||
appendOption={<>
|
||||
|
||||
{props.llmId && (
|
||||
{props.chatLlmId && (
|
||||
<ListItemButton key='menu-opt' onClick={handleOpenLLMOptions}>
|
||||
<ListItemDecorator><SettingsIcon color='success' /></ListItemDecorator>
|
||||
Options
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
Options
|
||||
<KeyStroke combo='Ctrl + Shift + O' />
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
)}
|
||||
|
||||
@@ -57,7 +59,7 @@ function AppBarLLMDropdown(props: {
|
||||
<ListItemDecorator><BuildCircleIcon color='success' /></ListItemDecorator>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
Models
|
||||
<KeyStroke light combo='Ctrl + Shift + M' sx={hideOnMobile} />
|
||||
<KeyStroke combo='Ctrl + Shift + M' />
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
|
||||
@@ -75,7 +77,7 @@ export function useChatLLMDropdown() {
|
||||
}), shallow);
|
||||
|
||||
const chatLLMDropdown = React.useMemo(
|
||||
() => <AppBarLLMDropdown llms={llms} llmId={chatLLMId} setLlmId={setChatLLMId} />,
|
||||
() => <AppBarLLMDropdown llms={llms} chatLlmId={chatLLMId} setChatLlmId={setChatLLMId} />,
|
||||
[llms, chatLLMId, setChatLLMId],
|
||||
);
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ import { shallow } from 'zustand/shallow';
|
||||
import { ListItemButton, ListItemDecorator } from '@mui/joy';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
|
||||
import { APP_CALL_ENABLED } from '../../../call/AppCall';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { AppBarDropdown } from '~/common/layout/AppBarDropdown';
|
||||
import { launchAppCall } from '~/common/routes';
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { launchAppCall } from '~/common/app.routes';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
|
||||
function AppBarPersonaDropdown(props: {
|
||||
@@ -52,9 +51,10 @@ function AppBarPersonaDropdown(props: {
|
||||
|
||||
}
|
||||
|
||||
export function usePersonaIdDropdown(conversationId: string | null) {
|
||||
export function usePersonaIdDropdown(conversationId: DConversationId | null) {
|
||||
|
||||
// external state
|
||||
const labsCalling = useUXLabsStore(state => state.labsCalling);
|
||||
const { systemPurposeId } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === conversationId);
|
||||
return {
|
||||
@@ -69,12 +69,12 @@ export function usePersonaIdDropdown(conversationId: string | null) {
|
||||
if (conversationId && systemPurposeId)
|
||||
useChatStore.getState().setSystemPurposeId(conversationId, systemPurposeId);
|
||||
}}
|
||||
onCall={APP_CALL_ENABLED ? () => {
|
||||
onCall={labsCalling ? () => {
|
||||
if (conversationId && systemPurposeId)
|
||||
launchAppCall(conversationId, systemPurposeId);
|
||||
} : undefined}
|
||||
/> : null,
|
||||
[conversationId, systemPurposeId],
|
||||
[conversationId, labsCalling, systemPurposeId],
|
||||
);
|
||||
|
||||
return { personaDropdown };
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
|
||||
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
|
||||
|
||||
import { CameraCaptureModal } from './CameraCaptureModal';
|
||||
|
||||
|
||||
const attachCameraLegend = (isMobile: boolean) =>
|
||||
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
|
||||
<b>Attach photo</b><br />
|
||||
{isMobile ? 'Auto-OCR to read text' : 'See the world, on the go'}
|
||||
</Box>;
|
||||
|
||||
|
||||
export const ButtonAttachCameraMemo = React.memo(ButtonAttachCamera);
|
||||
|
||||
function ButtonAttachCamera(props: { isMobile?: boolean, onAttachImage: (file: File) => void }) {
|
||||
// state
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return <>
|
||||
|
||||
{/* The Button */}
|
||||
{props.isMobile ? (
|
||||
<IconButton variant='plain' color='neutral' onClick={() => setOpen(true)}>
|
||||
<AddAPhotoIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' placement='top-start' title={attachCameraLegend(!!props.isMobile)}>
|
||||
<Button fullWidth variant='plain' color='neutral' onClick={() => setOpen(true)} startDecorator={<AddAPhotoIcon />}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
Camera
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* The actual capture dialog, which will stream the video */}
|
||||
{open && (
|
||||
<CameraCaptureModal
|
||||
onCloseModal={() => setOpen(false)}
|
||||
onAttachImage={props.onAttachImage}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
|
||||
import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo';
|
||||
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
|
||||
|
||||
const pasteClipboardLegend =
|
||||
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
|
||||
<b>Attach clipboard 📚</b><br />
|
||||
Auto-converts to the best types<br />
|
||||
<KeyStroke combo='Ctrl + Shift + V' sx={{ mt: 1, mb: 0.5 }} />
|
||||
</Box>;
|
||||
|
||||
|
||||
export const ButtonAttachClipboardMemo = React.memo(ButtonAttachClipboard);
|
||||
|
||||
function ButtonAttachClipboard(props: { isMobile?: boolean, onClick: () => void }) {
|
||||
return props.isMobile ? (
|
||||
<IconButton onClick={props.onClick}>
|
||||
<ContentPasteGoIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' placement='top-start' title={pasteClipboardLegend}>
|
||||
<Button fullWidth variant='plain' color='neutral' startDecorator={<ContentPasteGoIcon />} onClick={props.onClick}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
Paste
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
|
||||
import AttachFileOutlinedIcon from '@mui/icons-material/AttachFileOutlined';
|
||||
|
||||
|
||||
const attachFileLegend =
|
||||
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
|
||||
<b>Attach files</b><br />
|
||||
Drag & drop in chat for faster loads ⚡
|
||||
</Box>;
|
||||
|
||||
|
||||
export const ButtonAttachFileMemo = React.memo(ButtonAttachFile);
|
||||
|
||||
function ButtonAttachFile(props: { isMobile?: boolean, onAttachFilePicker: () => void }) {
|
||||
return props.isMobile ? (
|
||||
<IconButton onClick={props.onAttachFilePicker}>
|
||||
<AttachFileOutlinedIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' placement='top-start' title={attachFileLegend}>
|
||||
<Button fullWidth variant='plain' color='neutral' onClick={props.onAttachFilePicker} startDecorator={<AttachFileOutlinedIcon />}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
File
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
|
||||
|
||||
const callConversationLegend =
|
||||
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
|
||||
Quick call regarding this chat
|
||||
</Box>;
|
||||
|
||||
export function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onClick: () => void, sx?: SxProps }) {
|
||||
return props.isMobile ? (
|
||||
<IconButton variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} sx={props.sx}>
|
||||
<CallIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' arrow placement='right' title={callConversationLegend}>
|
||||
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<CallIcon />} sx={props.sx}>
|
||||
Call
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, IconButton } from '@mui/joy';
|
||||
import { ColorPaletteProp, VariantProp } from '@mui/joy/styles/types';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
|
||||
|
||||
const micLegend =
|
||||
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
|
||||
Voice input<br />
|
||||
<KeyStroke combo='Ctrl + M' sx={{ mt: 1, mb: 0.5 }} />
|
||||
</Box>;
|
||||
|
||||
|
||||
export const ButtonMicMemo = React.memo(ButtonMic);
|
||||
|
||||
function ButtonMic(props: { variant: VariantProp, color: ColorPaletteProp, noBackground?: boolean, onClick: () => void }) {
|
||||
return <GoodTooltip placement='top' title={micLegend}>
|
||||
<IconButton variant={props.variant} color={props.color} onClick={props.onClick} sx={props.noBackground ? { background: 'none' } : {}}>
|
||||
<MicIcon />
|
||||
</IconButton>
|
||||
</GoodTooltip>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, IconButton, Tooltip } from '@mui/joy';
|
||||
import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
|
||||
import AutoModeIcon from '@mui/icons-material/AutoMode';
|
||||
|
||||
|
||||
const micContinuationLegend =
|
||||
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
|
||||
Voice Continuation
|
||||
</Box>;
|
||||
|
||||
|
||||
export const ButtonMicContinuationMemo = React.memo(ButtonMicContinuation);
|
||||
|
||||
function ButtonMicContinuation(props: { variant: VariantProp, color: ColorPaletteProp, onClick: () => void, sx?: SxProps }) {
|
||||
return <Tooltip placement='bottom' title={micContinuationLegend}>
|
||||
<IconButton variant={props.variant} color={props.color} onClick={props.onClick} sx={props.sx}>
|
||||
<AutoModeIcon />
|
||||
</IconButton>
|
||||
</Tooltip>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button, IconButton } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
|
||||
|
||||
export function ButtonOptionsDraw(props: { isMobile?: boolean, onClick: () => void, sx?: SxProps }) {
|
||||
return props.isMobile ? (
|
||||
<IconButton variant='soft' color='warning' onClick={props.onClick} sx={props.sx}>
|
||||
<FormatPaintIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button variant='soft' color='warning' onClick={props.onClick} endDecorator={<FormatPaintIcon />} sx={props.sx}>
|
||||
Options
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button, IconButton } from '@mui/joy';
|
||||
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
|
||||
|
||||
import { hideOnDesktop, hideOnMobile } from '~/common/theme';
|
||||
|
||||
import { CameraCaptureModal } from './CameraCaptureModal';
|
||||
|
||||
const showOnDesktop = false; // process.env.NODE_ENV === 'development';
|
||||
|
||||
|
||||
export function CameraCaptureButton(props: { onOCR: (ocrText: string) => void }) {
|
||||
// state
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return <>
|
||||
|
||||
{/* The Button */}
|
||||
<IconButton variant='plain' color='neutral' onClick={() => setOpen(true)} sx={hideOnDesktop}>
|
||||
<AddAPhotoIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* Also show a button on desktop while in development */}
|
||||
{showOnDesktop && <Button
|
||||
fullWidth variant='plain' color='neutral' onClick={() => setOpen(true)} startDecorator={<AddAPhotoIcon />}
|
||||
sx={{ ...hideOnMobile, justifyContent: 'flex-start' }}>
|
||||
OCR
|
||||
</Button>}
|
||||
|
||||
{/* The actual capture dialog, which will stream the video */}
|
||||
{open && <CameraCaptureModal onCloseModal={() => setOpen(false)} onOCR={props.onOCR} />}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, CircularProgress, IconButton, LinearProgress, Modal, ModalClose, Option, Select, Sheet, Typography } from '@mui/joy';
|
||||
import { Box, Button, IconButton, Modal, ModalClose, Option, Select, Sheet, Typography } from '@mui/joy';
|
||||
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
@@ -9,6 +10,12 @@ import { InlineError } from '~/common/components/InlineError';
|
||||
import { useCameraCapture } from '~/common/components/useCameraCapture';
|
||||
|
||||
|
||||
function prettyFileName(renderedFrame: HTMLCanvasElement) {
|
||||
const prettyDate = new Date().toISOString().replace(/[:-]/g, '').replace('T', '-').replace('Z', '');
|
||||
const prettyResolution = `${renderedFrame.width}x${renderedFrame.height}`;
|
||||
return `camera-${prettyDate}-${prettyResolution}.png`;
|
||||
}
|
||||
|
||||
function renderVideoFrameToCanvas(videoElement: HTMLVideoElement): HTMLCanvasElement {
|
||||
// paint the video on a canvas, to save it
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -19,6 +26,19 @@ function renderVideoFrameToCanvas(videoElement: HTMLVideoElement): HTMLCanvasEle
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function renderVideoFrameToFile(videoElement: HTMLVideoElement, callback: (file: File) => void) {
|
||||
// video to canvas
|
||||
const renderedFrame = renderVideoFrameToCanvas(videoElement);
|
||||
|
||||
// canvas to blob to file to callback
|
||||
renderedFrame.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const file = new File([blob], prettyFileName(renderedFrame), { type: blob.type });
|
||||
callback(file);
|
||||
}
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
function downloadVideoFrameAsPNG(videoElement: HTMLVideoElement) {
|
||||
// video to canvas to png
|
||||
const renderedFrame = renderVideoFrameToCanvas(videoElement);
|
||||
@@ -26,15 +46,19 @@ function downloadVideoFrameAsPNG(videoElement: HTMLVideoElement) {
|
||||
|
||||
// auto-download
|
||||
const link = document.createElement('a');
|
||||
link.download = 'image.png';
|
||||
link.download = prettyFileName(renderedFrame);
|
||||
link.href = imageDataURL;
|
||||
link.click();
|
||||
}
|
||||
|
||||
|
||||
export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (ocrText: string) => void }) {
|
||||
export function CameraCaptureModal(props: {
|
||||
onCloseModal: () => void,
|
||||
onAttachImage: (file: File) => void
|
||||
// onOCR: (ocrText: string) => void }
|
||||
}) {
|
||||
// state
|
||||
const [ocrProgress, setOCRProgress] = React.useState<number | null>(null);
|
||||
// const [ocrProgress/*, setOCRProgress*/] = React.useState<number | null>(null);
|
||||
const [showInfo, setShowInfo] = React.useState(false);
|
||||
|
||||
// camera operations
|
||||
@@ -51,7 +75,7 @@ export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (oc
|
||||
props.onCloseModal();
|
||||
};
|
||||
|
||||
const handleVideoOCRClicked = async () => {
|
||||
/*const handleVideoOCRClicked = async () => {
|
||||
if (!videoRef.current) return;
|
||||
const renderedFrame = renderVideoFrameToCanvas(videoRef.current);
|
||||
|
||||
@@ -68,6 +92,14 @@ export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (oc
|
||||
setOCRProgress(null);
|
||||
stopAndClose();
|
||||
props.onOCR(result.data.text);
|
||||
};*/
|
||||
|
||||
const handleVideoSnapClicked = () => {
|
||||
if (!videoRef.current) return;
|
||||
renderVideoFrameToFile(videoRef.current, (file) => {
|
||||
props.onAttachImage(file);
|
||||
stopAndClose();
|
||||
});
|
||||
};
|
||||
|
||||
const handleVideoDownloadClicked = () => {
|
||||
@@ -111,7 +143,7 @@ export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (oc
|
||||
ref={videoRef} autoPlay playsInline
|
||||
style={{
|
||||
display: 'block', width: '100%', maxHeight: 'calc(100vh - 200px)',
|
||||
background: '#8888', opacity: ocrProgress !== null ? 0.5 : 1,
|
||||
background: '#8888', //opacity: ocrProgress !== null ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -124,7 +156,7 @@ export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (oc
|
||||
{info}
|
||||
</Typography>}
|
||||
|
||||
{ocrProgress !== null && <CircularProgress sx={{ position: 'absolute', top: 'calc(50% - 34px / 2)', left: 'calc(50% - 34px / 2)', zIndex: 2 }} />}
|
||||
{/*{ocrProgress !== null && <CircularProgress sx={{ position: 'absolute', top: 'calc(50% - 34px / 2)', left: 'calc(50% - 34px / 2)', zIndex: 2 }} />}*/}
|
||||
</Box>
|
||||
|
||||
{/* Bottom controls (zoom, ocr, download) & progress */}
|
||||
@@ -134,16 +166,30 @@ export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (oc
|
||||
|
||||
{zoomControl}
|
||||
|
||||
{ocrProgress !== null && <LinearProgress color='primary' determinate value={100 * ocrProgress} sx={{ px: 2 }} />}
|
||||
{/*{ocrProgress !== null && <LinearProgress color='primary' determinate value={100 * ocrProgress} sx={{ px: 2 }} />}*/}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'space-between' }}>
|
||||
<IconButton disabled={!info} variant='soft' color='neutral' size='lg' onClick={() => setShowInfo(info => !info)} sx={{ zIndex: 30 }}>
|
||||
{/* Info */}
|
||||
<IconButton disabled={!info} variant='soft' color='neutral' onClick={() => setShowInfo(info => !info)} sx={{ zIndex: 30 }}>
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
<Button disabled={ocrProgress !== null} fullWidth variant='solid' size='lg' onClick={handleVideoOCRClicked} sx={{ flex: 1, maxWidth: 260 }}>
|
||||
Extract Text
|
||||
{/*<Button disabled={ocrProgress !== null} fullWidth variant='solid' size='lg' onClick={handleVideoOCRClicked} sx={{ flex: 1, maxWidth: 260 }}>*/}
|
||||
{/* Extract Text*/}
|
||||
{/*</Button>*/}
|
||||
|
||||
{/* Capture */}
|
||||
<Button
|
||||
fullWidth
|
||||
variant='solid' color='neutral'
|
||||
onClick={handleVideoSnapClicked}
|
||||
endDecorator={<CameraAltIcon />}
|
||||
sx={{ flex: 1, maxWidth: 200, py: 2, borderRadius: '3rem' }}
|
||||
>
|
||||
Capture
|
||||
</Button>
|
||||
<IconButton variant='soft' color='neutral' size='lg' onClick={handleVideoDownloadClicked}>
|
||||
|
||||
{/* Download */}
|
||||
<IconButton variant='soft' color='neutral' onClick={handleVideoDownloadClicked}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -3,12 +3,59 @@ import * as React from 'react';
|
||||
import { Box, MenuItem, Radio, Typography } from '@mui/joy';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { ChatModeId, ChatModeItems } from './store-composer';
|
||||
import { ChatModeId } from '../../AppChat';
|
||||
|
||||
|
||||
export const ChatModeMenu = (props: { anchorEl: HTMLAnchorElement | null, onClose: () => void, experimental: boolean, chatModeId: ChatModeId, onSetChatModeId: (chatMode: ChatModeId) => void }) =>
|
||||
<CloseableMenu
|
||||
interface ChatModeDescription {
|
||||
label: string;
|
||||
description: string | React.JSX.Element;
|
||||
shortcut?: string;
|
||||
experimental?: boolean;
|
||||
}
|
||||
|
||||
const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = {
|
||||
'immediate': {
|
||||
label: 'Chat',
|
||||
description: 'Persona replies',
|
||||
},
|
||||
'write-user': {
|
||||
label: 'Write',
|
||||
description: 'Appends a message',
|
||||
shortcut: 'Alt + Enter',
|
||||
},
|
||||
'draw-imagine': {
|
||||
label: 'Draw',
|
||||
description: 'AI Image Generation',
|
||||
},
|
||||
'draw-imagine-plus': {
|
||||
label: 'Assisted Draw',
|
||||
description: 'Assisted Image Generation',
|
||||
experimental: true,
|
||||
},
|
||||
'react': {
|
||||
label: 'Reason + Act · α',
|
||||
description: 'Answers questions in multiple steps',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) {
|
||||
if (shortcut === 'ENTER')
|
||||
return enterIsNewLine ? 'Shift + Enter' : 'Enter';
|
||||
return shortcut;
|
||||
}
|
||||
|
||||
export function ChatModeMenu(props: { anchorEl: HTMLAnchorElement | null, onClose: () => void, chatModeId: ChatModeId, onSetChatModeId: (chatMode: ChatModeId) => void }) {
|
||||
|
||||
// external state
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
const labsMagicDraw = useUXLabsStore(state => state.labsMagicDraw);
|
||||
|
||||
return <CloseableMenu
|
||||
placement='top-end' sx={{ minWidth: 320 }}
|
||||
open anchorEl={props.anchorEl} onClose={props.onClose}
|
||||
>
|
||||
@@ -20,15 +67,21 @@ export const ChatModeMenu = (props: { anchorEl: HTMLAnchorElement | null, onClos
|
||||
{/*<ListDivider />*/}
|
||||
|
||||
{/* ChatMode items */}
|
||||
{Object.entries(ChatModeItems).filter(([, { experimental }]) => props.experimental || !experimental).map(([key, data]) =>
|
||||
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
|
||||
<Radio checked={key === props.chatModeId} />
|
||||
<Box>
|
||||
<Typography>{data.label}</Typography>
|
||||
<Typography level='body-sm'>{data.description}</Typography>
|
||||
{Object.entries(ChatModeItems)
|
||||
.filter(([, { experimental }]) => labsMagicDraw || !experimental)
|
||||
.map(([key, data]) =>
|
||||
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
|
||||
<Radio checked={key === props.chatModeId} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography>{data.label}</Typography>
|
||||
<Typography level='body-xs'>{data.description}</Typography>
|
||||
</Box>
|
||||
{(key === props.chatModeId || !!data.shortcut) && (
|
||||
<KeyStroke combo={fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline)} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</MenuItem>)}
|
||||
</MenuItem>)}
|
||||
|
||||
</CloseableMenu>;
|
||||
</CloseableMenu>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,18 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Badge, ColorPaletteProp, Tooltip } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import { Badge, Box, ColorPaletteProp, Tooltip } from '@mui/joy';
|
||||
|
||||
|
||||
export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, indirectTokens?: number) {
|
||||
const usedTokens = directTokens + (indirectTokens || 0);
|
||||
function alignRight(value: number, columnSize: number = 7) {
|
||||
const str = value.toLocaleString();
|
||||
return str.padStart(columnSize);
|
||||
}
|
||||
|
||||
|
||||
export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, historyTokens?: number, responseMaxTokens?: number): {
|
||||
color: ColorPaletteProp, message: string, remainingTokens: number
|
||||
} {
|
||||
const usedTokens = directTokens + (historyTokens || 0) + (responseMaxTokens || 0);
|
||||
const remainingTokens = tokenLimit - usedTokens;
|
||||
const gteLimit = (remainingTokens <= 0 && tokenLimit > 0);
|
||||
|
||||
@@ -17,23 +24,24 @@ export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, i
|
||||
message += `Requested: ${usedTokens.toLocaleString()} tokens`;
|
||||
}
|
||||
// has full information (d + i < l)
|
||||
else if (indirectTokens) {
|
||||
else if (historyTokens || responseMaxTokens) {
|
||||
message +=
|
||||
`${Math.abs(remainingTokens).toLocaleString()} ${remainingTokens > 0 ? 'available' : 'excess'} tokens\n\n` +
|
||||
` = Model max tokens: ${tokenLimit.toLocaleString()}\n` +
|
||||
` - Chat Message: ${directTokens.toLocaleString()}` +
|
||||
(indirectTokens ? `\n- History + Response: ${indirectTokens?.toLocaleString()}` : '');
|
||||
`${Math.abs(remainingTokens).toLocaleString()} ${remainingTokens >= 0 ? 'available' : 'excess'} message tokens\n\n` +
|
||||
` = Model max tokens: ${alignRight(tokenLimit)}\n` +
|
||||
` - This message: ${alignRight(directTokens)}\n` +
|
||||
` - History: ${alignRight(historyTokens || 0)}\n` +
|
||||
` - Max response: ${alignRight(responseMaxTokens || 0)}`;
|
||||
}
|
||||
// Cleaner mode: d + ? < R (total is the remaining in this case)
|
||||
else {
|
||||
message +=
|
||||
`${(tokenLimit + usedTokens).toLocaleString()} available tokens after deleting this\n\n` +
|
||||
` = Currently free: ${tokenLimit.toLocaleString()}\n` +
|
||||
` + This message: ${usedTokens.toLocaleString()}`;
|
||||
` = Currently free: ${alignRight(tokenLimit)}\n` +
|
||||
` + This message: ${alignRight(usedTokens)}`;
|
||||
}
|
||||
|
||||
const color: ColorPaletteProp =
|
||||
(tokenLimit && remainingTokens < 1)
|
||||
(tokenLimit && remainingTokens < 0)
|
||||
? 'danger'
|
||||
: remainingTokens < tokenLimit / 4
|
||||
? 'warning'
|
||||
@@ -43,35 +51,61 @@ export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, i
|
||||
}
|
||||
|
||||
|
||||
export const TokenTooltip = (props: { message: string | null, color: ColorPaletteProp, placement?: 'top' | 'top-end', children: React.JSX.Element }) =>
|
||||
<Tooltip
|
||||
placement={props.placement}
|
||||
variant={props.color !== 'primary' ? 'solid' : 'soft'} color={props.color}
|
||||
title={props.message
|
||||
? <Box sx={{ p: 2, whiteSpace: 'pre' }}>
|
||||
{props.message}
|
||||
</Box>
|
||||
: null
|
||||
}
|
||||
sx={{
|
||||
fontFamily: 'code',
|
||||
boxShadow: 'xl',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</Tooltip>;
|
||||
|
||||
|
||||
/**
|
||||
* Simple little component to show the token count (and a tooltip on hover)
|
||||
*/
|
||||
export function TokenBadge({ directTokens, indirectTokens, tokenLimit, showExcess, absoluteBottomRight, inline, sx }: { directTokens: number, indirectTokens?: number, tokenLimit: number, showExcess?: boolean, absoluteBottomRight?: boolean, inline?: boolean, sx?: SxProps }) {
|
||||
export const TokenBadgeMemo = React.memo(TokenBadge);
|
||||
|
||||
const fontSx: SxProps = { fontFamily: 'code', ...(sx || {}) };
|
||||
const outerSx: SxProps = absoluteBottomRight ? { position: 'absolute', bottom: 8, right: 8 } : {};
|
||||
const innerSx: SxProps = (absoluteBottomRight || inline) ? { position: 'static', transform: 'none', ...fontSx } : fontSx;
|
||||
function TokenBadge(props: {
|
||||
direct: number, history?: number, responseMax?: number, limit: number,
|
||||
showExcess?: boolean, absoluteBottomRight?: boolean, inline?: boolean,
|
||||
}) {
|
||||
|
||||
const { message, color, remainingTokens } = tokensPrettyMath(tokenLimit, directTokens, indirectTokens);
|
||||
const { message, color, remainingTokens } = tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax);
|
||||
|
||||
// show the direct tokens, unless we exceed the limit and 'showExcess' is enabled
|
||||
const value = (showExcess && (tokenLimit && remainingTokens <= 0))
|
||||
const value = (props.showExcess && (props.limit && remainingTokens <= 0))
|
||||
? Math.abs(remainingTokens)
|
||||
: directTokens;
|
||||
: props.direct;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant='solid' color={color} max={100000}
|
||||
invisible={!directTokens && remainingTokens >= 0}
|
||||
invisible={!props.direct && remainingTokens >= 0}
|
||||
badgeContent={
|
||||
<Tooltip title={<span style={{ whiteSpace: 'pre' }}>{message}</span>} color={color} sx={fontSx}>
|
||||
<TokenTooltip color={color} message={message}>
|
||||
<span>{value.toLocaleString()}</span>
|
||||
</Tooltip>
|
||||
</TokenTooltip>
|
||||
}
|
||||
sx={outerSx}
|
||||
sx={{
|
||||
...((props.absoluteBottomRight) && { position: 'absolute', bottom: 8, right: 8 }),
|
||||
cursor: 'help',
|
||||
}}
|
||||
slotProps={{
|
||||
badge: {
|
||||
sx: innerSx,
|
||||
sx: {
|
||||
fontFamily: 'code',
|
||||
...((props.absoluteBottomRight || props.inline) && { position: 'static', transform: 'none' }),
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Tooltip, useTheme } from '@mui/joy';
|
||||
import { Box, useTheme } from '@mui/joy';
|
||||
|
||||
import { tokensPrettyMath } from './TokenBadge';
|
||||
import { tokensPrettyMath, TokenTooltip } from './TokenBadge';
|
||||
|
||||
|
||||
/**
|
||||
@@ -10,15 +10,17 @@ import { tokensPrettyMath } from './TokenBadge';
|
||||
*
|
||||
* The Textarea contains it within the Composer (at least).
|
||||
*/
|
||||
export function TokenProgressbar(props: { history: number, response: number, direct: number, limit: number }) {
|
||||
export const TokenProgressbarMemo = React.memo(TokenProgressbar);
|
||||
|
||||
function TokenProgressbar(props: { direct: number, history: number, responseMax: number, limit: number }) {
|
||||
// external state
|
||||
const theme = useTheme();
|
||||
|
||||
if (!(props.limit > 0) || (!props.direct && !props.history && !props.response)) return null;
|
||||
if (!(props.limit > 0) || (!props.direct && !props.history && !props.responseMax)) return null;
|
||||
|
||||
// compute percentages
|
||||
let historyPct = 100 * props.history / props.limit;
|
||||
let responsePct = 100 * props.response / props.limit;
|
||||
let responsePct = 100 * props.responseMax / props.limit;
|
||||
let directPct = 100 * props.direct / props.limit;
|
||||
const totalPct = historyPct + responsePct + directPct;
|
||||
const isOverflow = totalPct >= 100;
|
||||
@@ -38,7 +40,7 @@ export function TokenProgressbar(props: { history: number, response: number, dir
|
||||
const overflowColor = theme.palette.danger.softColor;
|
||||
|
||||
// tooltip message/color
|
||||
const { message, color } = tokensPrettyMath(props.limit, props.direct, props.history + props.response);
|
||||
const { message, color } = tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax);
|
||||
|
||||
// sizes
|
||||
const containerHeight = 8;
|
||||
@@ -46,11 +48,11 @@ export function TokenProgressbar(props: { history: number, response: number, dir
|
||||
|
||||
return (
|
||||
|
||||
<Tooltip title={<span style={{ whiteSpace: 'pre' }}>{message}</span>} color={color} sx={{ fontFamily: 'code' }}>
|
||||
<TokenTooltip color={color} message={props.direct ? null : message}>
|
||||
|
||||
<Box sx={{
|
||||
position: 'absolute', left: 1, right: 1, bottom: 1, height: containerHeight,
|
||||
overflow: 'hidden', borderBottomLeftRadius: 7, borderBottomRightRadius: 7,
|
||||
overflow: 'hidden', borderBottomLeftRadius: 5, borderBottomRightRadius: 5,
|
||||
}}>
|
||||
|
||||
{/* History */}
|
||||
@@ -79,6 +81,6 @@ export function TokenProgressbar(props: { history: number, response: number, dir
|
||||
|
||||
</Box>
|
||||
|
||||
</Tooltip>
|
||||
</TokenTooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, CircularProgress, ColorPaletteProp, Sheet, Typography } from '@mui/joy';
|
||||
import AbcIcon from '@mui/icons-material/Abc';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import PivotTableChartIcon from '@mui/icons-material/PivotTableChart';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import TextureIcon from '@mui/icons-material/Texture';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { ellipsizeFront, ellipsizeMiddle } from '~/common/util/textUtils';
|
||||
|
||||
import type { Attachment, AttachmentConverterType, AttachmentId } from './store-attachments';
|
||||
import type { LLMAttachment } from './useLLMAttachments';
|
||||
|
||||
|
||||
// default attachment width
|
||||
const ATTACHMENT_MIN_STYLE = {
|
||||
height: '100%',
|
||||
minHeight: '40px',
|
||||
minWidth: '64px',
|
||||
};
|
||||
|
||||
|
||||
const ellipsizeLabel = (label?: string) => {
|
||||
if (!label)
|
||||
return '';
|
||||
return ellipsizeMiddle((label || '')
|
||||
.replace(/https?:\/\/(?:www\.)?/, ''), 30)
|
||||
.replace(/\/$/, '')
|
||||
.replace('…', '…\n…');
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Displayed while a source is loading
|
||||
*/
|
||||
const LoadingIndicator = React.forwardRef((props: { label: string }, _ref) =>
|
||||
<Sheet
|
||||
color='success' variant='soft'
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'success.solidBg',
|
||||
borderRadius: 'sm',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1,
|
||||
...ATTACHMENT_MIN_STYLE,
|
||||
boxSizing: 'border-box',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
<CircularProgress color='success' size='sm' />
|
||||
<Typography level='title-sm' sx={{ whiteSpace: 'nowrap' }}>
|
||||
{ellipsizeLabel(props.label)}
|
||||
</Typography>
|
||||
</Sheet>,
|
||||
);
|
||||
LoadingIndicator.displayName = 'LoadingIndicator';
|
||||
|
||||
|
||||
const InputErrorIndicator = () =>
|
||||
<WarningRoundedIcon sx={{ color: 'danger.solidBg' }} />;
|
||||
|
||||
|
||||
const converterTypeToIconMap: { [key in AttachmentConverterType]: React.ComponentType<any> } = {
|
||||
'text': TextFieldsIcon,
|
||||
'rich-text': CodeIcon,
|
||||
'rich-text-table': PivotTableChartIcon,
|
||||
'pdf-text': PictureAsPdfIcon,
|
||||
'pdf-images': PictureAsPdfIcon,
|
||||
'image': ImageOutlinedIcon,
|
||||
'image-ocr': AbcIcon,
|
||||
'unhandled': TextureIcon,
|
||||
};
|
||||
|
||||
function attachmentConverterIcon(attachment: Attachment) {
|
||||
const converter = attachment.converterIdx !== null ? attachment.converters[attachment.converterIdx] ?? null : null;
|
||||
if (converter && converter.id) {
|
||||
const Icon = converterTypeToIconMap[converter.id] ?? null;
|
||||
if (Icon)
|
||||
return <Icon sx={{ width: 24, height: 24 }} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function attachmentLabelText(attachment: Attachment): string {
|
||||
return ellipsizeFront(attachment.label, 24);
|
||||
}
|
||||
|
||||
|
||||
export function AttachmentItem(props: {
|
||||
llmAttachment: LLMAttachment,
|
||||
menuShown: boolean,
|
||||
onItemMenuToggle: (attachmentId: AttachmentId, anchor: HTMLAnchorElement) => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
|
||||
const { onItemMenuToggle } = props;
|
||||
|
||||
const {
|
||||
attachment,
|
||||
isUnconvertible,
|
||||
isOutputMissing,
|
||||
isOutputAttachable,
|
||||
} = props.llmAttachment;
|
||||
|
||||
const {
|
||||
inputError,
|
||||
inputLoading: isInputLoading,
|
||||
outputsConverting: isOutputLoading,
|
||||
} = attachment;
|
||||
|
||||
const isInputError = !!inputError;
|
||||
const showWarning = isUnconvertible || isOutputMissing || !isOutputAttachable;
|
||||
|
||||
|
||||
const handleToggleMenu = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.stopPropagation();
|
||||
onItemMenuToggle(attachment.id, event.currentTarget);
|
||||
}, [attachment, onItemMenuToggle]);
|
||||
|
||||
|
||||
// compose tooltip
|
||||
let tooltip: string | null = '';
|
||||
if (attachment.source.media !== 'text')
|
||||
tooltip += attachment.source.media + ': ';
|
||||
tooltip += attachment.label;
|
||||
// if (hasInput)
|
||||
// tooltip += `\n(${aInput.mimeType}: ${aInput.dataSize.toLocaleString()} bytes)`;
|
||||
// if (aOutputs && aOutputs.length >= 1)
|
||||
// tooltip += `\n\n${JSON.stringify(aOutputs)}`;
|
||||
|
||||
// choose variants and color
|
||||
let color: ColorPaletteProp;
|
||||
let variant: 'soft' | 'outlined' | 'contained' = 'soft';
|
||||
if (isInputLoading || isOutputLoading) {
|
||||
color = 'success';
|
||||
} else if (isInputError) {
|
||||
tooltip = `Issue loading the attachment: ${attachment.inputError}\n\n${tooltip}`;
|
||||
color = 'danger';
|
||||
} else if (showWarning) {
|
||||
tooltip = props.menuShown
|
||||
? null
|
||||
: isUnconvertible
|
||||
? `Attachments of type '${attachment.input?.mimeType}' are not supported yet. You can open a feature request on GitHub.\n\n${tooltip}`
|
||||
: `Not compatible with the selected LLM or not supported. Please select another format.\n\n${tooltip}`;
|
||||
color = 'warning';
|
||||
} else {
|
||||
// all good
|
||||
tooltip = null;
|
||||
color = /*props.menuShown ? 'primary' :*/ 'neutral';
|
||||
variant = 'outlined';
|
||||
}
|
||||
|
||||
|
||||
return <Box>
|
||||
|
||||
<GoodTooltip
|
||||
title={tooltip}
|
||||
isError={isInputError}
|
||||
isWarning={showWarning}
|
||||
sx={{ p: 1, whiteSpace: 'break-spaces' }}
|
||||
>
|
||||
{isInputLoading
|
||||
? <LoadingIndicator label={attachment.label} />
|
||||
: (
|
||||
<Button
|
||||
size='sm'
|
||||
variant={variant} color={color}
|
||||
onClick={handleToggleMenu}
|
||||
sx={{
|
||||
backgroundColor: props.menuShown ? `${color}.softActiveBg` : variant === 'outlined' ? 'background.popup' : undefined,
|
||||
border: variant === 'soft' ? '1px solid' : undefined,
|
||||
borderColor: variant === 'soft' ? `${color}.solidBg` : undefined,
|
||||
borderRadius: 'sm',
|
||||
fontWeight: 'normal',
|
||||
...ATTACHMENT_MIN_STYLE,
|
||||
px: 1, py: 0.5,
|
||||
display: 'flex', flexDirection: 'row', gap: 1,
|
||||
}}
|
||||
>
|
||||
{isInputError
|
||||
? <InputErrorIndicator />
|
||||
: <>
|
||||
{attachmentConverterIcon(attachment)}
|
||||
{isOutputLoading
|
||||
? <>Converting <CircularProgress color='success' size='sm' /></>
|
||||
: <Typography level='title-sm' sx={{ whiteSpace: 'nowrap' }}>
|
||||
{attachmentLabelText(attachment)}
|
||||
</Typography>}
|
||||
</>}
|
||||
</Button>
|
||||
)}
|
||||
</GoodTooltip>
|
||||
|
||||
</Box>;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, ListDivider, ListItemDecorator, MenuItem, Radio, Typography } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft';
|
||||
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
|
||||
import type { LLMAttachment } from './useLLMAttachments';
|
||||
import { useAttachmentsStore } from './store-attachments';
|
||||
|
||||
|
||||
// enable for debugging
|
||||
export const DEBUG_ATTACHMENTS = true;
|
||||
|
||||
|
||||
export function AttachmentMenu(props: {
|
||||
llmAttachment: LLMAttachment,
|
||||
menuAnchor: HTMLAnchorElement,
|
||||
isPositionFirst: boolean,
|
||||
isPositionLast: boolean,
|
||||
onAttachmentInlineText: (attachmentId: string) => void,
|
||||
onClose: () => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
|
||||
const isPositionFixed = props.isPositionFirst && props.isPositionLast;
|
||||
|
||||
const {
|
||||
attachment,
|
||||
attachmentOutputs,
|
||||
isUnconvertible,
|
||||
isOutputMissing,
|
||||
isOutputTextInlineable,
|
||||
tokenCountApprox,
|
||||
} = props.llmAttachment;
|
||||
|
||||
const {
|
||||
id: aId,
|
||||
input: aInput,
|
||||
converters: aConverters,
|
||||
converterIdx: aConverterIdx,
|
||||
outputs: aOutputs,
|
||||
} = attachment;
|
||||
|
||||
|
||||
// operations
|
||||
|
||||
const { onClose, onAttachmentInlineText } = props;
|
||||
|
||||
const handleInlineText = React.useCallback(() => {
|
||||
onClose();
|
||||
onAttachmentInlineText(aId);
|
||||
}, [aId, onAttachmentInlineText, onClose]);
|
||||
|
||||
const handleMoveUp = React.useCallback(() => {
|
||||
useAttachmentsStore.getState().moveAttachment(aId, -1);
|
||||
}, [aId]);
|
||||
|
||||
const handleMoveDown = React.useCallback(() => {
|
||||
useAttachmentsStore.getState().moveAttachment(aId, 1);
|
||||
}, [aId]);
|
||||
|
||||
const handleRemove = React.useCallback(() => {
|
||||
onClose();
|
||||
useAttachmentsStore.getState().removeAttachment(aId);
|
||||
}, [aId, onClose]);
|
||||
|
||||
const handleSetConverterIdx = React.useCallback(async (converterIdx: number | null) => {
|
||||
return useAttachmentsStore.getState().setConverterIdx(aId, converterIdx);
|
||||
}, [aId]);
|
||||
|
||||
// const handleSummarizeText = React.useCallback(() => {
|
||||
// onAttachmentSummarizeText(aId);
|
||||
// }, [aId, onAttachmentSummarizeText]);
|
||||
|
||||
const handleCopyOutputToClipboard = React.useCallback(() => {
|
||||
if (attachmentOutputs.length >= 1) {
|
||||
const concat = attachmentOutputs.map(output => {
|
||||
if (output.type === 'text-block')
|
||||
return output.text;
|
||||
else if (output.type === 'image-part')
|
||||
return output.base64Url;
|
||||
else
|
||||
return null;
|
||||
}).join('\n\n---\n\n');
|
||||
copyToClipboard(concat.trim(), 'Converted attachment');
|
||||
}
|
||||
}, [attachmentOutputs]);
|
||||
|
||||
|
||||
return (
|
||||
<CloseableMenu
|
||||
dense placement='top' sx={{ minWidth: 200 }}
|
||||
open anchorEl={props.menuAnchor} onClose={props.onClose}
|
||||
noTopPadding noBottomPadding
|
||||
>
|
||||
|
||||
{/* Move Arrows */}
|
||||
{!isPositionFixed && <Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<MenuItem
|
||||
disabled={props.isPositionFirst}
|
||||
onClick={handleMoveUp}
|
||||
sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<KeyboardArrowLeftIcon />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={props.isPositionLast}
|
||||
onClick={handleMoveDown}
|
||||
sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<KeyboardArrowRightIcon />
|
||||
</MenuItem>
|
||||
</Box>}
|
||||
{!isPositionFixed && <ListDivider sx={{ mt: 0 }} />}
|
||||
|
||||
{/* Render Converters as menu items */}
|
||||
{/*{!isUnconvertible && <ListItem>*/}
|
||||
{/* <Typography level='body-md'>*/}
|
||||
{/* Attach as:*/}
|
||||
{/* </Typography>*/}
|
||||
{/*</ListItem>}*/}
|
||||
{!isUnconvertible && aConverters.map((c, idx) =>
|
||||
<MenuItem
|
||||
disabled={c.disabled}
|
||||
key={'c-' + c.id}
|
||||
onClick={async () => idx !== aConverterIdx && await handleSetConverterIdx(idx)}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<Radio checked={idx === aConverterIdx} />
|
||||
</ListItemDecorator>
|
||||
{c.unsupported
|
||||
? <Box>Unsupported 🤔 <Typography level='body-xs'>{c.name}</Typography></Box>
|
||||
: c.name}
|
||||
</MenuItem>,
|
||||
)}
|
||||
{!isUnconvertible && <ListDivider />}
|
||||
|
||||
{DEBUG_ATTACHMENTS && !!aInput && (
|
||||
<MenuItem onClick={handleCopyOutputToClipboard} disabled={!isOutputTextInlineable}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
<Box>
|
||||
{!!aInput && <Typography level='body-xs'>
|
||||
🡐 {aInput.mimeType}, {aInput.dataSize.toLocaleString()} bytes
|
||||
</Typography>}
|
||||
{/*<Typography level='body-xs'>*/}
|
||||
{/* Converters: {aConverters.map(((converter, idx) => ` ${converter.id}${(idx === aConverterIdx) ? '*' : ''}`)).join(', ')}*/}
|
||||
{/*</Typography>*/}
|
||||
<Typography level='body-xs'>
|
||||
🡒 {isOutputMissing ? 'empty' : aOutputs.map(output => `${output.type}, ${output.type === 'text-block' ? output.text.length.toLocaleString() : '(base64 image)'} bytes`).join(' · ')}
|
||||
</Typography>
|
||||
{!!tokenCountApprox && <Typography level='body-xs'>
|
||||
🡒 {tokenCountApprox.toLocaleString()} tokens
|
||||
</Typography>}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
)}
|
||||
{DEBUG_ATTACHMENTS && !!aInput && <ListDivider />}
|
||||
|
||||
{/* Destructive Operations */}
|
||||
{/*<MenuItem onClick={handleCopyOutputToClipboard} disabled={!isOutputTextInlineable}>*/}
|
||||
{/* <ListItemDecorator><ContentCopyIcon /></ListItemDecorator>*/}
|
||||
{/* Copy*/}
|
||||
{/*</MenuItem>*/}
|
||||
{/*<MenuItem onClick={handleSummarizeText} disabled={!isOutputTextInlineable}>*/}
|
||||
{/* <ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>*/}
|
||||
{/* Shrink*/}
|
||||
{/*</MenuItem>*/}
|
||||
<MenuItem onClick={handleInlineText} disabled={!isOutputTextInlineable}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
Inline text
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleRemove}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Remove
|
||||
</MenuItem>
|
||||
|
||||
</CloseableMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, IconButton, ListItemDecorator, MenuItem } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
|
||||
import type { AttachmentId } from './store-attachments';
|
||||
import type { LLMAttachments } from './useLLMAttachments';
|
||||
import { AttachmentItem } from './AttachmentItem';
|
||||
import { AttachmentMenu } from './AttachmentMenu';
|
||||
|
||||
|
||||
/**
|
||||
* Renderer of attachments, with menus, etc.
|
||||
*/
|
||||
export function Attachments(props: {
|
||||
llmAttachments: LLMAttachments,
|
||||
onAttachmentInlineText: (attachmentId: AttachmentId) => void,
|
||||
onAttachmentsClear: () => void,
|
||||
onAttachmentsInlineText: () => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [confirmClearAttachments, setConfirmClearAttachments] = React.useState<boolean>(false);
|
||||
const [itemMenu, setItemMenu] = React.useState<{ anchor: HTMLAnchorElement, attachmentId: AttachmentId } | null>(null);
|
||||
const [overallMenuAnchor, setOverallMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
|
||||
|
||||
// derived state
|
||||
const { llmAttachments, onAttachmentsClear, onAttachmentInlineText, onAttachmentsInlineText } = props;
|
||||
|
||||
const { attachments, isOutputTextInlineable } = llmAttachments;
|
||||
|
||||
const hasAttachments = attachments.length >= 1;
|
||||
|
||||
// derived item menu state
|
||||
|
||||
const itemMenuAnchor = itemMenu?.anchor;
|
||||
const itemMenuAttachmentId = itemMenu?.attachmentId;
|
||||
const itemMenuAttachment = itemMenuAttachmentId ? attachments.find(la => la.attachment.id === itemMenu.attachmentId) : undefined;
|
||||
const itemMenuIndex = itemMenuAttachment ? attachments.indexOf(itemMenuAttachment) : -1;
|
||||
|
||||
|
||||
// item menu
|
||||
|
||||
const handleItemMenuToggle = React.useCallback((attachmentId: AttachmentId, anchor: HTMLAnchorElement) => {
|
||||
handleOverallMenuHide();
|
||||
setItemMenu(prev => prev?.attachmentId === attachmentId ? null : { anchor, attachmentId });
|
||||
}, []);
|
||||
|
||||
const handleItemMenuHide = React.useCallback(() => {
|
||||
setItemMenu(null);
|
||||
}, []);
|
||||
|
||||
|
||||
// item menu operations
|
||||
|
||||
const handleAttachmentInlineText = React.useCallback((attachmentId: string) => {
|
||||
handleItemMenuHide();
|
||||
onAttachmentInlineText(attachmentId);
|
||||
}, [handleItemMenuHide, onAttachmentInlineText]);
|
||||
|
||||
|
||||
// menu
|
||||
|
||||
const handleOverallMenuHide = () => setOverallMenuAnchor(null);
|
||||
|
||||
const handleOverallMenuToggle = (event: React.MouseEvent<HTMLAnchorElement>) =>
|
||||
setOverallMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
|
||||
|
||||
// overall operations
|
||||
|
||||
const handleAttachmentsInlineText = React.useCallback(() => {
|
||||
handleOverallMenuHide();
|
||||
onAttachmentsInlineText();
|
||||
}, [onAttachmentsInlineText]);
|
||||
|
||||
const handleClearAttachments = () => setConfirmClearAttachments(true);
|
||||
|
||||
const handleClearAttachmentsConfirmed = React.useCallback(() => {
|
||||
handleOverallMenuHide();
|
||||
setConfirmClearAttachments(false);
|
||||
onAttachmentsClear();
|
||||
}, [onAttachmentsClear]);
|
||||
|
||||
|
||||
// no components without attachments
|
||||
if (!hasAttachments)
|
||||
return null;
|
||||
|
||||
return <>
|
||||
|
||||
{/* Attachments bar */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
|
||||
{/* Horizontally scrollable Attachments */}
|
||||
<Box sx={{ display: 'flex', overflowX: 'auto', gap: 1, height: '100%', pr: 5 }}>
|
||||
{attachments.map((llmAttachment) =>
|
||||
<AttachmentItem
|
||||
key={llmAttachment.attachment.id}
|
||||
llmAttachment={llmAttachment}
|
||||
menuShown={llmAttachment.attachment.id === itemMenuAttachmentId}
|
||||
onItemMenuToggle={handleItemMenuToggle}
|
||||
/>,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Overall Menu button */}
|
||||
<IconButton
|
||||
variant='plain' onClick={handleOverallMenuToggle}
|
||||
sx={{
|
||||
// borderRadius: 'sm',
|
||||
borderRadius: 0,
|
||||
position: 'absolute', right: 0, top: 0,
|
||||
backgroundColor: 'neutral.softDisabledBg',
|
||||
}}
|
||||
>
|
||||
<ExpandLessIcon />
|
||||
</IconButton>
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Attachment Menu */}
|
||||
{!!itemMenuAnchor && !!itemMenuAttachment && (
|
||||
<AttachmentMenu
|
||||
llmAttachment={itemMenuAttachment}
|
||||
menuAnchor={itemMenuAnchor}
|
||||
isPositionFirst={itemMenuIndex === 0}
|
||||
isPositionLast={itemMenuIndex === attachments.length - 1}
|
||||
onAttachmentInlineText={handleAttachmentInlineText}
|
||||
onClose={handleItemMenuHide}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Overall Menu */}
|
||||
{!!overallMenuAnchor && (
|
||||
<CloseableMenu
|
||||
placement='top-start'
|
||||
open anchorEl={overallMenuAnchor} onClose={handleOverallMenuHide}
|
||||
noTopPadding noBottomPadding
|
||||
>
|
||||
<MenuItem onClick={handleAttachmentsInlineText} disabled={!isOutputTextInlineable}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
Inline <span style={{ opacity: 0.5 }}>text attachments</span>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleClearAttachments}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Clear
|
||||
</MenuItem>
|
||||
</CloseableMenu>
|
||||
)}
|
||||
|
||||
{/* 'Clear' Confirmation */}
|
||||
{confirmClearAttachments && (
|
||||
<ConfirmationModal
|
||||
open onClose={() => setConfirmClearAttachments(false)} onPositive={handleClearAttachmentsConfirmed}
|
||||
title='Confirm Removal'
|
||||
positiveActionText='Remove All'
|
||||
confirmationText={`This action will remove all (${attachments.length}) attachments. Do you want to proceed?`}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
|
||||
import { createBase36Uid } from '~/common/util/textUtils';
|
||||
import { htmlTableToMarkdown } from '~/common/util/htmlTableToMarkdown';
|
||||
import { pdfToText } from '~/common/util/pdfUtils';
|
||||
|
||||
import type { Attachment, AttachmentConverter, AttachmentId, AttachmentInput, AttachmentSource } from './store-attachments';
|
||||
import type { ComposerOutputMultiPart } from '../composer.types';
|
||||
|
||||
|
||||
// extensions to treat as plain text
|
||||
const PLAIN_TEXT_EXTENSIONS: string[] = ['.ts', '.tsx'];
|
||||
|
||||
/**
|
||||
* Creates a new Attachment object.
|
||||
*/
|
||||
export function attachmentCreate(source: AttachmentSource, checkDuplicates: AttachmentId[]): Attachment {
|
||||
return {
|
||||
id: createBase36Uid(checkDuplicates),
|
||||
source: source,
|
||||
label: 'Loading...',
|
||||
ref: '',
|
||||
inputLoading: false,
|
||||
inputError: null,
|
||||
input: undefined,
|
||||
converters: [],
|
||||
converterIdx: null,
|
||||
outputsConverting: false,
|
||||
outputs: [],
|
||||
// metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously loads the input for an Attachment object.
|
||||
*
|
||||
* @param {Readonly<AttachmentSource>} source - The source of the attachment.
|
||||
* @param {(changes: Partial<Attachment>) => void} edit - A function to edit the Attachment object.
|
||||
*/
|
||||
export async function attachmentLoadInputAsync(source: Readonly<AttachmentSource>, edit: (changes: Partial<Attachment>) => void) {
|
||||
edit({ inputLoading: true });
|
||||
|
||||
switch (source.media) {
|
||||
|
||||
// Download URL (page, file, ..) and attach as input
|
||||
case 'url':
|
||||
edit({ label: source.refUrl, ref: source.refUrl });
|
||||
try {
|
||||
const page = await callBrowseFetchPage(source.url);
|
||||
if (page.content) {
|
||||
edit({
|
||||
input: {
|
||||
mimeType: 'text/plain',
|
||||
data: page.content,
|
||||
dataSize: page.content.length,
|
||||
},
|
||||
});
|
||||
} else
|
||||
edit({ inputError: 'No content found at this link' });
|
||||
} catch (error: any) {
|
||||
edit({ inputError: `Issue downloading page: ${error?.message || (typeof error === 'string' ? error : JSON.stringify(error))}` });
|
||||
}
|
||||
break;
|
||||
|
||||
// Attach file as input
|
||||
case 'file':
|
||||
edit({ label: source.refPath, ref: source.refPath });
|
||||
|
||||
// fix missing/wrong mimetypes
|
||||
let mimeType = source.fileWithHandle.type;
|
||||
if (!mimeType) {
|
||||
// see note on 'attachAppendDataTransfer'; this is a fallback for drag/drop missing Mimes sometimes
|
||||
console.warn('Assuming the attachment is text/plain. From:', source.origin, ', name:', source.refPath);
|
||||
mimeType = 'text/plain';
|
||||
} else {
|
||||
// possibly fix wrongly assigned mimetypes (from the extension alone)
|
||||
if (!mimeType.startsWith('text/') && PLAIN_TEXT_EXTENSIONS.some(ext => source.refPath.endsWith(ext)))
|
||||
mimeType = 'text/plain';
|
||||
}
|
||||
|
||||
// UX: just a hint of a loading state
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
try {
|
||||
const fileArrayBuffer = await source.fileWithHandle.arrayBuffer();
|
||||
edit({
|
||||
input: {
|
||||
mimeType,
|
||||
data: fileArrayBuffer,
|
||||
dataSize: fileArrayBuffer.byteLength,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
edit({ inputError: `Issue loading file: ${error?.message || (typeof error === 'string' ? error : JSON.stringify(error))}` });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
if (source.textHtml && source.textPlain) {
|
||||
edit({
|
||||
label: 'Rich Text',
|
||||
ref: '',
|
||||
input: {
|
||||
mimeType: 'text/plain',
|
||||
data: source.textPlain,
|
||||
dataSize: source.textPlain!.length,
|
||||
altMimeType: 'text/html',
|
||||
altData: source.textHtml,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const text = source.textHtml || source.textPlain || '';
|
||||
edit({
|
||||
label: 'Text',
|
||||
ref: '',
|
||||
input: {
|
||||
mimeType: 'text/plain',
|
||||
data: text,
|
||||
dataSize: text.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
edit({ inputLoading: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the possible converters for an Attachment object based on its input type.
|
||||
*
|
||||
* @param {AttachmentSource['media']} sourceType - The media type of the attachment source.
|
||||
* @param {Readonly<AttachmentInput>} input - The input of the attachment.
|
||||
* @param {(changes: Partial<Attachment>) => void} edit - A function to edit the Attachment object.
|
||||
*/
|
||||
export function attachmentDefineConverters(sourceType: AttachmentSource['media'], input: Readonly<AttachmentInput>, edit: (changes: Partial<Attachment>) => void) {
|
||||
|
||||
// return all the possible converters for the input
|
||||
const converters: AttachmentConverter[] = [];
|
||||
|
||||
switch (true) {
|
||||
|
||||
// plain text types
|
||||
case ['text/plain', 'text/html', 'text/markdown', 'text/csv', 'application/json'].includes(input.mimeType):
|
||||
// handle a secondary layer of HTML 'text' origins: drop, paste, and clipboard-read
|
||||
const textOriginHtml = sourceType === 'text' && input.altMimeType === 'text/html' && !!input.altData;
|
||||
const isHtmlTable = !!input.altData?.startsWith('<table');
|
||||
|
||||
// p1: Tables
|
||||
if (textOriginHtml && isHtmlTable) {
|
||||
converters.push({
|
||||
id: 'rich-text-table',
|
||||
name: 'Markdown Table',
|
||||
});
|
||||
}
|
||||
|
||||
// p2: Text
|
||||
converters.push({
|
||||
id: 'text',
|
||||
name: 'Text',
|
||||
});
|
||||
|
||||
// p3: Html
|
||||
if (textOriginHtml) {
|
||||
converters.push({
|
||||
id: 'rich-text',
|
||||
name: 'HTML',
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
// PDF
|
||||
case ['application/pdf', 'application/x-pdf', 'application/acrobat'].includes(input.mimeType):
|
||||
converters.push({ id: 'pdf-text', name: `PDF To Text` });
|
||||
converters.push({ id: 'pdf-images', name: `PDF To Images`, disabled: true });
|
||||
break;
|
||||
|
||||
// images
|
||||
case input.mimeType.startsWith('image/'):
|
||||
converters.push({ id: 'image', name: `Image (coming soon)` });
|
||||
converters.push({ id: 'image-ocr', name: 'As Text (OCR)' });
|
||||
break;
|
||||
|
||||
// catch-all
|
||||
default:
|
||||
converters.push({ id: 'unhandled', name: `${input.mimeType}`, unsupported: true });
|
||||
converters.push({ id: 'text', name: 'As Text' });
|
||||
break;
|
||||
}
|
||||
|
||||
edit({ converters });
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the input of an Attachment object based on the selected converter.
|
||||
*
|
||||
* @param {Readonly<Attachment>} attachment - The Attachment object to convert.
|
||||
* @param {number | null} converterIdx - The index of the selected conversion in the Attachment object's converters array.
|
||||
* @param {(changes: Partial<Attachment>) => void} edit - A function to edit the Attachment object.
|
||||
*/
|
||||
export async function attachmentPerformConversion(attachment: Readonly<Attachment>, converterIdx: number | null, edit: (changes: Partial<Attachment>) => void) {
|
||||
|
||||
// set converter index
|
||||
converterIdx = (converterIdx !== null && converterIdx >= 0 && converterIdx < attachment.converters.length) ? converterIdx : null;
|
||||
edit({
|
||||
converterIdx: converterIdx,
|
||||
outputs: [],
|
||||
});
|
||||
|
||||
// get converter
|
||||
const { ref, input } = attachment;
|
||||
const converter = converterIdx !== null ? attachment.converters[converterIdx] : null;
|
||||
if (!converter || !input)
|
||||
return;
|
||||
|
||||
edit({
|
||||
outputsConverting: true,
|
||||
});
|
||||
|
||||
// input datacould be a string or an ArrayBuffer
|
||||
function inputDataToString(data: string | ArrayBuffer | null | undefined): string {
|
||||
if (typeof data === 'string')
|
||||
return data;
|
||||
if (data instanceof ArrayBuffer)
|
||||
return new TextDecoder().decode(data);
|
||||
return '';
|
||||
}
|
||||
|
||||
// apply converter to the input
|
||||
const outputs: ComposerOutputMultiPart = [];
|
||||
switch (converter.id) {
|
||||
|
||||
// text as-is
|
||||
case 'text':
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: inputDataToString(input.data),
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
// html as-is
|
||||
case 'rich-text':
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: input.altData!,
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
// html to markdown table
|
||||
case 'rich-text-table':
|
||||
let mdTable: string;
|
||||
try {
|
||||
mdTable = htmlTableToMarkdown(input.altData!, false);
|
||||
} catch (error) {
|
||||
// fallback to text/plain
|
||||
mdTable = inputDataToString(input.data);
|
||||
}
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: mdTable,
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'pdf-text':
|
||||
if (!(input.data instanceof ArrayBuffer)) {
|
||||
console.log('Expected ArrayBuffer for PDF converter, got:', typeof input.data);
|
||||
break;
|
||||
}
|
||||
// duplicate the ArrayBuffer to avoid mutation
|
||||
const pdfData = new Uint8Array(input.data.slice(0));
|
||||
const pdfText = await pdfToText(pdfData);
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: pdfText,
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'pdf-images':
|
||||
// TODO: extract all pages as individual images
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
// TODO: continue here
|
||||
/*outputs.push({
|
||||
type: 'image-part',
|
||||
base64Url: `data:notImplemented.yet:)`,
|
||||
collapsible: false,
|
||||
});*/
|
||||
break;
|
||||
|
||||
case 'image-ocr':
|
||||
if (!(input.data instanceof ArrayBuffer)) {
|
||||
console.log('Expected ArrayBuffer for Image OCR converter, got:', typeof input.data);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const { recognize } = await import('tesseract.js');
|
||||
const buffer = Buffer.from(input.data);
|
||||
const result = await recognize(buffer, undefined, {
|
||||
errorHandler: e => console.error(e),
|
||||
logger: (message) => {
|
||||
if (message.status === 'recognizing text')
|
||||
console.log('OCR progress:', message.progress);
|
||||
},
|
||||
});
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: result.data.text,
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'unhandled':
|
||||
// force the user to explicitly select 'as text' if they want to proceed
|
||||
break;
|
||||
}
|
||||
|
||||
// update
|
||||
edit({
|
||||
outputsConverting: false,
|
||||
outputs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
|
||||
/// REDUCER
|
||||
|
||||
import { ContentReducer } from '~/modules/aifn/summarize/ContentReducer';
|
||||
|
||||
const [reducerText, setReducerText] = React.useState('');
|
||||
const [reducerTextTokens, setReducerTextTokens] = React.useState(0);
|
||||
|
||||
{reducerText?.length >= 1 &&
|
||||
<ContentReducer
|
||||
initialText={reducerText} initialTokens={reducerTextTokens} tokenLimit={remainingTokens}
|
||||
onReducedText={handleReducedText} onClose={handleReducerClose}
|
||||
/>
|
||||
}
|
||||
const handleReducerClose = () => setReducerText('');
|
||||
|
||||
const handleReducedText = (text: string) => {
|
||||
handleReducerClose();
|
||||
setComposeText(_t => _t + text);
|
||||
};
|
||||
|
||||
const handleAttachFiles = async (files: FileList, overrideFileNames?: string[]): Promise<void> => {
|
||||
|
||||
// see how we fare on budget
|
||||
if (chatLLMId) {
|
||||
const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger');
|
||||
|
||||
// simple trigger for the reduction dialog
|
||||
if (newTextTokens > remainingTokens) {
|
||||
setReducerTextTokens(newTextTokens);
|
||||
setReducerText(newText);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// within the budget, so just append
|
||||
setComposeText(text => expandPromptTemplate(PromptTemplates.Concatenate, { text: newText })(text));
|
||||
|
||||
|
||||
|
||||
*/
|
||||
@@ -0,0 +1,201 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FileWithHandle } from 'browser-fs-access';
|
||||
|
||||
import type { ComposerOutputMultiPart } from '../composer.types';
|
||||
import { attachmentPerformConversion, attachmentCreate, attachmentDefineConverters, attachmentLoadInputAsync } from './pipeline';
|
||||
|
||||
|
||||
// Attachment Types
|
||||
|
||||
export type AttachmentSourceOriginDTO = 'drop' | 'paste';
|
||||
export type AttachmentSourceOriginFile = 'camera' | 'file-open' | 'clipboard-read' | AttachmentSourceOriginDTO;
|
||||
|
||||
export type AttachmentSource = {
|
||||
media: 'url';
|
||||
url: string;
|
||||
refUrl: string;
|
||||
} | {
|
||||
media: 'file';
|
||||
origin: AttachmentSourceOriginFile,
|
||||
fileWithHandle: FileWithHandle;
|
||||
refPath: string;
|
||||
} | {
|
||||
media: 'text';
|
||||
method: 'clipboard-read' | AttachmentSourceOriginDTO;
|
||||
textPlain?: string;
|
||||
textHtml?: string;
|
||||
};
|
||||
|
||||
|
||||
export type AttachmentInput = {
|
||||
mimeType: string; // Original MIME type of the file
|
||||
data: string | ArrayBuffer; // The original data of the attachment
|
||||
dataSize: number; // Size of the original data in bytes
|
||||
altMimeType?: string; // Alternative MIME type for the input
|
||||
altData?: string; // Alternative data for the input
|
||||
// preview?: AttachmentPreview; // Preview of the input
|
||||
};
|
||||
|
||||
|
||||
export type AttachmentConverterType =
|
||||
| 'text' | 'rich-text' | 'rich-text-table'
|
||||
| 'pdf-text' | 'pdf-images'
|
||||
| 'image' | 'image-ocr'
|
||||
| 'unhandled';
|
||||
|
||||
export type AttachmentConverter = {
|
||||
id: AttachmentConverterType;
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
unsupported?: boolean;
|
||||
// outputType: ComposerOutputPartType; // The type of the output after conversion
|
||||
// isAutonomous: boolean; // Whether the conversion does not require user input
|
||||
// isAsync: boolean; // Whether the conversion is asynchronous
|
||||
// progress: number; // Conversion progress percentage (0..1)
|
||||
// errorMessage?: string; // Error message if the conversion failed
|
||||
}
|
||||
|
||||
|
||||
export type AttachmentId = string;
|
||||
|
||||
export type Attachment = {
|
||||
readonly id: AttachmentId;
|
||||
readonly source: AttachmentSource,
|
||||
label: string;
|
||||
ref: string;
|
||||
|
||||
inputLoading: boolean;
|
||||
inputError: string | null;
|
||||
input?: AttachmentInput;
|
||||
|
||||
// options to convert the input
|
||||
converters: AttachmentConverter[]; // List of available converters for this attachment
|
||||
converterIdx: number | null; // Index of the selected converter
|
||||
|
||||
outputsConverting: boolean;
|
||||
outputs: ComposerOutputMultiPart; // undefined: not yet converted, []: conversion failed, [ {}+ ]: conversion succeeded
|
||||
|
||||
// metadata: {
|
||||
// size?: number; // Size of the attachment in bytes
|
||||
// creationDate?: Date; // Creation date of the file
|
||||
// modifiedDate?: Date; // Last modified date of the file
|
||||
// altText?: string; // Alternative text for images for screen readers
|
||||
// };
|
||||
};
|
||||
|
||||
|
||||
/*export type AttachmentPreview = {
|
||||
renderer: 'noPreview',
|
||||
title: string; // A title for the preview
|
||||
} | {
|
||||
renderer: 'textPreview'
|
||||
fileName: string; // The name of the file
|
||||
snippet: string; // A text snippet for documents
|
||||
tooltip?: string; // A tooltip for the preview
|
||||
} | {
|
||||
renderer: 'imagePreview'
|
||||
thumbnail: string; // A thumbnail preview for images, videos, etc.
|
||||
tooltip?: string; // A tooltip for the preview
|
||||
};*/
|
||||
|
||||
|
||||
/// Store
|
||||
|
||||
interface AttachmentsStore {
|
||||
|
||||
attachments: Attachment[];
|
||||
|
||||
createAttachment: (source: AttachmentSource) => Promise<void>;
|
||||
clearAttachments: () => void;
|
||||
removeAttachment: (attachmentId: AttachmentId) => void;
|
||||
moveAttachment: (attachmentId: AttachmentId, delta: 1 | -1) => void;
|
||||
setConverterIdx: (attachmentId: AttachmentId, converterIdx: number | null) => Promise<void>;
|
||||
|
||||
_editAttachment: (attachmentId: AttachmentId, update: Partial<Attachment> | ((attachment: Attachment) => Partial<Attachment>)) => void;
|
||||
_getAttachment: (attachmentId: AttachmentId) => Attachment | undefined;
|
||||
|
||||
}
|
||||
|
||||
export const useAttachmentsStore = create<AttachmentsStore>()(
|
||||
(_set, _get) => ({
|
||||
|
||||
attachments: [],
|
||||
|
||||
createAttachment: async (source: AttachmentSource) => {
|
||||
const { attachments, _getAttachment, _editAttachment, setConverterIdx } = _get();
|
||||
|
||||
const attachment = attachmentCreate(source, attachments.map(a => a.id));
|
||||
|
||||
_set({
|
||||
attachments: [...attachments, attachment],
|
||||
});
|
||||
|
||||
const editFn = (changes: Partial<Attachment>) => _editAttachment(attachment.id, changes);
|
||||
|
||||
// 1.Resolve the Input
|
||||
await attachmentLoadInputAsync(source, editFn);
|
||||
const loaded = _getAttachment(attachment.id);
|
||||
if (!loaded || !loaded.input)
|
||||
return;
|
||||
|
||||
// 2. Define the I->O Converters
|
||||
attachmentDefineConverters(source.media, loaded.input, editFn);
|
||||
const defined = _getAttachment(attachment.id);
|
||||
if (!defined || !defined.converters.length || defined.converterIdx !== null)
|
||||
return;
|
||||
|
||||
// 3. Select the first Converter
|
||||
const firstEnabledIndex = defined.converters.findIndex(_c => !_c.disabled);
|
||||
await setConverterIdx(attachment.id, firstEnabledIndex > -1 ? firstEnabledIndex : 0);
|
||||
},
|
||||
|
||||
clearAttachments: () => _set({
|
||||
attachments: [],
|
||||
}),
|
||||
|
||||
removeAttachment: (attachmentId: AttachmentId) =>
|
||||
_set(state => ({
|
||||
attachments: state.attachments.filter(attachment => attachment.id !== attachmentId),
|
||||
})),
|
||||
|
||||
moveAttachment: (attachmentId: AttachmentId, delta: 1 | -1) =>
|
||||
_set(state => {
|
||||
const attachments = [...state.attachments];
|
||||
const currentIdx = attachments.findIndex(a => a.id === attachmentId);
|
||||
|
||||
// If the attachment is not found, or if trying to move beyond the array boundaries, no move is needed
|
||||
if (currentIdx === -1 || (currentIdx === 0 && delta === -1) || (currentIdx === attachments.length - 1 && delta === 1))
|
||||
return state;
|
||||
|
||||
// Swap the attachment with the adjacent one in the direction of delta
|
||||
const targetIdx = currentIdx + delta;
|
||||
[attachments[currentIdx], attachments[targetIdx]] = [attachments[targetIdx], attachments[currentIdx]];
|
||||
|
||||
return { attachments };
|
||||
}),
|
||||
|
||||
setConverterIdx: async (attachmentId: AttachmentId, converterIdx: number | null) => {
|
||||
const { _getAttachment, _editAttachment } = _get();
|
||||
const attachment = _getAttachment(attachmentId);
|
||||
if (!attachment || attachment.converterIdx === converterIdx)
|
||||
return;
|
||||
|
||||
const editFn = (changes: Partial<Attachment>) => _editAttachment(attachmentId, changes);
|
||||
|
||||
await attachmentPerformConversion(attachment, converterIdx, editFn);
|
||||
},
|
||||
|
||||
_editAttachment: (attachmentId: AttachmentId, update: Partial<Attachment> | ((attachment: Attachment) => Partial<Attachment>)) =>
|
||||
_set(state => ({
|
||||
attachments: state.attachments.map((attachment: Attachment): Attachment =>
|
||||
attachment.id === attachmentId
|
||||
? { ...attachment, ...(typeof update === 'function' ? update(attachment) : update) }
|
||||
: attachment,
|
||||
),
|
||||
})),
|
||||
|
||||
_getAttachment: (attachmentId: AttachmentId) =>
|
||||
_get().attachments.find(a => a.id === attachmentId),
|
||||
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,165 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import type { FileWithHandle } from 'browser-fs-access';
|
||||
|
||||
import { addSnackbar } from '~/common/components/useSnackbarsStore';
|
||||
import { asValidURL } from '~/common/util/urlUtils';
|
||||
import { extractFilePathsWithCommonRadix } from '~/common/util/dropTextUtils';
|
||||
import { getClipboardItems } from '~/common/util/clipboardUtils';
|
||||
|
||||
import { AttachmentSourceOriginDTO, AttachmentSourceOriginFile, useAttachmentsStore } from './store-attachments';
|
||||
|
||||
|
||||
export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
|
||||
// state
|
||||
|
||||
const { attachments, clearAttachments, createAttachment, removeAttachment } = useAttachmentsStore(state => ({
|
||||
attachments: state.attachments,
|
||||
clearAttachments: state.clearAttachments,
|
||||
createAttachment: state.createAttachment,
|
||||
removeAttachment: state.removeAttachment,
|
||||
}), shallow);
|
||||
|
||||
|
||||
// Creation helpers
|
||||
|
||||
const attachAppendFile = React.useCallback((origin: AttachmentSourceOriginFile, fileWithHandle: FileWithHandle, overrideFileName?: string) =>
|
||||
createAttachment({
|
||||
media: 'file', origin, fileWithHandle, refPath: overrideFileName || fileWithHandle.name,
|
||||
})
|
||||
, [createAttachment]);
|
||||
|
||||
|
||||
const attachAppendDataTransfer = React.useCallback((dt: DataTransfer, method: AttachmentSourceOriginDTO, attachText: boolean): 'as_files' | 'as_url' | 'as_text' | false => {
|
||||
|
||||
// attach File(s)
|
||||
if (dt.files.length >= 1) {
|
||||
// rename files from a common prefix, to better relate them (if the transfer contains a list of paths)
|
||||
let overrideFileNames: string[] = [];
|
||||
if (dt.types.includes('text/plain')) {
|
||||
const plainText = dt.getData('text/plain');
|
||||
overrideFileNames = extractFilePathsWithCommonRadix(plainText);
|
||||
}
|
||||
const overrideNames = overrideFileNames.length === dt.files.length;
|
||||
|
||||
// attach as Files (paste and drop keep the original filename)
|
||||
for (let i = 0; i < dt.files.length; i++) {
|
||||
const file = dt.files[i];
|
||||
// drag/drop of folders (or .tsx from IntelliJ) will have no type
|
||||
if (!file.type) {
|
||||
// NOTE: we are fixing it in attachmentLoadInputAsync, but would be better to do it here
|
||||
}
|
||||
void attachAppendFile(method, file, overrideNames ? overrideFileNames[i] || undefined : undefined);
|
||||
}
|
||||
return 'as_files';
|
||||
}
|
||||
|
||||
// attach as URL
|
||||
const textPlain = dt.getData('text/plain') || '';
|
||||
if (textPlain && enableLoadURLs) {
|
||||
const textPlainUrl = asValidURL(textPlain);
|
||||
if (textPlainUrl && textPlainUrl) {
|
||||
void createAttachment({
|
||||
media: 'url', url: textPlainUrl, refUrl: textPlain,
|
||||
});
|
||||
return 'as_url';
|
||||
}
|
||||
}
|
||||
|
||||
// attach as Text/Html (further conversion, e.g. to markdown is done later)
|
||||
const textHtml = dt.getData('text/html') || '';
|
||||
if (attachText && (textHtml || textPlain)) {
|
||||
void createAttachment({
|
||||
media: 'text', method, textPlain, textHtml,
|
||||
});
|
||||
return 'as_text';
|
||||
}
|
||||
|
||||
if (attachText)
|
||||
console.warn(`Unhandled '${method}' attachment: `, dt.types?.map(t => `${t}: ${dt.getData(t)}`));
|
||||
|
||||
// did not attach anything from this data transfer
|
||||
return false;
|
||||
}, [attachAppendFile, createAttachment, enableLoadURLs]);
|
||||
|
||||
|
||||
const attachAppendClipboardItems = React.useCallback(async () => {
|
||||
|
||||
// if there's an issue accessing the clipboard, show it passively
|
||||
const clipboardItems = await getClipboardItems();
|
||||
if (clipboardItems === null) {
|
||||
addSnackbar({
|
||||
key: 'clipboard-issue',
|
||||
type: 'issue',
|
||||
message: 'Clipboard empty or access denied',
|
||||
overrides: {
|
||||
autoHideDuration: 2000,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// loop on all the possible attachments
|
||||
for (const clipboardItem of clipboardItems) {
|
||||
|
||||
// attach as image
|
||||
let imageAttached = false;
|
||||
for (const mimeType of clipboardItem.types) {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
try {
|
||||
const imageBlob = await clipboardItem.getType(mimeType);
|
||||
const imageName = mimeType.replace('image/', 'clipboard.').replaceAll('/', '.') || 'clipboard.png';
|
||||
const imageFile = new File([imageBlob], imageName, { type: mimeType });
|
||||
void attachAppendFile('clipboard-read', imageFile);
|
||||
imageAttached = true;
|
||||
} catch (error) {
|
||||
// ignore getType error..
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imageAttached)
|
||||
continue;
|
||||
|
||||
// get the Plain text
|
||||
const textPlain = clipboardItem.types.includes('text/plain') ? await clipboardItem.getType('text/plain').then(blob => blob.text()) : '';
|
||||
|
||||
// attach as URL
|
||||
if (textPlain && enableLoadURLs) {
|
||||
const textPlainUrl = asValidURL(textPlain);
|
||||
if (textPlainUrl && textPlainUrl.trim()) {
|
||||
void createAttachment({
|
||||
media: 'url', url: textPlainUrl.trim(), refUrl: textPlain,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// attach as Text
|
||||
const textHtml = clipboardItem.types.includes('text/html') ? await clipboardItem.getType('text/html').then(blob => blob.text()) : '';
|
||||
if (textHtml || textPlain) {
|
||||
void createAttachment({
|
||||
media: 'text', method: 'clipboard-read', textPlain, textHtml,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
console.warn('Clipboard item has no text/html or text/plain item.', clipboardItem.types, clipboardItem);
|
||||
}
|
||||
}, [attachAppendFile, createAttachment, enableLoadURLs]);
|
||||
|
||||
|
||||
return {
|
||||
// state
|
||||
attachments,
|
||||
|
||||
// create attachments
|
||||
attachAppendClipboardItems,
|
||||
attachAppendDataTransfer,
|
||||
attachAppendFile,
|
||||
|
||||
// manage attachments
|
||||
clearAttachments,
|
||||
removeAttachment,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DLLMId } from '~/modules/llms/store-llms';
|
||||
|
||||
import { countModelTokens } from '~/common/util/token-counter';
|
||||
|
||||
import type { Attachment, AttachmentId } from './store-attachments';
|
||||
import type { ComposerOutputMultiPart, ComposerOutputPartType } from '../composer.types';
|
||||
|
||||
|
||||
export interface LLMAttachments {
|
||||
attachments: LLMAttachment[];
|
||||
getAttachmentOutputs: (initialTextBlockText: string | null, attachmentId: AttachmentId) => ComposerOutputMultiPart;
|
||||
getAttachmentsOutputs: (initialTextBlockText: string | null) => ComposerOutputMultiPart;
|
||||
isOutputAttacheable: boolean;
|
||||
isOutputTextInlineable: boolean;
|
||||
tokenCountApprox: number;
|
||||
}
|
||||
|
||||
export interface LLMAttachment {
|
||||
attachment: Attachment;
|
||||
attachmentOutputs: ComposerOutputMultiPart;
|
||||
isUnconvertible: boolean;
|
||||
isOutputMissing: boolean;
|
||||
isOutputAttachable: boolean;
|
||||
isOutputTextInlineable: boolean;
|
||||
tokenCountApprox: number | null;
|
||||
}
|
||||
|
||||
|
||||
export function useLLMAttachments(attachments: Attachment[], chatLLMId: DLLMId | null): LLMAttachments {
|
||||
return React.useMemo(() => {
|
||||
|
||||
// HACK: in the future, switch to LLM capabilities (LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, etc.)
|
||||
const supportsImages = !!chatLLMId?.endsWith('-vision-preview');
|
||||
const supportedOutputPartTypes: ComposerOutputPartType[] = supportsImages ? ['text-block', 'image-part'] : ['text-block'];
|
||||
|
||||
const llmAttachments = attachments.map(attachment => toLLMAttachment(attachment, supportedOutputPartTypes, chatLLMId));
|
||||
|
||||
const getAttachmentOutputs = (initialTextBlockText: string | null, attachmentId: AttachmentId): ComposerOutputMultiPart => {
|
||||
// get outputs of a specific attachment
|
||||
const outputs = attachments.find(a => a.id === attachmentId)?.outputs || [];
|
||||
return attachmentCollapseOutputs(initialTextBlockText, outputs);
|
||||
};
|
||||
|
||||
const getAttachmentsOutputs = (initialTextBlockText: string | null): ComposerOutputMultiPart => {
|
||||
// accumulate all outputs of all attachments
|
||||
const allOutputs = llmAttachments.reduce((acc, a) => acc.concat(a.attachment.outputs), [] as ComposerOutputMultiPart);
|
||||
return attachmentCollapseOutputs(initialTextBlockText, allOutputs);
|
||||
};
|
||||
|
||||
return {
|
||||
attachments: llmAttachments,
|
||||
getAttachmentOutputs,
|
||||
getAttachmentsOutputs,
|
||||
isOutputAttacheable: llmAttachments.every(a => a.isOutputAttachable),
|
||||
isOutputTextInlineable: llmAttachments.every(a => a.isOutputTextInlineable),
|
||||
tokenCountApprox: llmAttachments.reduce((acc, a) => acc + (a.tokenCountApprox || 0), 0),
|
||||
};
|
||||
}, [attachments, chatLLMId]);
|
||||
}
|
||||
|
||||
export function getTextBlockText(outputs: ComposerOutputMultiPart): string | null {
|
||||
const textOutputs = outputs.filter(part => part.type === 'text-block');
|
||||
return (textOutputs.length === 1 && textOutputs[0].type === 'text-block') ? textOutputs[0].text : null;
|
||||
}
|
||||
|
||||
|
||||
function toLLMAttachment(attachment: Attachment, supportedOutputPartTypes: ComposerOutputPartType[], llmForTokenCount: DLLMId | null): LLMAttachment {
|
||||
const { converters, outputs } = attachment;
|
||||
|
||||
const isUnconvertible = converters.length === 0;
|
||||
const isOutputMissing = outputs.length === 0;
|
||||
const isOutputAttachable = areAllOutputsSupported(outputs, supportedOutputPartTypes);
|
||||
const isOutputTextInlineable = areAllOutputsSupported(outputs, supportedOutputPartTypes.filter(pt => pt === 'text-block'));
|
||||
|
||||
const attachmentOutputs = attachmentCollapseOutputs(null, outputs);
|
||||
const tokenCountApprox = llmForTokenCount
|
||||
? attachmentOutputs.reduce((acc, output) => {
|
||||
if (output.type === 'text-block')
|
||||
return acc + countModelTokens(output.text, llmForTokenCount, 'attachments tokens count');
|
||||
console.warn('Unhandled token preview for output type:', output.type);
|
||||
return acc;
|
||||
}, 0)
|
||||
: null;
|
||||
|
||||
return {
|
||||
attachment,
|
||||
attachmentOutputs,
|
||||
isUnconvertible,
|
||||
isOutputMissing,
|
||||
isOutputAttachable,
|
||||
isOutputTextInlineable,
|
||||
tokenCountApprox,
|
||||
};
|
||||
}
|
||||
|
||||
function areAllOutputsSupported(outputs: ComposerOutputMultiPart, supportedOutputPartTypes: ComposerOutputPartType[]) {
|
||||
return outputs.length
|
||||
? outputs.every(output => supportedOutputPartTypes.includes(output.type))
|
||||
: false;
|
||||
}
|
||||
|
||||
function attachmentCollapseOutputs(initialTextBlockText: string | null, outputs: ComposerOutputMultiPart): ComposerOutputMultiPart {
|
||||
const accumulatedOutputs: ComposerOutputMultiPart = [];
|
||||
|
||||
// if there's initial text, make it a collapsible default (unquited) text block
|
||||
if (initialTextBlockText !== null) {
|
||||
accumulatedOutputs.push({
|
||||
type: 'text-block',
|
||||
text: initialTextBlockText,
|
||||
title: null,
|
||||
collapsible: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Accumulate attachment outputs of the same type and 'collapsible' into a single object of that type.
|
||||
for (const output of outputs) {
|
||||
const last = accumulatedOutputs[accumulatedOutputs.length - 1];
|
||||
|
||||
// accumulationg over an existing part of the same type
|
||||
if (last && last.type === output.type && output.collapsible) {
|
||||
switch (last.type) {
|
||||
case 'text-block':
|
||||
last.text += `\n\n\`\`\`${output.title}\n${output.text}\n\`\`\``;
|
||||
break;
|
||||
default:
|
||||
console.warn('Unhandled collapsing for output type:', output.type);
|
||||
}
|
||||
}
|
||||
// start a new part
|
||||
else {
|
||||
if (output.type === 'text-block') {
|
||||
accumulatedOutputs.push({
|
||||
type: 'text-block',
|
||||
text: `\n\n\`\`\`${output.title}\n${output.text}\n\`\`\``,
|
||||
title: null,
|
||||
collapsible: false,
|
||||
});
|
||||
} else {
|
||||
accumulatedOutputs.push(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulatedOutputs;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type ComposerOutputPartType = 'text-block' | 'image-part';
|
||||
|
||||
export type ComposerOutputPart = {
|
||||
type: 'text-block',
|
||||
text: string,
|
||||
title: string | null,
|
||||
collapsible: boolean,
|
||||
} | {
|
||||
// TODO: not implemented yet
|
||||
type: 'image-part',
|
||||
base64Url: string,
|
||||
collapsible: false,
|
||||
};
|
||||
|
||||
export type ComposerOutputMultiPart = ComposerOutputPart[];
|
||||
@@ -1,40 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
|
||||
export type ChatModeId = 'immediate' | 'immediate-follow-up' | 'write-user' | 'react' | 'draw-imagine' | 'draw-imagine-plus';
|
||||
|
||||
/// Describe the chat modes
|
||||
export const ChatModeItems: { [key in ChatModeId]: { label: string; description: string | React.JSX.Element; experimental?: boolean } } = {
|
||||
'immediate': {
|
||||
label: 'Chat',
|
||||
description: 'Persona answers',
|
||||
},
|
||||
'immediate-follow-up': {
|
||||
label: 'Chat Plus',
|
||||
description: 'Augmented chat (diagrams)',
|
||||
},
|
||||
'write-user': {
|
||||
label: 'Write',
|
||||
description: 'Just append a message',
|
||||
},
|
||||
'react': {
|
||||
label: 'Reason+Act',
|
||||
description: 'Answer your questions with ReAct and search',
|
||||
},
|
||||
'draw-imagine': {
|
||||
label: 'Draw',
|
||||
description: 'AI Image Generation',
|
||||
},
|
||||
'draw-imagine-plus': {
|
||||
label: 'Assisted Draw',
|
||||
description: 'Assisted Image Generation',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/// Composer Store
|
||||
|
||||
interface ComposerStore {
|
||||
@@ -54,20 +22,11 @@ const useComposerStore = create<ComposerStore>()(
|
||||
{
|
||||
name: 'app-composer',
|
||||
version: 1,
|
||||
/*migrate: (state: any, version): ComposerStore => {
|
||||
// 0 -> 1: rename history to sentMessages
|
||||
if (state && version === 0) {
|
||||
state.sentMessages = state.history;
|
||||
delete state.history;
|
||||
}
|
||||
return state as ComposerStore;
|
||||
},*/
|
||||
}),
|
||||
);
|
||||
|
||||
export const setComposerStartupText = (text: string | null) =>
|
||||
useComposerStore.getState().setStartupText(text);
|
||||
|
||||
export const useComposerStartupText = (): [string | null, (text: string | null) => void] =>
|
||||
useComposerStore(state => [state.startupText, state.setStartupText], shallow);
|
||||
|
||||
export const setComposerStartupText = (text: string | null) =>
|
||||
useComposerStore.getState().setStartupText(text);
|
||||
useComposerStore(state => [state.startupText, state.setStartupText], shallow);
|
||||
@@ -3,13 +3,15 @@ import TimeAgo from 'react-timeago';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { cleanupEfficiency, Diff as TextDiff, makeDiff } from '@sanity/diff-match-patch';
|
||||
|
||||
import { Avatar, Box, Button, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Stack, Tooltip, Typography, useTheme } from '@mui/joy';
|
||||
import { Avatar, Box, Button, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Stack, Switch, Tooltip, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DifferenceIcon from '@mui/icons-material/Difference';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import Face6Icon from '@mui/icons-material/Face6';
|
||||
import FastForwardIcon from '@mui/icons-material/FastForward';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import PaletteOutlinedIcon from '@mui/icons-material/PaletteOutlined';
|
||||
@@ -17,9 +19,8 @@ import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
|
||||
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
||||
|
||||
import { canUseElevenLabs, speakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
import { canUseProdia } from '~/modules/prodia/prodia.client';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { DMessage } from '~/common/state/store-chats';
|
||||
@@ -28,11 +29,13 @@ import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
import { copyToClipboard } from '~/common/util/copyToClipboard';
|
||||
import { cssRainbowColorKeyframes, hideOnMobile } from '~/common/theme';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { cssRainbowColorKeyframes } from '~/common/app.theme';
|
||||
import { prettyBaseModel } from '~/common/util/modelUtils';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { useChatShowTextDiff } from '../../store-app-chat';
|
||||
|
||||
import { RenderCode } from './RenderCode';
|
||||
import { RenderHtml } from './RenderHtml';
|
||||
import { RenderImage } from './RenderImage';
|
||||
@@ -43,8 +46,14 @@ import { RenderTextDiff } from './RenderTextDiff';
|
||||
import { parseBlocks } from './blocks';
|
||||
|
||||
|
||||
// How long is the user collapsed message
|
||||
const USER_COLLAPSED_LINES: number = 8;
|
||||
|
||||
// Enable the automatic menu on text selection
|
||||
const ENABLE_SELECTION_RIGHT_CLICK_MENU: boolean = true;
|
||||
|
||||
// Enable the hover button to copy the whole message. The Copy button is also available in Blocks, or in the Avatar Menu.
|
||||
const ENABLE_COPY_MESSAGE: boolean = false;
|
||||
const ENABLE_COPY_MESSAGE_OVERLAY: boolean = false;
|
||||
|
||||
|
||||
export function messageBackground(messageRole: DMessage['role'] | string, wasEdited: boolean, unknownAssistantIssue: boolean): string {
|
||||
@@ -162,6 +171,24 @@ function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: str
|
||||
return { errorMessage, isAssistantError };
|
||||
}
|
||||
|
||||
function useSanityTextDiffs(text: string, diffText: string | undefined, enabled: boolean) {
|
||||
const [diffs, setDiffs] = React.useState<TextDiff[] | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!diffText || !enabled)
|
||||
return setDiffs(null);
|
||||
setDiffs(
|
||||
cleanupEfficiency(makeDiff(diffText, text, {
|
||||
timeout: 1,
|
||||
checkLines: true,
|
||||
}), 4),
|
||||
);
|
||||
}, [text, diffText, enabled]);
|
||||
return diffs;
|
||||
}
|
||||
|
||||
|
||||
export const ChatMessageMemo = React.memo(ChatMessage);
|
||||
|
||||
/**
|
||||
* The Message component is a customizable chat message UI component that supports
|
||||
* different roles (user, assistant, and system), text editing, syntax highlighting,
|
||||
@@ -170,8 +197,44 @@ function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: str
|
||||
* or collapsing long user messages.
|
||||
*
|
||||
*/
|
||||
export function ChatMessage(props: { message: DMessage, diffText?: string, showDate?: boolean, isBottom?: boolean, noBottomBorder?: boolean, onMessageDelete?: () => void, onMessageEdit: (text: string) => void, onMessageRunFrom?: (offset: number) => void, onImagine?: (messageText: string) => Promise<void> }) {
|
||||
export function ChatMessage(props: {
|
||||
message: DMessage,
|
||||
showDate?: boolean, diffPreviousText?: string,
|
||||
hideAvatars?: boolean, codeBackground?: string,
|
||||
noMarkdown?: boolean, diagramMode?: boolean,
|
||||
isBottom?: boolean, noBottomBorder?: boolean,
|
||||
isImagining?: boolean, isSpeaking?: boolean,
|
||||
onConversationBranch?: (messageId: string) => void,
|
||||
onConversationRestartFrom?: (messageId: string, offset: number) => void,
|
||||
onConversationTruncate?: (messageId: string) => void,
|
||||
onMessageDelete?: (messageId: string) => void,
|
||||
onMessageEdit?: (messageId: string, text: string) => void,
|
||||
onTextDiagram?: (messageId: string, text: string) => Promise<void>
|
||||
onTextImagine?: (text: string) => Promise<void>
|
||||
onTextSpeak?: (text: string) => Promise<void>
|
||||
sx?: SxProps,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [forceUserExpanded, setForceUserExpanded] = React.useState(false);
|
||||
const [isHovering, setIsHovering] = React.useState(false);
|
||||
const [opsMenuAnchor, setOpsMenuAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [selMenuAnchor, setSelMenuAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [selMenuText, setSelMenuText] = React.useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const { cleanerLooks, renderMarkdown, doubleClickToEdit } = useUIPreferencesStore(state => ({
|
||||
cleanerLooks: state.zenMode === 'cleaner',
|
||||
renderMarkdown: state.renderMarkdown,
|
||||
doubleClickToEdit: state.doubleClickToEdit,
|
||||
}), shallow);
|
||||
const [showDiff, setShowDiff] = useChatShowTextDiff();
|
||||
const textDiffs = useSanityTextDiffs(props.message.text, props.diffPreviousText, showDiff);
|
||||
|
||||
// derived state
|
||||
const {
|
||||
id: messageId,
|
||||
text: messageText,
|
||||
sender: messageSender,
|
||||
avatar: messageAvatar,
|
||||
@@ -182,99 +245,148 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
|
||||
created: messageCreated,
|
||||
updated: messageUpdated,
|
||||
} = props.message;
|
||||
|
||||
const fromAssistant = messageRole === 'assistant';
|
||||
const fromSystem = messageRole === 'system';
|
||||
const fromUser = messageRole === 'user';
|
||||
const wasEdited = !!messageUpdated;
|
||||
|
||||
// state
|
||||
const [diffs, setDiffs] = React.useState<TextDiff[] | null>(null);
|
||||
const [forceExpanded, setForceExpanded] = React.useState(false);
|
||||
const [isHovering, setIsHovering] = React.useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [isImagining, setIsImagining] = React.useState(false);
|
||||
const [isSpeaking, setIsSpeaking] = React.useState(false);
|
||||
const showAvatars = props.hideAvatars !== true && !cleanerLooks;
|
||||
|
||||
// external state
|
||||
const theme = useTheme();
|
||||
const { showAvatars, renderMarkdown: _renderMarkdown, doubleClickToEdit } = useUIPreferencesStore(state => ({
|
||||
showAvatars: state.zenMode !== 'cleaner',
|
||||
renderMarkdown: state.renderMarkdown,
|
||||
doubleClickToEdit: state.doubleClickToEdit,
|
||||
}), shallow);
|
||||
const renderMarkdown = _renderMarkdown && !fromSystem;
|
||||
const isImaginable = canUseProdia() && !!props.onImagine;
|
||||
const isImaginableEnabled = messageText?.length >= 2 && !messageText.startsWith('https://images.prodia.xyz/') && !(messageText.startsWith('/imagine') || messageText.startsWith('/img'));
|
||||
const isSpeakable = canUseElevenLabs();
|
||||
const isSpeakableEnabled = isImaginableEnabled;
|
||||
const textSel = selMenuText ? selMenuText : messageText;
|
||||
const isSpecialProdia = textSel.startsWith('https://images.prodia.xyz/') || textSel.startsWith('/imagine') || textSel.startsWith('/img');
|
||||
const couldDiagram = textSel?.length >= 100 && !isSpecialProdia;
|
||||
const couldImagine = textSel?.length >= 2 && !isSpecialProdia;
|
||||
const couldSpeak = couldImagine;
|
||||
|
||||
|
||||
// Effect: text diffing vs the former message
|
||||
React.useEffect(() => {
|
||||
if (!props.diffText)
|
||||
return setDiffs(null);
|
||||
setDiffs(
|
||||
cleanupEfficiency(makeDiff(props.diffText, messageText, {
|
||||
timeout: 1,
|
||||
checkLines: true,
|
||||
}), 4),
|
||||
);
|
||||
}, [messageText, props.diffText]);
|
||||
|
||||
|
||||
const closeOperationsMenu = () => setMenuAnchor(null);
|
||||
|
||||
const handleMenuCopy = (e: React.MouseEvent) => {
|
||||
copyToClipboard(messageText);
|
||||
e.preventDefault();
|
||||
closeOperationsMenu();
|
||||
const handleTextEdited = (editedText: string) => {
|
||||
setIsEditing(false);
|
||||
if (props.onMessageEdit && editedText?.trim() && editedText !== messageText)
|
||||
props.onMessageEdit(messageId, editedText);
|
||||
};
|
||||
|
||||
const handleMenuEdit = (e: React.MouseEvent) => {
|
||||
const handleUncollapse = () => setForceUserExpanded(true);
|
||||
|
||||
|
||||
// Operations Menu
|
||||
|
||||
const closeOperationsMenu = () => setOpsMenuAnchor(null);
|
||||
|
||||
const handleOpsCopy = (e: React.MouseEvent) => {
|
||||
copyToClipboard(textSel, 'Text');
|
||||
e.preventDefault();
|
||||
closeOperationsMenu();
|
||||
closeSelectionMenu();
|
||||
};
|
||||
|
||||
const handleOpsEdit = (e: React.MouseEvent) => {
|
||||
if (messageTyping && !isEditing) return; // don't allow editing while typing
|
||||
setIsEditing(!isEditing);
|
||||
e.preventDefault();
|
||||
closeOperationsMenu();
|
||||
};
|
||||
|
||||
|
||||
const handleMenuImagine = async (e: React.MouseEvent) => {
|
||||
const handleOpsConversationBranch = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (props.onImagine) {
|
||||
setIsImagining(true);
|
||||
await props.onImagine(messageText);
|
||||
setIsImagining(false);
|
||||
closeOperationsMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuSpeak = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSpeaking(true);
|
||||
await speakText(messageText);
|
||||
setIsSpeaking(false);
|
||||
props.onConversationBranch && props.onConversationBranch(messageId);
|
||||
closeOperationsMenu();
|
||||
};
|
||||
|
||||
const handleMenuRunAgain = (e: React.MouseEvent) => {
|
||||
const handleOpsConversationRestartFrom = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (props.onMessageRunFrom) {
|
||||
props.onMessageRunFrom(fromAssistant ? -1 : 0);
|
||||
props.onConversationRestartFrom && props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
|
||||
closeOperationsMenu();
|
||||
};
|
||||
|
||||
const handleOpsToggleShowDiff = () => setShowDiff(!showDiff);
|
||||
|
||||
const handleOpsDiagram = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (props.onTextDiagram) {
|
||||
await props.onTextDiagram(messageId, textSel);
|
||||
closeOperationsMenu();
|
||||
closeSelectionMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextEdited = (editedText: string) => {
|
||||
setIsEditing(false);
|
||||
if (editedText?.trim() && editedText !== messageText)
|
||||
props.onMessageEdit(editedText);
|
||||
const handleOpsImagine = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (props.onTextImagine) {
|
||||
await props.onTextImagine(textSel);
|
||||
closeOperationsMenu();
|
||||
closeSelectionMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpand = () => setForceExpanded(true);
|
||||
const handleOpsSpeak = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (props.onTextSpeak) {
|
||||
await props.onTextSpeak(textSel);
|
||||
closeOperationsMenu();
|
||||
closeSelectionMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpsTruncate = (_e: React.MouseEvent) => {
|
||||
props.onConversationTruncate && props.onConversationTruncate(messageId);
|
||||
closeOperationsMenu();
|
||||
};
|
||||
|
||||
const handleOpsDelete = (_e: React.MouseEvent) => {
|
||||
props.onMessageDelete && props.onMessageDelete(messageId);
|
||||
};
|
||||
|
||||
|
||||
// soft error handling
|
||||
// Selection Menu
|
||||
|
||||
const removeSelectionAnchor = React.useCallback(() => {
|
||||
if (selMenuAnchor) {
|
||||
try {
|
||||
document.body.removeChild(selMenuAnchor);
|
||||
} catch (e) {
|
||||
// ignore...
|
||||
}
|
||||
}
|
||||
}, [selMenuAnchor]);
|
||||
|
||||
const openSelectionMenu = React.useCallback((event: MouseEvent, selectedText: string) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
// remove any stray anchor
|
||||
removeSelectionAnchor();
|
||||
|
||||
// create a temporary fixed anchor element to position the menu
|
||||
const anchorEl = document.createElement('div');
|
||||
anchorEl.style.position = 'fixed';
|
||||
anchorEl.style.left = `${event.clientX}px`;
|
||||
anchorEl.style.top = `${event.clientY}px`;
|
||||
document.body.appendChild(anchorEl);
|
||||
|
||||
setSelMenuAnchor(anchorEl);
|
||||
setSelMenuText(selectedText);
|
||||
}, [removeSelectionAnchor]);
|
||||
|
||||
const closeSelectionMenu = React.useCallback(() => {
|
||||
// window.getSelection()?.removeAllRanges?.();
|
||||
removeSelectionAnchor();
|
||||
setSelMenuAnchor(null);
|
||||
setSelMenuText(null);
|
||||
}, [removeSelectionAnchor]);
|
||||
|
||||
const handleMouseUp = React.useCallback((event: MouseEvent) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const selectedText = range.toString().trim();
|
||||
if (selectedText.length > 0)
|
||||
openSelectionMenu(event, selectedText);
|
||||
}
|
||||
}, [openSelectionMenu]);
|
||||
|
||||
|
||||
// prettier upstream errors
|
||||
const { isAssistantError, errorMessage } = React.useMemo(
|
||||
() => explainErrorInMessage(messageText, fromAssistant, messageOriginLLM),
|
||||
[messageText, fromAssistant, messageOriginLLM],
|
||||
@@ -295,9 +407,9 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
|
||||
};
|
||||
const codeSx: SxProps = {
|
||||
// backgroundColor: fromAssistant ? 'background.level1' : 'background.level1',
|
||||
backgroundColor: fromAssistant ? 'neutral.plainHoverBg' : 'primary.plainActiveBg',
|
||||
backgroundColor: props.codeBackground ? props.codeBackground : fromAssistant ? 'neutral.plainHoverBg' : 'primary.plainActiveBg',
|
||||
boxShadow: 'xs',
|
||||
fontFamily: theme.fontFamily.code,
|
||||
fontFamily: 'code',
|
||||
fontSize: '14px',
|
||||
fontVariantLigatures: 'none',
|
||||
lineHeight: 1.75,
|
||||
@@ -307,10 +419,10 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
|
||||
// user message truncation
|
||||
let collapsedText = messageText;
|
||||
let isCollapsed = false;
|
||||
if (fromUser && !forceExpanded) {
|
||||
if (fromUser && !forceUserExpanded) {
|
||||
const lines = messageText.split('\n');
|
||||
if (lines.length > 10) {
|
||||
collapsedText = lines.slice(0, 10).join('\n');
|
||||
if (lines.length > USER_COLLAPSED_LINES) {
|
||||
collapsedText = lines.slice(0, USER_COLLAPSED_LINES).join('\n');
|
||||
isCollapsed = true;
|
||||
}
|
||||
}
|
||||
@@ -318,8 +430,6 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
// [alpha] Right-click menu: still in early development
|
||||
// onContextMenu={event => setMenuAnchor(event.currentTarget)}
|
||||
sx={{
|
||||
display: 'flex', flexDirection: !fromAssistant ? 'row-reverse' : 'row', alignItems: 'flex-start',
|
||||
gap: { xs: 0, md: 1 }, px: { xs: 1, md: 2 }, py: 2,
|
||||
@@ -328,9 +438,10 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
|
||||
borderBottom: '1px solid',
|
||||
borderBottomColor: 'divider',
|
||||
}),
|
||||
...(ENABLE_COPY_MESSAGE && { position: 'relative' }),
|
||||
...(ENABLE_COPY_MESSAGE_OVERLAY && { position: 'relative' }),
|
||||
...(props.isBottom === true && { mb: 'auto' }),
|
||||
'&:hover > button': { opacity: 1 },
|
||||
...props.sx,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -338,7 +449,7 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
|
||||
{showAvatars && <Stack
|
||||
sx={{ alignItems: 'center', minWidth: { xs: 50, md: 64 }, maxWidth: 80, textAlign: 'center' }}
|
||||
onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)}
|
||||
onClick={event => setMenuAnchor(event.currentTarget)}>
|
||||
onClick={event => setOpsMenuAnchor(event.currentTarget)}>
|
||||
|
||||
{isHovering ? (
|
||||
<IconButton variant='soft' color={fromAssistant ? 'neutral' : 'primary'}>
|
||||
@@ -365,14 +476,21 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
|
||||
|
||||
|
||||
{/* Edit / Blocks */}
|
||||
{!isEditing ? (
|
||||
{isEditing
|
||||
|
||||
<Box
|
||||
onDoubleClick={(e) => doubleClickToEdit ? handleMenuEdit(e) : null}
|
||||
? <InlineTextarea initialText={messageText} onEdit={handleTextEdited} sx={{ ...blockSx, lineHeight: 1.75, flexGrow: 1 }} />
|
||||
|
||||
: <Box
|
||||
onContextMenu={(ENABLE_SELECTION_RIGHT_CLICK_MENU && props.onMessageEdit) ? event => handleMouseUp(event.nativeEvent) : undefined}
|
||||
onDoubleClick={event => (doubleClickToEdit && props.onMessageEdit) ? handleOpsEdit(event) : null}
|
||||
sx={{
|
||||
...blockSx,
|
||||
flexGrow: 0,
|
||||
overflowX: 'auto',
|
||||
...(!!props.diagramMode && {
|
||||
// width: '100%',
|
||||
boxShadow: 'md',
|
||||
}),
|
||||
}}>
|
||||
|
||||
{props.showDate === true && (
|
||||
@@ -386,30 +504,33 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
|
||||
<Typography level='body-sm' color='warning' sx={{ mt: 1, mx: 1.5 }}>modified by user - auto-update disabled</Typography>
|
||||
)}
|
||||
|
||||
{!errorMessage && parseBlocks(collapsedText, fromSystem, diffs).map((block, index) =>
|
||||
block.type === 'html'
|
||||
? <RenderHtml key={'html-' + index} htmlBlock={block} sx={codeSx} />
|
||||
: block.type === 'code'
|
||||
? <RenderCode key={'code-' + index} codeBlock={block} sx={codeSx} />
|
||||
: block.type === 'image'
|
||||
? <RenderImage key={'image-' + index} imageBlock={block} allowRunAgain={props.isBottom === true} onRunAgain={handleMenuRunAgain} />
|
||||
: block.type === 'latex'
|
||||
? <RenderLatex key={'latex-' + index} latexBlock={block} />
|
||||
: block.type === 'diff'
|
||||
? <RenderTextDiff key={'latex-' + index} diffBlock={block} />
|
||||
: renderMarkdown
|
||||
? <RenderMarkdown key={'text-md-' + index} textBlock={block} />
|
||||
: <RenderText key={'text-' + index} textBlock={block} />,
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Tooltip title={<Typography sx={{ maxWidth: 800 }}>{collapsedText}</Typography>} variant='soft'>
|
||||
<InlineError error={errorMessage} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* sequence of render components, for each Block */}
|
||||
{!errorMessage && parseBlocks(collapsedText, fromSystem, textDiffs)
|
||||
.filter((block, _, blocks) => !props.diagramMode || block.type === 'code' || blocks.length === 1)
|
||||
.map(
|
||||
(block, index) =>
|
||||
block.type === 'html'
|
||||
? <RenderHtml key={'html-' + index} htmlBlock={block} sx={codeSx} />
|
||||
: block.type === 'code'
|
||||
? <RenderCode key={'code-' + index} codeBlock={block} sx={codeSx} noCopyButton={props.diagramMode} />
|
||||
: block.type === 'image'
|
||||
? <RenderImage key={'image-' + index} imageBlock={block} allowRunAgain={props.isBottom === true} onRunAgain={handleOpsConversationRestartFrom} />
|
||||
: block.type === 'latex'
|
||||
? <RenderLatex key={'latex-' + index} latexBlock={block} />
|
||||
: block.type === 'diff'
|
||||
? <RenderTextDiff key={'latex-' + index} diffBlock={block} />
|
||||
: (renderMarkdown && props.noMarkdown !== true && !fromSystem && !(fromUser && block.content.startsWith('/')))
|
||||
? <RenderMarkdown key={'text-md-' + index} textBlock={block} />
|
||||
: <RenderText key={'text-' + index} textBlock={block} />)}
|
||||
|
||||
{isCollapsed && (
|
||||
<Button variant='plain' color='neutral' onClick={handleExpand}>... expand ...</Button>
|
||||
<Button variant='plain' color='neutral' onClick={handleUncollapse}>... expand ...</Button>
|
||||
)}
|
||||
|
||||
{/* import VisibilityIcon from '@mui/icons-material/Visibility'; */}
|
||||
@@ -419,19 +540,14 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
|
||||
{/*</Chip>*/}
|
||||
|
||||
</Box>
|
||||
|
||||
) : (
|
||||
|
||||
<InlineTextarea initialText={messageText} onEdit={handleTextEdited} sx={{ ...blockSx, lineHeight: 1.75, flexGrow: 1 }} />
|
||||
|
||||
)}
|
||||
}
|
||||
|
||||
|
||||
{/* Copy message */}
|
||||
{ENABLE_COPY_MESSAGE && !fromSystem && !isEditing && (
|
||||
{/* Overlay copy icon */}
|
||||
{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditing && (
|
||||
<Tooltip title={fromAssistant ? 'Copy message' : 'Copy input'} variant='solid'>
|
||||
<IconButton
|
||||
variant='outlined' color='neutral' onClick={handleMenuCopy}
|
||||
variant='outlined' color='neutral' onClick={handleOpsCopy}
|
||||
sx={{
|
||||
position: 'absolute', ...(fromAssistant ? { right: { xs: 12, md: 28 } } : { left: { xs: 12, md: 28 } }), zIndex: 10,
|
||||
opacity: 0, transition: 'opacity 0.3s',
|
||||
@@ -442,60 +558,110 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
|
||||
)}
|
||||
|
||||
|
||||
{/* Message Operations menu */}
|
||||
{!!menuAnchor && (
|
||||
{/* Operations Menu (3 dots) */}
|
||||
{!!opsMenuAnchor && (
|
||||
<CloseableMenu
|
||||
placement='bottom-end' sx={{ minWidth: 280 }}
|
||||
open anchorEl={menuAnchor} onClose={closeOperationsMenu}
|
||||
dense placement='bottom-end' sx={{ minWidth: 280 }}
|
||||
open anchorEl={opsMenuAnchor} onClose={closeOperationsMenu}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<MenuItem variant='plain' disabled={messageTyping} onClick={handleMenuEdit} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator><EditIcon /></ListItemDecorator>
|
||||
{isEditing ? 'Discard' : 'Edit'}
|
||||
{/*{!isEditing && <span style={{ opacity: 0.5, marginLeft: '8px' }}>{doubleClickToEdit ? '(double-click)' : ''}</span>}*/}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleMenuCopy} sx={{ flex: 1 }}>
|
||||
{!!props.onMessageEdit && (
|
||||
<MenuItem variant='plain' disabled={messageTyping} onClick={handleOpsEdit} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator><EditIcon /></ListItemDecorator>
|
||||
{isEditing ? 'Discard' : 'Edit'}
|
||||
{/*{!isEditing && <span style={{ opacity: 0.5, marginLeft: '8px' }}>{doubleClickToEdit ? '(double-click)' : ''}</span>}*/}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
Copy
|
||||
</MenuItem>
|
||||
</Box>
|
||||
{!!props.diffPreviousText && <ListDivider />}
|
||||
{!!props.diffPreviousText && (
|
||||
<MenuItem onClick={handleOpsToggleShowDiff}>
|
||||
<ListItemDecorator><DifferenceIcon /></ListItemDecorator>
|
||||
Show difference
|
||||
<Switch checked={showDiff} onChange={handleOpsToggleShowDiff} sx={{ ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
)}
|
||||
<ListDivider />
|
||||
{!!props.onMessageRunFrom && (
|
||||
<MenuItem onClick={handleMenuRunAgain}>
|
||||
<ListItemDecorator>{fromAssistant ? <ReplayIcon /> : <FastForwardIcon />}</ListItemDecorator>
|
||||
{!!props.onConversationRestartFrom && (
|
||||
<MenuItem onClick={handleOpsConversationRestartFrom}>
|
||||
<ListItemDecorator>{fromAssistant ? <ReplayIcon /> : <TelegramIcon />}</ListItemDecorator>
|
||||
{!fromAssistant
|
||||
? 'Run from here'
|
||||
? <>Restart <span style={{ opacity: 0.5 }}>from here</span></>
|
||||
: !props.isBottom
|
||||
? 'Retry from here'
|
||||
? <>Retry <span style={{ opacity: 0.5 }}>from here</span></>
|
||||
: <Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
Retry
|
||||
<KeyStroke light combo='Ctrl + Shift + R' sx={hideOnMobile} />
|
||||
<KeyStroke combo='Ctrl + Shift + R' />
|
||||
</Box>
|
||||
}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isImaginable && isImaginableEnabled && (
|
||||
<MenuItem onClick={handleMenuImagine} disabled={!isImaginableEnabled || isImagining}>
|
||||
<ListItemDecorator>{isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
|
||||
Imagine
|
||||
{!!props.onConversationBranch && (
|
||||
<MenuItem onClick={handleOpsConversationBranch} disabled={fromSystem}>
|
||||
<ListItemDecorator>
|
||||
<ForkRightIcon />
|
||||
</ListItemDecorator>
|
||||
Branch {!props.isBottom && <span style={{ opacity: 0.5 }}>from here</span>}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isSpeakable && isSpeakableEnabled && (
|
||||
<MenuItem onClick={handleMenuSpeak} disabled={isSpeaking}>
|
||||
<ListItemDecorator>{isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
|
||||
Speak
|
||||
{!!props.onConversationBranch && <ListDivider />}
|
||||
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
|
||||
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
|
||||
Visualize ...
|
||||
</MenuItem>}
|
||||
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
|
||||
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
|
||||
Imagine
|
||||
</MenuItem>}
|
||||
{!!props.onTextSpeak && <MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
|
||||
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
|
||||
Speak
|
||||
</MenuItem>}
|
||||
{!!props.onConversationRestartFrom && <ListDivider />}
|
||||
{!!props.onConversationTruncate && (
|
||||
<MenuItem onClick={handleOpsTruncate} disabled={props.isBottom}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
Truncate <span style={{ opacity: 0.5 }}>after</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{!!props.onMessageRunFrom && <ListDivider />}
|
||||
{!!props.onMessageDelete && (
|
||||
<MenuItem onClick={props.onMessageDelete} disabled={false /*fromSystem*/}>
|
||||
<MenuItem onClick={handleOpsDelete} disabled={false /*fromSystem*/}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Delete
|
||||
Delete <span style={{ opacity: 0.5 }}>message</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</CloseableMenu>
|
||||
)}
|
||||
|
||||
{/* Selection (Contextual) Menu */}
|
||||
{!!selMenuAnchor && (
|
||||
<CloseableMenu
|
||||
dense placement='bottom-start' sx={{ minWidth: 220 }}
|
||||
open anchorEl={selMenuAnchor} onClose={closeSelectionMenu}
|
||||
>
|
||||
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
Copy <span style={{ opacity: 0.5 }}>selection</span>
|
||||
</MenuItem>
|
||||
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram || props.isImagining}>
|
||||
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
|
||||
Visualize ...
|
||||
</MenuItem>}
|
||||
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
|
||||
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
|
||||
Imagine
|
||||
</MenuItem>}
|
||||
{!!props.onTextSpeak && <MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
|
||||
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
|
||||
Speak
|
||||
</MenuItem>}
|
||||
</CloseableMenu>
|
||||
)}
|
||||
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
|
||||
import { DMessage } from '~/common/state/store-chats';
|
||||
|
||||
import { TokenBadge } from '../composer/TokenBadge';
|
||||
import { TokenBadgeMemo } from '../composer/TokenBadge';
|
||||
import { makeAvatar, messageBackground } from './ChatMessage';
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export function CleanerMessage(props: { message: DMessage, isBottom: boolean, se
|
||||
<Checkbox size='md' checked={props.selected} onChange={handleCheckedChange} />
|
||||
</Box>}
|
||||
|
||||
<Box sx={{ display: 'flex', minWidth: { xs: 40, sm: 48 }, justifyContent: 'center' }}>
|
||||
<Box sx={{ display: { xs: 'none', sm: 'flex' }, minWidth: { xs: 40, sm: 48 }, justifyContent: 'center' }}>
|
||||
{avatarEl}
|
||||
</Box>
|
||||
|
||||
@@ -94,10 +94,18 @@ export function CleanerMessage(props: { message: DMessage, isBottom: boolean, se
|
||||
</Typography>
|
||||
|
||||
{props.remainingTokens !== undefined && <Box sx={{ display: 'flex', minWidth: { xs: 32, sm: 45 }, justifyContent: 'flex-end' }}>
|
||||
<TokenBadge directTokens={messageTokenCount} tokenLimit={props.remainingTokens} inline />
|
||||
<TokenBadgeMemo direct={messageTokenCount} limit={props.remainingTokens} inline />
|
||||
</Box>}
|
||||
|
||||
<Typography sx={{ flexGrow: 1, textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap' }}>
|
||||
<Typography level='body-md' sx={{
|
||||
flexGrow: 1,
|
||||
textOverflow: 'ellipsis', overflow: 'hidden',
|
||||
// whiteSpace: 'nowrap',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
maxHeight: '2.9em',
|
||||
}}>
|
||||
{messageText}
|
||||
</Typography>
|
||||
|
||||
|
||||
@@ -8,66 +8,39 @@ import HtmlIcon from '@mui/icons-material/Html';
|
||||
import SchemaIcon from '@mui/icons-material/Schema';
|
||||
import ShapeLineOutlinedIcon from '@mui/icons-material/ShapeLineOutlined';
|
||||
|
||||
import { copyToClipboard } from '~/common/util/copyToClipboard';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
|
||||
import { CodeBlock } from './blocks';
|
||||
import { OpenInCodepen } from './OpenInCodepen';
|
||||
import { OpenInReplit } from './OpenInReplit';
|
||||
import { RenderCodeMermaid } from './RenderCodeMermaid';
|
||||
import { heuristicIsHtml, IFrameComponent } from './RenderHtml';
|
||||
|
||||
|
||||
export const overlayButtonsSx: SxProps = {
|
||||
position: 'absolute', top: 0, right: 0, zIndex: 10,
|
||||
display: 'flex', flexDirection: 'row', gap: 1,
|
||||
opacity: 0, transition: 'opacity 0.2s',
|
||||
'& > button': { backdropFilter: 'blur(12px)' },
|
||||
};
|
||||
|
||||
function RenderCodeImpl(props: {
|
||||
codeBlock: CodeBlock, sx?: SxProps,
|
||||
codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps,
|
||||
highlightCode: (inferredCodeLanguage: string | null, blockCode: string) => string,
|
||||
inferCodeLanguage: (blockTitle: string, code: string) => string | null,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [showHTML, setShowHTML] = React.useState(false);
|
||||
const [showSVG, setShowSVG] = React.useState(true);
|
||||
const [showMermaid, setShowMermaid] = React.useState(true);
|
||||
const [showPlantUML, setShowPlantUML] = React.useState(true);
|
||||
const [showSVG, setShowSVG] = React.useState(true);
|
||||
|
||||
// derived props
|
||||
const { codeBlock: { blockTitle, blockCode }, highlightCode, inferCodeLanguage } = props;
|
||||
|
||||
const isHTML = heuristicIsHtml(blockCode);
|
||||
const renderHTML = isHTML && showHTML;
|
||||
|
||||
const isSVG = blockCode.startsWith('<svg') && blockCode.endsWith('</svg>');
|
||||
const renderSVG = isSVG && showSVG;
|
||||
|
||||
const isPlantUML =
|
||||
(blockCode.startsWith('@startuml') && blockCode.endsWith('@enduml'))
|
||||
|| (blockCode.startsWith('@startmindmap') && blockCode.endsWith('@endmindmap'))
|
||||
|| (blockCode.startsWith('@startsalt') && blockCode.endsWith('@endsalt'))
|
||||
|| (blockCode.startsWith('@startwbs') && blockCode.endsWith('@endwbs'))
|
||||
|| (blockCode.startsWith('@startgantt') && blockCode.endsWith('@endgantt'));
|
||||
|
||||
let renderPlantUML = isPlantUML && showPlantUML;
|
||||
const { data: plantUmlHtmlData } = useQuery({
|
||||
enabled: renderPlantUML,
|
||||
queryKey: ['plantuml', blockCode],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Dynamically import the PlantUML encoder - it's a large library that slows down app loading
|
||||
const { encode: plantUmlEncode } = await import('plantuml-encoder');
|
||||
|
||||
// retrieve and manually adapt the SVG, to remove the background
|
||||
const encodedPlantUML: string = plantUmlEncode(blockCode);
|
||||
const response = await fetch(`https://www.plantuml.com/plantuml/svg/${encodedPlantUML}`);
|
||||
const svg = await response.text();
|
||||
const start = svg.indexOf('<svg ');
|
||||
const end = svg.indexOf('</svg>');
|
||||
if (start < 0 || end <= start)
|
||||
return null;
|
||||
return svg.slice(start, end + 6).replace('background:#FFFFFF;', '');
|
||||
} catch (e) {
|
||||
// ignore errors, and disable the component in that case
|
||||
return null;
|
||||
}
|
||||
},
|
||||
staleTime: 24 * 60 * 60 * 1000, // 1 day
|
||||
});
|
||||
renderPlantUML = renderPlantUML && !!plantUmlHtmlData;
|
||||
const {
|
||||
codeBlock: { blockTitle, blockCode, complete: blockComplete },
|
||||
highlightCode, inferCodeLanguage,
|
||||
} = props;
|
||||
|
||||
// heuristic for language, and syntax highlight
|
||||
const { highlightedCode, inferredCodeLanguage } = React.useMemo(
|
||||
@@ -78,6 +51,62 @@ function RenderCodeImpl(props: {
|
||||
}, [inferCodeLanguage, blockTitle, blockCode, highlightCode]);
|
||||
|
||||
|
||||
// heuristics for specialized rendering
|
||||
|
||||
const isHTML = heuristicIsHtml(blockCode);
|
||||
const renderHTML = isHTML && showHTML;
|
||||
|
||||
const isMermaid = blockTitle === 'mermaid' && blockComplete;
|
||||
const renderMermaid = isMermaid && showMermaid;
|
||||
|
||||
const isPlantUML =
|
||||
(blockCode.startsWith('@startuml') && blockCode.endsWith('@enduml'))
|
||||
|| (blockCode.startsWith('@startmindmap') && blockCode.endsWith('@endmindmap'))
|
||||
|| (blockCode.startsWith('@startsalt') && blockCode.endsWith('@endsalt'))
|
||||
|| (blockCode.startsWith('@startwbs') && blockCode.endsWith('@endwbs'))
|
||||
|| (blockCode.startsWith('@startgantt') && blockCode.endsWith('@endgantt'));
|
||||
|
||||
let renderPlantUML = isPlantUML && showPlantUML;
|
||||
const { data: plantUmlHtmlData, error: plantUmlError } = useQuery({
|
||||
enabled: renderPlantUML,
|
||||
queryKey: ['plantuml', blockCode],
|
||||
queryFn: async () => {
|
||||
// fetch the PlantUML SVG
|
||||
let text: string = '';
|
||||
try {
|
||||
// Dynamically import the PlantUML encoder - it's a large library that slows down app loading
|
||||
const { encode: plantUmlEncode } = await import('plantuml-encoder');
|
||||
|
||||
// retrieve and manually adapt the SVG, to remove the background
|
||||
const encodedPlantUML: string = plantUmlEncode(blockCode);
|
||||
const response = await fetch(`https://www.plantuml.com/plantuml/svg/${encodedPlantUML}`);
|
||||
text = await response.text();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
// validate/extract the SVG
|
||||
const start = text.indexOf('<svg ');
|
||||
const end = text.indexOf('</svg>');
|
||||
if (start < 0 || end <= start)
|
||||
throw new Error('Could not render PlantUML');
|
||||
const svg = text
|
||||
.slice(start, end + 6) // <svg ... </svg>
|
||||
.replace('background:#FFFFFF;', ''); // transparent background
|
||||
|
||||
// check for syntax errors
|
||||
if (svg.includes('>Syntax Error?</text>'))
|
||||
throw new Error('syntax issue (it happens!). Please regenerate or change generator model.');
|
||||
|
||||
return svg;
|
||||
},
|
||||
staleTime: 24 * 60 * 60 * 1000, // 1 day
|
||||
});
|
||||
renderPlantUML = renderPlantUML && (!!plantUmlHtmlData || !!plantUmlError);
|
||||
|
||||
const isSVG = blockCode.startsWith('<svg') && blockCode.endsWith('</svg>');
|
||||
const renderSVG = isSVG && showSVG;
|
||||
|
||||
|
||||
const languagesCodepen = ['html', 'css', 'javascript', 'json', 'typescript'];
|
||||
const canCodepen = isSVG || (!!inferredCodeLanguage && languagesCodepen.includes(inferredCodeLanguage));
|
||||
|
||||
@@ -86,84 +115,93 @@ function RenderCodeImpl(props: {
|
||||
|
||||
const handleCopyToClipboard = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(blockCode);
|
||||
copyToClipboard(blockCode, 'Code');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component='code'
|
||||
className={`language-${inferredCodeLanguage || 'unknown'}`}
|
||||
sx={{
|
||||
position: 'relative', mx: 0, p: 1.5, // this block gets a thicker border
|
||||
display: 'block', fontWeight: 500,
|
||||
whiteSpace: 'pre', // was 'break-spaces' before we implmented per-block scrolling
|
||||
overflowX: 'auto',
|
||||
'&:hover > .code-buttons': { opacity: 1 },
|
||||
...(props.sx || {}),
|
||||
}}>
|
||||
|
||||
{/* Overlay Buttons */}
|
||||
<Box sx={{ position: 'relative' /* for overlay buttons to stick properly */ }}>
|
||||
<Box
|
||||
className='code-buttons'
|
||||
component='code'
|
||||
className={`language-${inferredCodeLanguage || 'unknown'}`}
|
||||
sx={{
|
||||
backdropFilter: 'blur(8px)', // '... grayscale(0.8)
|
||||
position: 'absolute', top: 0, right: 0, zIndex: 10, p: 0.5,
|
||||
display: 'flex', flexDirection: 'row', gap: 1,
|
||||
opacity: 0, transition: 'opacity 0.3s',
|
||||
// '& > button': { backdropFilter: 'blur(6px)' },
|
||||
fontWeight: 500, whiteSpace: 'pre', // was 'break-spaces' before we implemented per-block scrolling
|
||||
mx: 0, p: 1.5, // this block gets a thicker border
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
'&:hover > .overlay-buttons': { opacity: 1 },
|
||||
...(props.sx || {}),
|
||||
}}>
|
||||
{isSVG && (
|
||||
<Tooltip title={renderSVG ? 'Show Code' : 'Render SVG'} variant='solid'>
|
||||
<IconButton variant={renderSVG ? 'solid' : 'soft'} color='neutral' onClick={() => setShowSVG(!showSVG)}>
|
||||
<ShapeLineOutlinedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{/* Markdown Title (File/Type) */}
|
||||
{blockTitle != inferredCodeLanguage && blockTitle.includes('.') && (
|
||||
<Sheet sx={{ boxShadow: 'sm', borderRadius: 'sm', mb: 1 }}>
|
||||
<Typography level='title-sm' sx={{ px: 1, py: 0.5 }}>
|
||||
{blockTitle}
|
||||
{/*{inferredCodeLanguage}*/}
|
||||
</Typography>
|
||||
</Sheet>
|
||||
)}
|
||||
{isHTML && (
|
||||
<Tooltip title={renderHTML ? 'Hide' : 'Show Web Page'} variant='solid'>
|
||||
<IconButton variant={renderHTML ? 'solid' : 'soft'} color='danger' onClick={() => setShowHTML(!showHTML)}>
|
||||
<HtmlIcon />
|
||||
|
||||
{/* Renders HTML, or inline SVG, inline plantUML rendered, or highlighted code */}
|
||||
{renderHTML
|
||||
? <IFrameComponent htmlString={blockCode} />
|
||||
: renderMermaid
|
||||
? <RenderCodeMermaid mermaidCode={blockCode} />
|
||||
: <Box component='div'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
renderSVG
|
||||
? blockCode
|
||||
: renderPlantUML
|
||||
? (plantUmlHtmlData || (plantUmlError as string) || 'No PlantUML rendering.')
|
||||
: highlightedCode,
|
||||
}}
|
||||
sx={{
|
||||
...(renderSVG ? { lineHeight: 0 } : {}),
|
||||
...(renderPlantUML ? { textAlign: 'center' } : {}),
|
||||
}}
|
||||
/>}
|
||||
|
||||
{/* Code Buttons */}
|
||||
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, p: 0.5 }}>
|
||||
{isHTML && (
|
||||
<Tooltip title={renderHTML ? 'Hide' : 'Show Web Page'} variant='solid'>
|
||||
<IconButton variant={renderHTML ? 'solid' : 'outlined'} color='danger' onClick={() => setShowHTML(!showHTML)}>
|
||||
<HtmlIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isMermaid && (
|
||||
<Tooltip title={renderMermaid ? 'Show Code' : 'Render Mermaid'} variant='solid'>
|
||||
<IconButton variant={renderMermaid ? 'solid' : 'outlined'} color='neutral' onClick={() => setShowMermaid(!showMermaid)}>
|
||||
<SchemaIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isPlantUML && (
|
||||
<Tooltip title={renderPlantUML ? 'Show Code' : 'Render PlantUML'} variant='solid'>
|
||||
<IconButton variant={renderPlantUML ? 'solid' : 'outlined'} color='neutral' onClick={() => setShowPlantUML(!showPlantUML)}>
|
||||
<SchemaIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isSVG && (
|
||||
<Tooltip title={renderSVG ? 'Show Code' : 'Render SVG'} variant='solid'>
|
||||
<IconButton variant={renderSVG ? 'solid' : 'outlined'} color='neutral' onClick={() => setShowSVG(!showSVG)}>
|
||||
<ShapeLineOutlinedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canCodepen && <OpenInCodepen codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
|
||||
{canReplit && <OpenInReplit codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
|
||||
{props.noCopyButton !== true && <Tooltip title='Copy Code' variant='solid'>
|
||||
<IconButton variant='outlined' color='neutral' onClick={handleCopyToClipboard}>
|
||||
<ContentCopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isPlantUML && (
|
||||
<Tooltip title={renderPlantUML ? 'Show Code' : 'Render PlantUML'} variant='solid'>
|
||||
<IconButton variant={renderPlantUML ? 'solid' : 'soft'} color='neutral' onClick={() => setShowPlantUML(!showPlantUML)}>
|
||||
<SchemaIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canCodepen && <OpenInCodepen codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
|
||||
{canReplit && <OpenInReplit codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
|
||||
<Tooltip title='Copy Code' variant='solid'>
|
||||
<IconButton variant='outlined' color='neutral' onClick={handleCopyToClipboard}>
|
||||
<ContentCopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Tooltip>}
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Title (highlighted code) */}
|
||||
{blockTitle != inferredCodeLanguage && blockTitle.includes('.') && <Sheet sx={{ boxShadow: 'sm', borderRadius: 'sm', mb: 1 }}>
|
||||
<Typography level='title-sm' sx={{ px: 1, py: 0.5 }}>
|
||||
{blockTitle}
|
||||
{/*{inferredCodeLanguage}*/}
|
||||
</Typography>
|
||||
</Sheet>}
|
||||
|
||||
{/* Renders HTML, or inline SVG, inline plantUML rendered, or highlighted code */}
|
||||
{renderHTML ? <IFrameComponent htmlString={blockCode} />
|
||||
: <Box
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
renderSVG ? blockCode
|
||||
: (renderPlantUML && plantUmlHtmlData) ? plantUmlHtmlData
|
||||
: highlightedCode,
|
||||
}}
|
||||
sx={{
|
||||
...(renderSVG ? { lineHeight: 0 } : {}),
|
||||
...(renderPlantUML ? { textAlign: 'center' } : {}),
|
||||
}}
|
||||
/>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -175,12 +213,12 @@ const RenderCodeDynamic = React.lazy(async () => {
|
||||
const { highlightCode, inferCodeLanguage } = await import('./codePrism');
|
||||
|
||||
return {
|
||||
default: (props: { codeBlock: CodeBlock, sx?: SxProps }) =>
|
||||
default: (props: { codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps }) =>
|
||||
<RenderCodeImpl highlightCode={highlightCode} inferCodeLanguage={inferCodeLanguage} {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
export const RenderCode = (props: { codeBlock: CodeBlock, sx?: SxProps }) =>
|
||||
export const RenderCode = (props: { codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps }) =>
|
||||
<React.Suspense fallback={<Box component='code' sx={{ p: 1.5, display: 'block', ...(props.sx || {}) }} />}>
|
||||
<RenderCodeDynamic {...props} />
|
||||
</React.Suspense>;
|
||||
@@ -0,0 +1,164 @@
|
||||
import * as React from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import { appTheme } from '~/common/app.theme';
|
||||
import { isBrowser } from '~/common/util/pwaUtils';
|
||||
|
||||
|
||||
/**
|
||||
* We are loading Mermaid from the CDN (and spending all the work to dynamically load it
|
||||
* and strong type it), because the Mermaid dependencies (npm i mermaid) are too heavy
|
||||
* and would slow down development for everyone.
|
||||
*
|
||||
* If you update this file, also make sure the interfaces/type definitions and initialization
|
||||
* options are updated accordingly.
|
||||
*/
|
||||
const MERMAID_CDN_FILE: string = 'https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js';
|
||||
|
||||
|
||||
interface MermaidAPI {
|
||||
initialize: (config: any) => void;
|
||||
render: (id: string, text: string, svgContainingElement?: Element) => Promise<{ svg: string, bindFunctions?: (element: Element) => void }>;
|
||||
}
|
||||
|
||||
// extend the Window interface, to allow for the mermaid API to be found
|
||||
declare global {
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
interface Window {
|
||||
mermaid: MermaidAPI;
|
||||
}
|
||||
}
|
||||
|
||||
interface MermaidAPIStore {
|
||||
mermaidAPI: MermaidAPI | null,
|
||||
loadingError: string | null,
|
||||
}
|
||||
|
||||
const useMermaidStore = create<MermaidAPIStore>()(
|
||||
() => ({
|
||||
mermaidAPI: null,
|
||||
loadingError: null,
|
||||
}),
|
||||
);
|
||||
|
||||
let loadingStarted: boolean = false;
|
||||
let loadingError: string | null = null;
|
||||
|
||||
|
||||
function loadMermaidFromCDN() {
|
||||
if (isBrowser && !loadingStarted) {
|
||||
loadingStarted = true;
|
||||
const script = document.createElement('script');
|
||||
script.src = MERMAID_CDN_FILE;
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
useMermaidStore.setState({
|
||||
mermaidAPI: initializeMermaid(window.mermaid),
|
||||
loadingError: null,
|
||||
});
|
||||
};
|
||||
script.onerror = () => {
|
||||
useMermaidStore.setState({
|
||||
mermaidAPI: null,
|
||||
loadingError: `Script load error for ${script.src}`,
|
||||
});
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeMermaid(mermaidAPI: MermaidAPI): MermaidAPI {
|
||||
mermaidAPI.initialize({
|
||||
startOnLoad: false,
|
||||
|
||||
// gfx options
|
||||
fontFamily: appTheme.fontFamily.code,
|
||||
altFontFamily: appTheme.fontFamily.body,
|
||||
|
||||
// style configuration
|
||||
htmlLabels: true,
|
||||
securityLevel: 'loose',
|
||||
theme: 'forest',
|
||||
|
||||
// per-chart configuration
|
||||
mindmap: { useMaxWidth: false },
|
||||
flowchart: { useMaxWidth: false },
|
||||
sequence: { useMaxWidth: false },
|
||||
timeline: { useMaxWidth: false },
|
||||
class: { useMaxWidth: false },
|
||||
state: { useMaxWidth: false },
|
||||
pie: { useMaxWidth: false },
|
||||
er: { useMaxWidth: false },
|
||||
gantt: { useMaxWidth: false },
|
||||
gitGraph: { useMaxWidth: false },
|
||||
});
|
||||
return mermaidAPI;
|
||||
}
|
||||
|
||||
function useMermaidLoader() {
|
||||
const { mermaidAPI } = useMermaidStore();
|
||||
React.useEffect(() => {
|
||||
if (!mermaidAPI)
|
||||
loadMermaidFromCDN();
|
||||
}, [mermaidAPI]);
|
||||
return { mermaidAPI, isSuccess: !!mermaidAPI, isLoading: loadingStarted, error: loadingError };
|
||||
}
|
||||
|
||||
|
||||
export function RenderCodeMermaid(props: { mermaidCode: string }) {
|
||||
|
||||
// state
|
||||
const [svgCode, setSvgCode] = React.useState<string | null>(null);
|
||||
const hasUnmounted = React.useRef(false);
|
||||
const mermaidContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// external state
|
||||
const { mermaidAPI, error: mermaidError } = useMermaidLoader();
|
||||
|
||||
|
||||
// [effect] re-render on code changes
|
||||
React.useEffect(() => {
|
||||
|
||||
if (!mermaidAPI)
|
||||
return;
|
||||
|
||||
const updateSvgCode = () => {
|
||||
const elementId = `mermaid-${Math.random().toString(36).substring(2, 9)}`;
|
||||
mermaidAPI
|
||||
.render(elementId, props.mermaidCode, mermaidContainerRef.current!)
|
||||
.then(({ svg }) => {
|
||||
if (mermaidContainerRef.current && !hasUnmounted.current) {
|
||||
setSvgCode(svg);
|
||||
// bindFunctions?.(mermaidContainerRef.current);
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
console.warn('The AI-generated Mermaid code is invalid, please try again. Details below:\n >>', error.message),
|
||||
);
|
||||
};
|
||||
|
||||
// strict-mode de-bounce, plus watch for unmounts
|
||||
hasUnmounted.current = false;
|
||||
const timeout = setTimeout(updateSvgCode, 0);
|
||||
return () => {
|
||||
hasUnmounted.current = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [mermaidAPI, props.mermaidCode]);
|
||||
|
||||
|
||||
// render errors when loading Mermaid. for syntax errors, the Error SVG will be rendered in-place
|
||||
if (mermaidError)
|
||||
return <div>Error: {mermaidError}</div>;
|
||||
|
||||
return (
|
||||
<Box
|
||||
component='div'
|
||||
ref={mermaidContainerRef}
|
||||
dangerouslySetInnerHTML={{ __html: svgCode || 'Loading Diagram...' }}
|
||||
/>
|
||||
);
|
||||
|
||||
}
|
||||
@@ -2,9 +2,13 @@ import * as React from 'react';
|
||||
|
||||
import { Box, Button, IconButton, Tooltip, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import WebIcon from '@mui/icons-material/Web';
|
||||
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
|
||||
import { HtmlBlock } from './blocks';
|
||||
import { overlayButtonsSx } from './RenderCode';
|
||||
|
||||
|
||||
// this is used by the blocks parser (for full text detection) and by the Code component (for inline rendering)
|
||||
@@ -53,50 +57,60 @@ export function RenderHtml(props: { htmlBlock: HtmlBlock, sx?: SxProps }) {
|
||||
if (key.startsWith('font'))
|
||||
delete sx[key];
|
||||
|
||||
const handleCopyToClipboard = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(props.htmlBlock.html, 'HTML');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative', mx: 0, p: 1.5, // this block gets a thicker border
|
||||
minWidth: { xs: '300px', md: '750px', lg: '900px', xl: '1100px' },
|
||||
'&:hover > .code-buttons': { opacity: 1 },
|
||||
...sx,
|
||||
}}>
|
||||
|
||||
{/* Buttons */}
|
||||
<Box sx={{ position: 'relative' /* for overlay buttons to stick properly */ }}>
|
||||
<Box
|
||||
className='code-buttons'
|
||||
sx={{
|
||||
position: 'absolute', top: 0, right: 0, zIndex: 10, mr: 7,
|
||||
display: 'flex', flexDirection: 'row', gap: 1,
|
||||
opacity: 0, transition: 'opacity 0.3s',
|
||||
}}>
|
||||
<Tooltip title={showHTML ? 'Hide' : 'Show Web Page'} variant='solid'>
|
||||
<IconButton variant={showHTML ? 'solid' : 'soft'} color='danger' onClick={() => setShowHTML(!showHTML)}>
|
||||
<WebIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
minWidth: { sm: '480px', md: '750px', lg: '950px', xl: '1200px' },
|
||||
mx: 0, p: 1.5, // this block gets a thicker border
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
'&:hover > .overlay-buttons': { opacity: 1 },
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Highlighted Code / SVG render */}
|
||||
{showHTML
|
||||
? <IFrameComponent htmlString={props.htmlBlock.html} />
|
||||
: <Box>
|
||||
<Typography>
|
||||
<b>CAUTION</b> - The content you are about to access is an HTML page. It is possible that an
|
||||
unauthorized entity is monitoring this connection and has generated this content.
|
||||
Please exercise caution and do not trust the contents blindly. Be aware that proceeding
|
||||
may pose potential risks. Click the button to view the content, if you wish to proceed.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 2 }}>
|
||||
<Button variant='plain' color='neutral' onClick={() => setShowHTML(false)}>
|
||||
Ignore
|
||||
</Button>
|
||||
<Button variant='solid' color='danger' onClick={() => setShowHTML(true)}>
|
||||
Show Web Page
|
||||
</Button>
|
||||
{/* Highlighted Code / SVG render */}
|
||||
{showHTML
|
||||
? <IFrameComponent htmlString={props.htmlBlock.html} />
|
||||
: <Box>
|
||||
<Typography>
|
||||
<b>CAUTION</b> - The content you are about to access is an HTML page. It is possible that an
|
||||
unauthorized entity is monitoring this connection and has generated this content.
|
||||
Please exercise caution and do not trust the contents blindly. Be aware that proceeding
|
||||
may pose potential risks. Click the button to view the content, if you wish to proceed.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 2 }}>
|
||||
<Button variant='plain' color='neutral' onClick={() => setShowHTML(false)}>
|
||||
Ignore
|
||||
</Button>
|
||||
<Button variant='solid' color='danger' onClick={() => setShowHTML(true)}>
|
||||
Show Web Page
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
|
||||
{/* External HTML Buttons */}
|
||||
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, p: 1.5 }}>
|
||||
<Tooltip title={showHTML ? 'Hide' : 'Show Web Page'} variant='solid'>
|
||||
<IconButton variant={showHTML ? 'solid' : 'outlined'} color='danger' onClick={() => setShowHTML(!showHTML)}>
|
||||
<WebIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Copy Code' variant='solid'>
|
||||
<IconButton variant='outlined' color='neutral' onClick={handleCopyToClipboard}>
|
||||
<ContentCopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap';
|
||||
import { Link } from '~/common/components/Link';
|
||||
|
||||
import { ImageBlock } from './blocks';
|
||||
import { overlayButtonsSx } from './RenderCode';
|
||||
|
||||
|
||||
export const RenderImage = (props: { imageBlock: ImageBlock, allowRunAgain: boolean, onRunAgain?: (e: React.MouseEvent) => void }) => {
|
||||
@@ -19,22 +20,18 @@ export const RenderImage = (props: { imageBlock: ImageBlock, allowRunAgain: bool
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', position: 'relative',
|
||||
mx: 1.5, mt: index > 0 ? 1.5 : 0,
|
||||
// p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1,
|
||||
minWidth: 32, minHeight: 32, boxShadow: 'md',
|
||||
minWidth: 64, minHeight: 64, boxShadow: 'lg',
|
||||
backgroundColor: 'neutral.solidBg',
|
||||
'& picture': { display: 'flex' },
|
||||
'& img': { maxWidth: '100%', maxHeight: '100%' },
|
||||
'&:hover > .image-buttons': { opacity: 1 },
|
||||
'&:hover > .overlay-buttons': { opacity: 1 },
|
||||
}}>
|
||||
|
||||
{/* External Image */}
|
||||
<picture><img src={url} alt='Generated Image' /></picture>
|
||||
|
||||
{/* Image Buttons */}
|
||||
<Box
|
||||
className='image-buttons'
|
||||
sx={{
|
||||
position: 'absolute', top: 0, right: 0, zIndex: 10, pt: 0.5, px: 0.5,
|
||||
display: 'flex', flexDirection: 'row', gap: 0.5,
|
||||
opacity: 0, transition: 'opacity 0.3s',
|
||||
}}>
|
||||
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, pt: 0.5, px: 0.5, gap: 0.5 }}>
|
||||
{props.allowRunAgain && !!props.onRunAgain && (
|
||||
<Tooltip title='Draw again' variant='solid'>
|
||||
<IconButton variant='solid' color='neutral' onClick={props.onRunAgain}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import { Chip, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import { extractCommands } from '../../commands';
|
||||
import { extractCommands } from '../../editors/commands';
|
||||
|
||||
import { TextBlock } from './blocks';
|
||||
|
||||
|
||||
@@ -7,16 +7,17 @@ import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import 'prismjs/components/prism-mermaid';
|
||||
import 'prismjs/components/prism-plant-uml';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
|
||||
// NOTE: must match Prism components imports
|
||||
const hPrismLanguages = ['bash', 'css', 'java', 'javascript', 'json', 'markdown', 'plant-uml', 'python', 'typescript'];
|
||||
const hPrismLanguages = ['bash', 'css', 'java', 'javascript', 'json', 'markdown', 'mermaid', 'plant-uml', 'python', 'typescript'];
|
||||
|
||||
const hFileExtensionsMap: { [key: string]: string } = {
|
||||
cs: 'csharp', html: 'html', java: 'java', js: 'javascript', json: 'json', jsx: 'javascript',
|
||||
md: 'markdown', py: 'python', sh: 'bash', ts: 'typescript', tsx: 'typescript', xml: 'xml',
|
||||
md: 'markdown', mmd: 'mermaid', py: 'python', sh: 'bash', ts: 'typescript', tsx: 'typescript', xml: 'xml',
|
||||
};
|
||||
|
||||
const hCodeIncipitMap: { starts: string[], language: string }[] = [
|
||||
@@ -77,6 +78,7 @@ export function inferCodeLanguage(blockTitle: string, code: string): string | nu
|
||||
}
|
||||
|
||||
export function highlightCode(inferredCodeLanguage: string | null, blockCode: string): string {
|
||||
// NOTE: to save power, we could skip highlighting until the block is complete (future feature)
|
||||
const safeHighlightLanguage = inferredCodeLanguage || 'typescript';
|
||||
return Prism.highlight(
|
||||
blockCode,
|
||||
|
||||
@@ -7,9 +7,10 @@ import ScienceIcon from '@mui/icons-material/Science';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
import { usePurposeStore } from './store-purposes';
|
||||
@@ -38,7 +39,7 @@ const getRandomElement = <T, >(array: T[]): T | undefined =>
|
||||
/**
|
||||
* Purpose selector for the current chat. Clicking on any item activates it for the current chat.
|
||||
*/
|
||||
export function PersonaSelector(props: { conversationId: string, runExample: (example: string) => void }) {
|
||||
export function PersonaSelector(props: { conversationId: DConversationId, runExample: (example: string) => void }) {
|
||||
// state
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [filteredIDs, setFilteredIDs] = React.useState<SystemPurposeId[] | null>(null);
|
||||
@@ -46,6 +47,7 @@ export function PersonaSelector(props: { conversationId: string, runExample: (ex
|
||||
|
||||
// external state
|
||||
const showFinder = useUIPreferencesStore(state => state.showPurposeFinder);
|
||||
const labsPersonaYTCreator = useUXLabsStore(state => state.labsPersonaYTCreator);
|
||||
const { systemPurposeId, setSystemPurposeId } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return {
|
||||
@@ -184,7 +186,7 @@ export function PersonaSelector(props: { conversationId: string, runExample: (ex
|
||||
</Grid>
|
||||
))}
|
||||
{/* Button to start the YouTube persona creator */}
|
||||
<Grid>
|
||||
{labsPersonaYTCreator && <Grid>
|
||||
<Button
|
||||
variant='soft' color='neutral'
|
||||
component={Link} noLinkStyle href='/personas'
|
||||
@@ -207,9 +209,8 @@ export function PersonaSelector(props: { conversationId: string, runExample: (ex
|
||||
YouTube persona creator
|
||||
</div>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>}
|
||||
</Grid>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import * as React from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
|
||||
// change this to increase/decrease the number history steps per pane
|
||||
const MAX_HISTORY_LENGTH = 10;
|
||||
|
||||
// change to true to enable verbose console logging
|
||||
const DEBUG_PANES_MANAGER = false;
|
||||
|
||||
|
||||
interface ChatPane {
|
||||
|
||||
conversationId: DConversationId | null;
|
||||
|
||||
history: DConversationId[]; // History of the conversationIds for this pane
|
||||
historyIndex: number; // Current position in the history for this pane
|
||||
|
||||
}
|
||||
|
||||
interface AppChatPanesStore {
|
||||
|
||||
// state
|
||||
chatPanes: ChatPane[];
|
||||
chatPaneFocusIndex: number | null;
|
||||
chatPaneInputMode: 'focused' | 'broadcast';
|
||||
|
||||
// actions
|
||||
openConversationInFocusedPane: (conversationId: DConversationId) => void;
|
||||
openConversationInSplitPane: (conversationId: DConversationId) => void;
|
||||
navigateHistoryInFocusedPane: (direction: 'back' | 'forward') => boolean;
|
||||
setFocusedPaneIndex: (paneIndex: number) => void;
|
||||
splitChatPane: (numberOfPanes: number) => void;
|
||||
unsplitChatPane: (paneIndexToKeep: number) => void;
|
||||
onConversationsChanged: (conversationIds: DConversationId[]) => void;
|
||||
|
||||
}
|
||||
|
||||
function createPane(conversationId: DConversationId | null = null): ChatPane {
|
||||
return {
|
||||
conversationId,
|
||||
history: conversationId ? [conversationId] : [],
|
||||
historyIndex: conversationId ? 0 : -1,
|
||||
};
|
||||
}
|
||||
|
||||
const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
|
||||
(_set, _get) => ({
|
||||
|
||||
// Initial state: no panes
|
||||
chatPanes: [] as ChatPane[],
|
||||
chatPaneFocusIndex: null as number | null,
|
||||
chatPaneInputMode: 'focused' as 'focused' | 'broadcast',
|
||||
|
||||
openConversationInFocusedPane: (conversationId: DConversationId) => {
|
||||
_set((state) => {
|
||||
const { chatPanes, chatPaneFocusIndex } = state;
|
||||
|
||||
// If there's no pane or no focused pane, create and focus a new one.
|
||||
if (!chatPanes.length || chatPaneFocusIndex === null) {
|
||||
const newPane = createPane(conversationId);
|
||||
return {
|
||||
chatPanes: [newPane],
|
||||
chatPaneFocusIndex: 0, // Focus the new pane
|
||||
};
|
||||
}
|
||||
|
||||
// Check if the conversation is already open in the focused pane.
|
||||
const focusedPane = chatPanes[chatPaneFocusIndex];
|
||||
if (focusedPane.conversationId === conversationId) {
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
console.log(`open-focuses: ${conversationId} is open in focused pane`, chatPaneFocusIndex, chatPanes);
|
||||
return state;
|
||||
}
|
||||
|
||||
// Truncate the future history before adding the new conversation.
|
||||
const truncatedHistory = focusedPane.history.slice(0, focusedPane.historyIndex + 1);
|
||||
const newHistory = [...truncatedHistory, conversationId].slice(-MAX_HISTORY_LENGTH);
|
||||
|
||||
// Update the focused pane with the new conversation.
|
||||
const newPanes = [...chatPanes];
|
||||
newPanes[chatPaneFocusIndex] = {
|
||||
...focusedPane,
|
||||
conversationId,
|
||||
history: newHistory,
|
||||
historyIndex: newHistory.length - 1,
|
||||
};
|
||||
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
console.log(`open-focuses: set ${conversationId} in focused pane`, chatPaneFocusIndex, chatPanes);
|
||||
|
||||
// Return the updated state.
|
||||
return {
|
||||
chatPanes: newPanes,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
openConversationInSplitPane: (conversationId: DConversationId) => {
|
||||
// Open a conversation in a new pane, reusing an existing pane if possible.
|
||||
const { chatPanes, chatPaneFocusIndex, openConversationInFocusedPane } = _get();
|
||||
|
||||
// one pane open: split it
|
||||
if (chatPanes.length === 1) {
|
||||
_set({
|
||||
chatPanes: Array.from({ length: 2 }, () => ({ ...chatPanes[0] })),
|
||||
chatPaneFocusIndex: 1,
|
||||
});
|
||||
}
|
||||
// more than 2 panes, reuse the alt pane
|
||||
else if (chatPanes.length >= 2 && chatPaneFocusIndex !== null) {
|
||||
_set({
|
||||
chatPaneFocusIndex: chatPaneFocusIndex === 0 ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
// will create a pane if none exists, or load the conversation in the focused pane
|
||||
openConversationInFocusedPane(conversationId);
|
||||
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
console.log(`open-split-pane: after:`, _get().chatPanes);
|
||||
},
|
||||
|
||||
navigateHistoryInFocusedPane: (direction: 'back' | 'forward'): boolean => {
|
||||
const { chatPanes, chatPaneFocusIndex } = _get();
|
||||
if (chatPaneFocusIndex === null)
|
||||
return false;
|
||||
|
||||
const focusedPane = chatPanes[chatPaneFocusIndex];
|
||||
let newHistoryIndex = focusedPane.historyIndex;
|
||||
|
||||
if (direction === 'back' && newHistoryIndex > 0)
|
||||
newHistoryIndex--;
|
||||
else if (direction === 'forward' && newHistoryIndex < focusedPane.history.length - 1)
|
||||
newHistoryIndex++;
|
||||
else {
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
console.log(`navigateHistoryInFocusedPane: no history ${direction} for`, focusedPane);
|
||||
return false;
|
||||
}
|
||||
|
||||
const newPanes = [...chatPanes];
|
||||
newPanes[chatPaneFocusIndex] = {
|
||||
...focusedPane,
|
||||
conversationId: focusedPane.history[newHistoryIndex],
|
||||
historyIndex: newHistoryIndex,
|
||||
};
|
||||
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
console.log(`navigateHistoryInFocusedPane: ${direction} to`, focusedPane, newPanes);
|
||||
|
||||
_set({
|
||||
chatPanes: newPanes,
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
setFocusedPaneIndex: (paneIndex: number) =>
|
||||
_set(state => {
|
||||
if (state.chatPaneFocusIndex === paneIndex)
|
||||
return state;
|
||||
return {
|
||||
chatPaneFocusIndex: paneIndex >= 0 && paneIndex < state.chatPanes.length ? paneIndex : null,
|
||||
};
|
||||
}),
|
||||
|
||||
splitChatPane: (numberOfPanes: number) => {
|
||||
const { chatPanes, chatPaneFocusIndex } = _get();
|
||||
const focusedPane = (chatPaneFocusIndex !== null ? chatPanes[chatPaneFocusIndex] : null) ?? createPane();
|
||||
|
||||
_set({
|
||||
chatPanes: Array.from({ length: numberOfPanes }, () => ({ ...focusedPane })),
|
||||
chatPaneFocusIndex: 0,
|
||||
});
|
||||
},
|
||||
|
||||
unsplitChatPane: (paneIndexToKeep: number) =>
|
||||
_set(state => ({
|
||||
chatPanes: [state.chatPanes[paneIndexToKeep] || createPane()],
|
||||
chatPaneFocusIndex: 0,
|
||||
})),
|
||||
|
||||
|
||||
/**
|
||||
* This function is vital, as is invoked when the conversationId[] changes in the global chats store.
|
||||
* It takes care of `creating the first pane` as well as `removing invalid history items, reassiging
|
||||
* conversationIds, and re-focusing the pane`.
|
||||
*/
|
||||
onConversationsChanged: (conversationIds: DConversationId[]) =>
|
||||
_set(state => {
|
||||
const { chatPanes, chatPaneFocusIndex } = state;
|
||||
|
||||
// handle panes
|
||||
let untouched = true;
|
||||
const newPanes: ChatPane[] = chatPanes.map(chatPane => {
|
||||
const { conversationId, history, historyIndex } = chatPane;
|
||||
|
||||
// adjust history if any is deleted
|
||||
let newHistoryIndex = historyIndex;
|
||||
const newHistory = history.filter((_hId, index) => {
|
||||
const historyStillPresent = conversationIds.includes(_hId);
|
||||
if (!historyStillPresent && index <= historyIndex)
|
||||
newHistoryIndex--;
|
||||
return historyStillPresent;
|
||||
});
|
||||
if (newHistoryIndex < 0 && newHistory.length > 0)
|
||||
newHistoryIndex = 0;
|
||||
|
||||
// check if pointing to a valid conversationId
|
||||
const needsNewConversationId = !conversationId || !conversationIds.includes(conversationId);
|
||||
if (!needsNewConversationId && newHistory.length === history.length)
|
||||
return chatPane;
|
||||
|
||||
const nextConversationId = newHistoryIndex >= 0 && newHistoryIndex < newHistory.length
|
||||
? newHistory[newHistoryIndex]
|
||||
: newHistory.length > 0
|
||||
? newHistory[newHistory.length - 1]
|
||||
: conversationIds[0] ?? null;
|
||||
|
||||
untouched = false;
|
||||
return {
|
||||
...chatPane,
|
||||
conversationId: nextConversationId,
|
||||
history: newHistory,
|
||||
historyIndex: newHistoryIndex,
|
||||
};
|
||||
}).filter(pane => !!pane.conversationId);
|
||||
|
||||
// if untouched, return state as-is
|
||||
if (untouched && newPanes.length >= 1)
|
||||
return state;
|
||||
|
||||
// play it safe, and make sure a pane exists, and is focused
|
||||
return {
|
||||
chatPanes: newPanes.length ? newPanes : [createPane(conversationIds[0] ?? null)],
|
||||
chatPaneFocusIndex: (newPanes.length && chatPaneFocusIndex !== null && chatPaneFocusIndex < newPanes.length) ? state.chatPaneFocusIndex : 0,
|
||||
};
|
||||
}),
|
||||
|
||||
}), {
|
||||
name: 'app-app-chat-panes',
|
||||
},
|
||||
));
|
||||
|
||||
|
||||
export function usePanesManager() {
|
||||
// use Panes
|
||||
const { onConversationsChanged, ...panesFunctions } = useAppChatPanesStore(state => {
|
||||
const {
|
||||
chatPaneFocusIndex,
|
||||
chatPanes,
|
||||
navigateHistoryInFocusedPane,
|
||||
onConversationsChanged,
|
||||
openConversationInFocusedPane,
|
||||
openConversationInSplitPane,
|
||||
setFocusedPaneIndex,
|
||||
} = state;
|
||||
const focusedConversationId = chatPaneFocusIndex !== null ? chatPanes[chatPaneFocusIndex]?.conversationId ?? null : null;
|
||||
return {
|
||||
chatPanes: chatPanes as Readonly<ChatPane[]>,
|
||||
focusedConversationId,
|
||||
navigateHistoryInFocusedPane,
|
||||
onConversationsChanged,
|
||||
openConversationInFocusedPane,
|
||||
openConversationInSplitPane,
|
||||
setFocusedPaneIndex,
|
||||
};
|
||||
}, shallow);
|
||||
|
||||
// use Conversation IDs[]
|
||||
const conversationIDs: DConversationId[] = useChatStore(state => {
|
||||
return state.conversations.map(_c => _c.id);
|
||||
}, shallow);
|
||||
|
||||
// [Effect] Ensure all Panes have a valid Conversation ID
|
||||
React.useEffect(() => {
|
||||
onConversationsChanged(conversationIDs);
|
||||
}, [conversationIDs, onConversationsChanged]);
|
||||
|
||||
return {
|
||||
...panesFunctions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
|
||||
import { DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
import { createAssistantTypingMessage } from './editors';
|
||||
|
||||
|
||||
export const runBrowseUpdatingState = async (conversationId: string, url: string) => {
|
||||
|
||||
const { editMessage } = useChatStore.getState();
|
||||
|
||||
// create a blank and 'typing' message for the assistant - to be filled when we're done
|
||||
// const assistantModelStr = 'react-' + assistantModelId.slice(4, 7); // HACK: this is used to change the Avatar animation
|
||||
// noinspection HttpUrlsUsage
|
||||
const shortUrl = url.replace('https://www.', '').replace('https://', '').replace('http://', '').replace('www.', '');
|
||||
const assistantMessageId = createAssistantTypingMessage(conversationId, 'web', undefined, `Loading page at ${shortUrl}...`);
|
||||
const updateAssistantMessage = (update: Partial<DMessage>) => editMessage(conversationId, assistantMessageId, update, false);
|
||||
|
||||
try {
|
||||
|
||||
const page = await callBrowseFetchPage(url);
|
||||
if (!page.content) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error('No text found.');
|
||||
}
|
||||
updateAssistantMessage({
|
||||
text: page.content,
|
||||
typing: false,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
updateAssistantMessage({
|
||||
text: 'Issue: browse did not produce an answer (error: ' + (error?.message || error?.toString() || 'unknown') + ').',
|
||||
typing: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,23 +1,26 @@
|
||||
import { SystemPurposeId } from '../../../data';
|
||||
import { DLLMId } from '~/modules/llms/store-llms';
|
||||
import { SystemPurposeId } from '../../../data';
|
||||
import { autoSuggestions } from '~/modules/aifn/autosuggestions/autoSuggestions';
|
||||
import { autoTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
import { streamChat } from '~/modules/llms/transports/streamChat';
|
||||
import { useElevenlabsStore } from '~/modules/elevenlabs/store-elevenlabs';
|
||||
|
||||
import { DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
import { ChatAutoSpeakType, getChatAutoAI } from '../store-app-chat';
|
||||
import { createAssistantTypingMessage, updatePurposeInHistory } from './editors';
|
||||
|
||||
|
||||
/**
|
||||
* The main "chat" function. TODO: this is here so we can soon move it to the data model.
|
||||
*/
|
||||
export async function runAssistantUpdatingState(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, systemPurpose: SystemPurposeId, _autoTitle: boolean, enableFollowUps: boolean) {
|
||||
export async function runAssistantUpdatingState(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, systemPurpose: SystemPurposeId) {
|
||||
|
||||
// ai follow-up operations (fire/forget)
|
||||
const { autoSpeak, autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
|
||||
|
||||
// update the system message from the active Purpose, if not manually edited
|
||||
history = updatePurposeInHistory(conversationId, history, systemPurpose);
|
||||
history = updatePurposeInHistory(conversationId, history, assistantLlmId, systemPurpose);
|
||||
|
||||
// create a blank and 'typing' message for the assistant
|
||||
const assistantMessageId = createAssistantTypingMessage(conversationId, assistantLlmId, history[0].purposeId, '...');
|
||||
@@ -28,51 +31,61 @@ export async function runAssistantUpdatingState(conversationId: string, history:
|
||||
startTyping(conversationId, controller);
|
||||
|
||||
// stream the assistant's messages
|
||||
await streamAssistantMessage(assistantLlmId, history, controller.signal, (updatedMessage) =>
|
||||
editMessage(conversationId, assistantMessageId, updatedMessage, false));
|
||||
await streamAssistantMessage(
|
||||
assistantLlmId, history,
|
||||
autoSpeak,
|
||||
(updatedMessage) => editMessage(conversationId, assistantMessageId, updatedMessage, false),
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
// clear to send, again
|
||||
startTyping(conversationId, null);
|
||||
|
||||
// auto-suggestions (fire/forget)
|
||||
if (enableFollowUps)
|
||||
autoSuggestions(conversationId, assistantMessageId);
|
||||
|
||||
// update text, if needed (fire/forget)
|
||||
if (_autoTitle)
|
||||
if (autoTitleChat)
|
||||
autoTitle(conversationId);
|
||||
|
||||
if (autoSuggestDiagrams || autoSuggestQuestions)
|
||||
autoSuggestions(conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestQuestions);
|
||||
}
|
||||
|
||||
|
||||
async function streamAssistantMessage(
|
||||
llmId: DLLMId, history: DMessage[],
|
||||
abortSignal: AbortSignal,
|
||||
autoSpeak: ChatAutoSpeakType,
|
||||
editMessage: (updatedMessage: Partial<DMessage>) => void,
|
||||
abortSignal: AbortSignal,
|
||||
) {
|
||||
|
||||
// 📢 TTS: speak the first line, if configured
|
||||
const speakFirstLine = useElevenlabsStore.getState().elevenLabsAutoSpeak === 'firstLine';
|
||||
let firstLineSpoken = false;
|
||||
// speak once
|
||||
let spokenText = '';
|
||||
let spokenLine = false;
|
||||
|
||||
const messages = history.map(({ role, text }) => ({ role, content: text }));
|
||||
|
||||
try {
|
||||
const messages = history.map(({ role, text }) => ({ role, content: text }));
|
||||
await streamChat(llmId, messages, abortSignal, (updatedMessage: Partial<DMessage>) => {
|
||||
// update the message in the store (and thus schedule a re-render)
|
||||
editMessage(updatedMessage);
|
||||
await streamChat(llmId, messages, abortSignal,
|
||||
(updatedMessage: Partial<DMessage>) => {
|
||||
// update the message in the store (and thus schedule a re-render)
|
||||
editMessage(updatedMessage);
|
||||
|
||||
// 📢 TTS
|
||||
if (updatedMessage?.text && speakFirstLine && !firstLineSpoken) {
|
||||
let cutPoint = updatedMessage.text.lastIndexOf('\n');
|
||||
if (cutPoint < 0)
|
||||
cutPoint = updatedMessage.text.lastIndexOf('. ');
|
||||
if (cutPoint > 100 && cutPoint < 400) {
|
||||
firstLineSpoken = true;
|
||||
const firstParagraph = updatedMessage.text.substring(0, cutPoint);
|
||||
// fire/forget: we don't want to stall this loop
|
||||
void speakText(firstParagraph);
|
||||
// 📢 TTS: first-line
|
||||
if (updatedMessage?.text) {
|
||||
spokenText = updatedMessage.text;
|
||||
if (autoSpeak === 'firstLine' && !spokenLine) {
|
||||
let cutPoint = spokenText.lastIndexOf('\n');
|
||||
if (cutPoint < 0)
|
||||
cutPoint = spokenText.lastIndexOf('. ');
|
||||
if (cutPoint > 100 && cutPoint < 400) {
|
||||
spokenLine = true;
|
||||
const firstParagraph = spokenText.substring(0, cutPoint);
|
||||
|
||||
// fire/forget: we don't want to stall this loop
|
||||
void speakText(firstParagraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
console.error('Fetch request error:', error);
|
||||
@@ -80,6 +93,10 @@ async function streamAssistantMessage(
|
||||
}
|
||||
}
|
||||
|
||||
// 📢 TTS: all
|
||||
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && spokenText && !spokenLine && !abortSignal.aborted)
|
||||
void speakText(spokenText);
|
||||
|
||||
// finally, stop the typing animation
|
||||
editMessage({ typing: false });
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
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 commands = [...CmdRunProdia, ...CmdRunReact, ...CmdRunSearch, ...CmdAddRoleMessage];
|
||||
export const CmdHelp: string[] = ['/help', '/h', '/?'];
|
||||
|
||||
export const commands = [...CmdRunBrowse, ...CmdRunProdia, ...CmdRunReact, ...CmdRunSearch, ...CmdAddRoleMessage, ...CmdHelp];
|
||||
|
||||
export interface SentencePiece {
|
||||
type: 'text' | 'cmd';
|
||||
@@ -16,6 +22,9 @@ 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[] = [];
|
||||
@@ -37,4 +46,12 @@ 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;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { SystemPurposeId, SystemPurposes } from '../../../data';
|
||||
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
|
||||
export function createAssistantTypingMessage(conversationId: string, assistantLlmLabel: DLLMId | 'prodia' | 'react-...', assistantPurposeId: SystemPurposeId | undefined, text: string): string {
|
||||
export function createAssistantTypingMessage(conversationId: string, assistantLlmLabel: DLLMId | 'prodia' | 'react-...' | 'web', assistantPurposeId: SystemPurposeId | undefined, text: string): string {
|
||||
const assistantMessage: DMessage = createDMessage('assistant', text);
|
||||
assistantMessage.typing = true;
|
||||
assistantMessage.purposeId = assistantPurposeId;
|
||||
@@ -14,12 +14,13 @@ export function createAssistantTypingMessage(conversationId: string, assistantLl
|
||||
}
|
||||
|
||||
|
||||
export function updatePurposeInHistory(conversationId: string, history: DMessage[], purposeId: SystemPurposeId): DMessage[] {
|
||||
export function updatePurposeInHistory(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, purposeId: SystemPurposeId): DMessage[] {
|
||||
const systemMessageIndex = history.findIndex(m => m.role === 'system');
|
||||
const systemMessage: DMessage = systemMessageIndex >= 0 ? history.splice(systemMessageIndex, 1)[0] : createDMessage('system', '');
|
||||
if (!systemMessage.updated && purposeId && SystemPurposes[purposeId]?.systemMessage) {
|
||||
systemMessage.purposeId = purposeId;
|
||||
systemMessage.text = SystemPurposes[purposeId].systemMessage
|
||||
.replaceAll('{{Cutoff}}', assistantLlmId.includes('1106') ? '2023-04' : '2021-09')
|
||||
.replaceAll('{{Today}}', new Date().toISOString().split('T')[0]);
|
||||
|
||||
// HACK: this is a special case for the "Custom" persona, to set the message in stone (so it doesn't get updated when switching to another persona)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Agent } from '~/modules/aifn/react/react';
|
||||
import { DLLMId } from '~/modules/llms/store-llms';
|
||||
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import { createDEphemeral, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
@@ -11,6 +12,7 @@ import { createAssistantTypingMessage } from './editors';
|
||||
*/
|
||||
export async function runReActUpdatingState(conversationId: string, question: string, assistantLlmId: DLLMId) {
|
||||
|
||||
const { enableReactTool: enableBrowse } = useBrowseStore.getState();
|
||||
const { appendEphemeral, updateEphemeralText, updateEphemeralState, deleteEphemeral, editMessage } = useChatStore.getState();
|
||||
|
||||
// create a blank and 'typing' message for the assistant - to be filled when we're done
|
||||
@@ -30,15 +32,13 @@ export async function runReActUpdatingState(conversationId: string, question: st
|
||||
ephemeralText += (text.length > 300 ? text.slice(0, 300) + '...' : text) + '\n';
|
||||
updateEphemeralText(conversationId, ephemeral.id, ephemeralText);
|
||||
};
|
||||
const showStateInEphemeral = (state: object) => updateEphemeralState(conversationId, ephemeral.id, state);
|
||||
|
||||
try {
|
||||
|
||||
// react loop
|
||||
const agent = new Agent();
|
||||
const reactResult = await agent.reAct(question, assistantLlmId, 5,
|
||||
logToEphemeral,
|
||||
(state: object) => updateEphemeralState(conversationId, ephemeral.id, state),
|
||||
);
|
||||
const reactResult = await agent.reAct(question, assistantLlmId, 5, enableBrowse, logToEphemeral, showStateInEphemeral);
|
||||
|
||||
setTimeout(() => deleteEphemeral(conversationId, ephemeral.id), 2 * 1000);
|
||||
updateAssistantMessage({ text: reactResult, typing: false });
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { create } from 'zustand';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
|
||||
export type ChatAutoSpeakType = 'off' | 'firstLine' | 'all';
|
||||
|
||||
|
||||
// Chat Settings (Chat AI & Chat UI)
|
||||
|
||||
interface AppChatStore {
|
||||
|
||||
autoSpeak: ChatAutoSpeakType;
|
||||
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => void;
|
||||
|
||||
autoSuggestDiagrams: boolean,
|
||||
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => void;
|
||||
|
||||
autoSuggestQuestions: boolean,
|
||||
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => void;
|
||||
|
||||
autoTitleChat: boolean;
|
||||
setAutoTitleChat: (autoTitleChat: boolean) => void;
|
||||
|
||||
micTimeoutMs: number;
|
||||
setMicTimeoutMs: (micTimeoutMs: number) => void;
|
||||
|
||||
showTextDiff: boolean;
|
||||
setShowTextDiff: (showTextDiff: boolean) => void;
|
||||
|
||||
showSystemMessages: boolean;
|
||||
setShowSystemMessages: (showSystemMessages: boolean) => void;
|
||||
|
||||
}
|
||||
|
||||
|
||||
const useAppChatStore = create<AppChatStore>()(persist(
|
||||
(_set, _get) => ({
|
||||
|
||||
autoSpeak: 'off',
|
||||
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => _set({ autoSpeak }),
|
||||
|
||||
autoSuggestDiagrams: false,
|
||||
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => _set({ autoSuggestDiagrams }),
|
||||
|
||||
autoSuggestQuestions: false,
|
||||
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => _set({ autoSuggestQuestions }),
|
||||
|
||||
autoTitleChat: true,
|
||||
setAutoTitleChat: (autoTitleChat: boolean) => _set({ autoTitleChat }),
|
||||
|
||||
micTimeoutMs: 2000,
|
||||
setMicTimeoutMs: (micTimeoutMs: number) => _set({ micTimeoutMs }),
|
||||
|
||||
showTextDiff: false,
|
||||
setShowTextDiff: (showTextDiff: boolean) => _set({ showTextDiff }),
|
||||
|
||||
showSystemMessages: false,
|
||||
setShowSystemMessages: (showSystemMessages: boolean) => _set({ showSystemMessages }),
|
||||
|
||||
}), {
|
||||
name: 'app-app-chat',
|
||||
version: 1,
|
||||
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
|
||||
// for now, let text diff be off by default
|
||||
state.showTextDiff = false;
|
||||
},
|
||||
|
||||
migrate: (state: any, fromVersion: number): AppChatStore => {
|
||||
// 0 -> 1: autoTitleChat was off by mistake - turn it on [Remove past Dec 1, 2023]
|
||||
if (state && fromVersion < 1)
|
||||
state.autoTitleChat = true;
|
||||
return state;
|
||||
},
|
||||
},
|
||||
));
|
||||
|
||||
|
||||
export const useChatAutoAI = () => useAppChatStore(state => ({
|
||||
autoSpeak: state.autoSpeak,
|
||||
autoSuggestDiagrams: state.autoSuggestDiagrams,
|
||||
autoSuggestQuestions: state.autoSuggestQuestions,
|
||||
autoTitleChat: state.autoTitleChat,
|
||||
setAutoSpeak: state.setAutoSpeak,
|
||||
setAutoSuggestDiagrams: state.setAutoSuggestDiagrams,
|
||||
setAutoSuggestQuestions: state.setAutoSuggestQuestions,
|
||||
setAutoTitleChat: state.setAutoTitleChat,
|
||||
}), shallow);
|
||||
|
||||
export const getChatAutoAI = (): {
|
||||
autoSpeak: ChatAutoSpeakType,
|
||||
autoSuggestDiagrams: boolean,
|
||||
autoSuggestQuestions: boolean,
|
||||
autoTitleChat: boolean,
|
||||
} => useAppChatStore.getState();
|
||||
|
||||
export const useChatMicTimeoutMsValue = (): number =>
|
||||
useAppChatStore(state => state.micTimeoutMs);
|
||||
|
||||
export const useChatMicTimeoutMs = (): [number, (micTimeoutMs: number) => void] =>
|
||||
useAppChatStore(state => [state.micTimeoutMs, state.setMicTimeoutMs], shallow);
|
||||
|
||||
export const useChatShowTextDiff = (): [boolean, (showDiff: boolean) => void] =>
|
||||
useAppChatStore(state => [state.showTextDiff, state.setShowTextDiff], shallow);
|
||||
|
||||
export const getChatShowSystemMessages = (): boolean =>
|
||||
useAppChatStore.getState().showSystemMessages;
|
||||
|
||||
export const useChatShowSystemMessages = (): [boolean, (showSystemMessages: boolean) => void] =>
|
||||
useAppChatStore(state => [state.showSystemMessages, state.setShowSystemMessages], shallow);
|
||||
@@ -1,68 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, Button, Card, CardContent, Container, Switch, Typography } from '@mui/joy';
|
||||
import ScienceIcon from '@mui/icons-material/Science';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
export function AppLabs() {
|
||||
|
||||
// external state
|
||||
const { experimentalLabs, setExperimentalLabs } = useUIPreferencesStore(state => ({
|
||||
experimentalLabs: state.experimentalLabs, setExperimentalLabs: state.setExperimentalLabs,
|
||||
}), shallow);
|
||||
|
||||
const handleLabsChange = (event: React.ChangeEvent<HTMLInputElement>) => setExperimentalLabs(event.target.checked);
|
||||
|
||||
return (
|
||||
|
||||
<Box sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
minHeight: 96,
|
||||
p: { xs: 3, md: 6 },
|
||||
gap: 4,
|
||||
}}>
|
||||
|
||||
<Typography level='h1' sx={{ fontSize: '3.6rem' }}>
|
||||
Labs <ScienceIcon sx={{ fontSize: '3.3rem' }} />
|
||||
</Typography>
|
||||
|
||||
<Switch checked={experimentalLabs} onChange={handleLabsChange}
|
||||
endDecorator={experimentalLabs ? 'On' : 'Off'}
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }} />
|
||||
|
||||
<Container disableGutters maxWidth='sm'>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography>
|
||||
The Labs section is where we experiment with new features and ideas.
|
||||
</Typography>
|
||||
<Typography level='title-md' sx={{ mt: 2 }}>
|
||||
Features {experimentalLabs ? 'enabled' : 'disabled'}:
|
||||
</Typography>
|
||||
<ul style={{ marginTop: 8, marginBottom: 8, paddingInlineStart: 32 }}>
|
||||
<li><b>Text tools</b> - complete (highlight differences)</li>
|
||||
<li><b>YouTube persona synthesizer</b> - alpha, not persisted</li>
|
||||
<li><b>Chat mode: follow-up/augmentation</b> - alpha (diagrams)</li>
|
||||
<li><b>Relative chats size</b> - complete</li>
|
||||
</ul>
|
||||
<Typography sx={{ mt: 2 }}>
|
||||
For any questions and creative idea, please join us on Discord, and let's talk!
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
|
||||
<Button variant='solid' color='neutral' size='lg' component={Link} href='/' noLinkStyle>
|
||||
Got it!
|
||||
</Button>
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Box, Typography } from '@mui/joy';
|
||||
|
||||
import { createConversationFromJsonV1 } from '../chat/trade/trade.client';
|
||||
import { useHasChatLinkItems } from '../chat/trade/store-sharing';
|
||||
import { createConversationFromJsonV1 } from '~/modules/trade/trade.client';
|
||||
import { useHasChatLinkItems } from '~/modules/trade/store-module-trade';
|
||||
|
||||
import { Brand } from '~/common/brand';
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { LogoProgress } from '~/common/components/LogoProgress';
|
||||
import { apiAsyncNode } from '~/common/util/trpc.client';
|
||||
|
||||
@@ -4,12 +4,12 @@ import TimeAgo from 'react-timeago';
|
||||
import { Box, ListDivider, ListItem, ListItemDecorator, MenuItem, Typography } from '@mui/joy';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
import { useChatLinkItems } from '../chat/trade/store-sharing';
|
||||
import { useChatLinkItems } from '~/modules/trade/store-module-trade';
|
||||
|
||||
import { Brand } from '~/common/brand';
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { closeLayoutDrawer } from '~/common/layout/store-applayout';
|
||||
import { getChatLinkRelativePath, getHomeLink } from '~/common/routes';
|
||||
import { getChatLinkRelativePath, ROUTE_INDEX } from '~/common/app.routes';
|
||||
|
||||
|
||||
/**
|
||||
@@ -28,7 +28,7 @@ export function AppChatLinkDrawerItems() {
|
||||
|
||||
<MenuItem
|
||||
onClick={closeLayoutDrawer}
|
||||
component={Link} href={getHomeLink()} noLinkStyle
|
||||
component={Link} href={ROUTE_INDEX} noLinkStyle
|
||||
>
|
||||
<ListItemDecorator><ArrowBackIcon /></ListItemDecorator>
|
||||
{Brand.Title.Base}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { MenuItem, Switch, Typography } from '@mui/joy';
|
||||
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { useChatShowSystemMessages } from '../chat/store-app-chat';
|
||||
|
||||
|
||||
/**
|
||||
* Menu Items are the settings for the chat.
|
||||
@@ -12,12 +14,11 @@ import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
export function AppChatLinkMenuItems() {
|
||||
|
||||
// external state
|
||||
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
|
||||
const {
|
||||
showSystemMessages, setShowSystemMessages,
|
||||
renderMarkdown, setRenderMarkdown,
|
||||
zenMode, setZenMode,
|
||||
} = useUIPreferencesStore(state => ({
|
||||
showSystemMessages: state.showSystemMessages, setShowSystemMessages: state.setShowSystemMessages,
|
||||
renderMarkdown: state.renderMarkdown, setRenderMarkdown: state.setRenderMarkdown,
|
||||
zenMode: state.zenMode, setZenMode: state.setZenMode,
|
||||
}), shallow);
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Box, Button, Card, List, ListItem, Tooltip, Typography } from '@mui/joy
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
import { ChatMessage } from '../chat/components/message/ChatMessage';
|
||||
import { useChatShowSystemMessages } from '../chat/store-app-chat';
|
||||
|
||||
import { Brand } from '~/common/brand';
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { conversationTitle, DConversation, useChatStore } from '~/common/state/store-chats';
|
||||
import { navigateToChat } from '~/common/routes';
|
||||
import { navigateToChat } from '~/common/app.routes';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
@@ -22,7 +23,7 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
|
||||
const listBottomRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// external state
|
||||
const showSystemMessages = useUIPreferencesStore(state => state.showSystemMessages);
|
||||
const [showSystemMessages] = useChatShowSystemMessages();
|
||||
const hasExistingChat = useChatStore(state => state.conversations.some(c => c.id === props.conversation.id));
|
||||
|
||||
// derived state
|
||||
@@ -56,8 +57,8 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
|
||||
|
||||
const handleClone = async (canOverwrite: boolean) => {
|
||||
setCloning(true);
|
||||
useChatStore.getState().importConversation({ ...props.conversation }, !canOverwrite);
|
||||
await navigateToChat();
|
||||
const importedId = useChatStore.getState().importConversation({ ...props.conversation }, !canOverwrite);
|
||||
await navigateToChat(importedId);
|
||||
setCloning(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Button, ButtonGroup, Divider, FormControl, FormLabel, Input, Switch, Typography } from '@mui/joy';
|
||||
import { Box, Button, ButtonGroup, Divider, FormControl, Input, Switch, Typography } from '@mui/joy';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
|
||||
import { DLLMId, useModelsStore } from '~/modules/llms/store-llms';
|
||||
import { findVendorById } from '~/modules/llms/vendors/vendor.registry';
|
||||
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { GoodModal } from '~/common/components/GoodModal';
|
||||
import { closeLayoutLLMOptions } from '~/common/layout/store-applayout';
|
||||
import { settingsGap } from '~/common/app.theme';
|
||||
|
||||
import { VendorLLMOptions } from './VendorLLMOptions';
|
||||
|
||||
function VendorLLMOptions(props: { llmId: DLLMId }) {
|
||||
// get LLM (warning: this will refresh all children components on every change of any LLM field)
|
||||
const llm = useModelsStore(state => state.llms.find(llm => llm.id === props.llmId), shallow);
|
||||
if (!llm)
|
||||
return 'Options issue: LLM not found for id ' + props.llmId;
|
||||
|
||||
// get vendor
|
||||
const vendor = findVendorById(llm._source.vId);
|
||||
if (!vendor)
|
||||
return 'Options issue: Vendor not found for LLM ' + props.llmId + ', source ' + llm._source.id;
|
||||
|
||||
return <vendor.LLMOptionsComponent llm={llm} />;
|
||||
}
|
||||
|
||||
|
||||
export function LLMOptionsModal(props: { id: DLLMId }) {
|
||||
@@ -63,21 +79,19 @@ export function LLMOptionsModal(props: { id: DLLMId }) {
|
||||
}
|
||||
>
|
||||
|
||||
<VendorLLMOptions id={props.id} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: settingsGap }}>
|
||||
<VendorLLMOptions llmId={props.id} />
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormLabel sx={{ minWidth: 80 }}>
|
||||
Name
|
||||
</FormLabel>
|
||||
<FormLabelStart title='Name' sx={{ minWidth: 80 }} />
|
||||
<Input variant='outlined' value={llm.label} onChange={handleLlmLabelSet} />
|
||||
</FormControl>
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormLabel sx={{ minWidth: 80 }}>
|
||||
Defaults
|
||||
</FormLabel>
|
||||
<FormLabelStart title='Defaults' sx={{ minWidth: 80 }} />
|
||||
<ButtonGroup orientation='horizontal' size='sm' variant='outlined'>
|
||||
<Button variant={isChatLLM ? 'solid' : undefined} onClick={() => setChatLLMId(isChatLLM ? null : props.id)}>Chat</Button>
|
||||
<Button variant={isFastLLM ? 'solid' : undefined} onClick={() => setFastLLMId(isFastLLM ? null : props.id)}>Fast</Button>
|
||||
@@ -86,9 +100,7 @@ export function LLMOptionsModal(props: { id: DLLMId }) {
|
||||
</FormControl>
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<FormLabel sx={{ minWidth: 80 }}>
|
||||
Visible
|
||||
</FormLabel>
|
||||
<FormLabelStart title='Visible' sx={{ minWidth: 80 }} />
|
||||
<Switch checked={!llm.hidden} onChange={handleLlmVisibilityToggle}
|
||||
endDecorator={!llm.hidden ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }}
|
||||
@@ -96,17 +108,13 @@ export function LLMOptionsModal(props: { id: DLLMId }) {
|
||||
</FormControl>
|
||||
|
||||
{/*<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', alignItems: 'center' }}>*/}
|
||||
{/* <FormLabel sx={{ minWidth: 80 }}>*/}
|
||||
{/* Flags*/}
|
||||
{/* </FormLabel>*/}
|
||||
{/* <FormLabelStart title='Flags' sx={{ minWidth: 80 }} /> >*/}
|
||||
{/* <Checkbox color='neutral' checked={llm.tags?.includes('chat')} readOnly disabled label='Chat' sx={{ ml: 4 }} />*/}
|
||||
{/* <Checkbox color='neutral' checked={llm.tags?.includes('stream')} readOnly disabled label='Stream' sx={{ ml: 4 }} />*/}
|
||||
{/*</FormControl>*/}
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ flexWrap: 'nowrap' }}>
|
||||
<FormLabel onClick={() => setShowDetails(!showDetails)} sx={{ minWidth: 80, cursor: 'pointer', textDecoration: 'underline' }}>
|
||||
Details
|
||||
</FormLabel>
|
||||
<FormLabelStart title='Details' sx={{ minWidth: 80 }} onClick={() => setShowDetails(!showDetails)} />
|
||||
{showDetails && <Typography level='body-sm' sx={{ display: 'block' }}>
|
||||
[{llm.id}]: {llm.options.llmRef && `${llm.options.llmRef} · `}
|
||||
{llm.contextTokens && `context tokens: ${llm.contextTokens.toLocaleString()} · `}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, Chip, IconButton, List, ListItem, ListItemButton, Tooltip, Typography } from '@mui/joy';
|
||||
import { Box, Chip, IconButton, List, ListItem, ListItemButton, Typography } from '@mui/joy';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DLLM, DModelSourceId, useModelsStore } from '~/modules/llms/store-llms'
|
||||
import { IModelVendor } from '~/modules/llms/vendors/IModelVendor';
|
||||
import { findVendorById } from '~/modules/llms/vendors/vendor.registry';
|
||||
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { openLayoutLLMOptions } from '~/common/layout/store-applayout';
|
||||
|
||||
|
||||
@@ -17,18 +18,27 @@ function ModelItem(props: { llm: DLLM, vendor: IModelVendor, chipChat: boolean,
|
||||
// derived
|
||||
const llm = props.llm;
|
||||
const label = llm.label;
|
||||
const tooltip = `${llm._source.label}${llm.description ? ' - ' + llm.description : ''} - ${llm.contextTokens?.toLocaleString() || 'unknown tokens size'}`;
|
||||
let tooltip = llm._source.label;
|
||||
if (llm.description)
|
||||
tooltip += ' - ' + llm.description;
|
||||
tooltip += ' - ';
|
||||
if (llm.contextTokens) {
|
||||
tooltip += llm.contextTokens.toLocaleString() + ' tokens';
|
||||
// if (llm.maxOutputTokens)
|
||||
// tooltip += ' / ' + llm.maxOutputTokens.toLocaleString() + ' max';
|
||||
} else
|
||||
tooltip += 'unknown tokens size';
|
||||
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemButton onClick={() => openLayoutLLMOptions(llm.id)} sx={{ alignItems: 'center', gap: 1 }}>
|
||||
|
||||
{/* Model Name */}
|
||||
<Tooltip title={tooltip}>
|
||||
<GoodTooltip title={tooltip}>
|
||||
<Typography sx={llm.hidden ? { color: 'neutral.plainDisabledColor' } : undefined}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</GoodTooltip>
|
||||
|
||||
{/* --> */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Checkbox, Divider } from '@mui/joy';
|
||||
import { Box, Checkbox, Divider } from '@mui/joy';
|
||||
|
||||
import { DModelSource, DModelSourceId, useModelsStore } from '~/modules/llms/store-llms';
|
||||
import { createModelSourceForDefaultVendor, findVendorById } from '~/modules/llms/vendors/vendor.registry';
|
||||
|
||||
import { GoodModal } from '~/common/components/GoodModal';
|
||||
import { closeLayoutModelsSetup, openLayoutModelsSetup, useLayoutModelsSetup } from '~/common/layout/store-applayout';
|
||||
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
|
||||
import { DModelSourceId, useModelsStore } from '~/modules/llms/store-llms';
|
||||
import { createModelSourceForDefaultVendor } from '~/modules/llms/vendors/vendor.registry';
|
||||
import { settingsGap } from '~/common/app.theme';
|
||||
|
||||
import { LLMOptionsModal } from './LLMOptionsModal';
|
||||
import { ModelsList } from './ModelsList';
|
||||
import { ModelsSourceSelector } from './ModelsSourceSelector';
|
||||
import { VendorSourceSetup } from './VendorSourceSetup';
|
||||
|
||||
|
||||
function VendorSourceSetup(props: { source: DModelSource }) {
|
||||
const vendor = findVendorById(props.source.vId);
|
||||
if (!vendor)
|
||||
return 'Configuration issue: Vendor not found for Source ' + props.source.id;
|
||||
return <vendor.SourceSetupComponent sourceId={props.source.id} />;
|
||||
}
|
||||
|
||||
|
||||
export function ModelsModal(props: { suspendAutoModelsSetup?: boolean }) {
|
||||
@@ -28,7 +35,6 @@ export function ModelsModal(props: { suspendAutoModelsSetup?: boolean }) {
|
||||
modelSources: state.sources,
|
||||
llmCount: state.llms.length,
|
||||
}), shallow);
|
||||
useGlobalShortcut('m', true, true, openLayoutModelsSetup);
|
||||
|
||||
// auto-select the first source - note: we could use a useEffect() here, but this is more efficient
|
||||
// also note that state-persistence is unneeded
|
||||
@@ -70,7 +76,11 @@ export function ModelsModal(props: { suspendAutoModelsSetup?: boolean }) {
|
||||
|
||||
{!!activeSource && <Divider />}
|
||||
|
||||
{!!activeSource && <VendorSourceSetup source={activeSource} />}
|
||||
{!!activeSource && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: settingsGap }}>
|
||||
<VendorSourceSetup source={activeSource} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!!llmCount && <Divider />}
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ import { createModelSourceForVendor, findAllVendors, findVendorById } from '~/mo
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
import { hideOnDesktop, hideOnMobile } from '~/common/theme';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
|
||||
|
||||
/*function locationIcon(vendor?: IModelVendor | null) {
|
||||
if (vendor && vendor.id === 'openai' && ModelVendorOpenAI.hasServerKey)
|
||||
if (vendor && vendor.id === 'openai' && ModelVendorOpenAI.hasBackendCap?.())
|
||||
return <CloudDoneOutlinedIcon />;
|
||||
return !vendor ? null : vendor.location === 'local' ? <ComputerIcon /> : <CloudOutlinedIcon />;
|
||||
}*/
|
||||
@@ -43,6 +43,7 @@ export function ModelsSourceSelector(props: {
|
||||
const [confirmDeletionSourceId, setConfirmDeletionSourceId] = React.useState<DModelSourceId | null>(null);
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const { modelSources, addModelSource, removeModelSource } = useModelsStore(state => ({
|
||||
modelSources: state.sources,
|
||||
addModelSource: state.addSource, removeModelSource: state.removeSource,
|
||||
@@ -63,7 +64,7 @@ export function ModelsSourceSelector(props: {
|
||||
}, [addModelSource, props]);
|
||||
|
||||
|
||||
const enableDeleteButton = !!props.selectedSourceId && (modelSources.length > 1 /*|| (process.env.NODE_ENV === 'development')*/);
|
||||
const enableDeleteButton = !!props.selectedSourceId && modelSources.length > 1;
|
||||
|
||||
const handleDeleteSource = (id: DModelSourceId) => setConfirmDeletionSourceId(id);
|
||||
|
||||
@@ -89,7 +90,7 @@ export function ModelsSourceSelector(props: {
|
||||
component: (
|
||||
<MenuItem key={vendor.id} disabled={!enabled} onClick={() => handleAddSourceFromVendor(vendor.id)}>
|
||||
<ListItemDecorator>
|
||||
{vendorIcon(vendor, !!vendor.hasServerKey)}
|
||||
{vendorIcon(vendor, !!vendor.hasBackendCap && vendor.hasBackendCap())}
|
||||
</ListItemDecorator>
|
||||
{vendor.name}{/*{sourceCount > 0 && ` (added)`}*/}
|
||||
</MenuItem>
|
||||
@@ -115,9 +116,9 @@ export function ModelsSourceSelector(props: {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 1 }}>
|
||||
|
||||
{/* Models: [Select] Add Delete */}
|
||||
<Typography sx={{ mr: 1, ...hideOnMobile }}>
|
||||
{!isMobile && <Typography sx={{ mr: 1 }}>
|
||||
Service:
|
||||
</Typography>
|
||||
</Typography>}
|
||||
|
||||
<Select
|
||||
variant='outlined'
|
||||
@@ -133,12 +134,15 @@ export function ModelsSourceSelector(props: {
|
||||
{sourceItems.map(item => item.component)}
|
||||
</Select>
|
||||
|
||||
<IconButton variant={noSources ? 'solid' : 'plain'} color='primary' onClick={handleShowVendors} disabled={!!vendorsMenuAnchor} sx={{ ...hideOnDesktop }}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
<Button variant={noSources ? 'solid' : 'plain'} onClick={handleShowVendors} disabled={!!vendorsMenuAnchor} startDecorator={<AddIcon />} sx={{ ...hideOnMobile }}>
|
||||
Add
|
||||
</Button>
|
||||
{isMobile ? (
|
||||
<IconButton variant={noSources ? 'solid' : 'plain'} color='primary' onClick={handleShowVendors} disabled={!!vendorsMenuAnchor}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button variant={noSources ? 'solid' : 'plain'} onClick={handleShowVendors} disabled={!!vendorsMenuAnchor} startDecorator={<AddIcon />}>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
variant='plain' color='neutral' disabled={!enableDeleteButton} sx={{ ml: 'auto' }}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { DLLMId, useModelsStore } from '~/modules/llms/store-llms';
|
||||
import { findVendorById } from '~/modules/llms/vendors/vendor.registry';
|
||||
|
||||
|
||||
export function VendorLLMOptions(props: { id: DLLMId }) {
|
||||
// get LLM (warning: this will refresh all children components on every change of any LLM field)
|
||||
const llm = useModelsStore(state => state.llms.find(llm => llm.id === props.id), shallow);
|
||||
if (!llm)
|
||||
return <>Configuration issue: LLM not found for id {props.id}</>;
|
||||
|
||||
// get vendor
|
||||
const vendor = findVendorById(llm._source.vId);
|
||||
if (!vendor)
|
||||
return <>Configuration issue: Vendor not found for LLM {llm.id}, source: {llm.sId}</>;
|
||||
|
||||
const LLMOptionsComponent = vendor.LLMOptionsComponent;
|
||||
return <LLMOptionsComponent llm={llm} />;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { DModelSource } from '~/modules/llms/store-llms';
|
||||
import { findVendorById } from '~/modules/llms/vendors/vendor.registry';
|
||||
|
||||
|
||||
export function VendorSourceSetup(props: { source: DModelSource }) {
|
||||
const vendor = findVendorById(props.source.vId);
|
||||
if (!vendor)
|
||||
return <>Configuration issue: Vendor not found for Source {props.source.id}</>;
|
||||
|
||||
const SourceSetupComponent = vendor.SourceSetupComponent;
|
||||
return <SourceSetupComponent sourceId={props.source.id} />;
|
||||
}
|
||||
+96
-57
@@ -1,18 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import { Box, Button, Card, CardContent, Container, IconButton, Typography } from '@mui/joy';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
|
||||
import { Brand } from '~/common/brand';
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { ROUTE_INDEX } from '~/common/app.routes';
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
|
||||
import { NewsItems } from './news.data';
|
||||
import { newsCallout, NewsItems } from './news.data';
|
||||
|
||||
// number of news items to show by default, before the expander
|
||||
const DEFAULT_NEWS_COUNT = 2;
|
||||
|
||||
export const cssColorKeyframes = keyframes`
|
||||
0%, 100% {
|
||||
color: #636B74; /* Neutral main color (500) */
|
||||
}
|
||||
25% {
|
||||
color: #12467B; /* Primary darker shade (700) */
|
||||
}
|
||||
50% {
|
||||
color: #0B6BCB; /* Primary main color (500) */
|
||||
}
|
||||
75% {
|
||||
color: #97C3F0; /* Primary lighter shade (300) */
|
||||
}`;
|
||||
|
||||
|
||||
export function AppNews() {
|
||||
// state
|
||||
const [lastNewsIdx, setLastNewsIdx] = React.useState<number>(0);
|
||||
const [lastNewsIdx, setLastNewsIdx] = React.useState<number>(DEFAULT_NEWS_COUNT - 1);
|
||||
|
||||
// news selection
|
||||
const news = NewsItems.filter((_, idx) => idx <= lastNewsIdx);
|
||||
@@ -21,73 +42,91 @@ export function AppNews() {
|
||||
return (
|
||||
|
||||
<Box sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
backgroundColor: 'background.level1',
|
||||
overflowY: 'auto',
|
||||
minHeight: 96,
|
||||
display: 'flex', justifyContent: 'center',
|
||||
p: { xs: 3, md: 6 },
|
||||
gap: 4,
|
||||
}}>
|
||||
|
||||
<Typography level='h1' sx={{fontSize: '3.6rem'}}>
|
||||
New updates!
|
||||
</Typography>
|
||||
<Box sx={{
|
||||
my: 'auto',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: 4,
|
||||
}}>
|
||||
|
||||
<Typography>
|
||||
{capitalizeFirstLetter(Brand.Title.Base)} has been updated to version {firstNews?.versionName}. Enjoy what's new:
|
||||
</Typography>
|
||||
<Typography level='h1' sx={{ fontSize: '3rem' }}>
|
||||
Welcome to {Brand.Title.Base} <Box component='span' sx={{ animation: `${cssColorKeyframes} 10s infinite` }}>{firstNews?.versionCode}</Box>!
|
||||
</Typography>
|
||||
|
||||
{!!news && <Container disableGutters maxWidth='sm'>
|
||||
{news?.map((ni, idx) => {
|
||||
const firstCard = idx === 0;
|
||||
const hasCardAfter = news.length < NewsItems.length;
|
||||
const showExpander = hasCardAfter && (idx === news.length - 1);
|
||||
const addPadding = !firstCard || showExpander;
|
||||
return <Card key={'news-' + idx} sx={{ mb: 2, minHeight: 32 }}>
|
||||
<CardContent sx={{ position: 'relative', pr: addPadding ? 4 : 0 }}>
|
||||
{!!ni.text && <Typography component='div'>
|
||||
{ni.text}
|
||||
</Typography>}
|
||||
<Typography>
|
||||
{capitalizeFirstLetter(Brand.Title.Base)} has been updated to version {firstNews?.versionCode}
|
||||
</Typography>
|
||||
|
||||
{!!ni.items && (ni.items.length > 0) && <ul style={{ marginTop: 8, marginBottom: 8, paddingInlineStart: 24 }}>
|
||||
{ni.items.map((item, idx) => <li key={idx}>
|
||||
<Typography component='div'>
|
||||
{item.text}
|
||||
</Typography>
|
||||
</li>)}
|
||||
</ul>}
|
||||
<Box>
|
||||
<Button
|
||||
variant='solid' color='neutral' size='lg'
|
||||
component={Link} href={ROUTE_INDEX} noLinkStyle
|
||||
endDecorator='✨'
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
Sweet
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/*!firstCard &&*/ (
|
||||
<Typography level='body-sm' sx={{ position: 'absolute', right: 0, top: 0 }}>
|
||||
{ni.versionName}
|
||||
</Typography>
|
||||
)}
|
||||
{!!newsCallout && <Container disableGutters maxWidth='sm'>{newsCallout}</Container>}
|
||||
|
||||
{showExpander && (
|
||||
<IconButton
|
||||
variant='plain' size='sm'
|
||||
onClick={() => setLastNewsIdx(idx + 1)}
|
||||
sx={{ position: 'absolute', right: 0, bottom: 0, mr: -1, mb: -1 }}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
{!!news && <Container disableGutters maxWidth='sm'>
|
||||
{news?.map((ni, idx) => {
|
||||
const firstCard = idx === 0;
|
||||
const hasCardAfter = news.length < NewsItems.length;
|
||||
const showExpander = hasCardAfter && (idx === news.length - 1);
|
||||
const addPadding = false; //!firstCard; // || showExpander;
|
||||
return <Card key={'news-' + idx} sx={{ mb: 2, minHeight: 32 }}>
|
||||
<CardContent sx={{ position: 'relative', pr: addPadding ? 4 : 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<GoodTooltip title={ni.versionName || null} placement='top-start'>
|
||||
<Typography level='title-sm' component='div' sx={{ flexGrow: 1 }}>
|
||||
{ni.text ? ni.text : ni.versionName ? `${ni.versionCode} · ${ni.versionName}` : `Version ${ni.versionCode}:`}
|
||||
</Typography>
|
||||
</GoodTooltip>
|
||||
{/*!firstCard &&*/ (
|
||||
<Typography level='body-sm'>
|
||||
{!!ni.versionDate && <TimeAgo date={ni.versionDate} />}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
</CardContent>
|
||||
</Card>;
|
||||
})}
|
||||
</Container>}
|
||||
{!!ni.items && (ni.items.length > 0) && <ul style={{ marginTop: 8, marginBottom: 8, paddingInlineStart: 24 }}>
|
||||
{ni.items.filter(item => item.dev !== true).map((item, idx) => <li key={idx}>
|
||||
<Typography component='div' level='body-sm'>
|
||||
{item.text}
|
||||
</Typography>
|
||||
</li>)}
|
||||
</ul>}
|
||||
|
||||
<Button variant='solid' color='neutral' size='lg' component={Link} href='/' noLinkStyle>
|
||||
Got it!
|
||||
</Button>
|
||||
{showExpander && (
|
||||
<IconButton
|
||||
variant='plain' size='sm'
|
||||
onClick={() => setLastNewsIdx(idx + 1)}
|
||||
sx={{ position: 'absolute', right: 0, bottom: 0, mr: -1, mb: -1 }}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/*<Typography sx={{ textAlign: 'center' }}>*/}
|
||||
{/* Enjoy!*/}
|
||||
{/* <br /><br />*/}
|
||||
{/* -- The {Brand.Title.Base} Team*/}
|
||||
{/*</Typography>*/}
|
||||
</CardContent>
|
||||
</Card>;
|
||||
})}
|
||||
</Container>}
|
||||
|
||||
{/*<Typography sx={{ textAlign: 'center' }}>*/}
|
||||
{/* Enjoy!*/}
|
||||
{/* <br /><br />*/}
|
||||
{/* -- The {Brand.Title.Base} Team*/}
|
||||
{/*</Typography>*/}
|
||||
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
);
|
||||
|
||||
+111
-16
@@ -1,40 +1,132 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Typography } from '@mui/joy';
|
||||
import { Box, Button, Card, CardContent, Chip, Grid, Typography } from '@mui/joy';
|
||||
import LaunchIcon from '@mui/icons-material/Launch';
|
||||
|
||||
import { Brand } from '~/common/brand';
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { clientUtmSource } from '~/common/util/pwaUtils';
|
||||
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
|
||||
|
||||
// update this variable every time you want to broadcast a new version to clients
|
||||
export const incrementalVersion: number = 5;
|
||||
export const incrementalVersion: number = 8;
|
||||
|
||||
const B = (props: { href?: string, children: React.ReactNode }) => {
|
||||
const boldText = <Typography color={!!props.href ? 'primary' : 'warning'} sx={{ fontWeight: 600 }}>{props.children}</Typography>;
|
||||
return props.href ?
|
||||
<Link href={props.href + clientUtmSource()} target='_blank' sx={{ /*textDecoration: 'underline'*/ }}>{boldText} <LaunchIcon sx={{ ml: 1 }} /></Link> :
|
||||
boldText;
|
||||
};
|
||||
|
||||
const { OpenRepo, OpenProject } = Brand.URIs;
|
||||
const RCode = `${OpenRepo}/blob/main`;
|
||||
const RIssues = `${OpenRepo}/issues`;
|
||||
|
||||
// callout, for special occasions
|
||||
export const newsCallout =
|
||||
<Card>
|
||||
<CardContent sx={{ gap: 2 }}>
|
||||
<Typography level='h4'>
|
||||
Open Roadmap
|
||||
</Typography>
|
||||
<Typography>
|
||||
The roadmap is officially out. For the first time you get a look at what's brewing, up and coming, and get a chance to pick up cool features!
|
||||
</Typography>
|
||||
<Grid container spacing={1}>
|
||||
<Grid xs={12} sm={7}>
|
||||
<Button
|
||||
fullWidth variant='soft' color='primary' endDecorator={<LaunchIcon />}
|
||||
component={Link} href={OpenProject} noLinkStyle target='_blank'
|
||||
>
|
||||
Explore the Roadmap
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid xs={12} sm={5} sx={{ display: 'flex', flexAlign: 'center', justifyContent: 'center' }}>
|
||||
<Button
|
||||
fullWidth variant='plain' color='primary' endDecorator={<LaunchIcon />}
|
||||
component={Link} href={RIssues + '/new?template=roadmap-request.md&title=%5BSuggestion%5D'} noLinkStyle target='_blank'
|
||||
>
|
||||
Suggest a Feature
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>;
|
||||
|
||||
const B = (props: { children: React.ReactNode }) => <Typography color='danger' sx={{ fontWeight: 600 }}>{props.children}</Typography>;
|
||||
|
||||
// news and feature surfaces
|
||||
export const NewsItems: NewsItem[] = [
|
||||
/*{
|
||||
versionName: 'NEXT',
|
||||
// https://github.com/enricoros/big-agi/milestone/7
|
||||
// https://github.com/users/enricoros/projects/4/views/2
|
||||
versionName: '1.7.0',
|
||||
items: [
|
||||
{ text: <>CloudFlare OpenAI API Gateway</> },
|
||||
{ text: <>Helicone Anthropic support</> },
|
||||
{ text: <>Highlight differneces (Labs)</> },
|
||||
{ text: <>(Labs mode) YouTube personas creator</> },
|
||||
// multi-window support
|
||||
// phone calls
|
||||
],
|
||||
},*/
|
||||
{
|
||||
versionName: '1.4.0',
|
||||
versionCode: '1.7.2',
|
||||
versionName: 'Attachment Theory',
|
||||
versionDate: new Date('2023-12-11T06:00:00Z'), // new Date().toISOString()
|
||||
// versionDate: new Date('2023-12-10T12:00:00Z'), // 1.7.0
|
||||
items: [
|
||||
{ text: <>Redesigned <B href={RIssues + '/251'}>attachments system</B>: drag, paste, link, snap, images, text, pdfs</> },
|
||||
{ text: <>Desktop <B href={RIssues + '/253'}>webcam access</B> for direct image capture (Labs option)</> },
|
||||
{ text: <>Independent browsing with <B href={RCode + '/docs/config-browse.md'}>Browserless</B> support</> },
|
||||
{ text: <><B href={RIssues + '/256'}>Overheat</B> LLMs with higher temperature limits</> },
|
||||
{ text: <>Enhanced security via <B href={RCode + '/docs/deploy-authentication.md'}>password protection</B></> },
|
||||
{ text: <>{platformAwareKeystrokes('Ctrl+Shift+O')}: quick access to model options</> },
|
||||
{ text: <>Optimized voice input and performance</> },
|
||||
{ text: <>Latest Ollama and Oobabooga models</> },
|
||||
{ text: <>1.7.1: Improved <B href={RIssues + '/270'}>Ollama chats</B></> },
|
||||
{ text: <>1.7.2: Updated OpenRouter models</> },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionCode: '1.6.0',
|
||||
versionName: 'Surf\'s Up',
|
||||
versionDate: new Date('2023-11-28T21:00:00Z'),
|
||||
items: [
|
||||
{ text: <><B href={RIssues + '/237'}>Web Browsing</B> support, see the <B href={RCode + '/docs/config-browse.md'}>browsing user guide</B></> },
|
||||
{ text: <><B href={RIssues + '/235'}>Branching Discussions</B> at any message</> },
|
||||
{ text: <><B href={RIssues + '/207'}>Keyboard Navigation</B>: use {platformAwareKeystrokes('Ctrl+Shift+Left/Right')} to navigate chats</> },
|
||||
{ text: <><B href={RIssues + '/236'}>UI fixes</B> (thanks to the first sponsor)</> },
|
||||
{ text: <>Added support for Anthropic Claude 2.1</> },
|
||||
{ text: <>Large rendering performance optimization</> },
|
||||
{ text: <>More: <Chip>/help</Chip>, import ChatGPT from source, new Flattener</> },
|
||||
{ text: <>Devs: improved code quality, snackbar framework</>, dev: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionCode: '1.5.0',
|
||||
versionName: 'Loaded!',
|
||||
versionDate: new Date('2023-11-19T21:00:00Z'),
|
||||
items: [
|
||||
{ text: <><B href={RIssues + '/190'}>Continued Voice</B> for hands-free interaction</> },
|
||||
{ text: <><B href={RIssues + '/192'}>Visualization</B> Tool for data representations</> },
|
||||
{ text: <><B href={RCode + '/docs/config-ollama.md'}>Ollama (guide)</B> local models support</> },
|
||||
{ text: <><B href={RIssues + '/194'}>Text Tools</B> including highlight differences</> },
|
||||
{ text: <><B href='https://mermaid.js.org/'>Mermaid</B> Diagramming Rendering</> },
|
||||
{ text: <><B>OpenAI 1106</B> Chat Models</> },
|
||||
{ text: <><B>SDXL</B> support with Prodia</> },
|
||||
{ text: <>Cloudflare OpenAI API Gateway</> },
|
||||
{ text: <>Helicone for Anthropic</> },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionCode: '1.4.0',
|
||||
items: [
|
||||
{ text: <><B>Share and clone</B> conversations, with public links</> },
|
||||
{ text: <><B>Azure</B> models <Link href='https://github.com/enricoros/big-agi/blob/main/docs/config-azure-openai.md' target='_blank'>full support</Link>, incl. gpt-4-32k</> },
|
||||
{ text: <><B href={RCode + '/docs/config-azure-openai.md'}>Azure</B> models, incl. gpt-4-32k</> },
|
||||
{ text: <><B>OpenRouter</B> models full support, incl. gpt-4-32k</> },
|
||||
{ text: <>Latex Rendering</> },
|
||||
{ text: <>Augmented Chat modes (Labs)</> },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionName: '1.3.5',
|
||||
versionCode: '1.3.5',
|
||||
items: [
|
||||
{ text: <>AI in the real world with <B>Camera OCR</B> - MOBILE-ONLY</> },
|
||||
{ text: <><B>Anthropic</B> models full support</> },
|
||||
@@ -45,17 +137,17 @@ export const NewsItems: NewsItem[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
versionName: '1.3.1',
|
||||
versionCode: '1.3.1',
|
||||
items: [
|
||||
{ text: <><B>Flattener</B> - 4-mode conversations summarizer</> },
|
||||
{ text: <><B>Forking</B> - branch your conversations</> },
|
||||
{ text: <><B>/s</B> and <B>/a</B> to append a <i>system</i> or <i>assistant</i> message</> },
|
||||
{ text: <>Local LLMs with <Link href='https://github.com/enricoros/big-agi/blob/main/docs/config-local-oobabooga.md' target='_blank'>Oobabooga server</Link></> },
|
||||
{ text: <>Local LLMs with <Link href={RCode + '/docs/config-local-oobabooga.md'} target='_blank'>Oobabooga server</Link></> },
|
||||
{ text: 'NextJS STOP bug.. squashed, with Vercel!' },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionName: '1.2.1',
|
||||
versionCode: '1.2.1',
|
||||
// text: '',
|
||||
items: [
|
||||
{ text: <>New home page: <b><Link href={Brand.URIs.Home + clientUtmSource()} target='_blank'>{Brand.URIs.Home.replace('https://', '')}</Link></b></> },
|
||||
@@ -67,9 +159,12 @@ export const NewsItems: NewsItem[] = [
|
||||
|
||||
|
||||
interface NewsItem {
|
||||
versionName: string;
|
||||
versionCode: string;
|
||||
versionName?: string;
|
||||
versionDate?: Date;
|
||||
text?: string | React.JSX.Element;
|
||||
items?: {
|
||||
text: string | React.JSX.Element;
|
||||
dev?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Alert, Box, Button, Card, CardContent, CircularProgress, Grid, IconButton, Input, LinearProgress, Modal, ModalDialog, Radio, RadioGroup, Tooltip, Typography } from '@mui/joy';
|
||||
import { Alert, Box, Button, Card, CardContent, CircularProgress, Grid, IconButton, Input, LinearProgress, Tooltip, Typography } from '@mui/joy';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import WhatshotIcon from '@mui/icons-material/Whatshot';
|
||||
import YouTubeIcon from '@mui/icons-material/YouTube';
|
||||
|
||||
import { GoodModal } from '~/common/components/GoodModal';
|
||||
import { apiQuery } from '~/common/util/trpc.client';
|
||||
import { useModelsStore } from '~/modules/llms/store-llms';
|
||||
|
||||
import { copyToClipboard } from '~/common/util/copyToClipboard';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { useFormRadioLlmType } from '~/common/components/forms/useFormRadioLlmType';
|
||||
|
||||
import { LLMChainStep, useLLMChain } from './useLLMChain';
|
||||
|
||||
@@ -66,21 +65,11 @@ const YouTubePersonaSteps: LLMChainStep[] = [
|
||||
export function YTPersonaCreator() {
|
||||
// state
|
||||
const [videoURL, setVideoURL] = React.useState('');
|
||||
const [selectedModelType, setSelectedModelType] = React.useState<'chat' | 'fast'>('fast');
|
||||
// const [selectedLLMLabel, setSelectedLLMLabel] = React.useState<string | null>(null);
|
||||
const [videoID, setVideoID] = React.useState('');
|
||||
const [personaTranscript, setPersonaTranscript] = React.useState<string | null>(null);
|
||||
|
||||
// external state
|
||||
const { chatLLM, fastLLM } = useModelsStore(state => {
|
||||
const { chatLLMId, fastLLMId } = state;
|
||||
const chatLLM = state.llms.find(llm => llm.id === chatLLMId) ?? null;
|
||||
const fastLLM = state.llms.find(llm => llm.id === fastLLMId) ?? null;
|
||||
return {
|
||||
chatLLM: chatLLM,
|
||||
fastLLM: /*chatLLM === fastLLM ? null :*/ fastLLM,
|
||||
};
|
||||
}, shallow);
|
||||
const [diagramLlm, llmComponent] = useFormRadioLlmType();
|
||||
|
||||
// fetch transcript when the Video ID is ready, then store it
|
||||
const { transcript, thumbnailUrl, title, isFetching, isError, error: transcriptError } =
|
||||
@@ -88,9 +77,8 @@ export function YTPersonaCreator() {
|
||||
React.useEffect(() => setPersonaTranscript(transcript), [transcript]);
|
||||
|
||||
// use the transformation sequence to create a persona
|
||||
const llm = selectedModelType === 'chat' ? chatLLM : fastLLM;
|
||||
const { isFinished, isTransforming, chainProgress, chainIntermediates, chainStepName, chainOutput, chainError, abortChain } =
|
||||
useLLMChain(YouTubePersonaSteps, llm?.id, personaTranscript ?? undefined);
|
||||
useLLMChain(YouTubePersonaSteps, diagramLlm?.id, personaTranscript ?? undefined);
|
||||
|
||||
const handleVideoIdChange = (e: React.ChangeEvent<HTMLInputElement>) => setVideoURL(e.target.value);
|
||||
|
||||
@@ -142,17 +130,7 @@ export function YTPersonaCreator() {
|
||||
</form>
|
||||
|
||||
{/* LLM selector (chat vs fast) */}
|
||||
{!isTransforming && !isFinished && !!chatLLM && !!fastLLM && (
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={selectedModelType}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setSelectedModelType(event.target.value as 'chat' | 'fast')}
|
||||
>
|
||||
<Radio value='chat' label={chatLLM.label.startsWith('GPT-4') ? chatLLM.label + ' (slow, accurate)' : chatLLM.label} />
|
||||
<Radio value='fast' label={fastLLM.label} />
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{!isTransforming && !isFinished && llmComponent}
|
||||
|
||||
{/* 1. Transcript*/}
|
||||
{personaTranscript && (
|
||||
@@ -199,7 +177,7 @@ export function YTPersonaCreator() {
|
||||
</Alert>
|
||||
<Tooltip title='Copy system prompt' variant='solid'>
|
||||
<IconButton
|
||||
variant='outlined' color='neutral' onClick={() => copyToClipboard(chainOutput)}
|
||||
variant='outlined' color='neutral' onClick={() => copyToClipboard(chainOutput, 'Persona prompt')}
|
||||
sx={{
|
||||
position: 'absolute', right: 0, zIndex: 10,
|
||||
// opacity: 0, transition: 'opacity 0.3s',
|
||||
@@ -239,28 +217,26 @@ export function YTPersonaCreator() {
|
||||
|
||||
|
||||
{/* Embodiment Progress */}
|
||||
{isTransforming && <Modal open>
|
||||
<ModalDialog>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', my: 2 }}>
|
||||
<CircularProgress color='primary' value={Math.max(10, 100 * chainProgress)} />
|
||||
</Box>
|
||||
<Typography color='success' level='title-lg' sx={{ mt: 1 }}>
|
||||
Embodying Persona ...
|
||||
</Typography>
|
||||
<Typography color='success' level='title-sm' sx={{ mt: 1, fontWeight: 600 }}>
|
||||
{chainStepName}
|
||||
</Typography>
|
||||
<LinearProgress color='success' determinate value={Math.max(10, 100 * chainProgress)} sx={{ mt: 1, mb: 2 }} />
|
||||
<Typography level='title-sm'>
|
||||
This may take 1-2 minutes. Do not close this window or the progress will be lost.
|
||||
If you experience any errors (e.g. LLM timeouts, or context overflows for larger videos)
|
||||
please try again with faster/smaller models.
|
||||
</Typography>
|
||||
<Button variant='soft' color='neutral' onClick={abortChain} sx={{ ml: 'auto', minWidth: 100, mt: 5 }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalDialog>
|
||||
</Modal>}
|
||||
{isTransforming && <GoodModal open>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', my: 2 }}>
|
||||
<CircularProgress color='primary' value={Math.max(10, 100 * chainProgress)} />
|
||||
</Box>
|
||||
<Typography color='success' level='title-lg' sx={{ mt: 1 }}>
|
||||
Embodying Persona ...
|
||||
</Typography>
|
||||
<Typography color='success' level='title-sm' sx={{ mt: 1, fontWeight: 600 }}>
|
||||
{chainStepName}
|
||||
</Typography>
|
||||
<LinearProgress color='success' determinate value={Math.max(10, 100 * chainProgress)} sx={{ mt: 1, mb: 2 }} />
|
||||
<Typography level='title-sm'>
|
||||
This may take 1-2 minutes. Do not close this window or the progress will be lost.
|
||||
If you experience any errors (e.g. LLM timeouts, or context overflows for larger videos)
|
||||
please try again with faster/smaller models.
|
||||
</Typography>
|
||||
<Button variant='soft' color='neutral' onClick={abortChain} sx={{ ml: 'auto', minWidth: 100, mt: 5 }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</GoodModal>}
|
||||
|
||||
</>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user