mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
385 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c44804d50 | |||
| 8589376c66 | |||
| d53a8b4941 | |||
| af819da623 | |||
| 3addc4e2ac | |||
| 7ff7e489ab | |||
| 95aa0da014 | |||
| b12637267b | |||
| 3a44f70db9 | |||
| 92206d9740 | |||
| bddd91df2a | |||
| 144ead8cfe | |||
| 185f8e7f44 | |||
| 1538cd83af | |||
| 027f7deb3a | |||
| 4043a6098b | |||
| 92b913be98 | |||
| 8505ba6b84 | |||
| c6973f6b4e | |||
| 94eddaff3f | |||
| f38be4aff3 | |||
| 3ea78fcf9f | |||
| 78cfcc6206 | |||
| 9c5d4a18ce | |||
| aa48b4d596 | |||
| 265acd9345 | |||
| 34ec1d5671 | |||
| 4a1f4f0a01 | |||
| 850528820f | |||
| 4dc8197c51 | |||
| 42e97eed4c | |||
| 065f30ac38 | |||
| 9e705a12b1 | |||
| b8144f0748 | |||
| e5b5faad3e | |||
| f840c1d424 | |||
| eabd268874 | |||
| 06aadc543a | |||
| 2a410f52b5 | |||
| eb7a32ed16 | |||
| 14118d3056 | |||
| c8b3d8ad9b | |||
| a097b32d5c | |||
| 0a88a9cee6 | |||
| bef1c0c5fc | |||
| 52e6ef436f | |||
| ad0617de90 | |||
| 1753c1a40a | |||
| 13b7004959 | |||
| 3b9a21bbf7 | |||
| 5f0beb9d00 | |||
| 8411a73589 | |||
| 009a3751c0 | |||
| adef88e358 | |||
| f8b9df7bf0 | |||
| c6fa3e1d24 | |||
| ae24dd1e28 | |||
| 1efca7dd48 | |||
| 3178f4e7e9 | |||
| e00f61dcd0 | |||
| 6a5774aae7 | |||
| 5119061861 | |||
| fdfbae334a | |||
| e3fce43e62 | |||
| 9251f8ff0e | |||
| 18ef40f6f4 | |||
| 46887d1d9f | |||
| 632d10e9e3 | |||
| 9fa33eea73 | |||
| 2c4c13bc2c | |||
| 33f8a4eb3a | |||
| aa7959a970 | |||
| 7471bc0bb2 | |||
| b257f75e53 | |||
| 455e279216 | |||
| 7fd359852a | |||
| 82ecfdbd37 | |||
| 478452983f | |||
| 5c1a7d485f | |||
| 39c4ce9240 | |||
| da49585df5 | |||
| 0b9bee02fe | |||
| 00e5d1ae27 | |||
| b290d63926 | |||
| 1b5438cc6c | |||
| 17323facce | |||
| bc9dedeea4 | |||
| 1b3a383b53 | |||
| 4e0a535402 | |||
| 0005db1b33 | |||
| 5cd74031be | |||
| facb85b5da | |||
| 5f97d17837 | |||
| af722e09f8 | |||
| 959edf6010 | |||
| d08f183394 | |||
| da541ae182 | |||
| 4582c4c03d | |||
| 8c7d70d434 | |||
| fcf9f9e562 | |||
| 7bb0fb294a | |||
| 2e7b5ba5f0 | |||
| 6b017f3678 | |||
| a303d00900 | |||
| aaa351dca4 | |||
| ee5fb5361c | |||
| aaffcdbfeb | |||
| a8fefb5a90 | |||
| 8e3b07fa49 | |||
| 36ac618e88 | |||
| ab0eeae1e3 | |||
| f74adffa12 | |||
| 8f23f41e2f | |||
| 7d04844c6a | |||
| c301dcc226 | |||
| 8dd4ece730 | |||
| 75bd68f9fe | |||
| 96af022afa | |||
| c570c68f1b | |||
| 21a226a486 | |||
| 2695cb8e46 | |||
| 2207405ebc | |||
| 3802123147 | |||
| c6c630f5c6 | |||
| 7c76a17c08 | |||
| 5ba7723fa0 | |||
| 87ff07c850 | |||
| 71e1a2eeec | |||
| 88fba0f53a | |||
| 07260a8e06 | |||
| c1d155b569 | |||
| 7e7cfe1db1 | |||
| d27a44ab7f | |||
| 2adcca1cda | |||
| cf854b7262 | |||
| ecb0e07312 | |||
| 8b2b88c7cb | |||
| 9af1a6a16b | |||
| d1ac9adc7e | |||
| 513edf90f7 | |||
| 60d47510ab | |||
| 5b7b9837f0 | |||
| 333c3327c4 | |||
| 9723c98940 | |||
| 97604f3c5b | |||
| 044f18da46 | |||
| 53946b9523 | |||
| fd8f88c5e4 | |||
| e7d15ce2b0 | |||
| ff1d98a87e | |||
| accc68cd28 | |||
| b2c7bc980f | |||
| 75fbe8d5d8 | |||
| 13ebf3b3aa | |||
| 916d3812db | |||
| 90610c819b | |||
| a5f6f62559 | |||
| bfb3501dec | |||
| c0513c50b1 | |||
| bcf4baf004 | |||
| 53bf948a04 | |||
| 2186d91f89 | |||
| aaf856a503 | |||
| 8af625b7dc | |||
| 4690891757 | |||
| bb3e17c0fa | |||
| 7965df5ff2 | |||
| 5b5f0a5a8d | |||
| fdb087a39b | |||
| 97749378d6 | |||
| 63dc2301ff | |||
| 5659c0bc70 | |||
| 1e288ab0fd | |||
| 4f058a0174 | |||
| 7284114565 | |||
| 0b2592dbd7 | |||
| edfaf6f002 | |||
| da3990b614 | |||
| 25740ae13c | |||
| fb4c05f698 | |||
| a0c4e37c94 | |||
| 278caf6f0c | |||
| 2ce0c61f83 | |||
| afb25324a7 | |||
| ba1b761c08 | |||
| 0e2d4af617 | |||
| 1b0b54a072 | |||
| 9c629d3c5c | |||
| 173af4e459 | |||
| c0f12c0a5d | |||
| 390605fe66 | |||
| e4bd5f865c | |||
| b31c891772 | |||
| 08e4016972 | |||
| aea7eb6ba3 | |||
| 5496750085 | |||
| 4b9709898c | |||
| 705daac737 | |||
| a802b32f47 | |||
| 8b8db5e447 | |||
| 3ee44599c7 | |||
| 2955a41ed5 | |||
| a52802c882 | |||
| b46c70512a | |||
| 18f91e2eeb | |||
| 9296984569 | |||
| 7b835d9855 | |||
| ce23b9169b | |||
| 47a535d309 | |||
| 6342801aa0 | |||
| 50c00f5516 | |||
| 4a49678fb6 | |||
| 0f10b8f677 | |||
| d8433b79cc | |||
| f94f640212 | |||
| 5cf779757f | |||
| d49acf379e | |||
| b9bff4abc0 | |||
| 6fc4dbe9d1 | |||
| cca8132a2c | |||
| 91654ca219 | |||
| 547d7eca59 | |||
| b86bf31baa | |||
| 5b5b4efe42 | |||
| e9fb65edba | |||
| cc1cba9aa8 | |||
| a765c566c8 | |||
| 63e9022b84 | |||
| 368a995e7f | |||
| c844c66b5a | |||
| 73b18313e9 | |||
| bdd68dc6c9 | |||
| 3901b94382 | |||
| 82ac276338 | |||
| 02c9f3ebdb | |||
| 364ad63877 | |||
| 5fc4196d01 | |||
| 3a1e10bd21 | |||
| 73519ec562 | |||
| bf9c9916b1 | |||
| 01d017c6cd | |||
| ca98ab02d8 | |||
| 347804a02e | |||
| 4c80f8dbf4 | |||
| 73ee96040f | |||
| 6180da1333 | |||
| 2756ff6ad0 | |||
| e57491b812 | |||
| 9d8ae538d9 | |||
| dd7defd2c7 | |||
| e79ec45b5b | |||
| 1a138bbc16 | |||
| b067165471 | |||
| 6fbcbb9399 | |||
| aaf77b4e20 | |||
| f5cc2e952b | |||
| eeab362567 | |||
| 834205c426 | |||
| fbad8ca62e | |||
| 1e4c6f13c5 | |||
| b7c2b3d4cb | |||
| 0d5b7d36f1 | |||
| 059886fede | |||
| db7dd0ca43 | |||
| f4c611b47d | |||
| 39c32646c5 | |||
| 1720fffbdc | |||
| 6c51cd0d1d | |||
| cb9cdc508a | |||
| 7d037a206f | |||
| ace10ab4be | |||
| bc0a7b6ac3 | |||
| e77e2045e3 | |||
| abbd55c740 | |||
| bf5e80a462 | |||
| 121deaae5f | |||
| 80317232ba | |||
| 22f815dcd1 | |||
| fb96c3ab47 | |||
| 3b15ad51a1 | |||
| 5066336c75 | |||
| 0807744577 | |||
| 59f871d3ec | |||
| fed351a2fc | |||
| aeb129e422 | |||
| 3050b546ac | |||
| 1429726ba6 | |||
| 4075581acd | |||
| 56774fd974 | |||
| 5e674d2299 | |||
| 06f5b6d6ff | |||
| b25b4e6c8f | |||
| 645e07dba8 | |||
| 46181fcaa2 | |||
| 8d7ae425f9 | |||
| 7d572334a1 | |||
| 5dab6f68e6 | |||
| d1c595d8db | |||
| eaa2635b51 | |||
| dc2d226ddb | |||
| 336a4e1f35 | |||
| 4d3b6b4f43 | |||
| a12601b49c | |||
| 15a895064e | |||
| 8bd1507ace | |||
| 89d7ec5d0b | |||
| 670e57735a | |||
| fa703c25e8 | |||
| f58161b1d1 | |||
| 8db2a37a59 | |||
| bfdb9c2624 | |||
| 240e984737 | |||
| fe128c18b1 | |||
| b208d8c40d | |||
| 556641e1f4 | |||
| 464eb671db | |||
| 12b8f1e3ef | |||
| ab199afe0d | |||
| fe1a498da0 | |||
| 4f9d55eb42 | |||
| 70f450f547 | |||
| 28fc7deefc | |||
| 428babf856 | |||
| b824ddf2e3 | |||
| 2396966740 | |||
| 23ca49128a | |||
| ec6bdede20 | |||
| 4ada2013d2 | |||
| 79afef6bc1 | |||
| e7000df89f | |||
| 59f77a64ea | |||
| 8be152666e | |||
| 10488854ce | |||
| 6586aafed8 | |||
| 4568a60be3 | |||
| 193bc8bb8e | |||
| ce381b7690 | |||
| b238428816 | |||
| 0ac37f50cf | |||
| 54b9389b77 | |||
| a183c26e51 | |||
| 01a03d164c | |||
| cdff1fde2d | |||
| c38b9998a6 | |||
| 77c1a335ad | |||
| 07a0fe6249 | |||
| 204bc46976 | |||
| b910506519 | |||
| 3cef39da17 | |||
| 3aea29bcb5 | |||
| dd0d19168b | |||
| 6727fcd111 | |||
| 9d347f4a5a | |||
| 084e48ddc2 | |||
| 31e89ce9a1 | |||
| baad3ae1c3 | |||
| 7c099cab94 | |||
| 811875dd2e | |||
| 127443d550 | |||
| d2064605bf | |||
| 4c6fb61ca8 | |||
| 608ba8bcb4 | |||
| b53c054dee | |||
| 05aa4b547f | |||
| 6afb61d25d | |||
| a7ce5c1ca6 | |||
| 952bd2bd93 | |||
| f9d33d4888 | |||
| 81d99f19d4 | |||
| 454a4257da | |||
| e513b42786 | |||
| b607e3c034 | |||
| d5c3f5012b | |||
| 21d045be59 | |||
| a9c1c34dc9 | |||
| 44ab0483b6 | |||
| 9eb0cc0b62 | |||
| 2db74867f5 | |||
| fd30baafb8 | |||
| 3623eef47f | |||
| 7b07bb7884 | |||
| 7946cd6614 | |||
| 51b6e30986 | |||
| 002df7b0f9 | |||
| 2ac1789312 |
@@ -11,8 +11,11 @@ name: Create and publish Docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
#- main-stable # Disabled as the v* tag is used for stable releases
|
||||
tags:
|
||||
- 'v1.1*' # V1 legacy tags only (v1.10.x - v1.19.x range)
|
||||
- 'v*' # Trigger on version tags (e.g., v1.7.0)
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@@ -48,13 +51,11 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# V1 legacy pointers
|
||||
type=raw,value=v1
|
||||
type=raw,value=v1-stable
|
||||
|
||||
# Exact version tags (v1.16.11 and 1.16.11)
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
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
|
||||
type=sha # Just in case none of the above applies
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
@@ -64,10 +65,5 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=Big-AGI v1 (Legacy)
|
||||
org.opencontainers.image.description=Big-AGI v1 - Legacy version. For the latest version, see GitHub and big-agi.com
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.documentation=https://big-agi.com
|
||||
build-args: |
|
||||
NEXT_PUBLIC_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: NEXT_PUBLIC_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}
|
||||
|
||||
+6
-12
@@ -1,6 +1,6 @@
|
||||
# Base
|
||||
FROM node:22-alpine AS base
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
FROM node:18-alpine AS base
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
|
||||
# Dependencies
|
||||
@@ -11,11 +11,8 @@ WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY src/server/prisma ./src/server/prisma
|
||||
|
||||
# link ssl3 for latest Alpine
|
||||
RUN sh -c '[ ! -e /lib/libssl.so.3 ] && ln -s /usr/lib/libssl.so.3 /lib/libssl.so.3 || echo "Link already exists"'
|
||||
|
||||
# Install dependencies, including dev (release builds should use npm ci)
|
||||
ENV NODE_ENV=development
|
||||
ENV NODE_ENV development
|
||||
RUN npm ci
|
||||
|
||||
|
||||
@@ -31,11 +28,8 @@ ENV NEXT_PUBLIC_GA4_MEASUREMENT_ID=${NEXT_PUBLIC_GA4_MEASUREMENT_ID}
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# link ssl3 for latest Alpine
|
||||
RUN sh -c '[ ! -e /lib/libssl.so.3 ] && ln -s /usr/lib/libssl.so.3 /lib/libssl.so.3 || echo "Link already exists"'
|
||||
|
||||
# Build the application
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_ENV production
|
||||
RUN npm run build
|
||||
|
||||
# Reduce installed packages to production-only
|
||||
@@ -57,8 +51,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/src/server/prisma ./src/server/prisma
|
||||
|
||||
# Minimal ENV for production
|
||||
ENV NODE_ENV=production
|
||||
ENV PATH=$PATH:/app/node_modules/.bin
|
||||
ENV NODE_ENV production
|
||||
ENV PATH $PATH:/app/node_modules/.bin
|
||||
|
||||
# Run as non-root user
|
||||
USER nextjs
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
# BIG-AGI Legacy ✨
|
||||
# BIG-AGI 🧠✨
|
||||
|
||||
> **⚠️ IMPORTANT: `v1` Branch - End of Life**
|
||||
>
|
||||
> This is the **v1** (formerly **v1-stable**) branch, which reached end-of-life in October 2025.
|
||||
> - **For the latest Big-AGI**, see the [**main** branch](https://github.com/enricoros/big-AGI/tree/main) and [**big-agi.com**](https://big-agi.com)
|
||||
> - **v1.16.11 is the final legacy release** - No further updates will be provided
|
||||
> - Docker users: `:stable`, `:development` and `:latest` point to the `main` branch
|
||||
Welcome to big-AGI, the AI suite for professionals that need function, form,
|
||||
simplicity, and speed. Powered by the latest models from 12 vendors and
|
||||
open-source servers, `big-AGI` offers best-in-class Chats,
|
||||
[Beams](https://github.com/enricoros/big-AGI/issues/470),
|
||||
and [Calls](https://github.com/enricoros/big-AGI/issues/354) with AI personas,
|
||||
visualizations, coding, drawing, side-by-side chatting, and more -- all wrapped in a polished UX.
|
||||
|
||||
---
|
||||
Stay ahead of the curve with big-AGI. 🚀 Pros & Devs love big-AGI. 🤖
|
||||
|
||||
**For the latest Big-AGI:**
|
||||
- [**Big-AGI Open**](https://github.com/enricoros/big-AGI/tree/main) - Open Source, for self-hosting, with bleeding edge models support
|
||||
- [**Big-AGI Pro**](https://big-agi.com) - Hosted for Professionals with extra services and Cloud Sync
|
||||
[](https://big-agi.com)
|
||||
|
||||
---
|
||||
Or fork & run on Vercel
|
||||
|
||||
### What's New in 1.16.11 · October 2025
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI&env=OPENAI_API_KEY&envDescription=Backend%20API%20keys%2C%20optional%20and%20may%20be%20overridden%20by%20the%20UI.&envLink=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI%2Fblob%2Fmain%2Fdocs%2Fenvironment-variables.md&project-name=big-AGI)
|
||||
|
||||
- Final v1 legacy release. Branch reaches end-of-life.
|
||||
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2) 👉 [installation](docs/installation.md) 👉 [documentation](docs/README.md)
|
||||
|
||||
### What's New in 1.16.1...1.16.10 · 2024-2025 (patch releases)
|
||||
> Note: bigger better features (incl. Beam-2) are being cooked outside of `main`.
|
||||
|
||||
[//]: # (big-AGI is an open book; see the **[ready-to-ship and future ideas](https://github.com/users/enricoros/projects/4/views/2)** in our open roadmap)
|
||||
|
||||
### What's New in 1.16.1...1.16.3 · Jun 20, 2024 (patch releases)
|
||||
|
||||
- 1.16.10: Openrouter models fixes
|
||||
- 1.16.9: Docker Gemini fix (R1 models are supported in latest Big-AGI)
|
||||
- 1.16.8: OpenAI ChatGPT-4o Latest (o1 models are supported in latest Big-AGI)
|
||||
- 1.16.7: OpenAI support for GPT-4o 2024-08-06
|
||||
- 1.16.6: Groq support for Llama 3.1 models
|
||||
- 1.16.5: GPT-4o Mini support
|
||||
- 1.16.4: 8192 tokens support for Claude 3.5 Sonnet
|
||||
- 1.16.3: Anthropic Claude 3.5 Sonnet model support
|
||||
- 1.16.2: Improve web downloads, as text, markdwon, or HTML
|
||||
- 1.16.2: Proper support for Gemini models
|
||||
@@ -36,6 +31,190 @@
|
||||
- 1.16.2: Updates to Beam
|
||||
- 1.16.1: Support for the new OpenAI GPT-4o 2024-05-13 model
|
||||
|
||||
### What's New in 1.16.0 · May 9, 2024 · Crystal Clear
|
||||
|
||||
- [Beam](https://big-agi.com/blog/beam-multi-model-ai-reasoning) core and UX improvements based on user feedback
|
||||
- Chat cost estimation 💰 (enable it in Labs / hover the token counter)
|
||||
- Save/load chat files with Ctrl+S / Ctrl+O on desktop
|
||||
- Major enhancements to the Auto-Diagrams tool
|
||||
- YouTube Transcriber Persona for chatting with video content, [#500](https://github.com/enricoros/big-AGI/pull/500)
|
||||
- Improved formula rendering (LaTeX), and dark-mode diagrams, [#508](https://github.com/enricoros/big-AGI/issues/508), [#520](https://github.com/enricoros/big-AGI/issues/520)
|
||||
- Models update: **Anthropic**, **Groq**, **Ollama**, **OpenAI**, **OpenRouter**, **Perplexity**
|
||||
- Code soft-wrap, chat text selection toolbar, 3x faster on Apple silicon, and more [#517](https://github.com/enricoros/big-AGI/issues/517), [507](https://github.com/enricoros/big-AGI/pull/507)
|
||||
|
||||
#### 3,000 Commits Milestone · April 7, 2024
|
||||
|
||||

|
||||
|
||||
- 🥇 Today we <b>celebrate commit 3000</b> in just over one year, and going stronger 🚀
|
||||
- 📢️ Thanks everyone for your support and words of love for Big-AGI, we are committed to creating the best AI experiences for everyone.
|
||||
|
||||
### What's New in 1.15.0 · April 1, 2024 · Beam
|
||||
|
||||
- ⚠️ [**Beam**: the multi-model AI chat](https://big-agi.com/blog/beam-multi-model-ai-reasoning). find better answers, faster - a game-changer for brainstorming, decision-making, and creativity. [#443](https://github.com/enricoros/big-AGI/issues/443)
|
||||
- Managed Deployments **Auto-Configuration**: simplify the UI models setup with backend-set models. [#436](https://github.com/enricoros/big-AGI/issues/436)
|
||||
- Message **Starring ⭐**: star important messages within chats, to attach them later. [#476](https://github.com/enricoros/big-AGI/issues/476)
|
||||
- Enhanced the default Persona
|
||||
- Fixes to Gemini models and SVGs, improvements to UI and icons
|
||||
- 1.15.1: Support for Gemini Pro 1.5 and OpenAI Turbo models
|
||||
- Beast release, over 430 commits, 10,000+ lines changed: [release notes](https://github.com/enricoros/big-AGI/releases/tag/v1.15.0), and changes [v1.14.1...v1.15.0](https://github.com/enricoros/big-AGI/compare/v1.14.1...v1.15.0)
|
||||
|
||||
<details>
|
||||
<summary>What's New in 1.14.1 · March 7, 2024 · Modelmorphic</summary>
|
||||
|
||||
- **Anthropic** [Claude-3](https://www.anthropic.com/news/claude-3-family) model family support. [#443](https://github.com/enricoros/big-AGI/issues/443)
|
||||
- New **[Perplexity](https://www.perplexity.ai/)** and **[Groq](https://groq.com/)** integration (thanks @Penagwin). [#407](https://github.com/enricoros/big-AGI/issues/407), [#427](https://github.com/enricoros/big-AGI/issues/427)
|
||||
- **[LocalAI](https://localai.io/models/)** deep integration, including support for [model galleries](https://github.com/enricoros/big-AGI/issues/411)
|
||||
- **Mistral** Large and Google **Gemini 1.5** support
|
||||
- Performance optimizations: runs [much faster](https://twitter.com/enricoros/status/1756553038293303434?utm_source=localhost:3000&utm_medium=big-agi), saves lots of power, reduces memory usage
|
||||
- Enhanced UX with auto-sizing charts, refined search and folder functionalities, perfected scaling
|
||||
- And with more UI improvements, documentation, bug fixes (20 tickets), and developer enhancements
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What's New in 1.13.0 · Feb 8, 2024 · Multi + Mind</summary>
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/32999/01732528-730e-41dc-adc7-511385686b13
|
||||
|
||||
- **Side-by-Side Split Windows**: multitask with parallel conversations. [#208](https://github.com/enricoros/big-AGI/issues/208)
|
||||
- **Multi-Chat Mode**: message everyone, all at once. [#388](https://github.com/enricoros/big-AGI/issues/388)
|
||||
- **Export tables as CSV**: big thanks to @aj47. [#392](https://github.com/enricoros/big-AGI/pull/392)
|
||||
- Adjustable text size: customize density. [#399](https://github.com/enricoros/big-AGI/issues/399)
|
||||
- Dev2 Persona Technology Preview
|
||||
- Better looking chats with improved spacing, fonts, and menus
|
||||
- More: new video player, [LM Studio tutorial](https://github.com/enricoros/big-AGI/blob/main/docs/config-local-lmstudio.md) (thanks @aj47), [MongoDB support](https://github.com/enricoros/big-AGI/blob/main/docs/deploy-database.md) (thanks @ranfysvalle02), and speedups
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline</summary>
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
|
||||
|
||||
- **Voice Calls**: real-time voice call your personas out of the blue or in relation to a chat [#354](https://github.com/enricoros/big-AGI/issues/354)
|
||||
- Support **OpenAI 0125** Models. [#364](https://github.com/enricoros/big-AGI/issues/364)
|
||||
- Rename or Auto-Rename chats. [#222](https://github.com/enricoros/big-AGI/issues/222), [#360](https://github.com/enricoros/big-AGI/issues/360)
|
||||
- More control over **Link Sharing** [#356](https://github.com/enricoros/big-AGI/issues/356)
|
||||
- **Accessibility** to screen readers [#358](https://github.com/enricoros/big-AGI/issues/358)
|
||||
- Export chats to Markdown [#337](https://github.com/enricoros/big-AGI/issues/337)
|
||||
- Paste tables from Excel [#286](https://github.com/enricoros/big-AGI/issues/286)
|
||||
- Ollama model updates and context window detection fixes [#309](https://github.com/enricoros/big-AGI/issues/309)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What's New in 1.11.0 · Jan 16, 2024 · Singularity</summary>
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cfcb110c68
|
||||
|
||||
- **Find chats**: search in titles and content, with frequency ranking. [#329](https://github.com/enricoros/big-AGI/issues/329)
|
||||
- **Commands**: command auto-completion (type '/'). [#327](https://github.com/enricoros/big-AGI/issues/327)
|
||||
- **[Together AI](https://www.together.ai/products#inference)** inference platform support (good speed and newer models). [#346](https://github.com/enricoros/big-AGI/issues/346)
|
||||
- Persona Creator history, deletion, custom creation, fix llm API timeouts
|
||||
- Enable adding up to five custom OpenAI-compatible endpoints
|
||||
- Developer enhancements: new 'Actiles' framework
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>What's New in 1.10.0 · Jan 6, 2024 · The Year of AGI</summary>
|
||||
|
||||
- **New UI**: for both desktop and mobile, sets the stage for future scale. [#201](https://github.com/enricoros/big-AGI/issues/201)
|
||||
- **Conversation Folders**: enhanced conversation organization. [#321](https://github.com/enricoros/big-AGI/issues/321)
|
||||
- **[LM Studio](https://lmstudio.ai/)** support and improved token management
|
||||
- Resizable panes in split-screen conversations.
|
||||
- Large performance optimizations
|
||||
- Developer enhancements: new UI framework, updated documentation for proxy settings on browserless/docker
|
||||
|
||||
</details>
|
||||
|
||||
For full details and former releases, check out the [changelog](docs/changelog.md).
|
||||
|
||||
## 👉 Key Features ✨
|
||||
|
||||
|  |  |  |  |  |
|
||||
|---------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|
|
||||
| **Chat**<br/>**Call**<br/>**Beam**<br/>**Draw**, ... | Local & Cloud<br/>Open & Closed<br/>Cheap & Heavy<br/>Google, Mistral, ... | Attachments<br/>Diagrams<br/>Multi-Chat<br/>Mobile-first UI | Stored Locally<br/>Easy self-Host<br/>Local actions<br/>Data = Gold | AI Personas<br/>Voice Modes<br/>Screen Capture<br/>Camera + OCR |
|
||||
|
||||

|
||||
|
||||
You can easily configure 100s of AI models in big-AGI:
|
||||
|
||||
| **AI models** | _supported vendors_ |
|
||||
|:--------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Opensource Servers | [LocalAI](https://localai.io/) (multimodal) · [Ollama](https://ollama.com/) · [Oobabooga](https://github.com/oobabooga/text-generation-webui) |
|
||||
| Local Servers | [LM Studio](https://lmstudio.ai/) |
|
||||
| Multimodal services | [Azure](https://azure.microsoft.com/en-us/products/ai-services/openai-service) · [Google Gemini](https://ai.google.dev/) · [OpenAI](https://platform.openai.com/docs/overview) |
|
||||
| Language services | [Anthropic](https://anthropic.com) · [Groq](https://wow.groq.com/) · [Mistral](https://mistral.ai/) · [OpenRouter](https://openrouter.ai/) · [Perplexity](https://www.perplexity.ai/) · [Together AI](https://www.together.ai/) |
|
||||
| Image services | [Prodia](https://prodia.com/) (SDXL) |
|
||||
| Speech services | [ElevenLabs](https://elevenlabs.io) (Voice synthesis / cloning) |
|
||||
|
||||
Add extra functionality with these integrations:
|
||||
|
||||
| **More** | _integrations_ |
|
||||
|:-------------|:---------------------------------------------------------------------------------------------------------------|
|
||||
| Web Browse | [Browserless](https://www.browserless.io/) · [Puppeteer](https://pptr.dev/)-based |
|
||||
| Web Search | [Google CSE](https://programmablesearchengine.google.com/) |
|
||||
| Code Editors | [CodePen](https://codepen.io/pen/) · [StackBlitz](https://stackblitz.com/) · [JSFiddle](https://jsfiddle.net/) |
|
||||
| Sharing | [Paste.gg](https://paste.gg/) (Paste chats) |
|
||||
| Tracking | [Helicone](https://www.helicone.ai) (LLM Observability) |
|
||||
|
||||
[//]: # (- [x] **Flow-state UX** for uncompromised productivity)
|
||||
|
||||
[//]: # (- [x] **AI Personas**: Tailor your AI interactions with customizable personas)
|
||||
|
||||
[//]: # (- [x] **Sleek UI/UX**: A smooth, intuitive, and mobile-responsive interface)
|
||||
|
||||
[//]: # (- [x] **Efficient Interaction**: Voice commands, OCR, and drag-and-drop file uploads)
|
||||
|
||||
[//]: # (- [x] **Privacy First**: Self-host and use your own API keys for full control)
|
||||
|
||||
[//]: # (- [x] **Advanced Tools**: Execute code, import PDFs, and summarize documents)
|
||||
|
||||
[//]: # (- [x] **Seamless Integrations**: Enhance functionality with various third-party services)
|
||||
|
||||
[//]: # (- [x] **Open Roadmap**: Contribute to the progress of big-AGI)
|
||||
|
||||
<br/>
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
To get started with big-AGI, follow our comprehensive [Installation Guide](docs/installation.md).
|
||||
The guide covers various installation options, whether you're spinning it up on
|
||||
your local computer, deploying on Vercel, on Cloudflare, or rolling it out
|
||||
through Docker.
|
||||
|
||||
Whether you're a developer, system integrator, or enterprise user, you'll find step-by-step instructions
|
||||
to set up big-AGI quickly and easily.
|
||||
|
||||
[](docs/installation.md)
|
||||
|
||||
Or bring your API keys and jump straight into our free instance on [big-AGI.com](https://big-agi.com).
|
||||
|
||||
<br/>
|
||||
|
||||
# 🌟 Get Involved!
|
||||
|
||||
[//]: # ([](https://discord.gg/MkH4qj2Jp9))
|
||||
[](https://discord.gg/MkH4qj2Jp9)
|
||||
|
||||
- [ ] 📢️ [**Chat with us** on Discord](https://discord.gg/MkH4qj2Jp9)
|
||||
- [ ] ⭐ **Give us a star** on GitHub 👆
|
||||
- [ ] 🚀 **Do you like code**? You'll love this gem of a project! [_Pick up a task!_](https://github.com/users/enricoros/projects/4/views/4) - _easy_ to _pro_
|
||||
- [ ] 💡 Got a feature suggestion? [_Add your roadmap ideas_](https://github.com/enricoros/big-agi/issues/new?&template=roadmap-request.md)
|
||||
- [ ] ✨ [Deploy](docs/installation.md) your [fork](docs/customizations.md) for your friends and family, or [customize it for work](docs/customizations.md)
|
||||
|
||||
<br/>
|
||||
|
||||
[//]: # ([](https://github.com/enricoros/big-agi/stargazers))
|
||||
|
||||
[//]: # ([](https://github.com/enricoros/big-agi/network))
|
||||
|
||||
[//]: # ([](https://github.com/enricoros/big-agi/pulls))
|
||||
|
||||
[//]: # ([](https://github.com/enricoros/big-agi/LICENSE))
|
||||
|
||||
---
|
||||
|
||||
2023-2025 · Enrico Ros x [Big-AGI](https://big-agi.com) · License: [MIT](LICENSE) · Made with 💙
|
||||
2023-2024 · Enrico Ros x [big-AGI](https://big-agi.com) · License: [MIT](LICENSE) · Made with 💙
|
||||
|
||||
@@ -5,13 +5,13 @@ import { createTRPCFetchContext } from '~/server/api/trpc.server';
|
||||
|
||||
const handlerEdgeRoutes = (req: Request) =>
|
||||
fetchRequestHandler({
|
||||
router: appRouterEdge,
|
||||
endpoint: '/api/trpc-edge',
|
||||
router: appRouterEdge,
|
||||
req,
|
||||
createContext: createTRPCFetchContext,
|
||||
onError:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? "<no-path>"}: ${error.message}`)
|
||||
? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? 'unk-path'}: ${error.message}`)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,19 +5,21 @@ import { createTRPCFetchContext } from '~/server/api/trpc.server';
|
||||
|
||||
const handlerNodeRoutes = (req: Request) =>
|
||||
fetchRequestHandler({
|
||||
router: appRouterNode,
|
||||
endpoint: '/api/trpc-node',
|
||||
router: appRouterNode,
|
||||
req,
|
||||
createContext: createTRPCFetchContext,
|
||||
onError:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? '<no-path>'}: ${error.message}`)
|
||||
? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? 'unk-path'}: ${error.message}`)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// NOTE: the following statement breaks the build on non-pro deployments, and conditionals don't work either
|
||||
// so we resorted to raising the timeout from 10s to 25s in the vercel.json file instead
|
||||
// export const maxDuration = 25;
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export { handlerNodeRoutes as GET, handlerNodeRoutes as POST };
|
||||
+4
-6
@@ -1,8 +1,6 @@
|
||||
# big-AGI v1 Documentation (Legacy)
|
||||
# big-AGI Documentation
|
||||
|
||||
> **Note:** This is documentation for the **v1 legacy branch**. For the latest Big-AGI, see the [main branch](https://github.com/enricoros/big-AGI/tree/main) or visit [big-agi.com](https://big-agi.com).
|
||||
|
||||
Find all the information you need to get started, configure, and effectively use big-AGI v1.
|
||||
Find all the information you need to get started, configure, and effectively use big-AGI.
|
||||
|
||||
[//]: # (## Quick Start)
|
||||
|
||||
@@ -35,7 +33,7 @@ Detailed guides to configure your big-AGI interface and models.
|
||||
|
||||
## Deployment
|
||||
|
||||
System integrators, administrators, whitelabelers: instead of using the public big-AGI instance on app.big-agi.com, you can deploy your own instance.
|
||||
System integrators, administrators, whitelabelers: instead of using the public big-AGI instance on get.big-agi.com, you can deploy your own instance.
|
||||
|
||||
Step-by-step deployment and system configuration instructions.
|
||||
|
||||
@@ -55,7 +53,7 @@ Step-by-step deployment and system configuration instructions.
|
||||
Join our community or get support:
|
||||
|
||||
- Visit our [GitHub repository](https://github.com/enricoros/big-AGI) for source code and issue tracking
|
||||
- Check the latest updates and features on [Changelog](changelog.md) or the in-app [News](https://big-agi.com/news)
|
||||
- Check the latest updates and features on [Changelog](changelog.md) or the in-app [News](https://get.big-agi.com/news)
|
||||
- Connect with us and other users on [Discord](https://discord.gg/MkH4qj2Jp9) for discussions, help, and sharing your experiences with big-AGI
|
||||
|
||||
Thank you for choosing big-AGI. We're excited to see what you'll build.
|
||||
|
||||
+9
-11
@@ -1,19 +1,17 @@
|
||||
## Changelog (v1 Legacy Branch)
|
||||
## Changelog
|
||||
|
||||
This is a high-level changelog for the v1 legacy branch. For the latest Big-AGI, see the [main branch](https://github.com/enricoros/big-AGI).
|
||||
This is a high-level changelog. Calls out some of the high level features batched
|
||||
by release.
|
||||
|
||||
### 1.16.11 · October 2025 (Final Release)
|
||||
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
|
||||
|
||||
Final v1 legacy release. Branch reaches end-of-life.
|
||||
### 1.17.0 - Jun 2024
|
||||
|
||||
### What's New in 1.16.1...1.16.10 · 2024-2025 (patch releases)
|
||||
- milestone: [1.17.0](https://github.com/enricoros/big-agi/milestone/17)
|
||||
- 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)
|
||||
|
||||
### What's New in 1.16.1...1.16.3 · Jun 20, 2024 (patch releases)
|
||||
|
||||
- 1.16.9: Docker Gemini fix (R1 models are supported in Big-AGI 2)
|
||||
- 1.16.8: OpenAI ChatGPT-4o Latest (o1 models are supported in Big-AGI 2)
|
||||
- 1.16.7: OpenAI support for GPT-4o 2024-08-06
|
||||
- 1.16.6: Groq support for Llama 3.1 models
|
||||
- 1.16.5: GPT-4o Mini support
|
||||
- 1.16.4: 8192 tokens support for Claude 3.5 Sonnet
|
||||
- 1.16.3: Anthropic Claude 3.5 Sonnet model support
|
||||
- 1.16.2: Improve web downloads, as text, markdwon, or HTML
|
||||
- 1.16.2: Proper support for Gemini models
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
# Deploying `big-AGI` with Docker (v1 Legacy)
|
||||
|
||||
> **Note:** This documentation is for the **v1 legacy branch**. For the latest Big-AGI, use Docker tags `:latest`, `:stable`, or `:development` which point to the [main branch](https://github.com/enricoros/big-AGI/tree/main).
|
||||
>
|
||||
> To use v1 legacy specifically, use Docker tags `:v1` or `:v1-stable`.
|
||||
# Deploying `big-AGI` with Docker
|
||||
|
||||
Utilize Docker containers to deploy the big-AGI application for an efficient and automated deployment process.
|
||||
Docker ensures faster development cycles, easier collaboration, and seamless environment management.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
From root:
|
||||
```bash
|
||||
BIG_AGI_BUILD=standalone next build
|
||||
electron . --enable-logging
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
background: #2e2c29;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.loader-container {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border: 5px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 5px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: url('tray-icon.png') no-repeat center center;
|
||||
background-size: contain;
|
||||
animation: counter-spin 3.33s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes counter-spin {
|
||||
0% { transform: rotate(360deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="loader-container">
|
||||
<div class="spinner"></div>
|
||||
<div class="logo"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,178 @@
|
||||
const { app, BrowserWindow, Tray, Menu, ipcMain, screen, nativeTheme, shell } = require('electron');
|
||||
const path = require('path');
|
||||
const startServer = require('./server.js');
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
|
||||
let mainWindow;
|
||||
let tray;
|
||||
const port = 3000;
|
||||
|
||||
async function createWindow() {
|
||||
try {
|
||||
console.log('Starting server...');
|
||||
await startServer(port);
|
||||
console.log('Server started successfully');
|
||||
|
||||
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
|
||||
|
||||
// // Set up a loading screen
|
||||
// loadingScreen = new BrowserWindow({
|
||||
// // width: 150,
|
||||
// // height: 150,
|
||||
// frame: false,
|
||||
// transparent: false,
|
||||
// alwaysOnTop: true,
|
||||
// webPreferences: {
|
||||
// nodeIntegration: true,
|
||||
// },
|
||||
// backgroundColor: '#2e2c29',
|
||||
// });
|
||||
//
|
||||
// loadingScreen.loadFile(path.join(__dirname, 'loading.html'));
|
||||
// loadingScreen.center();
|
||||
// console.log('Loading screen created');
|
||||
|
||||
console.log('Preload script path:', path.join(__dirname, 'preload.js'));
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: Math.min(1280, width * 0.8),
|
||||
height: Math.min(800, height * 0.8),
|
||||
minWidth: 430,
|
||||
minHeight: 600,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
sandbox: false,
|
||||
devTools: false,
|
||||
},
|
||||
backgroundColor: nativeTheme.shouldUseDarkColors ? '#1a1a1a' : '#ffffff',
|
||||
show: true,
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
icon: path.join(__dirname, 'tray-icon.png'),
|
||||
// New "insane" features:
|
||||
// transparent: true, // Enable window transparency
|
||||
vibrancy: 'under-window', // Add vibrancy effect (macOS only)
|
||||
visualEffectState: 'active', // Keep vibrancy active even when not focused (macOS only)
|
||||
roundedCorners: true, // Enable rounded corners (macOS only)
|
||||
// thickFrame: false, // Use a thinner frame on Windows
|
||||
autoHideMenuBar: true, // Auto-hide the menu bar, press Alt to show it
|
||||
scrollBounce: true, // Enable bounce effect when scrolling (macOS only)
|
||||
});
|
||||
|
||||
mainWindow.removeMenu();
|
||||
mainWindow.setTitle('Your Professional App Name');
|
||||
|
||||
console.log('Attempting to load main window URL...');
|
||||
await mainWindow.loadURL(`http://localhost:${port}`);
|
||||
console.log('Main window URL loaded successfully');
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
console.log('Main window ready to show');
|
||||
// if (loadingScreen) {
|
||||
// loadingScreen.close();
|
||||
// }
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
createTray();
|
||||
autoUpdater.checkForUpdatesAndNotify();
|
||||
|
||||
// Handle window state
|
||||
let isQuitting = false;
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true;
|
||||
});
|
||||
|
||||
// Adjust window behavior
|
||||
mainWindow.on('maximize', () => {
|
||||
mainWindow.webContents.send('window-maximized');
|
||||
});
|
||||
|
||||
mainWindow.on('unmaximize', () => {
|
||||
mainWindow.webContents.send('window-unmaximized');
|
||||
});
|
||||
|
||||
|
||||
// Warn if preloads fail
|
||||
mainWindow.webContents.on('preload-error', (event, preloadPath, error) => {
|
||||
console.error('Preload error:', preloadPath, error);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
|
||||
console.error('Failed to load:', errorCode, errorDescription);
|
||||
});
|
||||
|
||||
|
||||
// Handle external links
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error in createWindow:', err);
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
|
||||
function createTray() {
|
||||
tray = new Tray(path.join(__dirname, 'tray-icon.png'));
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: 'Show App', click: () => mainWindow.show() },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit', click: () => app.quit() },
|
||||
]);
|
||||
tray.setToolTip('Your Professional App Name');
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray.on('click', () => {
|
||||
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
console.log('App is ready, creating window...');
|
||||
createWindow().catch((err) => {
|
||||
console.error('Failed to create window:', err);
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', function() {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', function() {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
// IPC handlers for window controls
|
||||
ipcMain.on('minimize-window', () => mainWindow.minimize());
|
||||
ipcMain.on('maximize-window', () => {
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
});
|
||||
ipcMain.on('close-window', () => mainWindow.close());
|
||||
|
||||
|
||||
// Auto-updater events
|
||||
autoUpdater.on('update-available', () => {
|
||||
mainWindow.webContents.send('update_available');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
mainWindow.webContents.send('update_downloaded');
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
const { contextBridge, desktopCapturer, ipcRenderer } = require('electron');
|
||||
const { readFileSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
// Main bridge
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
sendEvent: (event) => ipcRenderer.send('app-event', event),
|
||||
onUpdateAvailable: (callback) => ipcRenderer.on('update_available', callback),
|
||||
onUpdateDownloaded: (callback) => ipcRenderer.on('update_downloaded', callback),
|
||||
});
|
||||
|
||||
|
||||
// Screen Capture: inject renderer.js into the web page
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('Screen Capture: Injecting renderer.js into the web page');
|
||||
const rendererScript = document.createElement('script');
|
||||
rendererScript.text = readFileSync(join(__dirname, 'renderer.js'), 'utf8');
|
||||
document.body.appendChild(rendererScript);
|
||||
});
|
||||
|
||||
// Screen Capture: expose desktopCapturer to the web page
|
||||
contextBridge.exposeInMainWorld('myCustomGetDisplayMedia', async () => {
|
||||
console.log('Screen Capture: Calling desktopCapturer.getSources');
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
});
|
||||
|
||||
console.log('Available sources:', sources);
|
||||
|
||||
// you should create some kind of UI to prompt the user
|
||||
// to select the correct source like Google Chrome does
|
||||
// this is just for testing purposes
|
||||
return sources[0];
|
||||
});
|
||||
|
||||
console.log('Preload script loaded');
|
||||
@@ -0,0 +1,30 @@
|
||||
// https://github.com/aabuhijleh/override-getDisplayMedia/blob/main/renderer.js
|
||||
|
||||
// This file is required by the index.html file and will
|
||||
// be executed in the renderer process for that window.
|
||||
// No Node.js APIs are available in this process because
|
||||
// `nodeIntegration` is turned off. Use `preload.js` to
|
||||
// selectively enable features needed in the rendering
|
||||
// process.
|
||||
|
||||
// override getDisplayMedia
|
||||
navigator.mediaDevices.getDisplayMedia = async () => {
|
||||
const selectedSource = await globalThis.myCustomGetDisplayMedia();
|
||||
|
||||
// create MediaStream
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop',
|
||||
chromeMediaSourceId: selectedSource.id,
|
||||
minWidth: 1280,
|
||||
maxWidth: 1280,
|
||||
minHeight: 720,
|
||||
maxHeight: 720,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return stream;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
const { createServer } = require('http');
|
||||
const { parse } = require('url');
|
||||
const next = require('next');
|
||||
const path = require('path');
|
||||
|
||||
// const dev = process.env.NODE_ENV !== 'production';
|
||||
const dir = path.join(__dirname, '..'); // This points to the root of your project
|
||||
const app = next({ dev: false, dir });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
function startServer(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
app.prepare()
|
||||
.then(() => {
|
||||
const server = createServer((req, res) => {
|
||||
// Basic request logging
|
||||
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
||||
|
||||
// Simple rate limiting
|
||||
if (rateLimiter(req)) {
|
||||
res.statusCode = 429;
|
||||
res.end('Too Many Requests');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the request
|
||||
const parsedUrl = parse(req.url, true);
|
||||
handle(req, res, parsedUrl);
|
||||
});
|
||||
|
||||
server.listen(port, (err) => {
|
||||
if (err) reject(err);
|
||||
console.log(`> Ready on http://localhost:${port}`);
|
||||
resolve(server);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SIGTERM signal received: closing HTTP server');
|
||||
server.close(() => {
|
||||
console.log('HTTP server closed');
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(err => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
// Simple in-memory rate limiter
|
||||
const MAX_REQUESTS_PER_MINUTE = 100;
|
||||
const requestCounts = new Map();
|
||||
|
||||
function rateLimiter(req) {
|
||||
const ip = req.socket.remoteAddress;
|
||||
const now = Date.now();
|
||||
const windowStart = now - 60000; // 1 minute ago
|
||||
|
||||
const requestTimestamps = requestCounts.get(ip) || [];
|
||||
const requestsInWindow = requestTimestamps.filter(timestamp => timestamp > windowStart);
|
||||
|
||||
if (requestsInWindow.length >= MAX_REQUESTS_PER_MINUTE) {
|
||||
return true; // Rate limit exceeded
|
||||
}
|
||||
|
||||
requestTimestamps.push(now);
|
||||
requestCounts.set(ip, requestTimestamps);
|
||||
|
||||
return false; // Rate limit not exceeded
|
||||
}
|
||||
|
||||
module.exports = startServer;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 993 B |
+11
-3
@@ -13,7 +13,7 @@ let nextConfig = {
|
||||
// [exports] https://nextjs.org/docs/advanced-features/static-html-export
|
||||
...buildType && {
|
||||
output: buildType,
|
||||
distDir: 'dist',
|
||||
// distDir: 'dist',
|
||||
|
||||
// disable image optimization for exports
|
||||
images: { unoptimized: true },
|
||||
@@ -27,7 +27,7 @@ let nextConfig = {
|
||||
serverComponentsExternalPackages: ['puppeteer-core'],
|
||||
},
|
||||
|
||||
webpack: (config, _options) => {
|
||||
webpack: (config, { isServer }) => {
|
||||
// @mui/joy: anything material gets redirected to Joy
|
||||
config.resolve.alias['@mui/material'] = '@mui/joy';
|
||||
|
||||
@@ -37,9 +37,17 @@ let nextConfig = {
|
||||
layers: true,
|
||||
};
|
||||
|
||||
// fix warnings for async functions in the browser (https://github.com/vercel/next.js/issues/64792)
|
||||
if (!isServer) {
|
||||
config.output.environment = { ...config.output.environment, asyncFunction: true };
|
||||
}
|
||||
|
||||
// prevent too many small chunks (40kb min) on 'client' packs (not 'server' or 'edge-server')
|
||||
if (typeof config.optimization.splitChunks === 'object' && config.optimization.splitChunks.minSize)
|
||||
// noinspection JSUnresolvedReference
|
||||
if (typeof config.optimization.splitChunks === 'object' && config.optimization.splitChunks.minSize) {
|
||||
// noinspection JSUnresolvedReference
|
||||
config.optimization.splitChunks.minSize = 40 * 1024;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
Generated
+4371
-831
File diff suppressed because it is too large
Load Diff
+58
-32
@@ -4,15 +4,20 @@
|
||||
"private": true,
|
||||
"author": "Enrico Ros <enrico.ros@gmail.com>",
|
||||
"repository": "https://github.com/enricoros/big-agi",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "node electron/server.js",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "NODE_ENV=production node electron/server.js",
|
||||
"lint": "next lint",
|
||||
"postinstall": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio",
|
||||
"vercel:env:pull": "npx vercel env pull .env.development.local"
|
||||
"vercel:env:pull": "npx vercel env pull .env.development.local",
|
||||
"electron": "electron .",
|
||||
"electron-dev": "concurrently \"npm run dev\" \"electron .\"",
|
||||
"electron-build": "next build && electron-builder",
|
||||
"electron-start": "npm run build && electron ."
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "src/server/prisma/schema.prisma"
|
||||
@@ -22,28 +27,32 @@
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@mui/icons-material": "^5.15.17",
|
||||
"@mui/joy": "^5.0.0-beta.36",
|
||||
"@mui/material": "^5.15.17",
|
||||
"@next/bundle-analyzer": "^14.2.3",
|
||||
"@next/third-parties": "^14.2.3",
|
||||
"@prisma/client": "^5.13.0",
|
||||
"@mui/icons-material": "^5.16.0",
|
||||
"@mui/joy": "^5.0.0-beta.47",
|
||||
"@mui/material": "^5.16.0",
|
||||
"@next/bundle-analyzer": "^14.2.4",
|
||||
"@next/third-parties": "^14.2.4",
|
||||
"@prisma/client": "^5.16.1",
|
||||
"@sanity/diff-match-patch": "^3.1.1",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tanstack/react-query": "~4.36.1",
|
||||
"@trpc/client": "10.44.1",
|
||||
"@trpc/next": "10.44.1",
|
||||
"@trpc/react-query": "10.44.1",
|
||||
"@trpc/server": "10.44.1",
|
||||
"@vercel/analytics": "^1.2.2",
|
||||
"@vercel/speed-insights": "^1.0.10",
|
||||
"@tanstack/react-query": "^5.50.1",
|
||||
"@trpc/client": "11.0.0-alpha-tmp-issues-5851-take-two.496",
|
||||
"@trpc/next": "11.0.0-alpha-tmp-issues-5851-take-two.496",
|
||||
"@trpc/react-query": "11.0.0-alpha-tmp-issues-5851-take-two.496",
|
||||
"@trpc/server": "11.0.0-alpha-tmp-issues-5851-take-two.496",
|
||||
"@vercel/analytics": "^1.3.1",
|
||||
"@vercel/speed-insights": "^1.0.12",
|
||||
"browser-fs-access": "^0.35.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"dexie": "^4.0.7",
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"electron-updater": "^6.2.1",
|
||||
"eventsource-parser": "^1.1.2",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"next": "~14.1.4",
|
||||
"nanoid": "^5.0.7",
|
||||
"next": "~14.2.4",
|
||||
"nprogress": "^0.2.0",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"pdfjs-dist": "4.4.168",
|
||||
"plantuml-encoder": "^1.4.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -53,41 +62,58 @@
|
||||
"react-katex": "^3.0.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-player": "^2.16.0",
|
||||
"react-resizable-panels": "^2.0.19",
|
||||
"react-resizable-panels": "^2.0.20",
|
||||
"react-timeago": "^7.2.0",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"sharp": "^0.33.3",
|
||||
"sharp": "^0.33.4",
|
||||
"superjson": "^2.2.1",
|
||||
"tesseract.js": "^5.1.0",
|
||||
"tiktoken": "^1.0.15",
|
||||
"turndown": "^7.2.0",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.2"
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/puppeteer": "0.0.5",
|
||||
"@types/node": "^20.12.11",
|
||||
"@cloudflare/puppeteer": "0.0.11",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/plantuml-encoder": "^1.4.2",
|
||||
"@types/prismjs": "^1.26.4",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@types/react-csv": "^1.1.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-katex": "^3.0.4",
|
||||
"@types/react-timeago": "^4.1.7",
|
||||
"@types/turndown": "^5.0.4",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^31.1.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.3",
|
||||
"prettier": "^3.2.5",
|
||||
"prisma": "^5.13.0",
|
||||
"typescript": "^5.4.5"
|
||||
"eslint-config-next": "^14.2.4",
|
||||
"prettier": "^3.3.2",
|
||||
"prisma": "^5.16.1",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.0.0 || ^22.0.0 || ^20.0.0 || ^18.0.0"
|
||||
"node": "^20.0.0 || ^18.0.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.yourcompany.yourappname",
|
||||
"productName": "Your App Name",
|
||||
"files": [
|
||||
"electron/**/*",
|
||||
".next/**/*",
|
||||
"public/**/*",
|
||||
"next.config.js"
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "electron"
|
||||
},
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'katex/dist/katex.min.css';
|
||||
import '~/common/styles/CodePrism.css';
|
||||
import '~/common/styles/GithubMarkdown.css';
|
||||
import '~/common/styles/NProgress.css';
|
||||
import '~/common/styles/agi.effects.css';
|
||||
import '~/common/styles/app.styles.css';
|
||||
|
||||
import { ProviderBackendCapabilities } from '~/common/providers/ProviderBackendCapabilities';
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs, useCapa
|
||||
// stores access
|
||||
import { getLLMsDebugInfo } from '~/modules/llms/store-llms';
|
||||
import { useAppStateStore } from '~/common/state/store-appstate';
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useFolderStore } from '~/common/state/store-folders';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { withLayout } from '~/common/layout/withLayout';
|
||||
function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
|
||||
|
||||
// external state
|
||||
const { data, isError, error, isLoading } = apiQuery.backend.exchangeOpenRouterKey.useQuery({ code: props.openRouterCode || '' }, {
|
||||
const { data, isError, error, isPending } = apiQuery.backend.exchangeOpenRouterKey.useQuery({ code: props.openRouterCode || '' }, {
|
||||
enabled: !!props.openRouterCode,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
@@ -56,7 +56,7 @@ function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
|
||||
Welcome Back
|
||||
</Typography>
|
||||
|
||||
{isLoading && <Typography level='body-sm'>Loading...</Typography>}
|
||||
{isPending && <Typography level='body-sm'>Loading...</Typography>}
|
||||
|
||||
{isErrorInput && <InlineError error='There was an issue retrieving the code from OpenRouter.' />}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { AppTokens } from '../src/apps/tokens/AppTokens';
|
||||
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
|
||||
export default function PersonasPage() {
|
||||
return withLayout({ type: 'optima' }, <AppTokens />);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -8,20 +8,21 @@ import { BeamView } from '~/modules/beam/BeamView';
|
||||
import { createBeamVanillaStore } from '~/modules/beam/store-beam-vanilla';
|
||||
import { useModelsStore } from '~/modules/llms/store-llms';
|
||||
|
||||
import { createDConversation, createDMessage, DConversation, DMessage } from '~/common/state/store-chats';
|
||||
import { createDConversation, DConversation } from '~/common/stores/chat/chat.conversation';
|
||||
import { createDMessageTextContent, DMessage } from '~/common/stores/chat/chat.message';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
|
||||
function initTestConversation(): DConversation {
|
||||
const conversation = createDConversation();
|
||||
conversation.messages.push(createDMessage('system', 'You are a helpful assistant.'));
|
||||
conversation.messages.push(createDMessage('user', 'Hello, who are you? (please expand...)'));
|
||||
conversation.messages.push(createDMessageTextContent('system', 'You are a helpful assistant.')); // Beam Test - seed1
|
||||
conversation.messages.push(createDMessageTextContent('user', 'Hello, who are you? (please expand...)')); // Beam Test - seed2
|
||||
return conversation;
|
||||
}
|
||||
|
||||
function initTestBeamStore(messages: DMessage[], beamStore: BeamStoreApi = createBeamVanillaStore()): BeamStoreApi {
|
||||
beamStore.getState().open(messages, useModelsStore.getState().chatLLMId, (text) => alert(text));
|
||||
beamStore.getState().open(messages, useModelsStore.getState().chatLLMId, (content) => alert(content));
|
||||
return beamStore;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
|
||||
import { Container, Sheet } from '@mui/joy';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { useRouterQuery } from '~/common/app.routes';
|
||||
|
||||
import { CallWizard } from './CallWizard';
|
||||
|
||||
@@ -13,7 +13,7 @@ import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptim
|
||||
import { animationColorRainbow } from '~/common/util/animUtils';
|
||||
import { navigateBack } from '~/common/app.routes';
|
||||
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs } from '~/common/components/useCapabilities';
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useUICounter } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Avatar, Box, Card, CardContent, Chip, IconButton, Link as MuiLink, ListDivider, MenuItem, Sheet, Switch, Typography } from '@mui/joy';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
|
||||
import { DConversation, DConversationId, conversationTitle } from '~/common/stores/chat/chat.conversation';
|
||||
import { GitHubProjectIssueCard } from '~/common/components/GitHubProjectIssueCard';
|
||||
import { animationShadowRingLimey } from '~/common/util/animUtils';
|
||||
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import type { AppCallIntent } from './AppCall';
|
||||
@@ -60,7 +60,7 @@ const ContactCardConversationCall = (props: { conversation: DConversation, onCon
|
||||
function CallContactCard(props: {
|
||||
persona: MockPersona,
|
||||
callGrayUI: boolean,
|
||||
conversations: DConversation[],
|
||||
conversations: Readonly<DConversation[]>,
|
||||
setCallIntent: (intent: AppCallIntent) => void,
|
||||
}) {
|
||||
|
||||
@@ -189,7 +189,7 @@ function CallContactCard(props: {
|
||||
|
||||
|
||||
function useConversationsByPersona() {
|
||||
const conversations = useChatStore(state => state.conversations, shallow);
|
||||
const conversations = useChatStore(state => state.conversations);
|
||||
|
||||
return React.useMemo(() => {
|
||||
// group by personaId
|
||||
|
||||
+26
-15
@@ -11,18 +11,20 @@ import RecordVoiceOverTwoToneIcon from '@mui/icons-material/RecordVoiceOverTwoTo
|
||||
|
||||
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
|
||||
import { useChatLLMDropdown } from '../chat/components/useLLMDropdown';
|
||||
import { useChatLLMDropdown } from '../chat/components/layout-bar/useLLMDropdown';
|
||||
|
||||
import { EXPERIMENTAL_speakTextStream } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
import { SystemPurposeId, SystemPurposes } from '../../data';
|
||||
import { llmStreamingChatGenerate, VChatMessageIn } from '~/modules/llms/llm.client';
|
||||
import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVoiceDropdown';
|
||||
|
||||
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
|
||||
import { conversationTitle, createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
import { conversationTitle } from '~/common/stores/chat/chat.conversation';
|
||||
import { createDMessageTextContent, DMessage, messageFragmentsReduceText, messageSingleTextOrThrow } from '~/common/stores/chat/chat.message';
|
||||
import { launchAppChat, navigateToIndex } from '~/common/app.routes';
|
||||
import { playSoundUrl, usePlaySoundUrl } from '~/common/util/audioUtils';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import type { AppCallIntent } from './AppCall';
|
||||
@@ -118,9 +120,9 @@ export function Telephone(props: {
|
||||
const onSpeechResultCallback = React.useCallback((result: SpeechResult) => {
|
||||
setSpeechInterim(result.done ? null : { ...result });
|
||||
if (result.done) {
|
||||
const transcribed = result.transcript.trim();
|
||||
if (transcribed.length >= 1)
|
||||
setCallMessages(messages => [...messages, createDMessage('user', transcribed)]);
|
||||
const userSpeechTranscribed = result.transcript.trim();
|
||||
if (userSpeechTranscribed.length >= 1)
|
||||
setCallMessages(messages => [...messages, createDMessageTextContent('user', userSpeechTranscribed)]); // [state] append user:speech
|
||||
}
|
||||
}, []);
|
||||
const { isSpeechEnabled, isRecording, isRecordingAudio, isRecordingSpeech, startRecording, stopRecording, toggleRecording } = useSpeechRecognition(onSpeechResultCallback, 1000);
|
||||
@@ -136,11 +138,11 @@ export function Telephone(props: {
|
||||
|
||||
// pickup / hangup
|
||||
React.useEffect(() => {
|
||||
!isRinging && playSoundUrl(isConnected ? '/sounds/chat-begin.mp3' : '/sounds/chat-end.mp3');
|
||||
!isRinging && AudioPlayer.playUrl(isConnected ? '/sounds/chat-begin.mp3' : '/sounds/chat-end.mp3');
|
||||
}, [isRinging, isConnected]);
|
||||
|
||||
// ringtone
|
||||
usePlaySoundUrl(isRinging ? '/sounds/chat-ringtone.mp3' : null, 300, 2800 * 2);
|
||||
AudioPlayer.usePlayUrl(isRinging ? '/sounds/chat-ringtone.mp3' : null, 300, 2800 * 2);
|
||||
|
||||
|
||||
/// CONNECTED
|
||||
@@ -169,7 +171,8 @@ export function Telephone(props: {
|
||||
const phoneMessages = personaCallStarters || ['Hello?', 'Hey!'];
|
||||
const firstMessage = phoneMessages[Math.floor(Math.random() * phoneMessages.length)];
|
||||
|
||||
setCallMessages([createDMessage('assistant', firstMessage)]);
|
||||
setCallMessages([createDMessageTextContent('assistant', firstMessage)]); // [state] set assistant:hello message
|
||||
|
||||
// fire/forget
|
||||
void EXPERIMENTAL_speakTextStream(firstMessage, personaVoiceId);
|
||||
|
||||
@@ -179,22 +182,30 @@ export function Telephone(props: {
|
||||
// [E] persona streaming response - upon new user message
|
||||
React.useEffect(() => {
|
||||
// only act when we have a new user message
|
||||
if (!isConnected || callMessages.length < 1 || callMessages[callMessages.length - 1].role !== 'user')
|
||||
if (!isConnected || callMessages.length < 1)
|
||||
return;
|
||||
switch (callMessages[callMessages.length - 1].text) {
|
||||
|
||||
// Voice commands
|
||||
const lastUserMessage = callMessages[callMessages.length - 1];
|
||||
if (lastUserMessage.role !== 'user')
|
||||
return;
|
||||
switch (messageFragmentsReduceText(lastUserMessage.fragments)) {
|
||||
// do not respond
|
||||
case 'Stop.':
|
||||
return;
|
||||
|
||||
// command: close the call
|
||||
case 'Goodbye.':
|
||||
setStage('ended');
|
||||
setTimeout(launchAppChat, 2000);
|
||||
return;
|
||||
|
||||
// command: regenerate answer
|
||||
case 'Retry.':
|
||||
case 'Try again.':
|
||||
setCallMessages(messages => messages.slice(0, messages.length - 2));
|
||||
return;
|
||||
|
||||
// command: restart chat
|
||||
case 'Restart.':
|
||||
setCallMessages([]);
|
||||
@@ -206,7 +217,7 @@ export function Telephone(props: {
|
||||
|
||||
// temp fix: when the chat has no messages, only assume a single system message
|
||||
const chatMessages: { role: VChatMessageIn['role'], text: string }[] = (reMessages && reMessages.length > 0)
|
||||
? reMessages
|
||||
? reMessages.map(message => ({ role: message.role, text: messageSingleTextOrThrow(message) }))
|
||||
: personaSystemMessage
|
||||
? [{ role: 'system', text: personaSystemMessage }]
|
||||
: [];
|
||||
@@ -217,7 +228,7 @@ export function Telephone(props: {
|
||||
{ role: 'system', content: 'You are having a phone call. Your response style is brief and to the point, and according to your personality, defined below.' },
|
||||
...chatMessages.map(message => ({ role: message.role, content: message.text })),
|
||||
{ role: 'system', content: 'You are now on the phone call related to the chat above. Respect your personality and answer with short, friendly and accurate thoughtful lines.' },
|
||||
...callMessages.map(message => ({ role: message.role, content: message.text })),
|
||||
...callMessages.map(message => ({ role: message.role, content: messageSingleTextOrThrow(message) })),
|
||||
];
|
||||
|
||||
// perform completion
|
||||
@@ -237,7 +248,7 @@ export function Telephone(props: {
|
||||
}).finally(() => {
|
||||
setPersonaTextInterim(null);
|
||||
if (finalText || error)
|
||||
setCallMessages(messages => [...messages, createDMessage('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]);
|
||||
setCallMessages(messages => [...messages, createDMessageTextContent('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]); // [state] append assistant:call_response
|
||||
// fire/forget
|
||||
if (finalText?.length >= 1)
|
||||
void EXPERIMENTAL_speakTextStream(finalText, personaVoiceId);
|
||||
@@ -339,7 +350,7 @@ export function Telephone(props: {
|
||||
{callMessages.map((message) =>
|
||||
<CallMessage
|
||||
key={message.id}
|
||||
text={message.text}
|
||||
text={messageSingleTextOrThrow(message)}
|
||||
variant={message.role === 'assistant' ? 'solid' : 'soft'}
|
||||
color={message.role === 'assistant' ? 'neutral' : 'primary'}
|
||||
role={message.role}
|
||||
|
||||
+77
-79
@@ -17,13 +17,16 @@ import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
|
||||
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
|
||||
import { DConversation, DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { DMessageAttachmentFragment, DMessageContentFragment, duplicateDMessageFragments } from '~/common/stores/chat/chat.fragments';
|
||||
import { GlobalShortcutDefinition, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
|
||||
import { PanelResizeInset } from '~/common/components/panes/GoodPanelResizeHandler';
|
||||
import { PreferencesTab, useOptimaLayout, usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
|
||||
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
|
||||
import { createDMessage, DConversationId, DMessage, DMessageMetadata, getConversation, getConversationSystemPurposeId, useConversation } from '~/common/state/store-chats';
|
||||
import { createDMessageFromFragments, createDMessageTextContent, DMessageMetadata, duplicateDMessageMetadata } from '~/common/stores/chat/chat.message';
|
||||
import { getConversation, getConversationSystemPurposeId, useConversation } from '~/common/stores/chat/store-chats';
|
||||
import { themeBgAppChatComposer } from '~/common/app.theme';
|
||||
import { useFolderStore } from '~/common/state/store-folders';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
@@ -31,35 +34,26 @@ import { useRouterQuery } from '~/common/app.routes';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
|
||||
import { ChatBarAltBeam } from './components/ChatBarAltBeam';
|
||||
import { ChatBarAltTitle } from './components/ChatBarAltTitle';
|
||||
import { ChatBarDropdowns } from './components/ChatBarDropdowns';
|
||||
import { ChatBarAltBeam } from './components/layout-bar/ChatBarAltBeam';
|
||||
import { ChatBarAltTitle } from './components/layout-bar/ChatBarAltTitle';
|
||||
import { ChatBarDropdowns } from './components/layout-bar/ChatBarDropdowns';
|
||||
import { ChatBeamWrapper } from './components/ChatBeamWrapper';
|
||||
import { ChatDrawerMemo } from './components/ChatDrawer';
|
||||
import { ChatDrawerMemo } from './components/layout-drawer/ChatDrawer';
|
||||
import { ChatMessageList } from './components/ChatMessageList';
|
||||
import { ChatPageMenuItems } from './components/ChatPageMenuItems';
|
||||
import { ChatPageMenuItems } from './components/layout-menu/ChatPageMenuItems';
|
||||
import { Composer } from './components/composer/Composer';
|
||||
import { usePanesManager } from './components/panes/usePanesManager';
|
||||
|
||||
import type { ChatExecuteMode } from './execute-mode/execute-mode.types';
|
||||
|
||||
import { _handleExecute } from './editors/_handleExecute';
|
||||
import { gcChatImageAssets } from './editors/image-generate';
|
||||
|
||||
|
||||
// what to say when a chat is new and has no title
|
||||
export const CHAT_NOVEL_TITLE = 'Chat';
|
||||
|
||||
|
||||
/**
|
||||
* Mode: how to treat the input from the Composer
|
||||
*/
|
||||
export type ChatModeId =
|
||||
| 'generate-text'
|
||||
| 'generate-text-beam'
|
||||
| 'append-user'
|
||||
| 'generate-image'
|
||||
| 'generate-react';
|
||||
|
||||
|
||||
export interface AppChatIntent {
|
||||
initialConversationId: string | null;
|
||||
}
|
||||
@@ -118,19 +112,23 @@ export function AppChat() {
|
||||
setFocusedPaneIndex,
|
||||
} = usePanesManager();
|
||||
|
||||
const chatHandlers = React.useMemo(() => chatPanes.map(pane => {
|
||||
return pane.conversationId ? ConversationsManager.getHandler(pane.conversationId) : null;
|
||||
}), [chatPanes]);
|
||||
const { paneUniqueConversationIds, paneHandlers, paneBeamStores } = React.useMemo(() => {
|
||||
const paneConversationIds: (DConversationId | null)[] = chatPanes.map(pane => pane.conversationId || null);
|
||||
const paneHandlers = paneConversationIds.map(cId => cId ? ConversationsManager.getHandler(cId) : null);
|
||||
const paneBeamStores = paneHandlers.map(handler => handler?.getBeamStore() ?? null);
|
||||
const paneUniqueConversationIds = Array.from(new Set(paneConversationIds.filter(Boolean))) as DConversationId[];
|
||||
return {
|
||||
paneHandlers: paneHandlers,
|
||||
paneBeamStores: paneBeamStores,
|
||||
paneUniqueConversationIds: paneUniqueConversationIds,
|
||||
};
|
||||
}, [chatPanes]);
|
||||
|
||||
const beamsStores = React.useMemo(() => chatHandlers.map(handler => {
|
||||
return handler?.getBeamStore() ?? null;
|
||||
}), [chatHandlers]);
|
||||
|
||||
const beamsOpens = useAreBeamsOpen(beamsStores);
|
||||
const beamsOpens = useAreBeamsOpen(paneBeamStores);
|
||||
const beamOpenStoreInFocusedPane = React.useMemo(() => {
|
||||
const open = focusedPaneIndex !== null ? (beamsOpens?.[focusedPaneIndex] ?? false) : false;
|
||||
return open ? beamsStores?.[focusedPaneIndex!] ?? null : null;
|
||||
}, [beamsOpens, beamsStores, focusedPaneIndex]);
|
||||
return open ? paneBeamStores?.[focusedPaneIndex!] ?? null : null;
|
||||
}, [beamsOpens, focusedPaneIndex, paneBeamStores]);
|
||||
|
||||
const {
|
||||
// focused
|
||||
@@ -162,7 +160,7 @@ export function AppChat() {
|
||||
|
||||
const isMultiPane = chatPanes.length >= 2;
|
||||
const isMultiAddable = chatPanes.length < 4;
|
||||
const isMultiConversationId = isMultiPane && new Set(chatPanes.map((pane) => pane.conversationId)).size >= 2;
|
||||
const isMultiConversationId = paneUniqueConversationIds.length >= 2;
|
||||
const willMulticast = isComposerMulticast && isMultiConversationId;
|
||||
const disableNewButton = isFocusedChatEmpty && !isMultiPane;
|
||||
|
||||
@@ -197,8 +195,8 @@ export function AppChat() {
|
||||
|
||||
// Execution
|
||||
|
||||
const handleExecuteAndOutcome = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) => {
|
||||
const outcome = await _handleExecute(chatModeId, conversationId, history);
|
||||
const handleExecuteAndOutcome = React.useCallback(async (chatExecuteMode: ChatExecuteMode, conversationId: DConversationId, callerNameDebug: string) => {
|
||||
const outcome = await _handleExecute(chatExecuteMode, conversationId, callerNameDebug);
|
||||
if (outcome === 'err-no-chatllm')
|
||||
openModelsSetup();
|
||||
else if (outcome === 'err-t2i-unconfigured')
|
||||
@@ -207,55 +205,52 @@ export function AppChat() {
|
||||
addSnackbar({ key: 'chat-no-persona', message: 'No persona selected.', type: 'issue' });
|
||||
else if (outcome === 'err-no-conversation')
|
||||
addSnackbar({ key: 'chat-no-conversation', message: 'No active conversation.', type: 'issue' });
|
||||
else if (outcome === 'err-no-last-message')
|
||||
addSnackbar({ key: 'chat-no-conversation', message: 'No conversation history.', type: 'issue' });
|
||||
return outcome === true;
|
||||
}, [openModelsSetup, openPreferencesTab]);
|
||||
|
||||
const handleComposerAction = React.useCallback((conversationId: DConversationId, chatModeId: ChatModeId, multiPartMessage: ComposerOutputMultiPart, metadata?: DMessageMetadata): boolean => {
|
||||
// validate inputs
|
||||
if (multiPartMessage.length !== 1 || multiPartMessage[0].type !== 'text-block') {
|
||||
addSnackbar({
|
||||
key: 'chat-composer-action-invalid',
|
||||
message: 'Only a single text part is supported for now.',
|
||||
type: 'issue',
|
||||
overrides: {
|
||||
autoHideDuration: 2000,
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const userText = multiPartMessage[0].text;
|
||||
const handleComposerAction = React.useCallback((conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata): boolean => {
|
||||
|
||||
// multicast: send the message to all the panes
|
||||
const uniqueConversationIds = new Set([conversationId]);
|
||||
if (willMulticast)
|
||||
chatPanes.forEach(pane => pane.conversationId && uniqueConversationIds.add(pane.conversationId));
|
||||
// [multicast] send the message to all the panes
|
||||
const uniqueConversationIds = willMulticast
|
||||
? Array.from(new Set([conversationId, ...paneUniqueConversationIds]))
|
||||
: [conversationId];
|
||||
|
||||
// validate conversation existence
|
||||
const uniqueConverations = uniqueConversationIds.map(cId => getConversation(cId)).filter(Boolean) as DConversation[];
|
||||
if (!uniqueConverations.length)
|
||||
return false;
|
||||
|
||||
// we loop to handle both the normal and multicast modes
|
||||
let enqueuedAny = false;
|
||||
for (const _cId of uniqueConversationIds) {
|
||||
const history = getConversation(_cId)?.messages;
|
||||
if (!history) continue;
|
||||
for (const conversation of uniqueConverations) {
|
||||
|
||||
const newUserMessage = createDMessage('user', userText);
|
||||
if (metadata) newUserMessage.metadata = metadata;
|
||||
// create the user:message
|
||||
// NOTE: this can lead to multiple chat messages with data refs that are referring to the same dblobs,
|
||||
// however, we already got transferred ownership of the dblobs at this point.
|
||||
const userMessage = createDMessageFromFragments('user', duplicateDMessageFragments(fragments)); // [chat] create user:message
|
||||
if (metadata) userMessage.metadata = duplicateDMessageMetadata(metadata);
|
||||
|
||||
ConversationsManager.getHandler(conversation.id).messageAppend(userMessage); // [chat] append user message in each conversation
|
||||
|
||||
// fire/forget
|
||||
void handleExecuteAndOutcome(chatModeId, _cId, [...history, newUserMessage]);
|
||||
enqueuedAny = true;
|
||||
void handleExecuteAndOutcome(chatExecuteMode /* various */, conversation.id, 'chat-composer-action'); // append user message, then '*-*'
|
||||
}
|
||||
return enqueuedAny;
|
||||
}, [chatPanes, handleExecuteAndOutcome, willMulticast]);
|
||||
|
||||
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[]) => {
|
||||
await handleExecuteAndOutcome('generate-text', conversationId, history);
|
||||
return true;
|
||||
}, [paneUniqueConversationIds, handleExecuteAndOutcome, willMulticast]);
|
||||
|
||||
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId) => {
|
||||
await handleExecuteAndOutcome('generate-content', conversationId, 'chat-execute-history'); // replace with 'history', then 'generate-text'
|
||||
}, [handleExecuteAndOutcome]);
|
||||
|
||||
const handleMessageRegenerateLastInFocusedPane = React.useCallback(async () => {
|
||||
const focusedConversation = getConversation(focusedPaneConversationId);
|
||||
if (focusedConversation?.messages?.length) {
|
||||
if (focusedPaneConversationId && focusedConversation?.messages?.length) {
|
||||
const lastMessage = focusedConversation.messages[focusedConversation.messages.length - 1];
|
||||
const history = lastMessage.role === 'assistant' ? focusedConversation.messages.slice(0, -1) : [...focusedConversation.messages];
|
||||
await handleExecuteAndOutcome('generate-text', focusedConversation.id, history);
|
||||
if (lastMessage.role === 'assistant')
|
||||
ConversationsManager.getHandler(focusedPaneConversationId).historyTruncateTo(lastMessage.id, -1);
|
||||
await handleExecuteAndOutcome('generate-content', focusedConversation.id, 'chat-regenerate-last'); // truncate if assistant, then gen-text
|
||||
}
|
||||
}, [focusedPaneConversationId, handleExecuteAndOutcome]);
|
||||
|
||||
@@ -273,15 +268,14 @@ export function AppChat() {
|
||||
|
||||
const handleTextDiagram = React.useCallback((diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig), []);
|
||||
|
||||
const handleTextImagine = React.useCallback(async (conversationId: DConversationId, messageText: string) => {
|
||||
const handleImagineFromText = React.useCallback(async (conversationId: DConversationId, messageText: string) => {
|
||||
const conversation = getConversation(conversationId);
|
||||
if (!conversation)
|
||||
return;
|
||||
const imaginedPrompt = await imaginePromptFromText(messageText, conversationId) || 'An error sign.';
|
||||
await handleExecuteAndOutcome('generate-image', conversationId, [
|
||||
...conversation.messages,
|
||||
createDMessage('user', imaginedPrompt),
|
||||
]);
|
||||
const imaginePrompMessage = createDMessageTextContent('user', imaginedPrompt);
|
||||
ConversationsManager.getHandler(conversationId).messageAppend(imaginePrompMessage); // [chat] append user:imagine prompt
|
||||
await handleExecuteAndOutcome('generate-image', conversationId, 'chat-imagine-from-text'); // append message for 'imagine', then generate-image
|
||||
}, [handleExecuteAndOutcome]);
|
||||
|
||||
const handleTextSpeak = React.useCallback(async (text: string): Promise<void> => {
|
||||
@@ -364,7 +358,7 @@ export function AppChat() {
|
||||
|
||||
const handleConfirmedClearConversation = React.useCallback(() => {
|
||||
if (clearConversationId) {
|
||||
ConversationsManager.getHandler(clearConversationId).messagesReplace([]);
|
||||
ConversationsManager.getHandler(clearConversationId).historyClear();
|
||||
setClearConversationId(null);
|
||||
}
|
||||
}, [clearConversationId]);
|
||||
@@ -382,6 +376,9 @@ export function AppChat() {
|
||||
handleOpenConversationInFocusedPane(nextConversationId);
|
||||
|
||||
setDeleteConversationIds(null);
|
||||
|
||||
// run GC for dblobs in this conversation
|
||||
void gcChatImageAssets(); // fire/forget
|
||||
}, [deleteConversations, handleOpenConversationInFocusedPane]);
|
||||
|
||||
const handleConfirmedDeleteConversations = React.useCallback(() => {
|
||||
@@ -397,7 +394,7 @@ export function AppChat() {
|
||||
openLlmOptions(chatLLMId);
|
||||
}, [openLlmOptions]);
|
||||
|
||||
const shortcuts = React.useMemo((): GlobalShortcutItem[] => [
|
||||
const shortcuts = React.useMemo((): GlobalShortcutDefinition[] => [
|
||||
// focused conversation
|
||||
['b', true, true, false, handleMessageBeamLastInFocusedPane],
|
||||
['r', true, true, false, handleMessageRegenerateLastInFocusedPane],
|
||||
@@ -434,7 +431,7 @@ export function AppChat() {
|
||||
isMobile={isMobile}
|
||||
activeConversationId={focusedPaneConversationId}
|
||||
activeFolderId={activeFolderId}
|
||||
chatPanesConversationIds={chatPanes.map(pane => pane.conversationId).filter(Boolean) as DConversationId[]}
|
||||
chatPanesConversationIds={paneUniqueConversationIds}
|
||||
disableNewButton={disableNewButton}
|
||||
onConversationActivate={handleOpenConversationInFocusedPane}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
@@ -444,7 +441,7 @@ export function AppChat() {
|
||||
onConversationsImportDialog={handleConversationImportDialog}
|
||||
setActiveFolderId={setActiveFolderId}
|
||||
/>,
|
||||
[activeFolderId, chatPanes, disableNewButton, focusedPaneConversationId, handleConversationBranch, handleConversationExport, handleConversationImportDialog, handleConversationNewInFocusedPane, handleDeleteConversations, handleOpenConversationInFocusedPane, isMobile],
|
||||
[activeFolderId, disableNewButton, focusedPaneConversationId, handleConversationBranch, handleConversationExport, handleConversationImportDialog, handleConversationNewInFocusedPane, handleDeleteConversations, handleOpenConversationInFocusedPane, isMobile, paneUniqueConversationIds],
|
||||
);
|
||||
|
||||
const focusedMenuItems = React.useMemo(() =>
|
||||
@@ -476,8 +473,8 @@ export function AppChat() {
|
||||
{chatPanes.map((pane, idx) => {
|
||||
const _paneIsFocused = idx === focusedPaneIndex;
|
||||
const _paneConversationId = pane.conversationId;
|
||||
const _paneChatHandler = chatHandlers[idx] ?? null;
|
||||
const _paneBeamStore = beamsStores[idx] ?? null;
|
||||
const _paneChatHandler = paneHandlers[idx] ?? null;
|
||||
const _paneBeamStore = paneBeamStores[idx] ?? null;
|
||||
const _paneBeamIsOpen = !!beamsOpens?.[idx] && !!_paneBeamStore;
|
||||
const _panesCount = chatPanes.length;
|
||||
const _keyAndId = `chat-pane-${pane.paneId}`;
|
||||
@@ -536,12 +533,13 @@ export function AppChat() {
|
||||
capabilityHasT2I={capabilityHasT2I}
|
||||
chatLLMContextTokens={chatLLM?.contextTokens ?? null}
|
||||
fitScreen={isMobile || isMultiPane}
|
||||
isMobile={isMobile}
|
||||
isMessageSelectionMode={isMessageSelectionMode}
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationExecuteHistory={handleConversationExecuteHistory}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextImagine={handleImagineFromText}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
@@ -583,12 +581,12 @@ export function AppChat() {
|
||||
isMobile={isMobile}
|
||||
chatLLM={chatLLM}
|
||||
composerTextAreaRef={composerTextAreaRef}
|
||||
conversationId={focusedPaneConversationId}
|
||||
targetConversationId={focusedPaneConversationId}
|
||||
capabilityHasT2I={capabilityHasT2I}
|
||||
isMulticast={!isMultiConversationId ? null : isComposerMulticast}
|
||||
isDeveloperMode={isFocusedChatDeveloper}
|
||||
onAction={handleComposerAction}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextImagine={handleImagineFromText}
|
||||
setIsMulticast={setIsComposerMulticast}
|
||||
sx={beamOpenStoreInFocusedPane ? composerClosedSx : composerOpenSx}
|
||||
/>
|
||||
|
||||
@@ -3,18 +3,18 @@ import ClearIcon from '@mui/icons-material/Clear';
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsAlter: ICommandsProvider = {
|
||||
id: 'chat-alter',
|
||||
id: 'cmd-chat-alter',
|
||||
rank: 25,
|
||||
|
||||
getCommands: () => [{
|
||||
primary: '/assistant',
|
||||
alternatives: ['/a'],
|
||||
arguments: ['text'],
|
||||
arguments: ['text...'],
|
||||
description: 'Injects assistant response',
|
||||
}, {
|
||||
primary: '/system',
|
||||
alternatives: ['/s'],
|
||||
arguments: ['text'],
|
||||
arguments: ['text...'],
|
||||
description: 'Injects system message',
|
||||
}, {
|
||||
primary: '/clear',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsBeam: ICommandsProvider = {
|
||||
id: 'mode-beam',
|
||||
id: 'cmd-mode-beam',
|
||||
rank: 9,
|
||||
|
||||
getCommands: () => [{
|
||||
|
||||
@@ -3,7 +3,7 @@ import LanguageIcon from '@mui/icons-material/Language';
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsBrowse: ICommandsProvider = {
|
||||
id: 'ass-browse',
|
||||
id: 'cmd-ass-browse',
|
||||
rank: 20,
|
||||
|
||||
getCommands: () => [{
|
||||
|
||||
@@ -2,8 +2,12 @@ import FormatPaintTwoToneIcon from '@mui/icons-material/FormatPaintTwoTone';
|
||||
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export function textToDrawCommand(text: string): string {
|
||||
return `/draw ${text}`;
|
||||
}
|
||||
|
||||
export const CommandsDraw: ICommandsProvider = {
|
||||
id: 'ass-t2i',
|
||||
id: 'cmd-ass-t2i',
|
||||
rank: 10,
|
||||
|
||||
getCommands: () => [{
|
||||
|
||||
@@ -3,7 +3,7 @@ import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsReact: ICommandsProvider = {
|
||||
id: 'ass-react',
|
||||
id: 'cmd-mode-react',
|
||||
rank: 15,
|
||||
|
||||
getCommands: () => [{
|
||||
|
||||
@@ -8,20 +8,20 @@ import { CommandsHelp } from './CommandsHelp';
|
||||
import { CommandsReact } from './CommandsReact';
|
||||
|
||||
|
||||
export type CommandsProviderId = 'ass-browse' | 'ass-t2i' | 'ass-react' | 'chat-alter' | 'cmd-help' | 'mode-beam';
|
||||
export type CommandsProviderId = 'cmd-ass-browse' | 'cmd-ass-t2i' | 'cmd-chat-alter' | 'cmd-help' | 'cmd-mode-beam' | 'cmd-mode-react';
|
||||
|
||||
type TextCommandPiece =
|
||||
| { type: 'text'; value: string; }
|
||||
| { type: 'cmd'; providerId: CommandsProviderId, command: string; params?: string, isError?: boolean };
|
||||
| { type: 'nocmd'; value: string; }
|
||||
| { type: 'cmd'; providerId: CommandsProviderId, command: string; params?: string, isErrorNoArgs?: boolean };
|
||||
|
||||
|
||||
const ChatCommandsProviders: Record<CommandsProviderId, ICommandsProvider> = {
|
||||
'ass-browse': CommandsBrowse,
|
||||
'ass-react': CommandsReact,
|
||||
'ass-t2i': CommandsDraw,
|
||||
'chat-alter': CommandsAlter,
|
||||
'cmd-ass-browse': CommandsBrowse,
|
||||
'cmd-ass-t2i': CommandsDraw,
|
||||
'cmd-chat-alter': CommandsAlter,
|
||||
'cmd-help': CommandsHelp,
|
||||
'mode-beam': CommandsBeam,
|
||||
'cmd-mode-beam': CommandsBeam,
|
||||
'cmd-mode-react': CommandsReact,
|
||||
};
|
||||
|
||||
export function findAllChatCommands(): ChatCommand[] {
|
||||
@@ -31,12 +31,18 @@ export function findAllChatCommands(): ChatCommand[] {
|
||||
.flat();
|
||||
}
|
||||
|
||||
export function helpPrettyChatCommands() {
|
||||
return findAllChatCommands()
|
||||
.map(cmd => ` - ${cmd.primary}` + (cmd.alternatives?.length ? ` (${cmd.alternatives.join(', ')})` : '') + `: ${cmd.description}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
const inputTrimmed = input.trim();
|
||||
|
||||
// quick exit: command does not start with '/'
|
||||
if (!inputTrimmed.startsWith('/'))
|
||||
return [{ type: 'text', value: input }];
|
||||
return [{ type: 'nocmd', value: input }];
|
||||
|
||||
// Find the first space to separate the command from its parameters (if any)
|
||||
const firstSpaceIndex = inputTrimmed.indexOf(' ');
|
||||
@@ -56,7 +62,7 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
providerId: provider.id,
|
||||
command: potentialCommand,
|
||||
params: textAfterCommand || undefined,
|
||||
isError: !textAfterCommand || undefined,
|
||||
isErrorNoArgs: !textAfterCommand,
|
||||
}];
|
||||
|
||||
// command without arguments, treat any text after as a separate text piece
|
||||
@@ -67,7 +73,7 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
params: undefined,
|
||||
}];
|
||||
textAfterCommand && pieces.push({
|
||||
type: 'text',
|
||||
type: 'nocmd',
|
||||
value: textAfterCommand,
|
||||
});
|
||||
return pieces;
|
||||
@@ -77,7 +83,7 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
|
||||
// No command found, return the entire input as text
|
||||
return [{
|
||||
type: 'text',
|
||||
type: 'nocmd',
|
||||
value: input,
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import { Box, List } from '@mui/joy';
|
||||
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
|
||||
|
||||
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import type { DMessageFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { createDMessage, DConversationId, DMessage, DMessageUserFlag, getConversation, messageToggleUserFlag, useChatStore } from '~/common/state/store-chats';
|
||||
import { ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
|
||||
import { createDMessageTextContent, DMessageId, DMessageUserFlag, messageToggleUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { getConversation, useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useBrowserTranslationWarning } from '~/common/components/useIsBrowserTranslating';
|
||||
import { useCapabilityElevenLabs } from '~/common/components/useCapabilities';
|
||||
import { useEphemerals } from '~/common/chats/EphemeralsStore';
|
||||
@@ -20,7 +23,7 @@ import { ChatMessage, ChatMessageMemo } from './message/ChatMessage';
|
||||
import { CleanerMessage, MessagesSelectionHeader } from './message/CleanerMessage';
|
||||
import { Ephemerals } from './Ephemerals';
|
||||
import { PersonaSelector } from './persona-selector/PersonaSelector';
|
||||
import { useChatShowSystemMessages } from '../store-app-chat';
|
||||
import { useChatAutoSuggestHTMLUI, useChatShowSystemMessages } from '../store-app-chat';
|
||||
|
||||
|
||||
/**
|
||||
@@ -32,9 +35,10 @@ export function ChatMessageList(props: {
|
||||
capabilityHasT2I: boolean,
|
||||
chatLLMContextTokens: number | null,
|
||||
fitScreen: boolean,
|
||||
isMobile: boolean,
|
||||
isMessageSelectionMode: boolean,
|
||||
onConversationBranch: (conversationId: DConversationId, messageId: string) => void,
|
||||
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => Promise<void>,
|
||||
onConversationExecuteHistory: (conversationId: DConversationId) => Promise<void>,
|
||||
onTextDiagram: (diagramConfig: DiagramConfig | null) => void,
|
||||
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<void>,
|
||||
onTextSpeak: (selectedText: string) => Promise<void>,
|
||||
@@ -50,43 +54,43 @@ export function ChatMessageList(props: {
|
||||
// external state
|
||||
const { notifyBooting } = useScrollToBottom();
|
||||
const { openPreferencesTab } = useOptimaLayout();
|
||||
const danger_experimentalHtmlWebUi = useChatAutoSuggestHTMLUI();
|
||||
const [showSystemMessages] = useChatShowSystemMessages();
|
||||
const optionalTranslationWarning = useBrowserTranslationWarning();
|
||||
const { conversationMessages, historyTokenCount, editMessage, deleteMessage, setMessages } = useChatStore(useShallow(state => {
|
||||
const { conversationMessages, historyTokenCount } = useChatStore(useShallow(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return {
|
||||
conversationMessages: conversation ? conversation.messages : [],
|
||||
historyTokenCount: conversation ? conversation.tokenCount : 0,
|
||||
deleteMessage: state.deleteMessage,
|
||||
editMessage: state.editMessage,
|
||||
setMessages: state.setMessages,
|
||||
};
|
||||
}));
|
||||
const ephemerals = useEphemerals(props.conversationHandler);
|
||||
const { mayWork: isSpeakable } = useCapabilityElevenLabs();
|
||||
|
||||
// derived state
|
||||
const { conversationId, capabilityHasT2I, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props;
|
||||
const { conversationHandler, conversationId, capabilityHasT2I, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props;
|
||||
|
||||
|
||||
// text actions
|
||||
|
||||
const handleRunExample = React.useCallback(async (examplePrompt: string) => {
|
||||
conversationId && await onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', examplePrompt)]);
|
||||
}, [conversationId, conversationMessages, onConversationExecuteHistory]);
|
||||
if (conversationId && conversationHandler) {
|
||||
conversationHandler.messageAppend(createDMessageTextContent('user', examplePrompt)); // [chat] append user:persona question
|
||||
await onConversationExecuteHistory(conversationId);
|
||||
}
|
||||
}, [conversationHandler, conversationId, onConversationExecuteHistory]);
|
||||
|
||||
|
||||
// message menu methods proxy
|
||||
|
||||
const handleMessageAssistantFrom = React.useCallback(async (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 && await onConversationExecuteHistory(conversationId, truncatedHistory);
|
||||
const handleMessageAssistantFrom = React.useCallback(async (messageId: DMessageId, offset: number) => {
|
||||
if (conversationId && conversationHandler) {
|
||||
conversationHandler.historyTruncateTo(messageId, offset);
|
||||
await onConversationExecuteHistory(conversationId);
|
||||
}
|
||||
}, [conversationId, onConversationExecuteHistory]);
|
||||
}, [conversationHandler, conversationId, onConversationExecuteHistory]);
|
||||
|
||||
const handleMessageBeam = React.useCallback(async (messageId: string) => {
|
||||
const handleMessageBeam = React.useCallback(async (messageId: DMessageId) => {
|
||||
// Right-click menu Beam
|
||||
if (!conversationId || !props.conversationHandler) return;
|
||||
const messages = getConversation(conversationId)?.messages;
|
||||
@@ -110,37 +114,41 @@ export function ChatMessageList(props: {
|
||||
}
|
||||
}, [conversationId, props.conversationHandler]);
|
||||
|
||||
const handleMessageBranch = React.useCallback((messageId: string) => {
|
||||
const handleMessageBranch = React.useCallback((messageId: DMessageId) => {
|
||||
conversationId && onConversationBranch(conversationId, messageId);
|
||||
}, [conversationId, onConversationBranch]);
|
||||
|
||||
const handleMessageTruncate = 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 handleMessageTruncate = React.useCallback((messageId: DMessageId) => {
|
||||
props.conversationHandler?.historyTruncateTo(messageId, 0);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageDelete = React.useCallback((messageId: string) => {
|
||||
conversationId && deleteMessage(conversationId, messageId);
|
||||
}, [conversationId, deleteMessage]);
|
||||
const handleMessageDelete = React.useCallback((messageId: DMessageId) => {
|
||||
props.conversationHandler?.messagesDelete([messageId]);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageEdit = React.useCallback((messageId: string, newText: string) => {
|
||||
conversationId && editMessage(conversationId, messageId, { text: newText }, true);
|
||||
}, [conversationId, editMessage]);
|
||||
const handleMessageAppendFragment = React.useCallback((messageId: DMessageId, fragment: DMessageFragment) => {
|
||||
props.conversationHandler?.messageFragmentAppend(messageId, fragment, false, false);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageToggleUserFlag = React.useCallback((messageId: string, userFlag: DMessageUserFlag) => {
|
||||
conversationId && editMessage(conversationId, messageId, (message) => ({
|
||||
const handleMessageDeleteFragment = React.useCallback((messageId: DMessageId, fragmentId: DMessageFragmentId) => {
|
||||
props.conversationHandler?.messageFragmentDelete(messageId, fragmentId, false, true);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageReplaceFragment = React.useCallback((messageId: DMessageId, fragmentId: DMessageFragmentId, newFragment: DMessageFragment) => {
|
||||
props.conversationHandler?.messageFragmentReplace(messageId, fragmentId, newFragment, false);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleMessageToggleUserFlag = React.useCallback((messageId: DMessageId, userFlag: DMessageUserFlag) => {
|
||||
props.conversationHandler?.messageEdit(messageId, (message) => ({
|
||||
userFlags: messageToggleUserFlag(message, userFlag),
|
||||
}), false);
|
||||
}, [conversationId, editMessage]);
|
||||
}), false, false);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleReplyTo = React.useCallback((_messageId: string, text: string) => {
|
||||
const handleReplyTo = React.useCallback((_messageId: DMessageId, text: string) => {
|
||||
props.conversationHandler?.getOverlayStore().getState().setReplyToText(text);
|
||||
}, [props.conversationHandler]);
|
||||
|
||||
const handleTextDiagram = React.useCallback(async (messageId: string, text: string) => {
|
||||
const handleTextDiagram = React.useCallback(async (messageId: DMessageId, text: string) => {
|
||||
conversationId && onTextDiagram({ conversationId: conversationId, messageId, text });
|
||||
}, [conversationId, onTextDiagram]);
|
||||
|
||||
@@ -173,36 +181,35 @@ export function ChatMessageList(props: {
|
||||
setSelectedMessages(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectMessage = (messageId: string, selected: boolean) => {
|
||||
const handleSelectMessage = (messageId: DMessageId, selected: boolean) => {
|
||||
const newSelected = new Set(selectedMessages);
|
||||
selected ? newSelected.add(messageId) : newSelected.delete(messageId);
|
||||
setSelectedMessages(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectionDelete = () => {
|
||||
if (conversationId)
|
||||
for (const selectedMessage of selectedMessages)
|
||||
deleteMessage(conversationId, selectedMessage);
|
||||
const handleSelectionDelete = React.useCallback(() => {
|
||||
props.conversationHandler?.messagesDelete(Array.from(selectedMessages));
|
||||
setSelectedMessages(new Set());
|
||||
};
|
||||
}, [props.conversationHandler, selectedMessages]);
|
||||
|
||||
useGlobalShortcut(props.isMessageSelectionMode && ShortcutKeyName.Esc, false, false, false, () => {
|
||||
useGlobalShortcuts([[props.isMessageSelectionMode && ShortcutKeyName.Esc, false, false, false, () => {
|
||||
props.setIsMessageSelectionMode(false);
|
||||
});
|
||||
}]]);
|
||||
|
||||
|
||||
// text-diff functionality: only diff the last message and when it's complete (not typing), and they're similar in size
|
||||
// text-diff functionality: only diff the last complete message, and they're similar in size
|
||||
|
||||
const { diffTargetMessage, diffPrevText } = React.useMemo(() => {
|
||||
const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
|
||||
if (msgB?.text && msgA?.text && !msgB?.typing) {
|
||||
const textA = msgA.text, textB = msgB.text;
|
||||
const lenA = textA.length, lenB = textB.length;
|
||||
if (lenA > 80 && lenB > 80 && lenA > lenB / 3 && lenB > lenA / 3)
|
||||
return { diffTargetMessage: msgB, diffPrevText: textA };
|
||||
}
|
||||
return { diffTargetMessage: undefined, diffPrevText: undefined };
|
||||
}, [conversationMessages]);
|
||||
// const { diffTargetMessage, diffPrevText } = React.useMemo(() => {
|
||||
// const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
|
||||
// const textB = msgB ? singleTextOrThrow(msgB) : undefined;
|
||||
// const textA = msgA ? singleTextOrThrow(msgA) : undefined;
|
||||
// if (textB && textA && !msgB?.pendingIncomplete) {
|
||||
// const lenA = textA.length, lenB = textB.length;
|
||||
// if (lenA > 80 && lenB > 80 && lenA > lenB / 3 && lenB > lenA / 3)
|
||||
// return { diffTargetMessage: msgB, diffPrevText: textA };
|
||||
// }
|
||||
// return { diffTargetMessage: undefined, diffPrevText: undefined };
|
||||
// }, [conversationMessages]);
|
||||
|
||||
|
||||
// scroll to the very bottom of a new chat
|
||||
@@ -228,7 +235,7 @@ export function ChatMessageList(props: {
|
||||
);
|
||||
|
||||
return (
|
||||
<List sx={{
|
||||
<List role='chat-messages-list' sx={{
|
||||
p: 0,
|
||||
...(props.sx || {}),
|
||||
|
||||
@@ -254,8 +261,8 @@ export function ChatMessageList(props: {
|
||||
|
||||
{filteredMessages.map((message, idx, { length: count }) => {
|
||||
|
||||
// Optimization: if the component is going to change (e.g. the message is typing), we don't want to memoize it to not throw garbage in memory
|
||||
const ChatMessageMemoOrNot = message.typing ? ChatMessage : ChatMessageMemo;
|
||||
// Optimization: only memo complete components, or we'd be memoizing garbage
|
||||
const ChatMessageMemoOrNot = !message.pendingIncomplete ? ChatMessageMemo : ChatMessage;
|
||||
|
||||
return props.isMessageSelectionMode ? (
|
||||
|
||||
@@ -271,19 +278,23 @@ export function ChatMessageList(props: {
|
||||
<ChatMessageMemoOrNot
|
||||
key={'msg-' + message.id}
|
||||
message={message}
|
||||
diffPreviousText={message === diffTargetMessage ? diffPrevText : undefined}
|
||||
// diffPreviousText={message === diffTargetMessage ? diffPrevText : undefined}
|
||||
fitScreen={props.fitScreen}
|
||||
isMobile={props.isMobile}
|
||||
isBottom={idx === count - 1}
|
||||
isImagining={isImagining}
|
||||
isSpeaking={isSpeaking}
|
||||
showUnsafeHtml={danger_experimentalHtmlWebUi}
|
||||
onMessageAssistantFrom={handleMessageAssistantFrom}
|
||||
onMessageBeam={handleMessageBeam}
|
||||
onMessageBranch={handleMessageBranch}
|
||||
onMessageDelete={handleMessageDelete}
|
||||
onMessageEdit={handleMessageEdit}
|
||||
onMessageFragmentAppend={handleMessageAppendFragment}
|
||||
onMessageFragmentDelete={handleMessageDeleteFragment}
|
||||
onMessageFragmentReplace={handleMessageReplaceFragment}
|
||||
onMessageToggleUserFlag={handleMessageToggleUserFlag}
|
||||
onMessageTruncate={handleMessageTruncate}
|
||||
// onReplyTo={handleReplyTo}
|
||||
onReplyTo={handleReplyTo}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={capabilityHasT2I ? handleTextImagine : undefined}
|
||||
onTextSpeak={isSpeakable ? handleTextSpeak : undefined}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { Box, Grid, IconButton, Sheet, styled, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import type { DEphemeral } from '~/common/chats/EphemeralsStore';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { DConversationId } from '~/common/state/store-chats';
|
||||
import { DEphemeral } from '~/common/chats/EphemeralsStore';
|
||||
import { lineHeightChatTextMd } from '~/common/app.theme';
|
||||
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, MenuItem, Radio, Typography } from '@mui/joy';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { KeyStroke, platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { ChatModeId } from '../../AppChat';
|
||||
|
||||
|
||||
interface ChatModeDescription {
|
||||
label: string;
|
||||
description: string | React.JSX.Element;
|
||||
highlight?: boolean;
|
||||
shortcut?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
requiresTTI?: boolean;
|
||||
}
|
||||
|
||||
const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = {
|
||||
'generate-text': {
|
||||
label: 'Chat',
|
||||
description: 'Persona replies',
|
||||
},
|
||||
'generate-text-beam': {
|
||||
label: 'Beam', // Best of, Auto-Prime, Top Pick, Select Best
|
||||
description: 'Combine multiple models', // Smarter: combine...
|
||||
shortcut: 'Ctrl + Enter',
|
||||
hideOnDesktop: true,
|
||||
},
|
||||
'append-user': {
|
||||
label: 'Write',
|
||||
description: 'Append a message',
|
||||
shortcut: 'Alt + Enter',
|
||||
},
|
||||
'generate-image': {
|
||||
label: 'Draw',
|
||||
description: 'AI Image Generation',
|
||||
requiresTTI: true,
|
||||
},
|
||||
'generate-react': {
|
||||
label: 'Reason + Act', // · α
|
||||
description: 'Answer questions in multiple steps',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) {
|
||||
if (shortcut === 'ENTER')
|
||||
return enterIsNewLine ? 'Shift + Enter' : 'Enter';
|
||||
return shortcut;
|
||||
}
|
||||
|
||||
export function ChatModeMenu(props: {
|
||||
isMobile: boolean,
|
||||
anchorEl: HTMLAnchorElement | null,
|
||||
onClose: () => void,
|
||||
chatModeId: ChatModeId,
|
||||
onSetChatModeId: (chatMode: ChatModeId) => void,
|
||||
capabilityHasTTI: boolean,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
|
||||
return (
|
||||
<CloseableMenu
|
||||
placement='top-end'
|
||||
open anchorEl={props.anchorEl} onClose={props.onClose}
|
||||
sx={{ minWidth: 320 }}
|
||||
>
|
||||
|
||||
{/*<MenuItem color='neutral' selected>*/}
|
||||
{/* Conversation Mode*/}
|
||||
{/*</MenuItem>*/}
|
||||
{/**/}
|
||||
{/*<ListDivider />*/}
|
||||
|
||||
{/* ChatMode items */}
|
||||
{Object.entries(ChatModeItems)
|
||||
.filter(([_key, data]) => !data.hideOnDesktop || props.isMobile)
|
||||
.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 color={data.highlight ? 'success' : undefined} checked={key === props.chatModeId} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography>{data.label}</Typography>
|
||||
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.capabilityHasTTI) ? 'Unconfigured' : ''}</Typography>
|
||||
</Box>
|
||||
{(key === props.chatModeId || !!data.shortcut) && (
|
||||
<KeyStroke combo={platformAwareKeystrokes(fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline))} />
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>)}
|
||||
|
||||
</CloseableMenu>
|
||||
);
|
||||
}
|
||||
@@ -15,31 +15,33 @@ import SendIcon from '@mui/icons-material/Send';
|
||||
import StopOutlinedIcon from '@mui/icons-material/StopOutlined';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
import type { ChatModeId } from '../../AppChat';
|
||||
import { useChatMicTimeoutMsValue } from '../../store-app-chat';
|
||||
|
||||
import type { DLLM } from '~/modules/llms/store-llms';
|
||||
import type { LLMOptionsOpenAI } from '~/modules/llms/vendors/openai/openai.vendor';
|
||||
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
|
||||
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { DMessageMetadata, messageFragmentsReduceText } from '~/common/stores/chat/chat.message';
|
||||
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
|
||||
import { animationEnterBelow } from '~/common/util/animUtils';
|
||||
import { conversationTitle, DConversationId, DMessageMetadata, getConversation, useChatStore } from '~/common/state/store-chats';
|
||||
import { countModelTokens } from '~/common/util/token-counter';
|
||||
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { copyToClipboard, supportsClipboardRead } from '~/common/util/clipboardUtils';
|
||||
import { createTextContentFragment, DMessageAttachmentFragment, DMessageContentFragment, duplicateDMessageFragments, isContentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
import { estimateTextTokens, glueForMessageTokens, marshallWrapDocFragments } from '~/common/stores/chat/chat.tokens';
|
||||
import { getConversation, isValidConversation, useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { isMacUser } from '~/common/util/pwaUtils';
|
||||
import { launchAppCall } from '~/common/app.routes';
|
||||
import { lineHeightTextareaMd } from '~/common/app.theme';
|
||||
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
import { playSoundUrl } from '~/common/util/audioUtils';
|
||||
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
|
||||
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
|
||||
import { useAppStateStore } from '~/common/state/store-appstate';
|
||||
import { useChatOverlayStore } from '~/common/chats/store-chat-overlay-vanilla';
|
||||
import { useChatOverlayStore } from '~/common/chats/store-chat-overlay';
|
||||
import { useDebouncer } from '~/common/components/useDebouncer';
|
||||
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
|
||||
import { useUICounter, useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
@@ -48,12 +50,14 @@ import { providerCommands } from './actile/providerCommands';
|
||||
import { providerStarredMessage, StarredMessageItem } from './actile/providerStarredMessage';
|
||||
import { useActileManager } from './actile/useActileManager';
|
||||
|
||||
import type { AttachmentId } from './attachments/store-attachments';
|
||||
import { Attachments } from './attachments/Attachments';
|
||||
import { getSingleTextBlockText, useLLMAttachments } from './attachments/useLLMAttachments';
|
||||
import { useAttachments } from './attachments/useAttachments';
|
||||
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import { LLMAttachmentDraftsAction, LLMAttachmentsList } from './llmattachments/LLMAttachmentsList';
|
||||
import { useAttachmentDrafts } from '~/common/attachment-drafts/useAttachmentDrafts';
|
||||
import { useLLMAttachmentDrafts } from './llmattachments/useLLMAttachmentDrafts';
|
||||
|
||||
import type { ChatExecuteMode } from '../../execute-mode/execute-mode.types';
|
||||
import { chatExecuteModeCanAttach, useChatExecuteMode } from '../../execute-mode/useChatExecuteMode';
|
||||
|
||||
import type { ComposerOutputMultiPart } from './composer.types';
|
||||
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
|
||||
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
|
||||
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
|
||||
@@ -64,7 +68,6 @@ import { ButtonMicContinuationMemo } from './buttons/ButtonMicContinuation';
|
||||
import { ButtonMicMemo } from './buttons/ButtonMic';
|
||||
import { ButtonMultiChatMemo } from './buttons/ButtonMultiChat';
|
||||
import { ButtonOptionsDraw } from './buttons/ButtonOptionsDraw';
|
||||
import { ChatModeMenu } from './ChatModeMenu';
|
||||
import { ReplyToBubble } from '../message/ReplyToBubble';
|
||||
import { TokenBadgeMemo } from './TokenBadge';
|
||||
import { TokenProgressbarMemo } from './TokenProgressbar';
|
||||
@@ -97,23 +100,26 @@ export function Composer(props: {
|
||||
isMobile?: boolean;
|
||||
chatLLM: DLLM | null;
|
||||
composerTextAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
conversationId: DConversationId | null;
|
||||
targetConversationId: DConversationId | null;
|
||||
capabilityHasT2I: boolean;
|
||||
isMulticast: boolean | null;
|
||||
isDeveloperMode: boolean;
|
||||
onAction: (conversationId: DConversationId, chatModeId: ChatModeId, multiPartMessage: ComposerOutputMultiPart, metadata?: DMessageMetadata) => boolean;
|
||||
onAction: (conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata) => boolean;
|
||||
onTextImagine: (conversationId: DConversationId, text: string) => void;
|
||||
setIsMulticast: (on: boolean) => void;
|
||||
sx?: SxProps;
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-text');
|
||||
const [composeText, debouncedText, setComposeText] = useDebouncer('', 300, 1200, true);
|
||||
const [micContinuation, setMicContinuation] = React.useState(false);
|
||||
const [speechInterimResult, setSpeechInterimResult] = React.useState<SpeechResult | null>(null);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [chatModeMenuAnchor, setChatModeMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
|
||||
const {
|
||||
chatExecuteMode,
|
||||
chatExecuteModeSendColor, chatExecuteModeSendLabel,
|
||||
chatExecuteMenuComponent, chatExecuteMenuShown, showChatExecuteMenu,
|
||||
} = useChatExecuteMode(props.capabilityHasT2I, !!props.isMobile);
|
||||
|
||||
// external state
|
||||
const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout();
|
||||
@@ -129,47 +135,60 @@ export function Composer(props: {
|
||||
const [startupText, setStartupText] = useComposerStartupText();
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
const chatMicTimeoutMs = useChatMicTimeoutMsValue();
|
||||
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, stopTyping } = useChatStore(useShallow(state => {
|
||||
const conversation = state.conversations.find(_c => _c.id === props.conversationId);
|
||||
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, abortConversationTemp } = useChatStore(useShallow(state => {
|
||||
const conversation = state.conversations.find(_c => _c.id === props.targetConversationId);
|
||||
return {
|
||||
assistantAbortible: conversation ? !!conversation.abortController : false,
|
||||
systemPurposeId: conversation?.systemPurposeId ?? null,
|
||||
tokenCount: conversation ? conversation.tokenCount : 0,
|
||||
stopTyping: state.stopTyping,
|
||||
abortConversationTemp: state.abortConversationTemp,
|
||||
};
|
||||
}));
|
||||
const { inComposer: browsingInComposer } = useBrowseCapability();
|
||||
const { attachAppendClipboardItems, attachAppendDataTransfer, attachAppendEgoMessage, attachAppendFile, attachments: _attachments, clearAttachments, removeAttachment } =
|
||||
useAttachments(browsingInComposer && !composeText.startsWith('/'));
|
||||
|
||||
// external overlay state (extra conversationId-dependent state)
|
||||
const conversationHandler = props.conversationId ? ConversationsManager.getHandler(props.conversationId) : null;
|
||||
const conversationOverlayStore = conversationHandler?.getOverlayStore() ?? null;
|
||||
const conversationOverlayStore = props.targetConversationId
|
||||
? ConversationsManager.getHandler(props.targetConversationId)?.getOverlayStore() || null
|
||||
: null;
|
||||
|
||||
// composer-overlay: for the reply-to state, comes from the conversation overlay
|
||||
const { replyToGenerateText } = useChatOverlayStore(conversationOverlayStore, useShallow(store => ({
|
||||
replyToGenerateText: chatModeId === 'generate-text' ? store.replyToText?.trim() || null : null,
|
||||
replyToGenerateText: (chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1') ? store.replyToText?.trim() || null : null,
|
||||
})));
|
||||
|
||||
// don't load URLs if the user is typing a command or there's no capability
|
||||
const enableLoadURLsInComposer = useBrowseCapability().inComposer && !composeText.startsWith('/');
|
||||
|
||||
// attachments-overlay: comes from the attachments slice of the conversation overlay
|
||||
const {
|
||||
/* items */ attachmentDrafts,
|
||||
/* append */ attachAppendClipboardItems, attachAppendDataTransfer, attachAppendEgoFragments, attachAppendFile,
|
||||
/* take */ attachmentsRemoveAll, attachmentsTakeAllFragments, attachmentsTakeFragmentsByType,
|
||||
} = useAttachmentDrafts(conversationOverlayStore, enableLoadURLsInComposer);
|
||||
|
||||
// attachments derived state
|
||||
const llmAttachmentDrafts = useLLMAttachmentDrafts(attachmentDrafts, props.chatLLM);
|
||||
|
||||
|
||||
// derived state
|
||||
|
||||
const { composerTextAreaRef, targetConversationId, onAction, onTextImagine } = props;
|
||||
const isMobile = !!props.isMobile;
|
||||
const isDesktop = !props.isMobile;
|
||||
const chatLLMId = props.chatLLM?.id || null;
|
||||
const noConversation = !targetConversationId;
|
||||
const noLLM = !props.chatLLM;
|
||||
const showLLMAttachments = chatExecuteModeCanAttach(chatExecuteMode);
|
||||
|
||||
// attachments derived state
|
||||
|
||||
const llmAttachments = useLLMAttachments(_attachments, chatLLMId);
|
||||
|
||||
// tokens derived state
|
||||
|
||||
const tokensComposerText = React.useMemo(() => {
|
||||
if (!debouncedText || !chatLLMId)
|
||||
return 0;
|
||||
return countModelTokens(debouncedText, chatLLMId, 'composer text') ?? 0;
|
||||
}, [chatLLMId, debouncedText]);
|
||||
let tokensComposer = tokensComposerText + llmAttachments.tokenCountApprox;
|
||||
if (tokensComposer > 0)
|
||||
tokensComposer += 4; // every user message has this many surrounding tokens (note: shall depend on llm..)
|
||||
const tokensComposerTextDebounced = React.useMemo(() => {
|
||||
return (debouncedText && props.chatLLM)
|
||||
? estimateTextTokens(debouncedText, props.chatLLM, 'composer text')
|
||||
: 0;
|
||||
}, [props.chatLLM, debouncedText]);
|
||||
let tokensComposer = tokensComposerTextDebounced + (llmAttachmentDrafts.llmTokenCountApprox || 0);
|
||||
if (props.chatLLM && tokensComposer > 0)
|
||||
tokensComposer += glueForMessageTokens(props.chatLLM);
|
||||
const tokensHistory = _historyTokenCount;
|
||||
const tokensReponseMax = (props.chatLLM?.options as LLMOptionsOpenAI /* FIXME: BIG ASSUMPTION */)?.llmResponseTokens || 0;
|
||||
const tokenLimit = props.chatLLM?.contextTokens || 0;
|
||||
@@ -188,95 +207,94 @@ export function Composer(props: {
|
||||
|
||||
// Overlay actions
|
||||
|
||||
const handleReplyToCleared = React.useCallback(() => {
|
||||
const handleReplyToClear = React.useCallback(() => {
|
||||
conversationOverlayStore?.getState().setReplyToText(null);
|
||||
}, [conversationOverlayStore]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (replyToGenerateText)
|
||||
setTimeout(() => props.composerTextAreaRef.current?.focus(), 1 /* prevent focus theft */);
|
||||
}, [replyToGenerateText, props.composerTextAreaRef]);
|
||||
setTimeout(() => composerTextAreaRef.current?.focus(), 1 /* prevent focus theft */);
|
||||
}, [composerTextAreaRef, replyToGenerateText]);
|
||||
|
||||
|
||||
// Primary button
|
||||
|
||||
const { conversationId, onAction } = props;
|
||||
const handleClear = React.useCallback(() => {
|
||||
setComposeText('');
|
||||
attachmentsRemoveAll();
|
||||
handleReplyToClear();
|
||||
}, [attachmentsRemoveAll, handleReplyToClear, setComposeText]);
|
||||
|
||||
const handleSendAction = React.useCallback((_chatModeId: ChatModeId, composerText: string): boolean => {
|
||||
if (!conversationId)
|
||||
|
||||
const handleSendAction = React.useCallback(async (_chatExecuteMode: ChatExecuteMode, composerText: string): Promise<boolean> => {
|
||||
if (!isValidConversation(targetConversationId)) return false;
|
||||
|
||||
// validate some chat mode inputs
|
||||
const isDraw = _chatExecuteMode === 'generate-image';
|
||||
const isBlank = !composerText.trim();
|
||||
if (isDraw && isBlank)
|
||||
return false;
|
||||
|
||||
// get the multipart output including all attachments
|
||||
const multiPartMessage = llmAttachments.collapseWithAttachments(composerText || null);
|
||||
if (!multiPartMessage.length)
|
||||
return false;
|
||||
// prepare the fragments: content (if any) and attachments (if allowed, and any)
|
||||
const fragments: (DMessageContentFragment | DMessageAttachmentFragment)[] = [];
|
||||
if (composerText)
|
||||
fragments.push(createTextContentFragment(composerText));
|
||||
|
||||
// metadata
|
||||
const metadata = replyToGenerateText ? { inReplyToText: replyToGenerateText } : undefined;
|
||||
|
||||
// send the message
|
||||
const enqueued = onAction(conversationId, _chatModeId, multiPartMessage, metadata);
|
||||
if (enqueued) {
|
||||
clearAttachments();
|
||||
handleReplyToCleared();
|
||||
setComposeText('');
|
||||
const canAttach = chatExecuteModeCanAttach(_chatExecuteMode);
|
||||
if (canAttach) {
|
||||
const attachmentFragments = await attachmentsTakeAllFragments('global', 'app-chat');
|
||||
fragments.push(...attachmentFragments);
|
||||
}
|
||||
|
||||
if (!fragments.length) {
|
||||
// addSnackbar({ key: 'chat-composer-empty', message: 'Nothing to send', type: 'info' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// send the message - NOTE: if successful, the ownership of the fragments is transferred to the receiver, so we just clear them
|
||||
const metadata = replyToGenerateText ? { inReplyToText: replyToGenerateText } : undefined;
|
||||
const enqueued = onAction(targetConversationId, _chatExecuteMode, fragments, metadata);
|
||||
if (enqueued)
|
||||
handleClear();
|
||||
return enqueued;
|
||||
}, [clearAttachments, conversationId, handleReplyToCleared, llmAttachments, onAction, replyToGenerateText, setComposeText]);
|
||||
}, [attachmentsTakeAllFragments, handleClear, onAction, replyToGenerateText, targetConversationId]);
|
||||
|
||||
const handleSendClicked = React.useCallback(() => {
|
||||
handleSendAction(chatModeId, composeText);
|
||||
}, [chatModeId, composeText, handleSendAction]);
|
||||
|
||||
const handleSendTextBeamClicked = React.useCallback(() => {
|
||||
handleSendAction('generate-text-beam', composeText);
|
||||
const handleSendClicked = React.useCallback(async () => {
|
||||
await handleSendAction(chatExecuteMode, composeText); // 'chat/write/...' button
|
||||
}, [chatExecuteMode, composeText, handleSendAction]);
|
||||
|
||||
const handleSendTextBeamClicked = React.useCallback(async () => {
|
||||
await handleSendAction('beam-content', composeText); // 'beam' button
|
||||
}, [composeText, handleSendAction]);
|
||||
|
||||
const handleStopClicked = React.useCallback(() => {
|
||||
!!props.conversationId && stopTyping(props.conversationId);
|
||||
}, [props.conversationId, stopTyping]);
|
||||
targetConversationId && abortConversationTemp(targetConversationId);
|
||||
}, [abortConversationTemp, targetConversationId]);
|
||||
|
||||
|
||||
// Secondary buttons
|
||||
|
||||
const handleCallClicked = React.useCallback(() => {
|
||||
props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
|
||||
}, [props.conversationId, systemPurposeId]);
|
||||
targetConversationId && systemPurposeId && launchAppCall(targetConversationId, systemPurposeId);
|
||||
}, [systemPurposeId, targetConversationId]);
|
||||
|
||||
const handleDrawOptionsClicked = React.useCallback(() => {
|
||||
openPreferencesTab(PreferencesTab.Draw);
|
||||
}, [openPreferencesTab]);
|
||||
|
||||
const handleTextImagineClicked = React.useCallback(() => {
|
||||
if (!composeText || !props.conversationId)
|
||||
return;
|
||||
props.onTextImagine(props.conversationId, composeText);
|
||||
if (!composeText || !targetConversationId) return;
|
||||
onTextImagine(targetConversationId, composeText);
|
||||
setComposeText('');
|
||||
}, [composeText, props, setComposeText]);
|
||||
|
||||
|
||||
// Mode menu
|
||||
|
||||
const handleModeSelectorHide = React.useCallback(() => {
|
||||
setChatModeMenuAnchor(null);
|
||||
}, []);
|
||||
|
||||
const handleModeSelectorShow = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const handleModeChange = React.useCallback((_chatModeId: ChatModeId) => {
|
||||
handleModeSelectorHide();
|
||||
setChatModeId(_chatModeId);
|
||||
}, [handleModeSelectorHide]);
|
||||
}, [composeText, onTextImagine, setComposeText, targetConversationId]);
|
||||
|
||||
|
||||
// Actiles
|
||||
|
||||
const onActileCommandPaste = React.useCallback((item: ActileItem) => {
|
||||
if (props.composerTextAreaRef.current) {
|
||||
const textArea = props.composerTextAreaRef.current;
|
||||
if (composerTextAreaRef.current) {
|
||||
const textArea = composerTextAreaRef.current;
|
||||
const currentText = textArea.value;
|
||||
const cursorPos = textArea.selectionStart;
|
||||
|
||||
@@ -293,36 +311,39 @@ export function Composer(props: {
|
||||
const newCursorPos = commandStart + item.label.length + 1;
|
||||
textArea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}
|
||||
}, [props.composerTextAreaRef, setComposeText]);
|
||||
}, [composerTextAreaRef, setComposeText]);
|
||||
|
||||
const onActileMessageAttach = React.useCallback((item: StarredMessageItem) => {
|
||||
const onActileEmbedMessage = React.useCallback(async ({ conversationId, messageId }: StarredMessageItem) => {
|
||||
// get the message
|
||||
const conversation = getConversation(item.conversationId);
|
||||
const messageToAttach = conversation?.messages.find(m => m.id === item.messageId);
|
||||
if (conversation && messageToAttach && messageToAttach.text) {
|
||||
// Testing with this serialization for LLM. Note it will still be within a multi-part message,
|
||||
// this could be in a titled markdown block. Don't know yet how this fares with different LLMs.
|
||||
const chatTitle = conversationTitle(conversation);
|
||||
const textPlain = `---\nitem id: ${messageToAttach.id}\ncontext title: ${chatTitle}\n---\n${messageToAttach.text.trim()}\n`;
|
||||
void attachAppendEgoMessage('context-item', textPlain, `${chatTitle} > ${messageToAttach.text.slice(0, 10)}...`);
|
||||
const conversation = getConversation(conversationId);
|
||||
const messageToEmbed = conversation?.messages.find(m => m.id === messageId);
|
||||
if (conversation && messageToEmbed) {
|
||||
const fragmentsCopy = duplicateDMessageFragments(messageToEmbed.fragments)
|
||||
.filter(isContentFragment);
|
||||
if (fragmentsCopy.length) {
|
||||
const chatTitle = conversationTitle(conversation);
|
||||
const messageText = messageFragmentsReduceText(fragmentsCopy);
|
||||
const label = `${chatTitle} > ${messageText.slice(0, 10)}...`;
|
||||
await attachAppendEgoFragments(fragmentsCopy, label, chatTitle, conversationId, messageId);
|
||||
}
|
||||
}
|
||||
}, [attachAppendEgoMessage]);
|
||||
}, [attachAppendEgoFragments]);
|
||||
|
||||
const actileProviders = React.useMemo(() => {
|
||||
return [providerCommands(onActileCommandPaste), providerStarredMessage(onActileMessageAttach)];
|
||||
}, [onActileCommandPaste, onActileMessageAttach]);
|
||||
return [providerCommands(onActileCommandPaste), providerStarredMessage(onActileEmbedMessage)];
|
||||
}, [onActileCommandPaste, onActileEmbedMessage]);
|
||||
|
||||
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, props.composerTextAreaRef);
|
||||
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, composerTextAreaRef);
|
||||
|
||||
|
||||
// Text typing
|
||||
// Type...
|
||||
|
||||
const handleTextareaTextChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setComposeText(e.target.value);
|
||||
isMobile && actileInterceptTextChange(e.target.value);
|
||||
}, [actileInterceptTextChange, isMobile, setComposeText]);
|
||||
|
||||
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const handleTextareaKeyDown = React.useCallback(async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// disable keyboard handling if the actile is visible
|
||||
if (actileInterceptKeydown(e))
|
||||
return;
|
||||
@@ -332,14 +353,14 @@ export function Composer(props: {
|
||||
|
||||
// Alt (Windows) or Option (Mac) + Enter: append the message instead of sending it
|
||||
if (e.altKey) {
|
||||
if (handleSendAction('append-user', composeText))
|
||||
if (await handleSendAction('append-user', composeText)) // 'alt+enter' -> write
|
||||
touchAltEnter();
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
// Ctrl (Windows) or Command (Mac) + Enter: send for beaming
|
||||
if ((isMacUser && e.metaKey && !e.ctrlKey) || (!isMacUser && e.ctrlKey && !e.metaKey)) {
|
||||
if (handleSendAction('generate-text-beam', composeText))
|
||||
if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam
|
||||
touchCtrlEnter();
|
||||
return e.preventDefault();
|
||||
}
|
||||
@@ -349,12 +370,12 @@ export function Composer(props: {
|
||||
touchShiftEnter();
|
||||
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
|
||||
if (!assistantAbortible)
|
||||
handleSendAction(chatModeId, composeText);
|
||||
await handleSendAction(chatExecuteMode, composeText); // enter -> send
|
||||
return e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
|
||||
}, [actileInterceptKeydown, assistantAbortible, chatExecuteMode, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
|
||||
|
||||
|
||||
// Focus mode
|
||||
@@ -380,26 +401,26 @@ export function Composer(props: {
|
||||
nextText = nextText ? nextText + ' ' + transcript : transcript;
|
||||
|
||||
// auto-send (mic continuation mode) if requested
|
||||
const autoSend = micContinuation && nextText.length >= 1 && !!props.conversationId; //&& assistantAbortible;
|
||||
const autoSend = micContinuation && nextText.length >= 1 && !noConversation; //&& assistantAbortible;
|
||||
const notUserStop = result.doneReason !== 'manual';
|
||||
if (autoSend) {
|
||||
if (notUserStop)
|
||||
playSoundUrl('/sounds/mic-off-mid.mp3');
|
||||
handleSendAction(chatModeId, nextText);
|
||||
void AudioPlayer.playUrl('/sounds/mic-off-mid.mp3');
|
||||
void handleSendAction(chatExecuteMode, nextText); // fire/forget
|
||||
} else {
|
||||
if (!micContinuation && notUserStop)
|
||||
playSoundUrl('/sounds/mic-off-mid.mp3');
|
||||
void AudioPlayer.playUrl('/sounds/mic-off-mid.mp3');
|
||||
if (nextText) {
|
||||
props.composerTextAreaRef.current?.focus();
|
||||
composerTextAreaRef.current?.focus();
|
||||
setComposeText(nextText);
|
||||
}
|
||||
}
|
||||
}, [chatModeId, composeText, handleSendAction, micContinuation, props.composerTextAreaRef, props.conversationId, setComposeText]);
|
||||
}, [chatExecuteMode, composeText, composerTextAreaRef, handleSendAction, micContinuation, noConversation, setComposeText]);
|
||||
|
||||
const { isSpeechEnabled, isSpeechError, isRecordingAudio, isRecordingSpeech, toggleRecording } =
|
||||
useSpeechRecognition(onSpeechResultCallback, chatMicTimeoutMs || 2000);
|
||||
|
||||
useGlobalShortcut('m', true, false, false, toggleRecording);
|
||||
useGlobalShortcuts([['m', true, false, false, toggleRecording]]);
|
||||
|
||||
const micIsRunning = !!speechInterimResult;
|
||||
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantAbortible && !isSpeechError;
|
||||
@@ -423,7 +444,7 @@ export function Composer(props: {
|
||||
}, [toggleRecording, micContinuationTrigger]);
|
||||
|
||||
|
||||
// Attachments
|
||||
// Attachment Up
|
||||
|
||||
const handleAttachCtrlV = React.useCallback((event: React.ClipboardEvent) => {
|
||||
if (attachAppendDataTransfer(event.clipboardData, 'paste', false) === 'as_files')
|
||||
@@ -434,12 +455,12 @@ export function Composer(props: {
|
||||
void attachAppendFile('camera', file);
|
||||
}, [attachAppendFile]);
|
||||
|
||||
const { openCamera, cameraCaptureComponent } = useCameraCaptureModal(handleAttachCameraImage);
|
||||
|
||||
const handleAttachScreenCapture = React.useCallback((file: File) => {
|
||||
void attachAppendFile('screencapture', file);
|
||||
}, [attachAppendFile]);
|
||||
|
||||
const { openCamera, cameraCaptureComponent } = useCameraCaptureModal(handleAttachCameraImage);
|
||||
|
||||
const handleAttachFilePicker = React.useCallback(async () => {
|
||||
try {
|
||||
const selectedFiles: FileWithHandle[] = await fileOpen({ multiple: true });
|
||||
@@ -451,25 +472,24 @@ export function Composer(props: {
|
||||
}
|
||||
}, [attachAppendFile]);
|
||||
|
||||
useGlobalShortcut(supportsClipboardRead ? 'v' : false, true, true, false, attachAppendClipboardItems);
|
||||
useGlobalShortcuts([[supportsClipboardRead ? 'v' : false, true, true, false, attachAppendClipboardItems]]);
|
||||
|
||||
const handleAttachmentInlineText = React.useCallback((attachmentId: AttachmentId) => {
|
||||
setComposeText(currentText => {
|
||||
const inlinedMultiPart = llmAttachments.collapseWithAttachment(currentText, attachmentId);
|
||||
const inlinedText = getSingleTextBlockText(inlinedMultiPart) || '';
|
||||
removeAttachment(attachmentId);
|
||||
return inlinedText;
|
||||
});
|
||||
}, [llmAttachments, removeAttachment, setComposeText]);
|
||||
|
||||
const handleAttachmentsInlineText = React.useCallback(() => {
|
||||
setComposeText(currentText => {
|
||||
const inlinedMultiPart = llmAttachments.collapseWithAttachments(currentText);
|
||||
const inlinedText = getSingleTextBlockText(inlinedMultiPart) || '';
|
||||
clearAttachments();
|
||||
return inlinedText;
|
||||
});
|
||||
}, [clearAttachments, llmAttachments, setComposeText]);
|
||||
// Attachments Down
|
||||
|
||||
const handleAttachmentDraftsAction = React.useCallback((attachmentDraftIdOrAll: AttachmentDraftId | null, action: LLMAttachmentDraftsAction) => {
|
||||
switch (action) {
|
||||
case 'copy-text':
|
||||
const copyFragments = attachmentsTakeFragmentsByType('doc', attachmentDraftIdOrAll, false);
|
||||
const copyString = marshallWrapDocFragments(null, copyFragments, false, '\n\n---\n\n');
|
||||
copyToClipboard(copyString, attachmentDraftIdOrAll ? 'Attachment Text' : 'Attachments Text');
|
||||
break;
|
||||
case 'inline-text':
|
||||
const inlineFragments = attachmentsTakeFragmentsByType('doc', attachmentDraftIdOrAll, true);
|
||||
setComposeText(currentText => marshallWrapDocFragments(currentText, inlineFragments, 'markdown-code', '\n\n'));
|
||||
break;
|
||||
}
|
||||
}, [attachmentsTakeFragmentsByType, setComposeText]);
|
||||
|
||||
|
||||
// Drag & Drop
|
||||
@@ -498,7 +518,7 @@ export function Composer(props: {
|
||||
|
||||
const handleOverlayDragOver = React.useCallback((e: React.DragEvent) => {
|
||||
eatDragEvent(e);
|
||||
// this makes sure we don't "transfer" (or move) the attachment, but we tell the sender we'll copy it
|
||||
// this makes sure we don't "transfer" (or move) the item, but we tell the sender we'll copy it
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, [eatDragEvent]);
|
||||
|
||||
@@ -517,32 +537,22 @@ export function Composer(props: {
|
||||
}, [attachAppendDataTransfer, eatDragEvent, setComposeText]);
|
||||
|
||||
|
||||
const isText = chatModeId === 'generate-text';
|
||||
const isTextBeam = chatModeId === 'generate-text-beam';
|
||||
const isAppend = chatModeId === 'append-user';
|
||||
const isReAct = chatModeId === 'generate-react';
|
||||
const isDraw = chatModeId === 'generate-image';
|
||||
const isText = chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1';
|
||||
const isTextBeam = chatExecuteMode === 'beam-content';
|
||||
const isAppend = chatExecuteMode === 'append-user';
|
||||
const isReAct = chatExecuteMode === 'react-content';
|
||||
const isDraw = chatExecuteMode === 'generate-image';
|
||||
|
||||
const showChatReplyTo = !!replyToGenerateText;
|
||||
const showChatExtras = isText && !showChatReplyTo;
|
||||
|
||||
const buttonVariant: VariantProp = (isAppend || (isMobile && isTextBeam)) ? 'outlined' : 'solid';
|
||||
const sendButtonVariant: VariantProp = (isAppend || (isMobile && isTextBeam)) ? 'outlined' : 'solid';
|
||||
|
||||
const buttonColor: ColorPaletteProp =
|
||||
assistantAbortible ? 'warning'
|
||||
: isReAct ? 'success'
|
||||
: isTextBeam ? 'primary'
|
||||
: isDraw ? 'warning'
|
||||
: 'primary';
|
||||
const sendButtonColor: ColorPaletteProp = assistantAbortible ? 'warning' : chatExecuteModeSendColor;
|
||||
|
||||
const buttonText =
|
||||
isAppend ? 'Write'
|
||||
: isReAct ? 'ReAct'
|
||||
: isTextBeam ? 'Beam'
|
||||
: isDraw ? 'Draw'
|
||||
: 'Chat';
|
||||
const sendButtonLabel = chatExecuteModeSendLabel;
|
||||
|
||||
const buttonIcon =
|
||||
const sendButtonIcon =
|
||||
micContinuation ? <AutoModeIcon />
|
||||
: isAppend ? <SendIcon sx={{ fontSize: 18 }} />
|
||||
: isReAct ? <PsychologyIcon />
|
||||
@@ -571,45 +581,50 @@ export function Composer(props: {
|
||||
<Box aria-label='User Message' component='section' sx={props.sx}>
|
||||
<Grid container spacing={{ xs: 1, md: 2 }}>
|
||||
|
||||
{/* [Mobile: top, Desktop: left] */}
|
||||
<Grid xs={12} md={9}><Box sx={{ display: 'flex', gap: { xs: 1, md: 2 }, alignItems: 'flex-start' }}>
|
||||
|
||||
{/* Start buttons column */}
|
||||
<Box sx={{
|
||||
flexGrow: 0,
|
||||
display: 'grid', gap: 1,
|
||||
}}>
|
||||
{isMobile ? <>
|
||||
{/* [Mobile, Col1] Mic, Insert Multi-modal content, and Broadcast buttons */}
|
||||
{isMobile && (
|
||||
<Box sx={{ flexGrow: 0, display: 'grid', gap: 1 }}>
|
||||
|
||||
{/* [mobile] Mic button */}
|
||||
{isSpeechEnabled && <ButtonMicMemo variant={micVariant} color={micColor} onClick={handleToggleMic} />}
|
||||
|
||||
{/* [mobile] [+] button */}
|
||||
<Dropdown>
|
||||
<MenuButton slots={{ root: IconButton }}>
|
||||
<AddCircleOutlineIcon />
|
||||
</MenuButton>
|
||||
<Menu>
|
||||
{/* Responsive Camera OCR button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachCameraMemo onOpenCamera={openCamera} />
|
||||
</MenuItem>
|
||||
{showLLMAttachments && (
|
||||
<Dropdown>
|
||||
<MenuButton slots={{ root: IconButton }}>
|
||||
<AddCircleOutlineIcon />
|
||||
</MenuButton>
|
||||
<Menu>
|
||||
{/* Responsive Camera OCR button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachCameraMemo onOpenCamera={openCamera} />
|
||||
</MenuItem>
|
||||
|
||||
{/* Responsive Open Files button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachFileMemo onAttachFilePicker={handleAttachFilePicker} />
|
||||
</MenuItem>
|
||||
{/* Responsive Open Files button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachFileMemo onAttachFilePicker={handleAttachFilePicker} />
|
||||
</MenuItem>
|
||||
|
||||
{/* Responsive Paste button */}
|
||||
{supportsClipboardRead && <MenuItem>
|
||||
<ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />
|
||||
</MenuItem>}
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
{/* Responsive Paste button */}
|
||||
{supportsClipboardRead && <MenuItem>
|
||||
<ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />
|
||||
</MenuItem>}
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
{/* [Mobile] MultiChat button */}
|
||||
{props.isMulticast !== null && <ButtonMultiChatMemo isMobile multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
|
||||
|
||||
</> : <>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* [Desktop, Col1] Insert Multi-modal content buttons */}
|
||||
{isDesktop && showLLMAttachments && (
|
||||
<Box sx={{ flexGrow: 0, display: 'grid', gap: 1 }}>
|
||||
|
||||
{/*<FormHelperText sx={{ mx: 'auto' }}>*/}
|
||||
{/* Attach*/}
|
||||
@@ -627,22 +642,24 @@ export function Composer(props: {
|
||||
{/* Responsive Camera OCR button */}
|
||||
{labsCameraDesktop && <ButtonAttachCameraMemo onOpenCamera={openCamera} />}
|
||||
|
||||
</>}
|
||||
</Box>
|
||||
</Box>)}
|
||||
|
||||
{/* [ Textarea + Overlays + Mic | Attachments ] */}
|
||||
|
||||
{/* Top: Textarea & Mic & Overlays, Bottom, Attachment Drafts */}
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
// layout
|
||||
display: 'flex', flexDirection: 'column', gap: 1,
|
||||
minWidth: 200, // flex: enable X-scrolling (resetting any possible minWidth due to the attachments)
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
minWidth: 200, // flex: enable X-scrolling (resetting any possible minWidth due to the attachment drafts)
|
||||
}}>
|
||||
|
||||
{/* Textarea + Mic buttons + Mic/Drag overlay */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{/* Text Edit + Mic buttons + MicOverlay & DragOverlay */}
|
||||
<Box sx={{ position: 'relative' /* for overlays */ }}>
|
||||
|
||||
{/* Edit box with inner Token Progress bar */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Box sx={{ position: 'relative' /* for TokenBadge & TokenProgress */ }}>
|
||||
|
||||
<Textarea
|
||||
variant='outlined'
|
||||
@@ -659,7 +676,7 @@ export function Composer(props: {
|
||||
onPasteCapture={handleAttachCtrlV}
|
||||
// onFocusCapture={handleFocusModeOn}
|
||||
// onBlurCapture={handleFocusModeOff}
|
||||
endDecorator={showChatReplyTo && <ReplyToBubble replyToText={replyToGenerateText} onClear={handleReplyToCleared} className='reply-to-bubble' />}
|
||||
endDecorator={showChatReplyTo && <ReplyToBubble replyToText={replyToGenerateText} onClear={handleReplyToClear} className='reply-to-bubble' />}
|
||||
slotProps={{
|
||||
textarea: {
|
||||
enterKeyHint: enterIsNewline ? 'enter' : 'send',
|
||||
@@ -667,7 +684,7 @@ export function Composer(props: {
|
||||
...(isSpeechEnabled && { pr: { md: 5 } }),
|
||||
// mb: 0.5, // no need; the outer container already has enough p (for TokenProgressbar)
|
||||
},
|
||||
ref: props.composerTextAreaRef,
|
||||
ref: composerTextAreaRef,
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
@@ -681,7 +698,7 @@ export function Composer(props: {
|
||||
)}
|
||||
|
||||
{!showChatReplyTo && tokenLimit > 0 && (
|
||||
<TokenBadgeMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} tokenPriceIn={tokenPriceIn} tokenPriceOut={tokenPriceOut} showCost={labsShowCost} showExcess absoluteBottomRight />
|
||||
<TokenBadgeMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} tokenPriceIn={tokenPriceIn} tokenPriceOut={tokenPriceOut} showCost={labsShowCost} enableHover={!isMobile} showExcess absoluteBottomRight />
|
||||
)}
|
||||
|
||||
</Box>
|
||||
@@ -758,18 +775,20 @@ export function Composer(props: {
|
||||
</Box>
|
||||
|
||||
{/* Render any Attachments & menu items */}
|
||||
<Attachments
|
||||
llmAttachments={llmAttachments}
|
||||
onAttachmentInlineText={handleAttachmentInlineText}
|
||||
onAttachmentsClear={clearAttachments}
|
||||
onAttachmentsInlineText={handleAttachmentsInlineText}
|
||||
/>
|
||||
{!!conversationOverlayStore && showLLMAttachments && (
|
||||
<LLMAttachmentsList
|
||||
attachmentDraftsStoreApi={conversationOverlayStore}
|
||||
llmAttachmentDrafts={llmAttachmentDrafts}
|
||||
onAttachmentDraftsAction={handleAttachmentDraftsAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
|
||||
</Box></Grid>
|
||||
|
||||
|
||||
{/* [Mobile: bottom, Desktop: right] */}
|
||||
<Grid xs={12} md={3}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, height: '100%' } as const}>
|
||||
|
||||
@@ -779,7 +798,7 @@ export function Composer(props: {
|
||||
|
||||
{/* [mobile] bottom-corner secondary button */}
|
||||
{isMobile && (showChatExtras
|
||||
? <ButtonCallMemo isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />
|
||||
? <ButtonCallMemo isMobile disabled={noConversation || noLLM} onClick={handleCallClicked} />
|
||||
: isDraw
|
||||
? <ButtonOptionsDraw isMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
: <IconButton disabled sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
@@ -787,28 +806,28 @@ export function Composer(props: {
|
||||
|
||||
{/* Responsive Send/Stop buttons */}
|
||||
<ButtonGroup
|
||||
variant={buttonVariant}
|
||||
color={buttonColor}
|
||||
variant={sendButtonVariant}
|
||||
color={sendButtonColor}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
backgroundColor: (isMobile && buttonVariant === 'outlined') ? 'background.popup' : undefined,
|
||||
boxShadow: (isMobile && buttonVariant !== 'outlined') ? 'none' : `0 8px 24px -4px rgb(var(--joy-palette-${buttonColor}-mainChannel) / 20%)`,
|
||||
backgroundColor: (isMobile && sendButtonVariant === 'outlined') ? 'background.popup' : undefined,
|
||||
boxShadow: (isMobile && sendButtonVariant !== 'outlined') ? 'none' : `0 8px 24px -4px rgb(var(--joy-palette-${sendButtonColor}-mainChannel) / 20%)`,
|
||||
}}
|
||||
>
|
||||
{!assistantAbortible ? (
|
||||
<Button
|
||||
key='composer-act'
|
||||
fullWidth disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
|
||||
fullWidth disabled={noConversation || noLLM || !llmAttachmentDrafts.canAttachAllFragments}
|
||||
onClick={handleSendClicked}
|
||||
endDecorator={buttonIcon}
|
||||
endDecorator={sendButtonIcon}
|
||||
sx={{ '--Button-gap': '1rem' }}
|
||||
>
|
||||
{micContinuation && 'Voice '}{buttonText}
|
||||
{micContinuation && 'Voice '}{sendButtonLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key='composer-stop'
|
||||
fullWidth variant='soft' disabled={!props.conversationId}
|
||||
fullWidth variant='soft' disabled={noConversation}
|
||||
onClick={handleStopClicked}
|
||||
endDecorator={<StopOutlinedIcon sx={{ fontSize: 18 }} />}
|
||||
sx={{ animation: `${animationEnterBelow} 0.1s ease-out` }}
|
||||
@@ -819,14 +838,14 @@ export function Composer(props: {
|
||||
|
||||
{/* [Beam] Open Beam */}
|
||||
{/*{isText && <Tooltip title='Open Beam'>*/}
|
||||
{/* <IconButton variant='outlined' disabled={!props.conversationId || !chatLLMId} onClick={handleSendTextBeamClicked}>*/}
|
||||
{/* <IconButton variant='outlined' disabled={noConversation || noLLM} onClick={handleSendTextBeamClicked}>*/}
|
||||
{/* <ChatBeamIcon />*/}
|
||||
{/* </IconButton>*/}
|
||||
{/*</Tooltip>}*/}
|
||||
|
||||
{/* [Draw] Imagine */}
|
||||
{isDraw && !!composeText && <Tooltip title='Imagine a drawing prompt'>
|
||||
<IconButton variant='outlined' disabled={!props.conversationId || !chatLLMId} onClick={handleTextImagineClicked}>
|
||||
<IconButton variant='outlined' disabled={noConversation || noLLM} onClick={handleTextImagineClicked}>
|
||||
<AutoAwesomeIcon />
|
||||
</IconButton>
|
||||
</Tooltip>}
|
||||
@@ -834,8 +853,8 @@ export function Composer(props: {
|
||||
{/* Mode expander */}
|
||||
<IconButton
|
||||
variant={assistantAbortible ? 'soft' : isDraw ? undefined : undefined}
|
||||
disabled={!props.conversationId || !chatLLMId || !!chatModeMenuAnchor}
|
||||
onClick={handleModeSelectorShow}
|
||||
disabled={noConversation || noLLM || chatExecuteMenuShown}
|
||||
onClick={showChatExecuteMenu}
|
||||
>
|
||||
<ExpandLessIcon />
|
||||
</IconButton>
|
||||
@@ -844,7 +863,7 @@ export function Composer(props: {
|
||||
{/* [desktop] secondary-top buttons */}
|
||||
{isDesktop && showChatExtras && !assistantAbortible && (
|
||||
<ButtonBeamMemo
|
||||
disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
|
||||
disabled={noConversation || noLLM || !llmAttachmentDrafts.canAttachAllFragments}
|
||||
hasContent={!!composeText}
|
||||
onClick={handleSendTextBeamClicked}
|
||||
/>
|
||||
@@ -855,11 +874,11 @@ export function Composer(props: {
|
||||
{/* [desktop] Multicast switch (under the Chat button) */}
|
||||
{isDesktop && props.isMulticast !== null && <ButtonMultiChatMemo multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
|
||||
|
||||
{/* [desktop] secondary buttons (aligned to bottom for now, and mutually exclusive) */}
|
||||
{/* [desktop] secondary bottom-buttons (aligned to bottom for now, and mutually exclusive) */}
|
||||
{isDesktop && <Box sx={{ mt: 'auto', display: 'grid', gap: 1 }}>
|
||||
|
||||
{/* [desktop] Call secondary button */}
|
||||
{showChatExtras && <ButtonCallMemo disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
|
||||
{showChatExtras && <ButtonCallMemo disabled={noConversation || noLLM} onClick={handleCallClicked} />}
|
||||
|
||||
{/* [desktop] Draw Options secondary button */}
|
||||
{isDraw && <ButtonOptionsDraw onClick={handleDrawOptionsClicked} />}
|
||||
@@ -871,20 +890,13 @@ export function Composer(props: {
|
||||
|
||||
</Grid>
|
||||
|
||||
{/* Mode selector */}
|
||||
{!!chatModeMenuAnchor && (
|
||||
<ChatModeMenu
|
||||
isMobile={isMobile}
|
||||
anchorEl={chatModeMenuAnchor} onClose={handleModeSelectorHide}
|
||||
chatModeId={chatModeId} onSetChatModeId={handleModeChange}
|
||||
capabilityHasTTI={props.capabilityHasT2I}
|
||||
/>
|
||||
)}
|
||||
{/* Execution Mode Menu */}
|
||||
{chatExecuteMenuComponent}
|
||||
|
||||
{/* Camera */}
|
||||
{/* Camera (when open) */}
|
||||
{cameraCaptureComponent}
|
||||
|
||||
{/* Actile */}
|
||||
{/* Actile (when open) */}
|
||||
{actileComponent}
|
||||
|
||||
</Box>
|
||||
|
||||
@@ -122,20 +122,33 @@ function TokenBadge(props: {
|
||||
tokenPriceIn?: number,
|
||||
tokenPriceOut?: number,
|
||||
|
||||
enableHover?: boolean,
|
||||
showCost?: boolean
|
||||
showExcess?: boolean,
|
||||
absoluteBottomRight?: boolean,
|
||||
inline?: boolean,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [isHovering, setIsHovering] = React.useState(false);
|
||||
|
||||
const { message, color, remainingTokens, costMax, costMin } =
|
||||
tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax, props.tokenPriceIn, props.tokenPriceOut);
|
||||
|
||||
|
||||
// handlers
|
||||
const handleHoverEnter = React.useCallback(() => setIsHovering(true), []);
|
||||
|
||||
const handleHoverLeave = React.useCallback(() => setIsHovering(false), []);
|
||||
|
||||
|
||||
let badgeValue: string;
|
||||
|
||||
const showAltCosts = !!props.showCost && !!costMax && costMin !== undefined;
|
||||
if (showAltCosts) {
|
||||
badgeValue = '< ' + formatCost(costMax);
|
||||
badgeValue = (!props.enableHover || isHovering)
|
||||
? '< ' + formatCost(costMax)
|
||||
: '> ' + formatCost(costMin);
|
||||
} else {
|
||||
|
||||
// show the direct tokens, unless we exceed the limit and 'showExcess' is enabled
|
||||
@@ -154,6 +167,8 @@ function TokenBadge(props: {
|
||||
<Badge
|
||||
variant='soft' color={color} max={1000000}
|
||||
// invisible={shallHide}
|
||||
onMouseEnter={props.enableHover ? handleHoverEnter : undefined}
|
||||
onMouseLeave={props.enableHover ? handleHoverLeave : undefined}
|
||||
badgeContent={badgeValue}
|
||||
slotProps={{
|
||||
root: {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { conversationTitle, DConversationId, messageHasUserFlag, useChatStore } from '~/common/state/store-chats';
|
||||
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
|
||||
import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
|
||||
@@ -27,7 +29,7 @@ export function providerStarredMessage(onMessageSeelect: (item: StarredMessageIt
|
||||
messageId: message.id,
|
||||
// looks
|
||||
key: message.id,
|
||||
label: conversationTitle(conversation) + ' - ' + message.text.slice(0, 32) + '...',
|
||||
label: conversationTitle(conversation) + ' - ' + messageFragmentsReduceText(message.fragments).slice(0, 32) + '...',
|
||||
// description: message.text.slice(32, 100),
|
||||
Icon: undefined,
|
||||
} satisfies StarredMessageItem);
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
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'
|
||||
open anchorEl={props.menuAnchor} onClose={props.onClose}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
|
||||
{/* 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()
|
||||
: output.type === 'image-part'
|
||||
? output.base64Url.length.toLocaleString()
|
||||
: '(other)'} 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>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
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>) => {
|
||||
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
|
||||
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
|
||||
onClick={handleOverallMenuToggle}
|
||||
onContextMenu={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
|
||||
dense placement='top-start'
|
||||
open anchorEl={overallMenuAnchor} onClose={handleOverallMenuHide}
|
||||
>
|
||||
<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{attachments.length > 5 ? <span style={{ opacity: 0.5 }}> {attachments.length} attachments</span> : null}
|
||||
</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?`}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
|
||||
import { createBase36Uid } from '~/common/util/textUtils';
|
||||
import { htmlTableToMarkdown } from '~/common/util/htmlTableToMarkdown';
|
||||
import { pdfToImageDataURLs, 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'];
|
||||
|
||||
// mimetypes to treat as plain text
|
||||
const PLAIN_TEXT_MIMETYPES: string[] = [
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/markdown',
|
||||
'text/csv',
|
||||
'text/css',
|
||||
'text/javascript',
|
||||
'application/json',
|
||||
];
|
||||
|
||||
/**
|
||||
* 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);
|
||||
edit(
|
||||
page.content.markdown ? { input: { mimeType: 'text/markdown', data: page.content.markdown, dataSize: page.content.markdown.length } }
|
||||
: page.content.text ? { input: { mimeType: 'text/plain', data: page.content.text, dataSize: page.content.text.length } }
|
||||
: page.content.html ? { input: { mimeType: 'text/html', data: page.content.html, dataSize: page.content.html.length } }
|
||||
: { 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;
|
||||
|
||||
case 'ego':
|
||||
edit({
|
||||
label: source.label,
|
||||
ref: source.blockTitle,
|
||||
input: {
|
||||
mimeType: 'ego/message',
|
||||
data: source.textPlain,
|
||||
dataSize: source.textPlain.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 PLAIN_TEXT_MIMETYPES.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;
|
||||
|
||||
// EGO
|
||||
case input.mimeType === 'ego/message':
|
||||
converters.push({ id: 'ego-message-md', name: 'Message' });
|
||||
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 || '\n<!DOCTYPE html>',
|
||||
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 text 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':
|
||||
if (!(input.data instanceof ArrayBuffer)) {
|
||||
console.log('Expected ArrayBuffer for PDF images converter, got:', typeof input.data);
|
||||
break;
|
||||
}
|
||||
// duplicate the ArrayBuffer to avoid mutation
|
||||
const pdfData2 = new Uint8Array(input.data.slice(0));
|
||||
try {
|
||||
const imageDataURLs = await pdfToImageDataURLs(pdfData2);
|
||||
imageDataURLs.forEach((pdfImg, index) => {
|
||||
outputs.push({
|
||||
type: 'image-part',
|
||||
base64Url: pdfImg.base64Url,
|
||||
metadata: {
|
||||
title: `Page ${index + 1}`,
|
||||
width: pdfImg.width,
|
||||
height: pdfImg.height,
|
||||
},
|
||||
collapsible: false,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error converting PDF to images:', error);
|
||||
}
|
||||
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 'ego-message-md':
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: inputDataToString(input.data),
|
||||
title: ref,
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'unhandled':
|
||||
// force the user to explicitly select 'as text' if they want to proceed
|
||||
break;
|
||||
}
|
||||
|
||||
// update
|
||||
edit({
|
||||
outputsConverting: false,
|
||||
outputs,
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/*
|
||||
|
||||
/// 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') ?? 0;
|
||||
|
||||
// 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));
|
||||
|
||||
|
||||
|
||||
*/
|
||||
@@ -1,208 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FileWithHandle } from 'browser-fs-access';
|
||||
|
||||
import type { ComposerOutputMultiPart } from '../composer.types';
|
||||
import { attachmentCreate, attachmentDefineConverters, attachmentLoadInputAsync, attachmentPerformConversion } from './pipeline';
|
||||
|
||||
|
||||
// Attachment Types
|
||||
|
||||
export type AttachmentSourceOriginDTO = 'drop' | 'paste';
|
||||
export type AttachmentSourceOriginFile = 'camera' | 'screencapture' | '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;
|
||||
} | {
|
||||
media: 'ego';
|
||||
method: 'ego-message';
|
||||
label: string;
|
||||
blockTitle: string;
|
||||
textPlain: 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'
|
||||
| 'ego-message-md'
|
||||
| '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; // will be used in ```ref\n...``` for instance
|
||||
|
||||
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),
|
||||
|
||||
}),
|
||||
);
|
||||
@@ -1,149 +0,0 @@
|
||||
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[];
|
||||
collapseWithAttachment: (initialTextBlockText: string | null, attachmentId: AttachmentId) => ComposerOutputMultiPart;
|
||||
collapseWithAttachments: (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 collapseWithAttachment = (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 collapseWithAttachments = (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,
|
||||
collapseWithAttachment,
|
||||
collapseWithAttachments,
|
||||
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 getSingleTextBlockText(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') ?? 0);
|
||||
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') {
|
||||
// THIS IS NOT CORRECT - we seem to be doing it just for downstream token counting - FIX IT
|
||||
// Do not serialize here
|
||||
accumulatedOutputs.push({
|
||||
type: 'text-block',
|
||||
text: `\n\n\`\`\`${output.title}\n${output.text}\n\`\`\``,
|
||||
title: null,
|
||||
collapsible: false, // Wrong
|
||||
});
|
||||
} else {
|
||||
accumulatedOutputs.push(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulatedOutputs;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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,
|
||||
metadata: {
|
||||
title?: string,
|
||||
generatedBy?: string,
|
||||
altText?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
},
|
||||
collapsible: false,
|
||||
};
|
||||
|
||||
export type ComposerOutputMultiPart = ComposerOutputPart[];
|
||||
+44
-43
@@ -4,6 +4,9 @@ import { Box, Button, CircularProgress, ColorPaletteProp, Sheet, Typography } fr
|
||||
import AbcIcon from '@mui/icons-material/Abc';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
|
||||
import PermMediaOutlinedIcon from '@mui/icons-material/PermMediaOutlined';
|
||||
import PhotoSizeSelectLargeOutlinedIcon from '@mui/icons-material/PhotoSizeSelectLargeOutlined';
|
||||
import PhotoSizeSelectSmallOutlinedIcon from '@mui/icons-material/PhotoSizeSelectSmallOutlined';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import PivotTableChartIcon from '@mui/icons-material/PivotTableChart';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
@@ -14,8 +17,8 @@ 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';
|
||||
import type { AttachmentDraft, AttachmentDraftConverterType, AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { LLMAttachmentDraft } from './useLLMAttachmentDrafts';
|
||||
|
||||
|
||||
// default attachment width
|
||||
@@ -66,20 +69,23 @@ const InputErrorIndicator = () =>
|
||||
<WarningRoundedIcon sx={{ color: 'danger.solidBg' }} />;
|
||||
|
||||
|
||||
const converterTypeToIconMap: { [key in AttachmentConverterType]: React.ComponentType<any> } = {
|
||||
const converterTypeToIconMap: { [key in AttachmentDraftConverterType]: React.ComponentType<any> } = {
|
||||
'text': TextFieldsIcon,
|
||||
'rich-text': CodeIcon,
|
||||
'rich-text-table': PivotTableChartIcon,
|
||||
'pdf-text': PictureAsPdfIcon,
|
||||
'pdf-images': PictureAsPdfIcon,
|
||||
'image': ImageOutlinedIcon,
|
||||
'pdf-images': PermMediaOutlinedIcon,
|
||||
'image-original': ImageOutlinedIcon,
|
||||
'image-resized-high': PhotoSizeSelectLargeOutlinedIcon,
|
||||
'image-resized-low': PhotoSizeSelectSmallOutlinedIcon,
|
||||
'image-to-default': ImageOutlinedIcon,
|
||||
'image-ocr': AbcIcon,
|
||||
'ego-message-md': TelegramIcon,
|
||||
'ego-fragments-inlined': TelegramIcon,
|
||||
'unhandled': TextureIcon,
|
||||
};
|
||||
|
||||
function attachmentConverterIcon(attachment: Attachment) {
|
||||
const converter = attachment.converterIdx !== null ? attachment.converters[attachment.converterIdx] ?? null : null;
|
||||
function attachmentConverterIcon(attachmentDraft: AttachmentDraft) {
|
||||
const converter = attachmentDraft.converterIdx !== null ? attachmentDraft.converters[attachmentDraft.converterIdx] ?? null : null;
|
||||
if (converter && converter.id) {
|
||||
const Icon = converterTypeToIconMap[converter.id] ?? null;
|
||||
if (Icon)
|
||||
@@ -88,56 +94,51 @@ function attachmentConverterIcon(attachment: Attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function attachmentLabelText(attachment: Attachment): string {
|
||||
const converter = attachment.converterIdx !== null ? attachment.converters[attachment.converterIdx] ?? null : null;
|
||||
if (converter && attachment.label === 'Rich Text') {
|
||||
function attachmentLabelText(attachmentDraft: AttachmentDraft): string {
|
||||
const converter = attachmentDraft.converterIdx !== null ? attachmentDraft.converters[attachmentDraft.converterIdx] ?? null : null;
|
||||
if (converter && attachmentDraft.label === 'Rich Text') {
|
||||
if (converter.id === 'rich-text-table')
|
||||
return 'Rich Table';
|
||||
if (converter.id === 'rich-text')
|
||||
return 'Rich HTML';
|
||||
}
|
||||
return ellipsizeFront(attachment.label, 24);
|
||||
return ellipsizeFront(attachmentDraft.label, 24);
|
||||
}
|
||||
|
||||
|
||||
export function AttachmentItem(props: {
|
||||
llmAttachment: LLMAttachment,
|
||||
export function LLMAttachmentItem(props: {
|
||||
llmAttachment: LLMAttachmentDraft,
|
||||
menuShown: boolean,
|
||||
onItemMenuToggle: (attachmentId: AttachmentId, anchor: HTMLAnchorElement) => void,
|
||||
onToggleMenu: (attachmentDraftId: AttachmentDraftId, anchor: HTMLAnchorElement) => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const { attachmentDraft: draft, llmSupportsAllFragments } = props.llmAttachment;
|
||||
|
||||
const { onItemMenuToggle } = props;
|
||||
const isInputLoading = draft.inputLoading;
|
||||
const isInputError = !!draft.inputError;
|
||||
const isUnconvertible = !draft.converters.length;
|
||||
const isOutputLoading = draft.outputsConverting;
|
||||
const isOutputMissing = !draft.outputFragments.length;
|
||||
|
||||
const {
|
||||
attachment,
|
||||
isUnconvertible,
|
||||
isOutputMissing,
|
||||
isOutputAttachable,
|
||||
} = props.llmAttachment;
|
||||
const showWarning = isUnconvertible || (isOutputMissing || !llmSupportsAllFragments);
|
||||
|
||||
const {
|
||||
inputError,
|
||||
inputLoading: isInputLoading,
|
||||
outputsConverting: isOutputLoading,
|
||||
} = attachment;
|
||||
|
||||
const isInputError = !!inputError;
|
||||
const showWarning = isUnconvertible || isOutputMissing || !isOutputAttachable;
|
||||
// handlers
|
||||
|
||||
const { onToggleMenu } = props;
|
||||
|
||||
const handleToggleMenu = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
|
||||
onItemMenuToggle(attachment.id, event.currentTarget);
|
||||
}, [attachment, onItemMenuToggle]);
|
||||
onToggleMenu(draft.id, event.currentTarget);
|
||||
}, [draft.id, onToggleMenu]);
|
||||
|
||||
|
||||
// compose tooltip
|
||||
let tooltip: string | null = '';
|
||||
if (attachment.source.media !== 'text')
|
||||
tooltip += attachment.source.media + ': ';
|
||||
tooltip += attachment.label;
|
||||
if (draft.source.media !== 'text')
|
||||
tooltip += draft.source.media + ': ';
|
||||
tooltip += draft.label;
|
||||
// if (hasInput)
|
||||
// tooltip += `\n(${aInput.mimeType}: ${aInput.dataSize.toLocaleString()} bytes)`;
|
||||
// if (aOutputs && aOutputs.length >= 1)
|
||||
@@ -149,15 +150,15 @@ export function AttachmentItem(props: {
|
||||
if (isInputLoading || isOutputLoading) {
|
||||
color = 'success';
|
||||
} else if (isInputError) {
|
||||
tooltip = `Issue loading the attachment: ${attachment.inputError}\n\n${tooltip}`;
|
||||
color = 'danger';
|
||||
tooltip = props.menuShown ? null
|
||||
: `Issue loading the attachment: ${draft.inputError}\n\n${tooltip}`;
|
||||
} 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';
|
||||
tooltip = props.menuShown ? null
|
||||
: isUnconvertible
|
||||
? `Attachments of type '${draft.input?.mimeType}' are not supported yet. You can open a feature request on GitHub.\n\n${tooltip}`
|
||||
: `Not compatible with the selected LLM or file not supported. Please try another format.\n\n${tooltip}`;
|
||||
} else {
|
||||
// all good
|
||||
tooltip = null;
|
||||
@@ -175,7 +176,7 @@ export function AttachmentItem(props: {
|
||||
sx={{ p: 1, whiteSpace: 'break-spaces' }}
|
||||
>
|
||||
{isInputLoading
|
||||
? <LoadingIndicator label={attachment.label} />
|
||||
? <LoadingIndicator label={draft.label} />
|
||||
: (
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -195,11 +196,11 @@ export function AttachmentItem(props: {
|
||||
{isInputError
|
||||
? <InputErrorIndicator />
|
||||
: <>
|
||||
{attachmentConverterIcon(attachment)}
|
||||
{attachmentConverterIcon(draft)}
|
||||
{isOutputLoading
|
||||
? <>Converting <CircularProgress color='success' size='sm' /></>
|
||||
: <Typography level='title-sm' sx={{ whiteSpace: 'nowrap' }}>
|
||||
{attachmentLabelText(attachment)}
|
||||
{attachmentLabelText(draft)}
|
||||
</Typography>}
|
||||
</>}
|
||||
</Button>
|
||||
@@ -0,0 +1,216 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, CircularProgress, Link, ListDivider, ListItem, 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 LaunchIcon from '@mui/icons-material/Launch';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import { showImageDataRefInNewTab } from '~/modules/blocks/image/RenderImageRefDBlob';
|
||||
|
||||
import { DMessageAttachmentFragment, isImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
|
||||
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-attachment-drafts-slice';
|
||||
import type { LLMAttachmentDraft } from './useLLMAttachmentDrafts';
|
||||
import type { LLMAttachmentDraftsAction } from './LLMAttachmentsList';
|
||||
|
||||
|
||||
// enable for debugging
|
||||
export const DEBUG_LLMATTACHMENTS = true;
|
||||
|
||||
|
||||
export function LLMAttachmentMenu(props: {
|
||||
attachmentDraftsStoreApi: AttachmentDraftsStoreApi,
|
||||
llmAttachmentDraft: LLMAttachmentDraft,
|
||||
menuAnchor: HTMLAnchorElement,
|
||||
isPositionFirst: boolean,
|
||||
isPositionLast: boolean,
|
||||
onDraftAction: (attachmentDraftId: AttachmentDraftId, actionId: LLMAttachmentDraftsAction) => void,
|
||||
onClose: () => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
|
||||
const {
|
||||
attachmentDraft: draft,
|
||||
llmSupportsTextFragments,
|
||||
llmTokenCountApprox,
|
||||
} = props.llmAttachmentDraft;
|
||||
|
||||
const draftId = draft.id;
|
||||
const draftInput = draft.input;
|
||||
const isConverting = draft.outputsConverting;
|
||||
const isUnconvertible = !draft.converters.length;
|
||||
const isOutputMissing = !draft.outputFragments.length;
|
||||
|
||||
const isUnmoveable = props.isPositionFirst && props.isPositionLast;
|
||||
|
||||
|
||||
// operations
|
||||
|
||||
const { attachmentDraftsStoreApi, onDraftAction, onClose } = props;
|
||||
|
||||
const handleMoveUp = React.useCallback(() => {
|
||||
attachmentDraftsStoreApi.getState().moveAttachmentDraft(draftId, -1);
|
||||
}, [draftId, attachmentDraftsStoreApi]);
|
||||
|
||||
const handleMoveDown = React.useCallback(() => {
|
||||
attachmentDraftsStoreApi.getState().moveAttachmentDraft(draftId, 1);
|
||||
}, [draftId, attachmentDraftsStoreApi]);
|
||||
|
||||
const handleRemove = React.useCallback(() => {
|
||||
onClose();
|
||||
attachmentDraftsStoreApi.getState().removeAttachmentDraft(draftId);
|
||||
}, [draftId, attachmentDraftsStoreApi, onClose]);
|
||||
|
||||
const handleSetConverterIdx = React.useCallback(async (converterIdx: number | null) => {
|
||||
return attachmentDraftsStoreApi.getState().setAttachmentDraftConverterIdxAndConvert(draftId, converterIdx);
|
||||
}, [draftId, attachmentDraftsStoreApi]);
|
||||
|
||||
// const handleSummarizeText = React.useCallback(() => {
|
||||
// onAttachmentDraftSummarizeText(draftId);
|
||||
// }, [draftId, onAttachmentDraftSummarizeText]);
|
||||
|
||||
|
||||
return (
|
||||
<CloseableMenu
|
||||
dense placement='top'
|
||||
open anchorEl={props.menuAnchor} onClose={props.onClose}
|
||||
sx={{ minWidth: 260 }}
|
||||
>
|
||||
|
||||
{/* Move Arrows */}
|
||||
{!isUnmoveable && <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>}
|
||||
{!isUnmoveable && <ListDivider sx={{ mt: 0 }} />}
|
||||
|
||||
{/* Render Converters as menu items */}
|
||||
{!isUnconvertible && (
|
||||
<ListItem>
|
||||
<Typography level='body-sm'>
|
||||
Attach as:
|
||||
</Typography>
|
||||
</ListItem>
|
||||
)}
|
||||
{!isUnconvertible && draft.converters.map((c, idx) =>
|
||||
<MenuItem
|
||||
disabled={c.disabled || isConverting}
|
||||
key={'c-' + c.id}
|
||||
onClick={async () => idx !== draft.converterIdx && await handleSetConverterIdx(idx)}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{(isConverting && idx === draft.converterIdx)
|
||||
? <CircularProgress size='sm' sx={{ '--CircularProgress-size': '1.25rem' }} />
|
||||
: <Radio checked={idx === draft.converterIdx} disabled={isConverting} />}
|
||||
</ListItemDecorator>
|
||||
{c.unsupported
|
||||
? <Box>Unsupported 🤔 <Typography level='body-xs'>{c.name}</Typography></Box>
|
||||
: c.name}
|
||||
</MenuItem>,
|
||||
)}
|
||||
{!isUnconvertible && <ListDivider />}
|
||||
|
||||
{DEBUG_LLMATTACHMENTS && !!draftInput && !isConverting && (
|
||||
<ListItem>
|
||||
<ListItemDecorator />
|
||||
<Box>
|
||||
{!!draftInput && (
|
||||
<Typography level='body-sm'>
|
||||
🡐 {draftInput.mimeType} · {draftInput.dataSize.toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
{!!draftInput?.altMimeType && (
|
||||
<Typography level='body-sm'>
|
||||
<span style={{ color: 'transparent' }}>🡐</span> {draftInput.altMimeType} · {draftInput.altData?.length.toLocaleString()}
|
||||
</Typography>
|
||||
)}
|
||||
{/*<Typography level='body-sm'>*/}
|
||||
{/* Converters: {aConverters.map(((converter, idx) => ` ${converter.id}${(idx === draft.converterIdx) ? '*' : ''}`)).join(', ')}*/}
|
||||
{/*</Typography>*/}
|
||||
<Box>
|
||||
{isOutputMissing ? (
|
||||
<Typography level='body-sm'>🡒 ...</Typography>
|
||||
) : (
|
||||
draft.outputFragments.map(({ part }, index) => {
|
||||
if (isImageRefPart(part)) {
|
||||
const resolution = part.width && part.height ? `${part.width} x ${part.height}` : 'unknown resolution';
|
||||
const mime = part.dataRef.reftype === 'dblob' ? part.dataRef.mimeType : 'unknown image';
|
||||
return (
|
||||
<Typography key={index} level='body-sm'>
|
||||
🡒 {mime/*unic.replace('image/', 'img: ')*/} · {resolution} · {part.dataRef.reftype === 'dblob' ? part.dataRef.bytesSize?.toLocaleString() : '(remote)'}
|
||||
{' · '}
|
||||
<Link onClick={() => showImageDataRefInNewTab(part.dataRef)}>
|
||||
open <LaunchIcon sx={{ mx: 0.5, fontSize: 16 }} />
|
||||
</Link>
|
||||
</Typography>
|
||||
);
|
||||
} else if (part.pt === 'doc') {
|
||||
return (
|
||||
<Typography key={index} level='body-sm'>
|
||||
🡒 text: {part.data.text.length.toLocaleString()} bytes
|
||||
</Typography>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Typography key={index} level='body-sm'>
|
||||
🡒 {(part as DMessageAttachmentFragment['part']).pt}: (other)
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
})
|
||||
)}
|
||||
{!!llmTokenCountApprox && (
|
||||
<Typography level='body-sm' sx={{ ml: 1.75 }}>
|
||||
~ {llmTokenCountApprox.toLocaleString()} tokens
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</ListItem>
|
||||
)}
|
||||
{DEBUG_LLMATTACHMENTS && !!draftInput && !isConverting && <ListDivider />}
|
||||
|
||||
{/* Destructive Operations */}
|
||||
{/*<MenuItem onClick={handleCopyToClipboard} disabled={!isOutputTextInlineable}>*/}
|
||||
{/* <ListItemDecorator><ContentCopyIcon /></ListItemDecorator>*/}
|
||||
{/* Copy*/}
|
||||
{/*</MenuItem>*/}
|
||||
{/*<MenuItem onClick={handleSummarizeText} disabled={!isOutputTextInlineable}>*/}
|
||||
{/* <ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>*/}
|
||||
{/* Shrink*/}
|
||||
{/*</MenuItem>*/}
|
||||
<MenuItem onClick={() => onDraftAction(draftId, 'inline-text')} disabled={!llmSupportsTextFragments || isConverting}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
Inline text
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => onDraftAction(draftId, 'copy-text')} disabled={!llmSupportsTextFragments || isConverting}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
Copy text
|
||||
</MenuItem>
|
||||
<ListDivider />
|
||||
<MenuItem onClick={handleRemove}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Remove
|
||||
</MenuItem>
|
||||
|
||||
</CloseableMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, IconButton, ListDivider, ListItemDecorator, MenuItem } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
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 { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-attachment-drafts-slice';
|
||||
|
||||
import type { LLMAttachmentDrafts } from './useLLMAttachmentDrafts';
|
||||
import { LLMAttachmentItem } from './LLMAttachmentItem';
|
||||
import { LLMAttachmentMenu } from './LLMAttachmentMenu';
|
||||
|
||||
|
||||
export type LLMAttachmentDraftsAction = 'inline-text' | 'copy-text';
|
||||
|
||||
|
||||
/**
|
||||
* Renderer of attachment drafts, with menus, etc.
|
||||
*/
|
||||
export function LLMAttachmentsList(props: {
|
||||
attachmentDraftsStoreApi: AttachmentDraftsStoreApi,
|
||||
llmAttachmentDrafts: LLMAttachmentDrafts,
|
||||
onAttachmentDraftsAction: (attachmentDraftId: AttachmentDraftId | null, actionId: LLMAttachmentDraftsAction) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [confirmClearAttachmentDrafts, setConfirmClearAttachmentDrafts] = React.useState<boolean>(false);
|
||||
const [draftMenu, setDraftMenu] = React.useState<{ anchor: HTMLAnchorElement, attachmentDraftId: AttachmentDraftId } | null>(null);
|
||||
const [overallMenuAnchor, setOverallMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
|
||||
|
||||
// derived state
|
||||
|
||||
const { llmAttachmentDrafts, canInlineSomeFragments } = props.llmAttachmentDrafts;
|
||||
|
||||
const hasAttachments = llmAttachmentDrafts.length >= 1;
|
||||
|
||||
// derived item menu state
|
||||
|
||||
const itemMenuAnchor = draftMenu?.anchor;
|
||||
const itemMenuAttachmentDraftId = draftMenu?.attachmentDraftId;
|
||||
const itemMenuAttachmentDraft = itemMenuAttachmentDraftId ? llmAttachmentDrafts.find(la => la.attachmentDraft.id === draftMenu.attachmentDraftId) : undefined;
|
||||
const itemMenuIndex = itemMenuAttachmentDraft ? llmAttachmentDrafts.indexOf(itemMenuAttachmentDraft) : -1;
|
||||
|
||||
|
||||
// overall menu
|
||||
|
||||
const { onAttachmentDraftsAction } = props;
|
||||
|
||||
const handleOverallMenuHide = React.useCallback(() => setOverallMenuAnchor(null), []);
|
||||
|
||||
const handleOverallMenuToggle = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.shiftKey && console.log(llmAttachmentDrafts);
|
||||
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
|
||||
setOverallMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
}, [llmAttachmentDrafts]);
|
||||
|
||||
const handleOverallCopyText = React.useCallback(() => {
|
||||
handleOverallMenuHide();
|
||||
onAttachmentDraftsAction(null, 'copy-text');
|
||||
}, [handleOverallMenuHide, onAttachmentDraftsAction]);
|
||||
|
||||
const handleOverallInlineText = React.useCallback(() => {
|
||||
handleOverallMenuHide();
|
||||
onAttachmentDraftsAction(null, 'inline-text');
|
||||
}, [handleOverallMenuHide, onAttachmentDraftsAction]);
|
||||
|
||||
const handleOverallClear = React.useCallback(() => setConfirmClearAttachmentDrafts(true), []);
|
||||
|
||||
const handleOverallClearConfirmed = React.useCallback(() => {
|
||||
handleOverallMenuHide();
|
||||
setConfirmClearAttachmentDrafts(false);
|
||||
props.attachmentDraftsStoreApi.getState().removeAllAttachmentDrafts();
|
||||
}, [handleOverallMenuHide, props.attachmentDraftsStoreApi]);
|
||||
|
||||
|
||||
// item menu
|
||||
|
||||
const handleDraftMenuHide = React.useCallback(() => setDraftMenu(null), []);
|
||||
|
||||
const handleDraftMenuToggle = React.useCallback((attachmentDraftId: AttachmentDraftId, anchor: HTMLAnchorElement) => {
|
||||
handleOverallMenuHide();
|
||||
setDraftMenu(prev => prev?.attachmentDraftId === attachmentDraftId ? null : { anchor, attachmentDraftId });
|
||||
}, [handleOverallMenuHide]);
|
||||
|
||||
const handleDraftAction = React.useCallback((attachmentDraftId: AttachmentDraftId, actionId: LLMAttachmentDraftsAction) => {
|
||||
// pass-through, but close the menu as well, as the action is destructive for the caller
|
||||
handleDraftMenuHide();
|
||||
onAttachmentDraftsAction(attachmentDraftId, actionId);
|
||||
}, [handleDraftMenuHide, onAttachmentDraftsAction]);
|
||||
|
||||
|
||||
// no components without attachments
|
||||
if (!hasAttachments)
|
||||
return null;
|
||||
|
||||
return <>
|
||||
|
||||
{/* Attachment Drafts bar */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
|
||||
{/* Horizontally scrollable Attachments */}
|
||||
<Box sx={{ display: 'flex', overflowX: 'auto', gap: 1, height: '100%', pr: 5 }}>
|
||||
{llmAttachmentDrafts.map((llmAttachment) =>
|
||||
<LLMAttachmentItem
|
||||
key={llmAttachment.attachmentDraft.id}
|
||||
llmAttachment={llmAttachment}
|
||||
menuShown={llmAttachment.attachmentDraft.id === itemMenuAttachmentDraftId}
|
||||
onToggleMenu={handleDraftMenuToggle}
|
||||
/>,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Overall Menu button */}
|
||||
<IconButton
|
||||
onClick={handleOverallMenuToggle}
|
||||
onContextMenu={handleOverallMenuToggle}
|
||||
sx={{
|
||||
// borderRadius: 'sm',
|
||||
borderRadius: 0,
|
||||
position: 'absolute', right: 0, top: 0,
|
||||
backgroundColor: 'neutral.softDisabledBg',
|
||||
}}
|
||||
>
|
||||
<ExpandLessIcon />
|
||||
</IconButton>
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
{/* LLM Draft Menu */}
|
||||
{!!itemMenuAnchor && !!itemMenuAttachmentDraft && !!props.attachmentDraftsStoreApi && (
|
||||
<LLMAttachmentMenu
|
||||
attachmentDraftsStoreApi={props.attachmentDraftsStoreApi}
|
||||
llmAttachmentDraft={itemMenuAttachmentDraft}
|
||||
menuAnchor={itemMenuAnchor}
|
||||
isPositionFirst={itemMenuIndex === 0}
|
||||
isPositionLast={itemMenuIndex === llmAttachmentDrafts.length - 1}
|
||||
onDraftAction={handleDraftAction}
|
||||
onClose={handleDraftMenuHide}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* All Drafts Menu */}
|
||||
{!!overallMenuAnchor && (
|
||||
<CloseableMenu
|
||||
dense placement='top-start'
|
||||
open anchorEl={overallMenuAnchor} onClose={handleOverallMenuHide}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
<MenuItem onClick={handleOverallInlineText} disabled={!canInlineSomeFragments}>
|
||||
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
|
||||
Inline all text
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleOverallCopyText} disabled={!canInlineSomeFragments}>
|
||||
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
|
||||
Copy all text
|
||||
</MenuItem>
|
||||
<ListDivider />
|
||||
<MenuItem onClick={handleOverallClear}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Remove All{llmAttachmentDrafts.length > 5 ? <span style={{ opacity: 0.5 }}> {llmAttachmentDrafts.length} attachments</span> : null}
|
||||
</MenuItem>
|
||||
</CloseableMenu>
|
||||
)}
|
||||
|
||||
{/* 'Clear' Confirmation */}
|
||||
{confirmClearAttachmentDrafts && (
|
||||
<ConfirmationModal
|
||||
open onClose={() => setConfirmClearAttachmentDrafts(false)} onPositive={handleOverallClearConfirmed}
|
||||
title='Confirm Removal'
|
||||
positiveActionText='Remove All'
|
||||
confirmationText={`This action will remove all (${llmAttachmentDrafts.length}) attachments. Do you want to proceed?`}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { DLLM, LLM_IF_OAI_Vision } from '~/modules/llms/store-llms';
|
||||
|
||||
import type { AttachmentDraft } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { DMessageAttachmentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
import { estimateTokensForFragments } from '~/common/stores/chat/chat.tokens';
|
||||
|
||||
|
||||
export interface LLMAttachmentDrafts {
|
||||
llmAttachmentDrafts: LLMAttachmentDraft[];
|
||||
canAttachAllFragments: boolean;
|
||||
canInlineSomeFragments: boolean;
|
||||
llmTokenCountApprox: number | null;
|
||||
}
|
||||
|
||||
|
||||
export interface LLMAttachmentDraft {
|
||||
attachmentDraft: AttachmentDraft;
|
||||
llmSupportsAllFragments: boolean;
|
||||
llmSupportsTextFragments: boolean;
|
||||
llmTokenCountApprox: number | null;
|
||||
}
|
||||
|
||||
|
||||
export function useLLMAttachmentDrafts(attachmentDrafts: AttachmentDraft[], chatLLM: DLLM | null): LLMAttachmentDrafts {
|
||||
return React.useMemo(() => {
|
||||
|
||||
// LLM-dependent multi-modal enablement
|
||||
const supportsImages = !!chatLLM?.interfaces?.includes(LLM_IF_OAI_Vision);
|
||||
const supportedTypes: DMessageAttachmentFragment['part']['pt'][] = supportsImages ? ['image_ref', 'doc'] : ['doc'];
|
||||
const supportedTextTypes: DMessageAttachmentFragment['part']['pt'][] = supportedTypes.filter(pt => pt === 'doc');
|
||||
|
||||
// Add LLM-specific properties to each attachment draft
|
||||
const llmAttachmentDrafts = attachmentDrafts.map((a): LLMAttachmentDraft => ({
|
||||
attachmentDraft: a,
|
||||
llmSupportsAllFragments: !a.outputFragments ? false : a.outputFragments.every(op => supportedTypes.includes(op.part.pt)),
|
||||
llmSupportsTextFragments: !a.outputFragments ? false : a.outputFragments.some(op => supportedTextTypes.includes(op.part.pt)),
|
||||
llmTokenCountApprox: chatLLM
|
||||
? estimateTokensForFragments(a.outputFragments, chatLLM, true, 'useLLMAttachmentDrafts')
|
||||
: null,
|
||||
}));
|
||||
|
||||
// Calculate the overall properties
|
||||
const canAttachAllFragments = llmAttachmentDrafts.every(a => a.llmSupportsAllFragments);
|
||||
const canInlineSomeFragments = llmAttachmentDrafts.some(a => a.llmSupportsTextFragments);
|
||||
const llmTokenCountApprox = chatLLM
|
||||
? llmAttachmentDrafts.reduce((acc, a) => acc + (a.llmTokenCountApprox || 0), 0)
|
||||
: null;
|
||||
|
||||
return {
|
||||
llmAttachmentDrafts,
|
||||
canAttachAllFragments,
|
||||
canInlineSomeFragments,
|
||||
llmTokenCountApprox,
|
||||
};
|
||||
}, [attachmentDrafts, chatLLM]);
|
||||
}
|
||||
+2
-2
@@ -10,7 +10,7 @@ import { BeamStoreApi, useBeamStore } from '~/modules/beam/store-beam.hooks';
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
|
||||
import { animationBackgroundBeamGather, animationColorBeamScatterINV, animationEnterBelow } from '~/common/util/animUtils';
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ChatBarAltBeam(props: {
|
||||
|
||||
|
||||
// intercept esc this beam is focused
|
||||
useGlobalShortcut(ShortcutKeyName.Esc, false, false, false, handleCloseBeam);
|
||||
useGlobalShortcuts([[ShortcutKeyName.Esc, false, false, false, handleCloseBeam]]);
|
||||
|
||||
|
||||
return (
|
||||
+5
-5
@@ -3,14 +3,14 @@ import * as React from 'react';
|
||||
import { Box, Typography } from '@mui/joy';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
|
||||
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
import { autoConversationTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
|
||||
import { CHAT_NOVEL_TITLE } from '../AppChat';
|
||||
import { CHAT_NOVEL_TITLE } from '../../AppChat';
|
||||
|
||||
import { FadeInButton } from './ChatDrawerItem';
|
||||
import { FadeInButton } from '../layout-drawer/ChatDrawerItem';
|
||||
|
||||
|
||||
export function ChatBarAltTitle(props: {
|
||||
@@ -29,7 +29,7 @@ export function ChatBarAltTitle(props: {
|
||||
const handleTitleEditAuto = React.useCallback(async () => {
|
||||
if (!conversationId) return;
|
||||
setIsEditingTitle(true);
|
||||
await conversationAutoTitle(conversationId, true);
|
||||
await autoConversationTitle(conversationId, true);
|
||||
setIsEditingTitle(false);
|
||||
}, [conversationId]);
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
|
||||
import { useChatLLMDropdown } from './useLLMDropdown';
|
||||
import { usePersonaIdDropdown } from './usePersonaDropdown';
|
||||
import { useFolderDropdown } from './folders/useFolderDropdown';
|
||||
import { useFolderDropdown } from './useFolderDropdown';
|
||||
|
||||
|
||||
export function ChatBarDropdowns(props: {
|
||||
+1
-1
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { DropdownItems, PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
|
||||
import { useFolderStore } from '~/common/state/store-folders';
|
||||
|
||||
+8
-9
@@ -1,13 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../data';
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { usePurposeStore } from './persona-selector/store-purposes';
|
||||
import { usePurposeStore } from '../persona-selector/store-purposes';
|
||||
|
||||
|
||||
function PersonaDropdown(props: {
|
||||
@@ -17,9 +18,7 @@ function PersonaDropdown(props: {
|
||||
|
||||
// external state
|
||||
const hiddenPurposeIDs = usePurposeStore(state => state.hiddenPurposeIDs);
|
||||
const { zenMode } = useUIPreferencesStore(state => ({
|
||||
zenMode: state.zenMode,
|
||||
}), shallow);
|
||||
const zenMode = useUIPreferencesStore(state => state.zenMode);
|
||||
|
||||
|
||||
// filter by key in the object - must be missing the system purpose ids hidden by the user, or be the currently active one
|
||||
@@ -54,12 +53,12 @@ function PersonaDropdown(props: {
|
||||
export function usePersonaIdDropdown(conversationId: DConversationId | null) {
|
||||
|
||||
// external state
|
||||
const { systemPurposeId } = useChatStore(state => {
|
||||
const { systemPurposeId } = useChatStore(useShallow(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === conversationId);
|
||||
return {
|
||||
systemPurposeId: conversation?.systemPurposeId ?? null,
|
||||
};
|
||||
}, shallow);
|
||||
}));
|
||||
|
||||
|
||||
const handleSetSystemPurposeId = React.useCallback((systemPurposeId: SystemPurposeId | null) => {
|
||||
+18
-67
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { Box, Button, Card, CardContent, Dropdown, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Menu, MenuButton, MenuItem, Tooltip, Typography } from '@mui/joy';
|
||||
import { Box, Button, Dropdown, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Menu, MenuButton, MenuItem, Tooltip, Typography } from '@mui/joy';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
@@ -9,20 +9,18 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
import FileUploadOutlinedIcon from '@mui/icons-material/FileUploadOutlined';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import RocketLaunchRoundedIcon from '@mui/icons-material/RocketLaunchRounded';
|
||||
import StarOutlineRoundedIcon from '@mui/icons-material/StarOutlineRounded';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { DFolder, useFolderStore } from '~/common/state/store-folders';
|
||||
import { DebounceInputMemo } from '~/common/components/DebounceInput';
|
||||
import { FoldersToggleOff } from '~/common/components/icons/FoldersToggleOff';
|
||||
import { FoldersToggleOn } from '~/common/components/icons/FoldersToggleOn';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
|
||||
import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList';
|
||||
import { ROUTE_APP_NEWS } from '~/common/app.routes';
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
import { themeScalingMap, themeZIndexOverMobileDrawer } from '~/common/app.theme';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
@@ -31,8 +29,8 @@ import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { ChatDrawerItemMemo, FolderChangeRequest } from './ChatDrawerItem';
|
||||
import { ChatFolderList } from './folders/ChatFolderList';
|
||||
import { ChatNavGrouping, ChatSearchSorting, isDrawerSearching, useChatDrawerRenderItems } from './useChatDrawerRenderItems';
|
||||
import { ClearFolderText } from './folders/useFolderDropdown';
|
||||
import { useChatDrawerFilters } from '../store-app-chat';
|
||||
import { ClearFolderText } from '../layout-bar/useFolderDropdown';
|
||||
import { useChatDrawerFilters } from '../../store-app-chat';
|
||||
|
||||
|
||||
// this is here to make shallow comparisons work on the next hook
|
||||
@@ -78,27 +76,21 @@ function ChatDrawer(props: {
|
||||
|
||||
// local state
|
||||
const [navGrouping, setNavGrouping] = React.useState<ChatNavGrouping>('date');
|
||||
const [searchSorting, setSearchSorting] = React.useState<ChatSearchSorting>('frequency');
|
||||
const [searchSorting, setSearchSorting] = React.useState<ChatSearchSorting>('date');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
|
||||
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);
|
||||
const [bigAgi2CalloutDismissed, setBigAgi2CalloutDismissed] = React.useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('dismissedBA2ChatDrawerNotice') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// external state
|
||||
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
|
||||
const {
|
||||
filterHasStars, toggleFilterHasStars,
|
||||
filterHasImageAssets, toggleFilterHasImageAssets,
|
||||
showPersonaIcons, toggleShowPersonaIcons,
|
||||
showRelativeSize, toggleShowRelativeSize,
|
||||
} = useChatDrawerFilters();
|
||||
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
|
||||
const { filteredChatsCount, filteredChatIDs, filteredChatsAreEmpty, filteredChatsBarBasis, filteredChatsIncludeActive, renderNavItems } = useChatDrawerRenderItems(
|
||||
props.activeConversationId, props.chatPanesConversationIds, debouncedSearchQuery, activeFolder, allFolders, filterHasStars, navGrouping, searchSorting, showRelativeSize,
|
||||
props.activeConversationId, props.chatPanesConversationIds, debouncedSearchQuery, activeFolder, allFolders, filterHasStars, filterHasImageAssets, navGrouping, searchSorting, showRelativeSize,
|
||||
);
|
||||
const { contentScaling, showSymbols } = useUIPreferencesStore(useShallow(state => ({
|
||||
contentScaling: state.contentScaling,
|
||||
@@ -135,15 +127,6 @@ function ChatDrawer(props: {
|
||||
props.activeConversationId && onConversationsExportDialog(props.activeConversationId, true);
|
||||
}, [onConversationsExportDialog, props.activeConversationId]);
|
||||
|
||||
const handleDismissBigAgi2Callout = React.useCallback(() => {
|
||||
setBigAgi2CalloutDismissed(true);
|
||||
try {
|
||||
localStorage.setItem('dismissedBA2ChatDrawerNotice', 'true');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
// Folder change request
|
||||
|
||||
@@ -178,11 +161,11 @@ function ChatDrawer(props: {
|
||||
|
||||
{!isSearching ? (
|
||||
// Search/Filter default menu: Grouping, Filtering, ...
|
||||
<Menu placement='bottom-start' sx={{ minWidth: 180, zIndex: themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */ }}>
|
||||
<Menu placement='bottom-start' sx={{ minWidth: 200, zIndex: themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */ }}>
|
||||
<ListItem>
|
||||
<Typography level='body-sm'>Group By</Typography>
|
||||
</ListItem>
|
||||
{(['date', 'persona'] as const).map(_gName => (
|
||||
{(['date', 'persona', 'dimension'] as Exclude<ChatNavGrouping, false>[]).map(_gName => (
|
||||
<MenuItem
|
||||
key={'group-' + _gName}
|
||||
aria-label={`Group by ${_gName}`}
|
||||
@@ -202,6 +185,10 @@ function ChatDrawer(props: {
|
||||
<ListItemDecorator>{filterHasStars && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
Starred <StarOutlineRoundedIcon />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={toggleFilterHasImageAssets}>
|
||||
<ListItemDecorator>{filterHasImageAssets && <CheckRoundedIcon />}</ListItemDecorator>
|
||||
Has Images <FormatPaintOutlinedIcon />
|
||||
</MenuItem>
|
||||
|
||||
<ListDivider />
|
||||
<ListItem>
|
||||
@@ -233,7 +220,10 @@ function ChatDrawer(props: {
|
||||
</Menu>
|
||||
)}
|
||||
</Dropdown>
|
||||
), [filterHasStars, isSearching, navGrouping, searchSorting, showPersonaIcons, showRelativeSize, toggleFilterHasStars, toggleShowPersonaIcons, toggleShowRelativeSize]);
|
||||
), [
|
||||
filterHasImageAssets, filterHasStars, isSearching, navGrouping, searchSorting, showPersonaIcons, showRelativeSize,
|
||||
toggleFilterHasImageAssets, toggleFilterHasStars, toggleShowPersonaIcons, toggleShowRelativeSize,
|
||||
]);
|
||||
|
||||
|
||||
return <>
|
||||
@@ -359,45 +349,6 @@ function ChatDrawer(props: {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Big-AGI 2.0 Callout */}
|
||||
{!bigAgi2CalloutDismissed && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Card variant='solid' color='primary' invertedColors>
|
||||
<CardContent sx={{ gap: 1, position: 'relative' }}>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={handleDismissBigAgi2Callout}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -8,
|
||||
}}
|
||||
>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
<Typography level='title-sm'>
|
||||
Big-AGI 2.0 ✨ is Live!
|
||||
</Typography>
|
||||
<Typography level='body-xs' sx={{ mb: 1 }}>
|
||||
Experience Beam 2, Personas, and Cloud Sync.
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
size='sm'
|
||||
variant='solid'
|
||||
color='neutral'
|
||||
endDecorator={<RocketLaunchRoundedIcon />}
|
||||
component={Link}
|
||||
href={ROUTE_APP_NEWS}
|
||||
noLinkStyle
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ListDivider sx={{ my: 0 }} />
|
||||
|
||||
{/* Bottom commands */}
|
||||
+45
-20
@@ -11,17 +11,18 @@ import FolderIcon from '@mui/icons-material/Folder';
|
||||
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../data';
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
import { autoConversationTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import type { DFolder } from '~/common/state/store-folders';
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { isDeepEqual } from '~/common/util/jsUtils';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
|
||||
import { CHAT_NOVEL_TITLE } from '../AppChat';
|
||||
import { STREAM_TEXT_INDICATOR } from '../editors/chat-stream';
|
||||
import { ANIM_BUSY_TYPING } from '../message/messageUtils';
|
||||
import { CHAT_NOVEL_TITLE } from '../../AppChat';
|
||||
|
||||
|
||||
// set to true to display the conversation IDs
|
||||
@@ -54,11 +55,13 @@ export interface ChatNavigationItemData {
|
||||
isAlsoOpen: string | false;
|
||||
isEmpty: boolean;
|
||||
title: string;
|
||||
userSymbol: string | undefined;
|
||||
userFlagsSummary: string | undefined;
|
||||
containsImageAssets: boolean;
|
||||
folder: DFolder | null | undefined; // null: 'All', undefined: do not show folder select
|
||||
updatedAt: number;
|
||||
messageCount: number;
|
||||
assistantTyping: boolean;
|
||||
beingGenerated: boolean;
|
||||
systemPurposeId: SystemPurposeId;
|
||||
searchFrequency: number;
|
||||
}
|
||||
@@ -88,7 +91,20 @@ function ChatDrawerItem(props: {
|
||||
|
||||
// derived state
|
||||
const { onConversationBranch, onConversationExport, onConversationFolderChange } = props;
|
||||
const { conversationId, isActive, isAlsoOpen, title, userFlagsSummary, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
|
||||
const {
|
||||
conversationId,
|
||||
isActive,
|
||||
isAlsoOpen,
|
||||
title,
|
||||
userSymbol,
|
||||
userFlagsSummary,
|
||||
containsImageAssets,
|
||||
folder,
|
||||
messageCount,
|
||||
beingGenerated,
|
||||
systemPurposeId,
|
||||
searchFrequency,
|
||||
} = props.item;
|
||||
const isNew = messageCount === 0;
|
||||
|
||||
|
||||
@@ -148,7 +164,7 @@ function ChatDrawerItem(props: {
|
||||
|
||||
const handleTitleEditAuto = React.useCallback(async () => {
|
||||
setIsAutoEditingTitle(true);
|
||||
await conversationAutoTitle(conversationId, true);
|
||||
await autoConversationTitle(conversationId, true);
|
||||
setIsAutoEditingTitle(false);
|
||||
}, [conversationId]);
|
||||
|
||||
@@ -158,7 +174,7 @@ function ChatDrawerItem(props: {
|
||||
const { onConversationDeleteNoConfirmation } = props;
|
||||
const handleDeleteButtonShow = React.useCallback((event: React.MouseEvent) => {
|
||||
// special case: if 'Shift' is pressed, delete immediately
|
||||
if (event.shiftKey) {
|
||||
if (event.shiftKey) { // immediately delete:conversation
|
||||
event.stopPropagation();
|
||||
onConversationDeleteNoConfirmation(conversationId);
|
||||
return;
|
||||
@@ -177,7 +193,7 @@ function ChatDrawerItem(props: {
|
||||
}, [conversationId, deleteArmed, onConversationDeleteNoConfirmation]);
|
||||
|
||||
|
||||
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
|
||||
const textSymbol = userSymbol || SystemPurposes[systemPurposeId]?.symbol || '❓';
|
||||
|
||||
const progress = props.bottomBarBasis ? 100 * (searchFrequency || messageCount) / props.bottomBarBasis : 0;
|
||||
|
||||
@@ -185,11 +201,11 @@ function ChatDrawerItem(props: {
|
||||
|
||||
{/* Symbol, if globally enabled */}
|
||||
{props.showSymbols && <ListItemDecorator>
|
||||
{assistantTyping
|
||||
{beingGenerated
|
||||
? (
|
||||
<Avatar
|
||||
alt='typing' variant='plain'
|
||||
src='https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'
|
||||
alt='activity' variant='plain'
|
||||
src={ANIM_BUSY_TYPING}
|
||||
sx={{
|
||||
width: '1.5rem',
|
||||
height: '1.5rem',
|
||||
@@ -211,11 +227,12 @@ function ChatDrawerItem(props: {
|
||||
onDoubleClick={handleTitleEditBegin}
|
||||
sx={{
|
||||
color: isActive ? 'text.primary' : 'text.secondary',
|
||||
overflowWrap: 'anywhere',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/*{DEBUG_CONVERSATION_IDS && `${conversationId} - `}*/}
|
||||
{title.trim() ? title : CHAT_NOVEL_TITLE}{assistantTyping && STREAM_TEXT_INDICATOR}
|
||||
{title.trim() ? title : CHAT_NOVEL_TITLE}{beingGenerated && ' ...'}
|
||||
</Box>
|
||||
) : (
|
||||
<InlineTextarea
|
||||
@@ -236,13 +253,20 @@ function ChatDrawerItem(props: {
|
||||
<Typography level='body-sm'>
|
||||
{searchFrequency}
|
||||
</Typography>
|
||||
) : (userFlagsSummary && props.showSymbols) ? (
|
||||
<Typography sx={{ mr: '5px' }}>
|
||||
{userFlagsSummary}
|
||||
</Typography>
|
||||
) : (props.showSymbols && (userFlagsSummary || containsImageAssets)) ? (
|
||||
<Box sx={{
|
||||
fontSize: 'xs',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{userFlagsSummary}{containsImageAssets && '🖍️'}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
</>, [assistantTyping, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive, isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title, userFlagsSummary]);
|
||||
</>, [
|
||||
beingGenerated, containsImageAssets, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive,
|
||||
isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title, userFlagsSummary,
|
||||
]);
|
||||
|
||||
const progressBarFixedComponent = React.useMemo(() =>
|
||||
progress > 0 && (
|
||||
@@ -273,6 +297,7 @@ function ChatDrawerItem(props: {
|
||||
}),
|
||||
|
||||
// style
|
||||
fontSize: 'inherit',
|
||||
backgroundColor: isActive ? 'neutral.solidActiveBg' : 'neutral.softBg',
|
||||
borderRadius: 'md',
|
||||
mx: '0.25rem',
|
||||
@@ -325,7 +350,7 @@ function ChatDrawerItem(props: {
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip disableInteractive title='Branch'>
|
||||
<Tooltip disableInteractive title='Duplicate (Branch)'>
|
||||
<FadeInButton size='sm' onClick={handleConversationBranch}>
|
||||
<ForkRightIcon />
|
||||
</FadeInButton>
|
||||
+65
-20
@@ -1,7 +1,11 @@
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useStoreWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
import type { DFolder } from '~/common/state/store-folders';
|
||||
import { conversationTitle, DConversationId, DMessageUserFlag, messageHasUserFlag, messageUserFlagToEmoji, useChatStore } from '~/common/state/store-chats';
|
||||
import { DMessage, DMessageUserFlag, messageFragmentsReduceText, messageHasUserFlag, messageUserFlagToEmoji } from '~/common/stores/chat/chat.message';
|
||||
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { isContentOrAttachmentFragment, isImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
|
||||
import type { ChatNavigationItemData } from './ChatDrawerItem';
|
||||
|
||||
@@ -10,7 +14,7 @@ import type { ChatNavigationItemData } from './ChatDrawerItem';
|
||||
const SEARCH_MIN_CHARS = 3;
|
||||
|
||||
|
||||
export type ChatNavGrouping = false | 'date' | 'persona';
|
||||
export type ChatNavGrouping = false | 'date' | 'persona' | 'dimension';
|
||||
|
||||
export type ChatSearchSorting = 'frequency' | 'date';
|
||||
|
||||
@@ -88,6 +92,7 @@ export function useChatDrawerRenderItems(
|
||||
activeFolder: DFolder | null,
|
||||
allFolders: DFolder[],
|
||||
filterHasStars: boolean,
|
||||
filterHasImageAssets: boolean,
|
||||
grouping: ChatNavGrouping,
|
||||
searchSorting: ChatSearchSorting,
|
||||
showRelativeSize: boolean,
|
||||
@@ -99,7 +104,7 @@ export function useChatDrawerRenderItems(
|
||||
filteredChatsBarBasis: number,
|
||||
filteredChatsIncludeActive: boolean,
|
||||
} {
|
||||
return useChatStore(({ conversations }) => {
|
||||
return useStoreWithEqualityFn(useChatStore, ({ conversations }) => {
|
||||
|
||||
// filter 1: select all conversations or just the ones in the active folder
|
||||
const selectedConversations = !activeFolder ? conversations : conversations.filter(_c => activeFolder.conversationIds.includes(_c.id));
|
||||
@@ -107,9 +112,14 @@ export function useChatDrawerRenderItems(
|
||||
// filter 2: preparation: lowercase the query
|
||||
const { isSearching, lcTextQuery } = isDrawerSearching(filterByQuery);
|
||||
|
||||
function messageHasImageFragments(message: DMessage): boolean {
|
||||
return message.fragments.some(fragment => isContentOrAttachmentFragment(fragment) && isImageRefPart(fragment.part) /*&& fragment.part.dataRef.reftype === 'dblob'*/);
|
||||
}
|
||||
|
||||
// transform (the conversations into ChatNavigationItemData) + filter2 (if searching)
|
||||
const chatNavItems = selectedConversations
|
||||
.filter(_c => !filterHasStars || _c.messages.some(m => messageHasUserFlag(m, 'starred')))
|
||||
.filter(_c => !filterHasImageAssets || _c.messages.some(messageHasImageFragments))
|
||||
.map((_c): ChatNavigationItemData => {
|
||||
// rich properties
|
||||
const title = conversationTitle(_c);
|
||||
@@ -119,7 +129,9 @@ export function useChatDrawerRenderItems(
|
||||
let searchFrequency: number = 0;
|
||||
if (isSearching) {
|
||||
const titleFrequency = title.toLowerCase().split(lcTextQuery).length - 1;
|
||||
const messageFrequency = _c.messages.reduce((count, message) => count + (message.text.toLowerCase().split(lcTextQuery).length - 1), 0);
|
||||
const messageFrequency = _c.messages.reduce((count, message) => {
|
||||
return count + messageFragmentsReduceText(message.fragments).toLowerCase().split(lcTextQuery).length - 1;
|
||||
}, 0);
|
||||
searchFrequency = titleFrequency + messageFrequency;
|
||||
}
|
||||
|
||||
@@ -127,6 +139,7 @@ export function useChatDrawerRenderItems(
|
||||
const allFlags = new Set<DMessageUserFlag>();
|
||||
_c.messages.forEach(_m => _m.userFlags?.forEach(flag => allFlags.add(flag)));
|
||||
const userFlagsSummary = !allFlags.size ? undefined : Array.from(allFlags).map(messageUserFlagToEmoji).join('');
|
||||
const containsImageAssets = filterHasImageAssets || _c.messages.some(messageHasImageFragments);
|
||||
|
||||
// create the ChatNavigationData
|
||||
return {
|
||||
@@ -136,7 +149,9 @@ export function useChatDrawerRenderItems(
|
||||
isAlsoOpen,
|
||||
isEmpty: !_c.messages.length && !_c.userTitle,
|
||||
title,
|
||||
userSymbol: _c.userSymbol || undefined,
|
||||
userFlagsSummary,
|
||||
containsImageAssets,
|
||||
folder: !allFolders.length
|
||||
? undefined // don't show folder select if folders are disabled
|
||||
: _c.id === activeConversationId // only show the folder for active conversation(s)
|
||||
@@ -144,7 +159,7 @@ export function useChatDrawerRenderItems(
|
||||
: null,
|
||||
updatedAt: _c.updated || _c.created || 0,
|
||||
messageCount: _c.messages.length,
|
||||
assistantTyping: !!_c.abortController,
|
||||
beingGenerated: !!_c.abortController, // FIXME: when the AbortController is moved at the message level, derive the state in the conv
|
||||
systemPurposeId: _c.systemPurposeId,
|
||||
searchFrequency,
|
||||
};
|
||||
@@ -173,25 +188,53 @@ export function useChatDrawerRenderItems(
|
||||
// [grouping] group by date or persona
|
||||
else if (grouping) {
|
||||
|
||||
// [grouping/date]: sort by update time
|
||||
const midnightTime = getNextMidnightTime();
|
||||
if (grouping === 'date')
|
||||
chatNavItems.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
switch (grouping) {
|
||||
// [grouping/date or persona]: sort by last updated
|
||||
case 'date':
|
||||
case 'persona':
|
||||
chatNavItems.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
break;
|
||||
// [grouping/dimension]: sort by message count
|
||||
case 'dimension':
|
||||
chatNavItems.sort((a, b) => b.messageCount - a.messageCount);
|
||||
break;
|
||||
}
|
||||
|
||||
// Array.groupBy(...)
|
||||
const midnightTime = getNextMidnightTime();
|
||||
const grouped = chatNavItems.reduce((acc, item) => {
|
||||
|
||||
const groupName = grouping === 'date'
|
||||
? getTimeBucketEn(item.updatedAt || midnightTime, midnightTime)
|
||||
: item.systemPurposeId;
|
||||
// derive the bucket name
|
||||
let bucket: string;
|
||||
switch (grouping) {
|
||||
case 'date':
|
||||
bucket = getTimeBucketEn(item.updatedAt || midnightTime, midnightTime);
|
||||
break;
|
||||
case 'persona':
|
||||
bucket = item.systemPurposeId;
|
||||
break;
|
||||
case 'dimension':
|
||||
if (item.messageCount > 20)
|
||||
bucket = 'Large chats';
|
||||
else if (item.messageCount > 10)
|
||||
bucket = 'Medium chats';
|
||||
else if (item.messageCount > 5)
|
||||
bucket = 'Small chats';
|
||||
else if (item.messageCount > 1)
|
||||
bucket = 'Tiny chats';
|
||||
else if (item.messageCount === 1)
|
||||
bucket = 'Single message';
|
||||
else
|
||||
bucket = 'Empty chats';
|
||||
break;
|
||||
}
|
||||
|
||||
if (!acc[groupName])
|
||||
acc[groupName] = [];
|
||||
acc[groupName].push(item);
|
||||
if (!acc[bucket])
|
||||
acc[bucket] = [];
|
||||
acc[bucket].push(item);
|
||||
return acc;
|
||||
}, {} as { [groupName: string]: ChatNavigationItemData[] });
|
||||
|
||||
// prepend groups
|
||||
// prepend group names as special items
|
||||
renderNavItems = Object.entries(grouped).flatMap(([groupName, items]) => [
|
||||
{ type: 'nav-item-group', title: groupName },
|
||||
...items,
|
||||
@@ -202,9 +245,11 @@ export function useChatDrawerRenderItems(
|
||||
if (!renderNavItems.length)
|
||||
renderNavItems.push({
|
||||
type: 'nav-item-info-message',
|
||||
message: filterHasStars ? 'No starred results'
|
||||
: isSearching ? 'No results found'
|
||||
: 'No conversations in folder',
|
||||
message: (filterHasStars && filterHasImageAssets) ? 'No starred results with images'
|
||||
: filterHasImageAssets ? 'No image results'
|
||||
: filterHasStars ? 'No starred results'
|
||||
: isSearching ? 'No results found'
|
||||
: 'No conversations in folder',
|
||||
});
|
||||
|
||||
// other derived state
|
||||
+3
-3
@@ -13,12 +13,12 @@ import SettingsSuggestOutlinedIcon from '@mui/icons-material/SettingsSuggestOutl
|
||||
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
|
||||
import VerticalSplitOutlinedIcon from '@mui/icons-material/VerticalSplitOutlined';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
|
||||
import { useChatShowSystemMessages } from '../store-app-chat';
|
||||
import { usePaneDuplicateOrClose } from './panes/usePanesManager';
|
||||
import { useChatShowSystemMessages } from '../../store-app-chat';
|
||||
import { usePaneDuplicateOrClose } from '../panes/usePanesManager';
|
||||
|
||||
|
||||
export function ChatPageMenuItems(props: {
|
||||
@@ -1,207 +1,57 @@
|
||||
import * as React from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Avatar, Box, ButtonGroup, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
|
||||
import { Box, Button, ButtonGroup, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
|
||||
import { ClickAwayListener, Popper } from '@mui/base';
|
||||
import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DifferenceIcon from '@mui/icons-material/Difference';
|
||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
import Face6Icon from '@mui/icons-material/Face6';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import RecordVoiceOverOutlinedIcon from '@mui/icons-material/RecordVoiceOverOutlined';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import ReplyRoundedIcon from '@mui/icons-material/ReplyRounded';
|
||||
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
|
||||
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
||||
import StarOutlineRoundedIcon from '@mui/icons-material/StarOutlineRounded';
|
||||
import StarRoundedIcon from '@mui/icons-material/StarRounded';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { BlocksRenderer, editBlocksSx } from '~/modules/blocks/BlocksRenderer';
|
||||
import { useSanityTextDiffs } from '~/modules/blocks/RenderTextDiff';
|
||||
|
||||
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { DMessage, DMessageUserFlag, messageHasUserFlag } from '~/common/state/store-chats';
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { DMessage, DMessageId, DMessageUserFlag, messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { adjustContentScaling, themeScalingMap, themeZIndexPageBar } from '~/common/app.theme';
|
||||
import { animationColorRainbow } from '~/common/util/animUtils';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { createTextContentFragment, DMessageAttachmentFragment, DMessageContentFragment, DMessageFragment, DMessageFragmentId, isAttachmentFragment, isContentFragment, isImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { prettyBaseModel } from '~/common/util/modelUtils';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { ContentFragments } from './fragments-content/ContentFragments';
|
||||
import { DocumentFragments } from './fragments-attachment-doc/DocumentFragments';
|
||||
import { ImageAttachmentFragments } from './fragments-attachment-image/ImageAttachmentFragments';
|
||||
import { ReplyToBubble } from './ReplyToBubble';
|
||||
import { avatarIconSx, makeMessageAvatar, messageAsideColumnSx, messageBackground } from './messageUtils';
|
||||
import { useChatShowTextDiff } from '../../store-app-chat';
|
||||
|
||||
|
||||
// Enable the menu on text selection
|
||||
const ENABLE_SELECTION_RIGHT_CLICK_MENU = false;
|
||||
const ENABLE_SELECTION_TOOLBAR = true;
|
||||
const SELECTION_TOOLBAR_MIN_LENGTH = 3;
|
||||
const ENABLE_CONTEXT_MENU = false;
|
||||
const ENABLE_BUBBLE = true;
|
||||
const BUBBLE_MIN_TEXT_LENGTH = 3;
|
||||
|
||||
// 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_OVERLAY: boolean = false;
|
||||
|
||||
|
||||
export function messageBackground(messageRole: DMessage['role'] | string, wasEdited: boolean, isAssistantIssue: boolean): string {
|
||||
switch (messageRole) {
|
||||
case 'user':
|
||||
return 'primary.plainHoverBg'; // was .background.level1
|
||||
case 'assistant':
|
||||
return isAssistantIssue ? 'danger.softBg' : 'background.surface';
|
||||
case 'system':
|
||||
return wasEdited ? 'warning.softHoverBg' : 'neutral.softBg';
|
||||
default:
|
||||
return '#ff0000';
|
||||
}
|
||||
}
|
||||
|
||||
const avatarIconSx = {
|
||||
width: 36,
|
||||
height: 36,
|
||||
};
|
||||
|
||||
const personaSx: SxProps = {
|
||||
// make this stick to the top of the screen
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
|
||||
// flexBasis: 0, // this won't let the item grow
|
||||
minWidth: { xs: 50, md: 64 },
|
||||
maxWidth: 80,
|
||||
textAlign: 'center',
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
|
||||
export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['role'] | string, messageOriginLLM: string | undefined, messagePurposeId: SystemPurposeId | undefined, messageSender: string, messageTyping: boolean, size: 'sm' | undefined = undefined): React.JSX.Element {
|
||||
if (typeof messageAvatar === 'string' && messageAvatar)
|
||||
return <Avatar alt={messageSender} src={messageAvatar} />;
|
||||
|
||||
const mascotSx = size === 'sm' ? avatarIconSx : { width: 64, height: 64 };
|
||||
switch (messageRole) {
|
||||
case 'system':
|
||||
return <SettingsSuggestIcon sx={avatarIconSx} />; // https://em-content.zobj.net/thumbs/120/apple/325/robot_1f916.png
|
||||
|
||||
case 'user':
|
||||
return <Face6Icon sx={avatarIconSx} />; // https://www.svgrepo.com/show/306500/openai.svg
|
||||
|
||||
case 'assistant':
|
||||
// typing gif (people seem to love this, so keeping it after april fools')
|
||||
const isDownload = messageOriginLLM === 'web';
|
||||
const isTextToImage = messageOriginLLM === 'DALL·E' || messageOriginLLM === 'Prodia';
|
||||
const isReact = messageOriginLLM?.startsWith('react-');
|
||||
|
||||
// animation: message typing
|
||||
if (messageTyping)
|
||||
return <Avatar
|
||||
alt={messageSender} variant='plain'
|
||||
src={isDownload ? 'https://i.giphy.com/26u6dIwIphLj8h10A.webp' // hourglass: https://i.giphy.com/TFSxpAIYz5inJGuY8f.webp, small-lq: https://i.giphy.com/131tNuGktpXGhy.webp, floppy: https://i.giphy.com/RxR1KghIie2iI.webp
|
||||
: isTextToImage ? 'https://i.giphy.com/media/5t9ujj9cMisyVjUZ0m/giphy.webp' // brush
|
||||
: isReact ? 'https://i.giphy.com/media/l44QzsOLXxcrigdgI/giphy.webp' // mind
|
||||
: 'https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'} // typing
|
||||
sx={{ ...mascotSx, borderRadius: 'sm' }}
|
||||
/>;
|
||||
|
||||
// icon: text-to-image
|
||||
if (isTextToImage)
|
||||
return <FormatPaintOutlinedIcon sx={{
|
||||
...avatarIconSx,
|
||||
animation: `${animationColorRainbow} 1s linear 2.66`,
|
||||
}} />;
|
||||
|
||||
// purpose symbol (if present)
|
||||
const symbol = SystemPurposes[messagePurposeId!]?.symbol;
|
||||
if (symbol)
|
||||
return <Box sx={{
|
||||
fontSize: '24px',
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
minWidth: `${avatarIconSx.width}px`,
|
||||
lineHeight: `${avatarIconSx.height}px`,
|
||||
}}>
|
||||
{symbol}
|
||||
</Box>;
|
||||
|
||||
// default assistant avatar
|
||||
return <SmartToyOutlinedIcon sx={avatarIconSx} />; // https://mui.com/static/images/avatar/2.jpg
|
||||
}
|
||||
return <Avatar alt={messageSender} />;
|
||||
}
|
||||
|
||||
function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: string) {
|
||||
const isAssistantError = isAssistant && (text.startsWith('[Issue] ') || text.startsWith('[OpenAI Issue]'));
|
||||
let errorMessage: React.JSX.Element | null = null;
|
||||
if (!isAssistantError)
|
||||
return { errorMessage, isAssistantError };
|
||||
|
||||
// [OpenAI] "Service Temporarily Unavailable (503)", {"code":503,"message":"Service Unavailable.","param":null,"type":"cf_service_unavailable"}
|
||||
if (text.includes('"cf_service_unavailable"')) {
|
||||
errorMessage = <>
|
||||
The OpenAI servers appear to be having trouble at the moment. Kindly follow
|
||||
the <Link noLinkStyle href='https://status.openai.com/' target='_blank'>OpenAI Status</Link> page
|
||||
for up to date information, and at your option try again.
|
||||
</>;
|
||||
}
|
||||
// ...
|
||||
else if (text.startsWith('OpenAI API error: 429 Too Many Requests')) {
|
||||
// TODO: retry at the api/chat level a few times instead of showing this error
|
||||
errorMessage = <>
|
||||
The model appears to be occupied at the moment. Kindly select <b>GPT-3.5 Turbo</b>,
|
||||
or give it another go by selecting <b>Run again</b> from the message menu.
|
||||
</>;
|
||||
} else if (text.includes('"model_not_found"')) {
|
||||
// note that "model_not_found" is different than "The model `gpt-xyz` does not exist" message
|
||||
errorMessage = <>
|
||||
The API key appears to be unauthorized for {modelId || 'this model'}. You can change to <b>GPT-3.5
|
||||
Turbo</b> and simultaneously <Link noLinkStyle href='https://openai.com/waitlist/gpt-4-api' target='_blank'>request
|
||||
access</Link> to the desired model.
|
||||
</>;
|
||||
} else if (text.includes('"context_length_exceeded"')) {
|
||||
// TODO: propose to summarize or split the input?
|
||||
const pattern = /maximum context length is (\d+) tokens.+resulted in (\d+) tokens/;
|
||||
const match = pattern.exec(text);
|
||||
const usedText = match ? <b>{parseInt(match[2] || '0').toLocaleString()} tokens > {parseInt(match[1] || '0').toLocaleString()}</b> : '';
|
||||
errorMessage = <>
|
||||
This thread <b>surpasses the maximum size</b> allowed for {modelId || 'this model'}. {usedText}.
|
||||
Please consider removing some earlier messages from the conversation, start a new conversation,
|
||||
choose a model with larger context, or submit a shorter new message.
|
||||
{!usedText && ` -- ${text}`}
|
||||
</>;
|
||||
}
|
||||
// [OpenAI] {"error":{"message":"Incorrect API key provided: ...","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}
|
||||
else if (text.includes('"invalid_api_key"')) {
|
||||
errorMessage = <>
|
||||
The API key appears to be incorrect or to have expired.
|
||||
Please <Link noLinkStyle href='https://platform.openai.com/account/api-keys' target='_blank'>check your
|
||||
API key</Link> and update it in <b>Models</b>.
|
||||
</>;
|
||||
} else if (text.includes('"insufficient_quota"')) {
|
||||
errorMessage = <>
|
||||
The API key appears to have <b>insufficient quota</b>. Please
|
||||
check <Link noLinkStyle href='https://platform.openai.com/account/usage' target='_blank'>your usage</Link> and
|
||||
make sure the usage is under <Link noLinkStyle href='https://platform.openai.com/account/billing/limits' target='_blank'>the limits</Link>.
|
||||
</>;
|
||||
}
|
||||
// else
|
||||
// errorMessage = <>{text || 'Unknown error'}</>;
|
||||
|
||||
return { errorMessage, isAssistantError };
|
||||
}
|
||||
|
||||
export type ChatMessageTextPartEditState = { [fragmentId: DMessageFragmentId]: string };
|
||||
|
||||
export const ChatMessageMemo = React.memo(ChatMessage);
|
||||
|
||||
@@ -217,6 +67,7 @@ export function ChatMessage(props: {
|
||||
message: DMessage,
|
||||
diffPreviousText?: string,
|
||||
fitScreen: boolean,
|
||||
isMobile?: boolean,
|
||||
isBottom?: boolean,
|
||||
isImagining?: boolean,
|
||||
isSpeaking?: boolean,
|
||||
@@ -229,24 +80,26 @@ export function ChatMessage(props: {
|
||||
onMessageBeam?: (messageId: string) => Promise<void>,
|
||||
onMessageBranch?: (messageId: string) => void,
|
||||
onMessageDelete?: (messageId: string) => void,
|
||||
onMessageEdit?: (messageId: string, text: string) => void,
|
||||
onMessageFragmentAppend?: (messageId: DMessageId, fragment: DMessageFragment) => void
|
||||
onMessageFragmentDelete?: (messageId: DMessageId, fragmentId: DMessageFragmentId) => void,
|
||||
onMessageFragmentReplace?: (messageId: DMessageId, fragmentId: DMessageFragmentId, newFragment: DMessageFragment) => void,
|
||||
onMessageToggleUserFlag?: (messageId: string, flag: DMessageUserFlag) => void,
|
||||
onMessageTruncate?: (messageId: string) => void,
|
||||
onReplyTo?: (messageId: string, selectedText: string) => void,
|
||||
onTextDiagram?: (messageId: string, text: string) => Promise<void>
|
||||
onTextImagine?: (text: string) => Promise<void>
|
||||
onTextSpeak?: (text: string) => Promise<void>
|
||||
onTextDiagram?: (messageId: string, text: string) => Promise<void>,
|
||||
onTextImagine?: (text: string) => Promise<void>,
|
||||
onTextSpeak?: (text: string) => Promise<void>,
|
||||
sx?: SxProps,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const blocksRendererRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isHovering, setIsHovering] = React.useState(false);
|
||||
const [opsMenuAnchor, setOpsMenuAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [selMenuAnchor, setSelMenuAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [selToolbarAnchor, setSelToolbarAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [selText, setSelText] = React.useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [bubbleAnchor, setBubbleAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [contextMenuAnchor, setContextMenuAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [opsMenuAnchor, setOpsMenuAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [textContentEditState, setTextContentEditState] = React.useState<ChatMessageTextPartEditState | null>(null);
|
||||
|
||||
// external state
|
||||
const { showAvatar, contentScaling, doubleClickToEdit, renderMarkdown } = useUIPreferencesStore(useShallow(state => ({
|
||||
@@ -256,16 +109,15 @@ export function ChatMessage(props: {
|
||||
renderMarkdown: state.renderMarkdown,
|
||||
})));
|
||||
const [showDiff, setShowDiff] = useChatShowTextDiff();
|
||||
const textDiffs = useSanityTextDiffs(props.message.text, props.diffPreviousText, showDiff);
|
||||
|
||||
|
||||
// derived state
|
||||
const {
|
||||
id: messageId,
|
||||
text: messageText,
|
||||
sender: messageSender,
|
||||
avatar: messageAvatar,
|
||||
typing: messageTyping,
|
||||
role: messageRole,
|
||||
fragments: messageFragments,
|
||||
pendingIncomplete: messagePendingIncomplete,
|
||||
avatar: messageAvatar,
|
||||
purposeId: messagePurposeId,
|
||||
originLLM: messageOriginLLM,
|
||||
metadata: messageMetadata,
|
||||
@@ -273,28 +125,75 @@ export function ChatMessage(props: {
|
||||
updated: messageUpdated,
|
||||
} = props.message;
|
||||
|
||||
|
||||
// split the fragments: Image Attachments are rendered as cards, Content is the body (sequence of parts), and other attachment fragments as documents
|
||||
const contentFragments: DMessageContentFragment[] = [];
|
||||
const imageAttachments: DMessageAttachmentFragment[] = [];
|
||||
const nonImageAttachments: DMessageAttachmentFragment[] = [];
|
||||
messageFragments.forEach(fragment => {
|
||||
if (isContentFragment(fragment)) contentFragments.push(fragment);
|
||||
else if (isAttachmentFragment(fragment)) {
|
||||
if (isImageRefPart(fragment.part)) imageAttachments.push(fragment);
|
||||
else nonImageAttachments.push(fragment);
|
||||
} else
|
||||
console.warn('Unexpected fragment type:', fragment.ft);
|
||||
});
|
||||
|
||||
const isUserStarred = messageHasUserFlag(props.message, 'starred');
|
||||
|
||||
const fromAssistant = messageRole === 'assistant';
|
||||
const fromSystem = messageRole === 'system';
|
||||
const wasEdited = !!messageUpdated;
|
||||
|
||||
const textSel = selText ? selText : messageText;
|
||||
// WARNING: if you get an issue here, you're downgrading from the new Big-AGI 2 data format to 1.x.
|
||||
const textSel = selText ? selText : messageFragmentsReduceText(contentFragments);
|
||||
const isSpecialT2I = textSel.startsWith('https://images.prodia.xyz/') || textSel.startsWith('/draw ') || textSel.startsWith('/imagine ') || textSel.startsWith('/img ');
|
||||
const couldDiagram = textSel.length >= 100 && !isSpecialT2I;
|
||||
const couldImagine = textSel.length >= 3 && !isSpecialT2I;
|
||||
const couldSpeak = couldImagine;
|
||||
|
||||
|
||||
const handleTextEdited = (editedText: string) => {
|
||||
setIsEditing(false);
|
||||
if (props.onMessageEdit && editedText?.trim() && editedText !== messageText)
|
||||
props.onMessageEdit(messageId, editedText);
|
||||
};
|
||||
// TODO: fix the diffing
|
||||
// const textDiffs = useSanityTextDiffs(messageText, props.diffPreviousText, showDiff);
|
||||
|
||||
|
||||
// Operations Menu
|
||||
const { onMessageFragmentAppend, onMessageFragmentDelete, onMessageFragmentReplace } = props;
|
||||
|
||||
const handleFragmentNew = React.useCallback(() => {
|
||||
onMessageFragmentAppend?.(messageId, createTextContentFragment(''));
|
||||
}, [messageId, onMessageFragmentAppend]);
|
||||
|
||||
const handleFragmentDelete = React.useCallback((fragmentId: DMessageFragmentId) => {
|
||||
onMessageFragmentDelete?.(messageId, fragmentId);
|
||||
}, [messageId, onMessageFragmentDelete]);
|
||||
|
||||
const handleFragmentReplace = React.useCallback((fragmentId: DMessageFragmentId, newFragment: DMessageFragment) => {
|
||||
onMessageFragmentReplace?.(messageId, fragmentId, newFragment);
|
||||
}, [messageId, onMessageFragmentReplace]);
|
||||
|
||||
|
||||
// Text Editing
|
||||
|
||||
const isEditingText = !!textContentEditState;
|
||||
|
||||
const handleEditsApply = React.useCallback(() => {
|
||||
const state = textContentEditState || {};
|
||||
setTextContentEditState(null);
|
||||
Object.entries(state).forEach(([fragmentId, editedText]) => {
|
||||
if (editedText.length > 0)
|
||||
handleFragmentReplace(fragmentId, createTextContentFragment(editedText));
|
||||
else
|
||||
handleFragmentDelete(fragmentId);
|
||||
});
|
||||
}, [handleFragmentDelete, handleFragmentReplace, textContentEditState]);
|
||||
|
||||
const handleEditsBegin = React.useCallback(() => setTextContentEditState({}), []);
|
||||
|
||||
const handleEditsCancel = React.useCallback(() => setTextContentEditState(null), []);
|
||||
|
||||
const handleEditSetText = React.useCallback((fragmentId: DMessageFragmentId, editedText: string) =>
|
||||
setTextContentEditState((prev): ChatMessageTextPartEditState => ({ ...prev, [fragmentId]: editedText || '' })), []);
|
||||
|
||||
|
||||
// Message Operations Menu
|
||||
|
||||
const { onMessageToggleUserFlag } = props;
|
||||
|
||||
@@ -309,16 +208,17 @@ export function ChatMessage(props: {
|
||||
copyToClipboard(textSel, 'Text');
|
||||
e.preventDefault();
|
||||
handleCloseOpsMenu();
|
||||
closeSelectionMenu();
|
||||
closeToolbar();
|
||||
closeContextMenu();
|
||||
closeBubble();
|
||||
};
|
||||
|
||||
const handleOpsEdit = React.useCallback((e: React.MouseEvent) => {
|
||||
if (messageTyping && !isEditing) return; // don't allow editing while typing
|
||||
setIsEditing(!isEditing);
|
||||
const handleOpsEditToggle = React.useCallback((e: React.MouseEvent) => {
|
||||
if (messagePendingIncomplete && !isEditingText) return; // don't allow editing while incomplete
|
||||
if (isEditingText) handleEditsCancel();
|
||||
else handleEditsBegin();
|
||||
e.preventDefault();
|
||||
handleCloseOpsMenu();
|
||||
}, [handleCloseOpsMenu, isEditing, messageTyping]);
|
||||
}, [handleCloseOpsMenu, handleEditsBegin, handleEditsCancel, isEditingText, messagePendingIncomplete]);
|
||||
|
||||
const handleOpsToggleStarred = React.useCallback(() => {
|
||||
onMessageToggleUserFlag?.(messageId, 'starred');
|
||||
@@ -350,8 +250,8 @@ export function ChatMessage(props: {
|
||||
if (props.onTextDiagram) {
|
||||
await props.onTextDiagram(messageId, textSel);
|
||||
handleCloseOpsMenu();
|
||||
closeSelectionMenu();
|
||||
closeToolbar();
|
||||
closeContextMenu();
|
||||
closeBubble();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -360,18 +260,18 @@ export function ChatMessage(props: {
|
||||
if (props.onTextImagine) {
|
||||
await props.onTextImagine(textSel);
|
||||
handleCloseOpsMenu();
|
||||
closeSelectionMenu();
|
||||
closeToolbar();
|
||||
closeContextMenu();
|
||||
closeBubble();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpsReplyTo = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (props.onReplyTo && textSel.trim().length >= SELECTION_TOOLBAR_MIN_LENGTH) {
|
||||
if (props.onReplyTo && textSel.trim().length >= BUBBLE_MIN_TEXT_LENGTH) {
|
||||
props.onReplyTo(messageId, textSel.trim());
|
||||
handleCloseOpsMenu();
|
||||
closeSelectionMenu();
|
||||
closeToolbar();
|
||||
closeContextMenu();
|
||||
closeBubble();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -380,8 +280,8 @@ export function ChatMessage(props: {
|
||||
if (props.onTextSpeak) {
|
||||
await props.onTextSpeak(textSel);
|
||||
handleCloseOpsMenu();
|
||||
closeSelectionMenu();
|
||||
closeToolbar();
|
||||
closeContextMenu();
|
||||
closeBubble();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -395,24 +295,24 @@ export function ChatMessage(props: {
|
||||
};
|
||||
|
||||
|
||||
// Selection Menu
|
||||
// Context Menu
|
||||
|
||||
const removeSelectionAnchor = React.useCallback(() => {
|
||||
if (selMenuAnchor) {
|
||||
const removeContextAnchor = React.useCallback(() => {
|
||||
if (contextMenuAnchor) {
|
||||
try {
|
||||
document.body.removeChild(selMenuAnchor);
|
||||
document.body.removeChild(contextMenuAnchor);
|
||||
} catch (e) {
|
||||
// ignore...
|
||||
}
|
||||
}
|
||||
}, [selMenuAnchor]);
|
||||
}, [contextMenuAnchor]);
|
||||
|
||||
const openSelectionMenu = React.useCallback((event: MouseEvent, selectedText: string) => {
|
||||
const openContextMenu = React.useCallback((event: MouseEvent, selectedText: string) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
// remove any stray anchor
|
||||
removeSelectionAnchor();
|
||||
removeContextAnchor();
|
||||
|
||||
// create a temporary fixed anchor element to position the menu
|
||||
const anchorEl = document.createElement('div');
|
||||
@@ -421,16 +321,16 @@ export function ChatMessage(props: {
|
||||
anchorEl.style.top = `${event.clientY}px`;
|
||||
document.body.appendChild(anchorEl);
|
||||
|
||||
setSelMenuAnchor(anchorEl);
|
||||
setContextMenuAnchor(anchorEl);
|
||||
setSelText(selectedText);
|
||||
}, [removeSelectionAnchor]);
|
||||
}, [removeContextAnchor]);
|
||||
|
||||
const closeSelectionMenu = React.useCallback(() => {
|
||||
const closeContextMenu = React.useCallback(() => {
|
||||
// window.getSelection()?.removeAllRanges?.();
|
||||
removeSelectionAnchor();
|
||||
setSelMenuAnchor(null);
|
||||
removeContextAnchor();
|
||||
setContextMenuAnchor(null);
|
||||
setSelText(null);
|
||||
}, [removeSelectionAnchor]);
|
||||
}, [removeContextAnchor]);
|
||||
|
||||
const handleContextMenu = React.useCallback((event: MouseEvent) => {
|
||||
const selection = window.getSelection();
|
||||
@@ -438,33 +338,34 @@ export function ChatMessage(props: {
|
||||
const range = selection.getRangeAt(0);
|
||||
const selectedText = range.toString().trim();
|
||||
if (selectedText.length > 0)
|
||||
openSelectionMenu(event, selectedText);
|
||||
openContextMenu(event, selectedText);
|
||||
}
|
||||
}, [openSelectionMenu]);
|
||||
}, [openContextMenu]);
|
||||
|
||||
|
||||
// Selection Toolbar
|
||||
// Bubble
|
||||
|
||||
const closeToolbar = React.useCallback((anchorEl?: HTMLElement) => {
|
||||
const closeBubble = React.useCallback((anchorEl?: HTMLElement) => {
|
||||
window.getSelection()?.removeAllRanges?.();
|
||||
try {
|
||||
const anchor = anchorEl || selToolbarAnchor;
|
||||
const anchor = anchorEl || bubbleAnchor;
|
||||
anchor && document.body.removeChild(anchor);
|
||||
} catch (e) {
|
||||
// ignore...
|
||||
}
|
||||
setSelToolbarAnchor(null);
|
||||
setBubbleAnchor(null);
|
||||
setSelText(null);
|
||||
}, [selToolbarAnchor]);
|
||||
}, [bubbleAnchor]);
|
||||
|
||||
const handleOpenToolbar = React.useCallback((_event: MouseEvent) => {
|
||||
// restore blocksRendererRef
|
||||
const handleOpenBubble = React.useCallback((_event: MouseEvent) => {
|
||||
// check for selection
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount <= 0) return;
|
||||
|
||||
// check for enought selection
|
||||
const selectionText = selection.toString().trim();
|
||||
if (selectionText.length < SELECTION_TOOLBAR_MIN_LENGTH) return;
|
||||
if (selectionText.length < BUBBLE_MIN_TEXT_LENGTH) return;
|
||||
|
||||
// check for the selection being inside the blocks renderer (core of the message)
|
||||
const selectionRange = selection.getRangeAt(0);
|
||||
@@ -486,15 +387,15 @@ export function ChatMessage(props: {
|
||||
const closeOnUnselect = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.toString().trim() === '') {
|
||||
closeToolbar(anchorEl);
|
||||
closeBubble(anchorEl);
|
||||
document.removeEventListener('selectionchange', closeOnUnselect);
|
||||
}
|
||||
};
|
||||
document.addEventListener('selectionchange', closeOnUnselect);
|
||||
|
||||
setSelToolbarAnchor(anchorEl);
|
||||
setSelText(selectionText);
|
||||
}, [closeToolbar]);
|
||||
setBubbleAnchor(anchorEl);
|
||||
setSelText(selectionText); /* TODO: operate on the underlying content, not the rendered text */
|
||||
}, [closeBubble]);
|
||||
|
||||
|
||||
// Blocks renderer
|
||||
@@ -504,34 +405,28 @@ export function ChatMessage(props: {
|
||||
}, [handleContextMenu]);
|
||||
|
||||
const handleBlocksDoubleClick = React.useCallback((event: React.MouseEvent) => {
|
||||
doubleClickToEdit && props.onMessageEdit && handleOpsEdit(event);
|
||||
}, [doubleClickToEdit, handleOpsEdit, props.onMessageEdit]);
|
||||
doubleClickToEdit && props.onMessageFragmentReplace && handleOpsEditToggle(event);
|
||||
}, [doubleClickToEdit, handleOpsEditToggle, props.onMessageFragmentReplace]);
|
||||
|
||||
const handleBlocksMouseUp = React.useCallback((event: React.MouseEvent) => {
|
||||
handleOpenToolbar(event.nativeEvent);
|
||||
}, [handleOpenToolbar]);
|
||||
handleOpenBubble(event.nativeEvent);
|
||||
}, [handleOpenBubble]);
|
||||
|
||||
|
||||
// prettier upstream errors
|
||||
const { isAssistantError, errorMessage } = React.useMemo(
|
||||
() => explainErrorInMessage(messageText, fromAssistant, messageOriginLLM),
|
||||
[messageText, fromAssistant, messageOriginLLM],
|
||||
);
|
||||
|
||||
// style
|
||||
const backgroundColor = messageBackground(messageRole, wasEdited, isAssistantError && !errorMessage);
|
||||
const backgroundColor = messageBackground(messageRole, wasEdited, false /*isAssistantError && !errorMessage*/);
|
||||
|
||||
// avatar
|
||||
const avatarEl: React.JSX.Element | null = React.useMemo(
|
||||
() => showAvatar ? makeAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, messageSender, messageTyping) : null,
|
||||
[messageAvatar, messageOriginLLM, messagePurposeId, messageRole, messageSender, messageTyping, showAvatar],
|
||||
() => showAvatar ? makeMessageAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, !!messagePendingIncomplete, true) : null,
|
||||
[messageAvatar, messageOriginLLM, messagePendingIncomplete, messagePurposeId, messageRole, showAvatar],
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
role='chat-message'
|
||||
onMouseUp={(ENABLE_SELECTION_TOOLBAR && !fromSystem && !isAssistantError) ? handleBlocksMouseUp : undefined}
|
||||
onMouseUp={(ENABLE_BUBBLE && !fromSystem /*&& !isAssistantError*/) ? handleBlocksMouseUp : undefined}
|
||||
sx={{
|
||||
// style
|
||||
backgroundColor: backgroundColor,
|
||||
@@ -566,24 +461,43 @@ export function ChatMessage(props: {
|
||||
{/* (Optional) underlayed top decorator */}
|
||||
{props.topDecorator}
|
||||
|
||||
{/* Message Row: Avatar, Blocks (1 text -> blocksRenderer) */}
|
||||
<Box sx={{
|
||||
{/* Message Row: Aside, Fragment[][], Aside2 */}
|
||||
<Box role={undefined /* aside | message | ops */} sx={{
|
||||
display: 'flex',
|
||||
flexDirection: !fromAssistant ? 'row-reverse' : 'row',
|
||||
alignItems: 'flex-start',
|
||||
alignItems: 'flex-start', // avatars at the top, and honor 'static' position
|
||||
gap: { xs: 0, md: 1 },
|
||||
}}>
|
||||
|
||||
{/* Avatar (Persona) */}
|
||||
{showAvatar && (
|
||||
<Box sx={personaSx}>
|
||||
|
||||
{/* [aside A] Editing: Apply */}
|
||||
{isEditingText && (
|
||||
<Box sx={messageAsideColumnSx}>
|
||||
{/*<Typography level='body-xs'> </Typography>*/}
|
||||
<Tooltip arrow disableInteractive title='Apply Edits'>
|
||||
<IconButton size='sm' variant='solid' color='warning' onClick={handleEditsApply} sx={{ mt: 0.25 }}>
|
||||
<CheckRoundedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography level='body-xs' sx={{ overflowWrap: 'anywhere', mt: 0.25 }}>
|
||||
Done
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* [aside B] Avatar (Persona) */}
|
||||
{showAvatar && !isEditingText && (
|
||||
<Box sx={messageAsideColumnSx}>
|
||||
|
||||
{/* Persona Avatar or Menu Button */}
|
||||
<Box
|
||||
onClick={handleOpsMenuToggle}
|
||||
onClick={(event) => {
|
||||
event.shiftKey && console.log(props.message);
|
||||
handleOpsMenuToggle(event);
|
||||
}}
|
||||
onContextMenu={handleOpsMenuToggle}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
onMouseEnter={props.isMobile ? undefined : () => setIsHovering(true)}
|
||||
onMouseLeave={props.isMobile ? undefined : () => setIsHovering(false)}
|
||||
sx={{ display: 'flex' }}
|
||||
>
|
||||
{(isHovering || opsMenuAnchor) ? (
|
||||
@@ -595,12 +509,12 @@ export function ChatMessage(props: {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Assistant model name */}
|
||||
{/* Assistant (llm/function) name */}
|
||||
{fromAssistant && (
|
||||
<Tooltip arrow title={messageTyping ? null : (messageOriginLLM || 'unk-model')} variant='solid'>
|
||||
<Tooltip arrow title={messagePendingIncomplete ? null : (messageOriginLLM || 'unk-model')} variant='solid'>
|
||||
<Typography level='body-xs' sx={{
|
||||
overflowWrap: 'anywhere',
|
||||
...(messageTyping ? { animation: `${animationColorRainbow} 5s linear infinite` } : {}),
|
||||
...(messagePendingIncomplete ? { animation: `${animationColorRainbow} 5s linear infinite` } : {}),
|
||||
}}>
|
||||
{prettyBaseModel(messageOriginLLM)}
|
||||
</Typography>
|
||||
@@ -611,45 +525,114 @@ export function ChatMessage(props: {
|
||||
)}
|
||||
|
||||
|
||||
{/* Edit / Blocks */}
|
||||
{isEditing ? (
|
||||
{/* (many-type) Fragment Classes */}
|
||||
<Box ref={blocksRendererRef /* restricts the BUBBLE menu to the children of this */} sx={{
|
||||
// style
|
||||
flexGrow: 1, // capture all the space, for edit modes
|
||||
minWidth: 0, // VERY important, otherwise very wide messages will overflow the container, causing scroll on the whole page
|
||||
my: 'auto', // v-center content if there's any gap (e.g. single line of text)
|
||||
|
||||
<InlineTextarea
|
||||
initialText={messageText} onEdit={handleTextEdited}
|
||||
sx={editBlocksSx}
|
||||
/>
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5, // we give a bit more space between the 'classes' of fragments (content, attachments, etc.)
|
||||
}}>
|
||||
|
||||
) : (
|
||||
{/* (optional) Message date */}
|
||||
{(props.showBlocksDate === true && !!(messageUpdated || messageCreated)) && (
|
||||
<Typography level='body-sm' sx={{ mx: 1.5, textAlign: fromAssistant ? 'left' : 'right' }}>
|
||||
<TimeAgo date={messageUpdated || messageCreated} />
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Image Attachment Fragments (just for a prettier display on top of the message) */}
|
||||
{imageAttachments.length >= 1 && !isEditingText && (
|
||||
<ImageAttachmentFragments
|
||||
imageAttachments={imageAttachments}
|
||||
contentScaling={contentScaling}
|
||||
messageRole={messageRole}
|
||||
isMobile={props.isMobile}
|
||||
onFragmentDelete={handleFragmentDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content Fragments (iterating all to preserve the index) */}
|
||||
<ContentFragments
|
||||
fragments={contentFragments}
|
||||
|
||||
<BlocksRenderer
|
||||
ref={blocksRendererRef}
|
||||
text={messageText}
|
||||
fromRole={messageRole}
|
||||
contentScaling={contentScaling}
|
||||
errorMessage={errorMessage}
|
||||
fitScreen={props.fitScreen}
|
||||
isBottom={props.isBottom}
|
||||
messageOriginLLM={messageOriginLLM}
|
||||
messageRole={messageRole}
|
||||
optiAllowSubBlocksMemo={!!messagePendingIncomplete}
|
||||
renderTextAsMarkdown={renderMarkdown}
|
||||
renderTextDiff={textDiffs || undefined}
|
||||
showDate={props.showBlocksDate === true ? messageUpdated || messageCreated || undefined : undefined}
|
||||
showTopWarning={(fromSystem && wasEdited) ? 'modified by user - auto-update disabled' : undefined}
|
||||
showUnsafeHtml={props.showUnsafeHtml}
|
||||
wasUserEdited={wasEdited}
|
||||
onContextMenu={(props.onMessageEdit && ENABLE_SELECTION_RIGHT_CLICK_MENU) ? handleBlocksContextMenu : undefined}
|
||||
onDoubleClick={(props.onMessageEdit && doubleClickToEdit) ? handleBlocksDoubleClick : undefined}
|
||||
optiAllowMemo={messageTyping}
|
||||
|
||||
textEditsState={textContentEditState}
|
||||
setEditedText={handleEditSetText}
|
||||
onEditsApply={handleEditsApply}
|
||||
onEditsCancel={handleEditsCancel}
|
||||
|
||||
onFragmentDelete={handleFragmentDelete}
|
||||
onFragmentReplace={handleFragmentReplace}
|
||||
|
||||
onContextMenu={(props.onMessageFragmentReplace && ENABLE_CONTEXT_MENU) ? handleBlocksContextMenu : undefined}
|
||||
onDoubleClick={(props.onMessageFragmentReplace && doubleClickToEdit) ? handleBlocksDoubleClick : undefined}
|
||||
/>
|
||||
|
||||
{/* If editing and there's no content, have a button to create a new TextContentFragment */}
|
||||
{isEditingText && !contentFragments.length && (
|
||||
<Button variant='plain' color='neutral' onClick={handleFragmentNew} sx={{ justifyContent: 'flex-start' }}>
|
||||
add text ...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Document Fragments */}
|
||||
{nonImageAttachments.length >= 1 && !isEditingText && (
|
||||
<DocumentFragments
|
||||
attachmentFragments={nonImageAttachments}
|
||||
messageRole={messageRole}
|
||||
contentScaling={contentScaling}
|
||||
isMobile={props.isMobile}
|
||||
renderTextAsMarkdown={renderMarkdown}
|
||||
onFragmentDelete={handleFragmentDelete}
|
||||
onFragmentReplace={handleFragmentReplace}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reply-To Bubble */}
|
||||
{!!messageMetadata?.inReplyToText && (
|
||||
<ReplyToBubble
|
||||
inlineUserMessage
|
||||
replyToText={messageMetadata.inReplyToText}
|
||||
className='reply-to-bubble'
|
||||
/>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Editing: Cancel */}
|
||||
{isEditingText && (
|
||||
<Box sx={messageAsideColumnSx}>
|
||||
{/*<Typography level='body-xs'> </Typography>*/}
|
||||
<Tooltip arrow disableInteractive title='Discard Edits'>
|
||||
<IconButton size='md' onClick={handleEditsCancel}>
|
||||
<CloseRoundedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography level='body-xs' sx={{ overflowWrap: 'anywhere' }}>
|
||||
Cancel
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Reply-To Bubble */}
|
||||
{!!messageMetadata?.inReplyToText && <ReplyToBubble inlineMessage replyToText={messageMetadata.inReplyToText} className='reply-to-bubble' />}
|
||||
|
||||
|
||||
{/* Overlay copy icon */}
|
||||
{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditing && (
|
||||
<Tooltip title={messageTyping ? null : (fromAssistant ? 'Copy message' : 'Copy input')} variant='solid'>
|
||||
{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditingText && (
|
||||
<Tooltip title={messagePendingIncomplete ? null : (fromAssistant ? 'Copy message' : 'Copy input')} variant='solid'>
|
||||
<IconButton
|
||||
variant='outlined' onClick={handleOpsCopy}
|
||||
sx={{
|
||||
@@ -662,7 +645,7 @@ export function ChatMessage(props: {
|
||||
)}
|
||||
|
||||
|
||||
{/* Operations Menu (3 dots) */}
|
||||
{/* Message Operations Menu (3 dots) */}
|
||||
{!!opsMenuAnchor && (
|
||||
<CloseableMenu
|
||||
dense placement='bottom-end'
|
||||
@@ -681,11 +664,10 @@ export function ChatMessage(props: {
|
||||
{/* Edit / Copy */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{/* Edit */}
|
||||
{!!props.onMessageEdit && (
|
||||
<MenuItem variant='plain' disabled={messageTyping} onClick={handleOpsEdit} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator><EditRoundedIcon /></ListItemDecorator>
|
||||
{isEditing ? 'Discard' : 'Edit'}
|
||||
{/*{!isEditing && <span style={{ opacity: 0.5, marginLeft: '8px' }}>{doubleClickToEdit ? '(double-click)' : ''}</span>}*/}
|
||||
{!!props.onMessageFragmentReplace && (
|
||||
<MenuItem variant='plain' disabled={!!messagePendingIncomplete} onClick={handleOpsEditToggle} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator>{isEditingText ? <CloseRoundedIcon /> : <EditRoundedIcon />}</ListItemDecorator>
|
||||
{isEditingText ? 'Discard' : 'Edit'}
|
||||
</MenuItem>
|
||||
)}
|
||||
{/* Copy */}
|
||||
@@ -696,10 +678,12 @@ export function ChatMessage(props: {
|
||||
{/* Starred */}
|
||||
{!!onMessageToggleUserFlag && (
|
||||
<MenuItem onClick={handleOpsToggleStarred} sx={{ flexGrow: 0, px: 1 }}>
|
||||
{isUserStarred
|
||||
? <StarRoundedIcon color='primary' sx={{ fontSize: 'xl2' }} />
|
||||
: <StarOutlineRoundedIcon sx={{ fontSize: 'xl2' }} />
|
||||
}
|
||||
<Tooltip disableInteractive title={!isUserStarred ? 'Star message - use @ to refer to it later' : 'Unstar'}>
|
||||
{isUserStarred
|
||||
? <StarRoundedIcon color='primary' sx={{ fontSize: 'xl2' }} />
|
||||
: <StarOutlineRoundedIcon sx={{ fontSize: 'xl2' }} />
|
||||
}
|
||||
</Tooltip>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Box>
|
||||
@@ -785,12 +769,12 @@ export function ChatMessage(props: {
|
||||
)}
|
||||
|
||||
|
||||
{/* Selection Toolbar */}
|
||||
{ENABLE_SELECTION_TOOLBAR && !!selToolbarAnchor && (
|
||||
<Popper placement='top-start' open anchorEl={selToolbarAnchor} slotProps={{
|
||||
{/* Bubble */}
|
||||
{ENABLE_BUBBLE && !!bubbleAnchor && (
|
||||
<Popper placement='top-start' open anchorEl={bubbleAnchor} slotProps={{
|
||||
root: { style: { zIndex: themeZIndexPageBar + 1 } },
|
||||
}}>
|
||||
<ClickAwayListener onClickAway={() => closeToolbar()}>
|
||||
<ClickAwayListener onClickAway={() => closeBubble()}>
|
||||
<ButtonGroup
|
||||
variant='plain'
|
||||
sx={{
|
||||
@@ -833,11 +817,11 @@ export function ChatMessage(props: {
|
||||
<AccountTreeOutlinedIcon sx={{ color: couldDiagram ? 'primary' : 'neutral.plainDisabledColor' }} />
|
||||
</IconButton>
|
||||
</Tooltip>}
|
||||
{/*{!!props.onTextImagine && <Tooltip disableInteractive arrow placement='top' title='Auto-Draw'>*/}
|
||||
{/* <IconButton onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>*/}
|
||||
{/* {!props.isImagining ? <FormatPaintOutlinedIcon /> : <CircularProgress sx={{ '--CircularProgress-size': '16px' }} />}*/}
|
||||
{/* </IconButton>*/}
|
||||
{/*</Tooltip>}*/}
|
||||
{!!props.onTextImagine && <Tooltip disableInteractive arrow placement='top' title='Auto-Draw'>
|
||||
<IconButton onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
|
||||
{!props.isImagining ? <FormatPaintOutlinedIcon /> : <CircularProgress sx={{ '--CircularProgress-size': '16px' }} />}
|
||||
</IconButton>
|
||||
</Tooltip>}
|
||||
{!!props.onTextSpeak && <Tooltip disableInteractive arrow placement='top' title='Speak'>
|
||||
<IconButton onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
|
||||
{!props.isSpeaking ? <RecordVoiceOverOutlinedIcon /> : <CircularProgress sx={{ '--CircularProgress-size': '16px' }} />}
|
||||
@@ -849,11 +833,11 @@ export function ChatMessage(props: {
|
||||
)}
|
||||
|
||||
|
||||
{/* Selection (Contextual) Menu */}
|
||||
{!!selMenuAnchor && (
|
||||
{/* Context (Right-click) Menu */}
|
||||
{!!contextMenuAnchor && (
|
||||
<CloseableMenu
|
||||
dense placement='bottom-start'
|
||||
open anchorEl={selMenuAnchor} onClose={closeSelectionMenu}
|
||||
open anchorEl={contextMenuAnchor} onClose={closeContextMenu}
|
||||
sx={{ minWidth: 220 }}
|
||||
>
|
||||
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1, alignItems: 'center' }}>
|
||||
|
||||
@@ -4,10 +4,11 @@ import { Box, Button, Checkbox, IconButton, ListItem, Sheet, Typography } from '
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
|
||||
import { DMessage } from '~/common/state/store-chats';
|
||||
import { DMessage, messageFragmentsReduceText } from '~/common/stores/chat/chat.message';
|
||||
|
||||
import { TokenBadgeMemo } from '../composer/TokenBadge';
|
||||
import { makeAvatar, messageBackground } from './ChatMessage';
|
||||
import { isErrorChatMessage } from './explainServiceErrors';
|
||||
import { makeMessageAvatar, messageBackground } from './messageUtils';
|
||||
|
||||
|
||||
/**
|
||||
@@ -44,10 +45,8 @@ export function CleanerMessage(props: { message: DMessage, selected: boolean, re
|
||||
// derived state
|
||||
const {
|
||||
id: messageId,
|
||||
text: messageText,
|
||||
sender: messageSender,
|
||||
avatar: messageAvatar,
|
||||
typing: messageTyping,
|
||||
pendingIncomplete: messagePendingIncomplete,
|
||||
role: messageRole,
|
||||
purposeId: messagePurposeId,
|
||||
originLLM: messageOriginLLM,
|
||||
@@ -55,15 +54,17 @@ export function CleanerMessage(props: { message: DMessage, selected: boolean, re
|
||||
updated: messageUpdated,
|
||||
} = props.message;
|
||||
|
||||
const messageText = messageFragmentsReduceText(props.message.fragments);
|
||||
|
||||
const fromAssistant = messageRole === 'assistant';
|
||||
|
||||
const isAssistantError = fromAssistant && (messageText.startsWith('[Issue] ') || messageText.startsWith('[OpenAI Issue]'));
|
||||
const isAssistantError = fromAssistant && isErrorChatMessage(messageText);
|
||||
|
||||
const backgroundColor = messageBackground(messageRole, !!messageUpdated, isAssistantError);
|
||||
|
||||
const avatarEl: React.JSX.Element | null = React.useMemo(() =>
|
||||
makeAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, messageSender, messageTyping, 'sm'),
|
||||
[messageAvatar, messageOriginLLM, messagePurposeId, messageRole, messageSender, messageTyping],
|
||||
makeMessageAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, !!messagePendingIncomplete),
|
||||
[messageAvatar, messageOriginLLM, messagePendingIncomplete, messagePurposeId, messageRole],
|
||||
);
|
||||
|
||||
const handleCheckedChange = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
|
||||
@@ -28,37 +28,39 @@ const bubbleComposerSx: SxProps = {
|
||||
alignItems: 'start',
|
||||
};
|
||||
|
||||
const inlineMessageSx: SxProps = {
|
||||
export const inlineMessageBubbleSx: SxProps = {
|
||||
...bubbleComposerSx,
|
||||
|
||||
// redefine
|
||||
// border: 'none',
|
||||
mt: 1,
|
||||
// mt: 1,
|
||||
borderColor: `${INLINE_COLOR}.outlinedColor`,
|
||||
borderRadius: 'sm',
|
||||
boxShadow: 'xs',
|
||||
width: undefined,
|
||||
padding: '0.375rem 0.25rem 0.375rem 0.5rem',
|
||||
|
||||
// self-layout (parent: 'block', as 'grid' was not working and the user would scroll the app on the x-axis on mobile)
|
||||
// ml: 'auto',
|
||||
float: 'inline-end',
|
||||
mr: { xs: 7.75, md: 10.5 }, // personaSx.minWidth + gap (md: 1) + 1.5 (text margin)
|
||||
// FORMERLY: self-layout (parent: 'block', as 'grid' was not working and the user would scroll the app on the x-axis on mobile)
|
||||
// float: 'inline-end',
|
||||
// mr: { xs: 7.75, md: 10.5 }, // personaSx.minWidth + gap (md: 1) + 1.5 (text margin)
|
||||
|
||||
// now: the parent is a 'grid' to v-layout fragment types
|
||||
mx: '0.75rem', // 1.5, like margin of text blocks
|
||||
|
||||
};
|
||||
|
||||
|
||||
export function ReplyToBubble(props: {
|
||||
replyToText: string | null,
|
||||
inlineMessage?: boolean
|
||||
inlineUserMessage?: boolean
|
||||
onClear?: () => void,
|
||||
className?: string,
|
||||
}) {
|
||||
return (
|
||||
<Box className={props.className} sx={!props.inlineMessage ? bubbleComposerSx : inlineMessageSx}>
|
||||
<Box className={props.className} sx={!props.inlineUserMessage ? bubbleComposerSx : inlineMessageBubbleSx}>
|
||||
<Tooltip disableInteractive arrow title='Referring to this assistant text' placement='top'>
|
||||
<ReplyRoundedIcon sx={{
|
||||
color: props.inlineMessage ? `${INLINE_COLOR}.outlinedColor` : 'primary.solidBg',
|
||||
color: props.inlineUserMessage ? `${INLINE_COLOR}.outlinedColor` : 'primary.solidBg',
|
||||
fontSize: 'xl',
|
||||
mt: 0.125,
|
||||
}} />
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
|
||||
|
||||
export function isErrorChatMessage(text: string) {
|
||||
return ['**[Service Issue] ', '[Issue] ', '[OpenAI Issue] '].some(prefix => text.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function explainServiceErrors(text: string, isAssistant: boolean, modelId?: string) {
|
||||
const isAssistantError = isAssistant && isErrorChatMessage(text);
|
||||
if (!isAssistantError)
|
||||
return null;
|
||||
|
||||
switch (true) {
|
||||
case text.includes('"insufficient_quota"'):
|
||||
return <>
|
||||
{/*The model appears to be occupied at the moment. Kindly try another model, try again after some time,*/}
|
||||
{/*or give it another go by selecting <b>Run again</b> from the message menu.*/}
|
||||
The OpenAI API key appears to have <b>insufficient quota</b>. Please
|
||||
check <Link noLinkStyle href='https://platform.openai.com/usage' target='_blank'>your usage</Link> and
|
||||
make sure the usage is under <Link noLinkStyle href='https://platform.openai.com/account/billing/limits' target='_blank'>the limits</Link>.
|
||||
</>;
|
||||
|
||||
case text.includes('"invalid_api_key"'):
|
||||
return <>
|
||||
The OpenAI API key appears to be incorrect or to have expired.
|
||||
Please <Link noLinkStyle href='https://platform.openai.com/api-keys' target='_blank'>check your
|
||||
API key</Link> and update it in <b>Models</b>.
|
||||
</>;
|
||||
|
||||
// [OpenAI] "Service Temporarily Unavailable (503)", {"code":503,"message":"Service Unavailable.","param":null,"type":"cf_service_unavailable"}
|
||||
case text.includes('"cf_service_unavailable"'):
|
||||
return <>
|
||||
The OpenAI servers appear to be having trouble at the moment. Kindly follow
|
||||
the <Link noLinkStyle href='https://status.openai.com/' target='_blank'>OpenAI Status</Link> page
|
||||
for up to date information, and at your option try again.
|
||||
</>;
|
||||
|
||||
case text.includes('"model_not_found"'):
|
||||
return <>
|
||||
The API key appears to be unauthorized for {modelId || 'this model'}. You can change to <b>GPT-3.5
|
||||
Turbo</b> and simultaneously <Link noLinkStyle href='https://openai.com/waitlist/gpt-4-api' target='_blank'>request
|
||||
access</Link> to the desired model.
|
||||
</>;
|
||||
|
||||
case text.includes('"context_length_exceeded"'):
|
||||
const pattern = /maximum context length is (\d+) tokens.+resulted in (\d+) tokens/;
|
||||
const match = pattern.exec(text);
|
||||
const usedText = match ? <b>{parseInt(match[2] || '0').toLocaleString()} tokens > {parseInt(match[1] || '0').toLocaleString()}</b> : '';
|
||||
return <>
|
||||
This thread <b>surpasses the maximum size</b> allowed for {modelId || 'this model'}. {usedText}.
|
||||
Please consider removing some earlier messages from the conversation, start a new conversation,
|
||||
choose a model with larger context, or submit a shorter new message.
|
||||
{!usedText && ` -- ${text}`}
|
||||
</>;
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Button } 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 TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import TextureIcon from '@mui/icons-material/Texture';
|
||||
|
||||
import type { DMessageAttachmentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
import { ellipsizeMiddle } from '~/common/util/textUtils';
|
||||
|
||||
|
||||
function iconForFragment({ part }: DMessageAttachmentFragment): React.ComponentType<any> {
|
||||
switch (part.pt) {
|
||||
case 'doc':
|
||||
switch (part.type) {
|
||||
case 'text/plain':
|
||||
return TextFieldsIcon;
|
||||
case 'text/html':
|
||||
return CodeIcon;
|
||||
case 'text/markdown':
|
||||
return CodeIcon;
|
||||
case 'application/vnd.agi.ocr':
|
||||
return part.meta?.srcOcrFrom === 'image' ? AbcIcon : PictureAsPdfIcon;
|
||||
case 'application/vnd.agi.ego':
|
||||
return TelegramIcon;
|
||||
default:
|
||||
return TextureIcon;
|
||||
}
|
||||
case 'image_ref':
|
||||
return ImageOutlinedIcon;
|
||||
case '_pt_sentinel':
|
||||
return TextureIcon;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function DocumentFragmentButton(props: {
|
||||
fragment: DMessageAttachmentFragment,
|
||||
contentScaling: ContentScaling,
|
||||
isSelected: boolean,
|
||||
toggleSelected: (fragmentId: DMessageFragmentId) => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const { fragment, isSelected, toggleSelected } = props;
|
||||
|
||||
// only operate on doc fragments
|
||||
if (fragment.part.pt !== 'doc')
|
||||
throw new Error('Unexpected part type: ' + fragment.part.pt);
|
||||
|
||||
// handlers
|
||||
const handleSelectFragment = React.useCallback(() => {
|
||||
toggleSelected(fragment.fId);
|
||||
}, [fragment.fId, toggleSelected]);
|
||||
|
||||
// memos
|
||||
const buttonSx = React.useMemo((): SxProps => ({
|
||||
// from ATTACHMENT_MIN_STYLE
|
||||
// height: '100%',
|
||||
minHeight: props.contentScaling === 'md' ? 40 : props.contentScaling === 'sm' ? 38 : 36,
|
||||
minWidth: '64px',
|
||||
maxWidth: '280px',
|
||||
padding: 0,
|
||||
|
||||
// style
|
||||
fontSize: themeScalingMap[props.contentScaling]?.fragmentButtonFontSize ?? undefined,
|
||||
border: '1px solid',
|
||||
borderRadius: 'sm',
|
||||
boxShadow: 'xs',
|
||||
...isSelected ? {
|
||||
borderColor: 'neutral.solidBg',
|
||||
} : {
|
||||
borderColor: 'primary.outlinedBorder',
|
||||
backgroundColor: 'background.surface',
|
||||
},
|
||||
|
||||
// from LLMAttachmentItem
|
||||
display: 'flex', flexDirection: 'row',
|
||||
}), [isSelected, props.contentScaling]);
|
||||
|
||||
const buttonText = ellipsizeMiddle(fragment.title || 'Text', 28 /* totally arbitrary length */);
|
||||
|
||||
const Icon = iconForFragment(fragment);
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={props.contentScaling === 'md' ? 'md' : 'sm'}
|
||||
variant={isSelected ? 'solid' : 'soft'}
|
||||
color={isSelected ? 'neutral' : 'neutral'}
|
||||
onClick={handleSelectFragment}
|
||||
sx={buttonSx}
|
||||
>
|
||||
{!!Icon && (
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
paddingX: '0.5rem',
|
||||
borderRight: '1px solid',
|
||||
borderRightColor: isSelected ? 'neutral.solidBg' : 'primary.outlinedBorder',
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>
|
||||
<Icon />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', paddingX: '0.5rem' }}>
|
||||
<Box sx={{ whiteSpace: 'nowrap', fontWeight: 'md' }}>
|
||||
{buttonText}
|
||||
</Box>
|
||||
{/*<Box sx={{ fontSize: 'xs', fontWeight: 'sm' }}>*/}
|
||||
{/* {fragment.caption}*/}
|
||||
{/*</Box>*/}
|
||||
</Box>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Button } from '@mui/joy';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
|
||||
import { AutoBlocksRenderer } from '~/modules/blocks/AutoBlocksRenderer';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
import { createDMessageDataInlineText, createDocAttachmentFragment, DMessageAttachmentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
import { marshallWrapText } from '~/common/stores/chat/chat.tokens';
|
||||
|
||||
import { ContentPartTextEditor } from '../fragments-content/ContentPartTextEditor';
|
||||
|
||||
|
||||
export function DocumentFragmentEditor(props: {
|
||||
fragment: DMessageAttachmentFragment,
|
||||
editedText?: string,
|
||||
setEditedText: (fragmentId: DMessageFragmentId, value: string) => void,
|
||||
messageRole: DMessageRole,
|
||||
contentScaling: ContentScaling,
|
||||
isMobile?: boolean,
|
||||
renderTextAsMarkdown: boolean,
|
||||
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
|
||||
onFragmentReplace: (fragmentId: DMessageFragmentId, newContent: DMessageAttachmentFragment) => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const { editedText, fragment, onFragmentDelete, onFragmentReplace } = props;
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [isDeleteArmed, setIsDeleteArmed] = React.useState(false);
|
||||
|
||||
const fragmentId = fragment.fId;
|
||||
const fragmentTitle = fragment.title;
|
||||
const fragmentCaption = fragment.caption;
|
||||
const part = fragment.part;
|
||||
|
||||
if (part.pt !== 'doc')
|
||||
throw new Error('Unexpected part type: ' + part.pt);
|
||||
|
||||
// delete
|
||||
|
||||
const handleToggleDeleteArmed = React.useCallback(() => {
|
||||
// setIsEditing(false);
|
||||
setIsDeleteArmed(on => !on);
|
||||
}, []);
|
||||
|
||||
const handleFragmentDelete = React.useCallback(() => {
|
||||
onFragmentDelete(fragmentId);
|
||||
}, [fragmentId, onFragmentDelete]);
|
||||
|
||||
|
||||
// edit
|
||||
|
||||
const handleToggleEdit = React.useCallback(() => {
|
||||
setIsDeleteArmed(false);
|
||||
setIsEditing(on => !on);
|
||||
}, []);
|
||||
|
||||
const handleEditApply = React.useCallback(() => {
|
||||
setIsDeleteArmed(false);
|
||||
if (editedText === undefined)
|
||||
return;
|
||||
|
||||
// only edit DOCs
|
||||
if (fragment.part.pt !== 'doc') {
|
||||
console.warn('handleEditApply: unexpected part type:', fragment.part.pt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editedText.length > 0) {
|
||||
const newData = createDMessageDataInlineText(editedText, fragment.part.data.mimeType);
|
||||
const newAttachment = createDocAttachmentFragment(fragment.title, fragment.caption, fragment.part.type, newData, fragment.part.ref, fragment.part.meta);
|
||||
// reuse the same fragment ID, which makes the screen not flash (otherwise the whole editor would disappear as the ID does not exist anymore)
|
||||
newAttachment.fId = fragmentId;
|
||||
onFragmentReplace(fragmentId, newAttachment);
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
// if the user deleted all text, let's remove the part
|
||||
handleFragmentDelete();
|
||||
}
|
||||
}, [editedText, fragment, fragmentId, handleFragmentDelete, onFragmentReplace]);
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
backgroundColor: 'background.level2',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
borderRadius: 'sm',
|
||||
boxShadow: 'inset 2px 0px 5px -4px var(--joy-palette-background-backdrop)',
|
||||
p: 1,
|
||||
mt: 0.5,
|
||||
}}>
|
||||
|
||||
{isEditing ? (
|
||||
// Document Editor
|
||||
<ContentPartTextEditor
|
||||
textPartText={part.data.text}
|
||||
fragmentId={fragmentId}
|
||||
contentScaling={props.contentScaling}
|
||||
editedText={props.editedText}
|
||||
setEditedText={props.setEditedText}
|
||||
onEnterPressed={handleEditApply}
|
||||
onEscapePressed={handleToggleEdit}
|
||||
/>
|
||||
) : (
|
||||
// Document viewer, including collapse/expand
|
||||
<AutoBlocksRenderer
|
||||
text={marshallWrapText(part.data.text, /*fragment.title ||*/ part.meta?.srcFileName || part.ref, 'markdown-code')}
|
||||
// text={selectedFragment.part.text}
|
||||
fromRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
fitScreen={props.isMobile}
|
||||
specialCodePlain
|
||||
renderTextAsMarkdown={props.renderTextAsMarkdown}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit / Delete commands */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', mt: 1 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{isDeleteArmed ? (
|
||||
<Button variant='solid' color='neutral' size='sm' onClick={handleToggleDeleteArmed} startDecorator={<CloseRoundedIcon />}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleDeleteArmed} startDecorator={<DeleteOutlineIcon />}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
{isDeleteArmed && (
|
||||
<Button variant='plain' color='danger' size='sm' onClick={handleFragmentDelete} startDecorator={<DeleteForeverIcon />}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
|
||||
{isEditing ? (
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleEdit} startDecorator={<CloseRoundedIcon />}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleEdit} startDecorator={<EditRoundedIcon />}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<Button variant='plain' color='success' onClick={handleEditApply} size='sm' startDecorator={<CheckRoundedIcon />}>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from 'react';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageAttachmentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
|
||||
import type { ChatMessageTextPartEditState } from '../ChatMessage';
|
||||
import { DocumentFragmentButton } from './DocumentFragmentButton';
|
||||
import { DocumentFragmentEditor } from './DocumentFragmentEditor';
|
||||
|
||||
|
||||
/**
|
||||
* Displays a list of 'cards' which are buttons with a mutually exclusive active state.
|
||||
* When one is active, there is a content part just right under (with the collapse mechanism in case it's a user role).
|
||||
* If one is clicked the content part (use ContentPartText) is displayed.
|
||||
*/
|
||||
export function DocumentFragments(props: {
|
||||
attachmentFragments: DMessageAttachmentFragment[],
|
||||
messageRole: DMessageRole,
|
||||
contentScaling: ContentScaling,
|
||||
isMobile?: boolean,
|
||||
renderTextAsMarkdown: boolean;
|
||||
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
|
||||
onFragmentReplace: (fragmentId: DMessageFragmentId, newFragment: DMessageAttachmentFragment) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [activeFragmentId, setActiveFragmentId] = React.useState<DMessageFragmentId | null>(null);
|
||||
const [editState, setEditState] = React.useState<ChatMessageTextPartEditState | null>(null);
|
||||
|
||||
|
||||
// selection
|
||||
|
||||
const handleToggleSelectedId = React.useCallback((fragmentId: DMessageFragmentId) => setActiveFragmentId(prevId => prevId === fragmentId ? null : fragmentId), []);
|
||||
|
||||
const selectedFragment = props.attachmentFragments.find(fragment => fragment.fId === activeFragmentId);
|
||||
|
||||
|
||||
// editing
|
||||
|
||||
const handleEditSetText = React.useCallback((fragmentId: DMessageFragmentId, value: string) => setEditState(prevState => ({ ...prevState, [fragmentId]: value })), []);
|
||||
|
||||
// [effect] clear edits on onmount
|
||||
React.useEffect(() => {
|
||||
return () => setEditState(null);
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<Box aria-label={`${props.attachmentFragments.length} attachments`} sx={{
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
|
||||
{/* Horizontally scrollable Document buttons */}
|
||||
<Box sx={{
|
||||
pb: 0.5, // 4px: to show the button shadow
|
||||
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
justifyContent: props.messageRole === 'assistant' ? 'flex-start' : 'flex-end',
|
||||
}}>
|
||||
{props.attachmentFragments.map((attachmentFragment) =>
|
||||
<DocumentFragmentButton
|
||||
key={attachmentFragment.fId}
|
||||
fragment={attachmentFragment}
|
||||
contentScaling={props.contentScaling}
|
||||
isSelected={activeFragmentId === attachmentFragment.fId}
|
||||
toggleSelected={handleToggleSelectedId}
|
||||
/>,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Document Viewer & Editor */}
|
||||
{!!selectedFragment && (
|
||||
<DocumentFragmentEditor
|
||||
fragment={selectedFragment}
|
||||
messageRole={props.messageRole}
|
||||
editedText={editState?.[selectedFragment.fId]}
|
||||
setEditedText={handleEditSetText}
|
||||
contentScaling={props.contentScaling}
|
||||
isMobile={props.isMobile}
|
||||
renderTextAsMarkdown={props.renderTextAsMarkdown}
|
||||
onFragmentDelete={props.onFragmentDelete}
|
||||
onFragmentReplace={props.onFragmentReplace}
|
||||
/>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import { RenderImageRefDBlob, showImageDataRefInNewTab } from '~/modules/blocks/image/RenderImageRefDBlob';
|
||||
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
import { DMessageAttachmentFragment, DMessageFragmentId, isImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
|
||||
// configuration
|
||||
const CARD_MIN_SQR = 84;
|
||||
const CARD_MAX_WIDTH = CARD_MIN_SQR * 3; // 3:1 max wide ratio (252px)
|
||||
const CARD_MAX_HEIGHT = CARD_MIN_SQR * 2.25; // 1:2.25 max tall ratio (189px)
|
||||
|
||||
|
||||
const layoutSx: SxProps = {
|
||||
// style
|
||||
my: 'auto',
|
||||
flex: 0,
|
||||
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
// alignItems: 'center', // commented to keep them to the top
|
||||
// justifyContent: 'flex-end', // commented as we do it dynamically
|
||||
gap: { xs: 0.5, md: 1 },
|
||||
};
|
||||
|
||||
const imageSheetPatchSx: SxProps = {
|
||||
// undo the RenderImageURL default style
|
||||
m: 0,
|
||||
minWidth: CARD_MIN_SQR,
|
||||
minHeight: CARD_MIN_SQR,
|
||||
boxShadow: 'xs',
|
||||
// border: 'none',
|
||||
|
||||
// style
|
||||
// backgroundColor: 'background.popup',
|
||||
borderRadius: 'sm',
|
||||
overflow: 'hidden',
|
||||
|
||||
// style the <img> tag
|
||||
'& picture > img': {
|
||||
// override the style in RenderImageURL
|
||||
maxWidth: CARD_MAX_WIDTH, // very important to keep the aspect ratio
|
||||
maxHeight: CARD_MAX_HEIGHT, // very important to keep the aspect ratio
|
||||
// width: '100%',
|
||||
// height: '100%',
|
||||
// objectFit: 'cover',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Shows image attachments in a flexbox that wraps the images (overflowing by rows)
|
||||
* Also see `TextAttachmentFragments` for the text version, and 'ContentFragments'.
|
||||
*/
|
||||
export function ImageAttachmentFragments(props: {
|
||||
imageAttachments: DMessageAttachmentFragment[],
|
||||
contentScaling: ContentScaling,
|
||||
messageRole: DMessageRole,
|
||||
isMobile?: boolean,
|
||||
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
|
||||
}) {
|
||||
|
||||
const layoutSxMemo = React.useMemo((): SxProps => ({
|
||||
...layoutSx,
|
||||
justifyContent: props.messageRole === 'assistant' ? 'flex-start' : 'flex-end',
|
||||
}), [props.messageRole]);
|
||||
|
||||
const cardStyleSxMemo = React.useMemo((): SxProps => ({
|
||||
fontSize: themeScalingMap[props.contentScaling]?.blockFontSize ?? undefined,
|
||||
lineHeight: themeScalingMap[props.contentScaling]?.blockLineHeight ?? 1.75,
|
||||
...imageSheetPatchSx,
|
||||
}), [props.contentScaling]);
|
||||
|
||||
|
||||
return (
|
||||
<Box aria-label={`${props.imageAttachments.length} images`} sx={layoutSxMemo}>
|
||||
|
||||
{/* render each image attachment */}
|
||||
{props.imageAttachments.map(attachmentFragment => {
|
||||
// only operate on image_ref
|
||||
if (!isImageRefPart(attachmentFragment.part))
|
||||
throw new Error('Unexpected part type: ' + attachmentFragment.part.pt);
|
||||
|
||||
const { title, part: imageRefPart } = attachmentFragment;
|
||||
const { dataRef, altText } = imageRefPart;
|
||||
|
||||
// only support rendering DBLob images as cards for now
|
||||
if (dataRef.reftype === 'dblob') {
|
||||
return (
|
||||
<RenderImageRefDBlob
|
||||
key={'att-img-' + attachmentFragment.fId}
|
||||
dataRefDBlobAssetId={dataRef.dblobAssetId}
|
||||
dataRefMimeType={dataRef.mimeType}
|
||||
imageAltText={imageRefPart.altText || title}
|
||||
imageWidth={imageRefPart.width}
|
||||
imageHeight={imageRefPart.height}
|
||||
onOpenInNewTab={() => showImageDataRefInNewTab(dataRef)}
|
||||
onDeleteFragment={() => props.onFragmentDelete(attachmentFragment.fId)}
|
||||
scaledImageSx={cardStyleSxMemo}
|
||||
variant='attachment-card'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error('Unexpected dataRef type: ' + dataRef.reftype);
|
||||
})}
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
import { DMessageContentFragment, DMessageFragment, DMessageFragmentId, isContentFragment, isTextPart } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
import type { ChatMessageTextPartEditState } from '../ChatMessage';
|
||||
import { ContentPartImageRef } from './ContentPartImageRef';
|
||||
import { ContentPartPlaceholder } from './ContentPartPlaceholder';
|
||||
import { ContentPartTextAutoBlocks } from './ContentPartTextAutoBlocks';
|
||||
import { ContentPartTextEditor } from './ContentPartTextEditor';
|
||||
|
||||
|
||||
const editLayoutSx: SxProps = {
|
||||
display: 'grid',
|
||||
gap: 1.5, // see why we give more space on ChatMessage
|
||||
|
||||
// horizontal separator between messages (second part+ and before)
|
||||
// '& > *:not(:first-child)': {
|
||||
// borderTop: '1px solid',
|
||||
// borderTopColor: 'background.level3',
|
||||
// },
|
||||
};
|
||||
|
||||
const startLayoutSx: SxProps = {
|
||||
...editLayoutSx,
|
||||
justifyContent: 'flex-start',
|
||||
};
|
||||
|
||||
const endLayoutSx: SxProps = {
|
||||
...editLayoutSx,
|
||||
justifyContent: 'flex-end',
|
||||
};
|
||||
|
||||
|
||||
export function ContentFragments(props: {
|
||||
|
||||
fragments: DMessageFragment[]
|
||||
|
||||
contentScaling: ContentScaling,
|
||||
fitScreen: boolean,
|
||||
messageOriginLLM?: string,
|
||||
messageRole: DMessageRole,
|
||||
optiAllowSubBlocksMemo?: boolean,
|
||||
renderTextAsMarkdown: boolean,
|
||||
showTopWarning?: string,
|
||||
showUnsafeHtml?: boolean,
|
||||
|
||||
textEditsState: ChatMessageTextPartEditState | null,
|
||||
setEditedText: (fragmentId: DMessageFragmentId, value: string) => void,
|
||||
onEditsApply: () => void,
|
||||
onEditsCancel: () => void,
|
||||
|
||||
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
|
||||
onFragmentReplace: (fragmentId: DMessageFragmentId, newFragment: DMessageContentFragment) => void,
|
||||
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
onDoubleClick?: (event: React.MouseEvent) => void;
|
||||
|
||||
}) {
|
||||
|
||||
const fromAssistant = props.messageRole === 'assistant';
|
||||
const isEditingText = !!props.textEditsState;
|
||||
const isMonoFragment = props.fragments.length < 2;
|
||||
|
||||
// if no fragments, don't box them
|
||||
if (!props.fragments.length)
|
||||
return null;
|
||||
|
||||
return <Box aria-label='message body' sx={isEditingText ? editLayoutSx : fromAssistant ? startLayoutSx : endLayoutSx}>
|
||||
{props.fragments.map((fragment) => {
|
||||
|
||||
// only proceed with DMessageContentFragment
|
||||
if (!isContentFragment(fragment))
|
||||
return null;
|
||||
|
||||
// editing for text parts
|
||||
if (props.textEditsState && (isTextPart(fragment.part) || fragment.part.pt === 'error')) {
|
||||
return (
|
||||
<ContentPartTextEditor
|
||||
key={'edit-' + fragment.fId}
|
||||
textPartText={isTextPart(fragment.part) ? fragment.part.text : fragment.part.error}
|
||||
fragmentId={fragment.fId}
|
||||
contentScaling={props.contentScaling}
|
||||
editedText={props.textEditsState[fragment.fId]}
|
||||
setEditedText={props.setEditedText}
|
||||
onEnterPressed={props.onEditsApply}
|
||||
onEscapePressed={props.onEditsCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (fragment.part.pt) {
|
||||
case 'error':
|
||||
return (
|
||||
<ContentPartPlaceholder
|
||||
key={fragment.fId}
|
||||
placeholderText={fragment.part.error}
|
||||
messageRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
showAsDanger
|
||||
showAsItalic
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
case 'image_ref':
|
||||
return (
|
||||
<ContentPartImageRef
|
||||
key={fragment.fId}
|
||||
imageRefPart={fragment.part}
|
||||
fragmentId={fragment.fId}
|
||||
contentScaling={props.contentScaling}
|
||||
onFragmentDelete={!isMonoFragment ? props.onFragmentDelete : undefined}
|
||||
onFragmentReplace={props.onFragmentReplace}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'ph':
|
||||
return (
|
||||
<ContentPartPlaceholder
|
||||
key={fragment.fId}
|
||||
placeholderText={fragment.part.pText}
|
||||
messageRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
showAsItalic
|
||||
/>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<ContentPartTextAutoBlocks
|
||||
key={fragment.fId}
|
||||
// ref={blocksRendererRef}
|
||||
textPartText={fragment.part.text}
|
||||
messageRole={props.messageRole}
|
||||
messageOriginLLM={props.messageOriginLLM}
|
||||
contentScaling={props.contentScaling}
|
||||
fitScreen={props.fitScreen}
|
||||
renderTextAsMarkdown={props.renderTextAsMarkdown}
|
||||
// renderTextDiff={textDiffs || undefined}
|
||||
showUnsafeHtml={props.showUnsafeHtml}
|
||||
showTopWarning={props.showTopWarning}
|
||||
optiAllowSubBlocksMemo={!!props.optiAllowSubBlocksMemo}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDoubleClick={props.onDoubleClick}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'tool_call':
|
||||
case 'tool_response':
|
||||
case '_pt_sentinel':
|
||||
default:
|
||||
return (
|
||||
<ContentPartPlaceholder
|
||||
key={fragment.fId}
|
||||
placeholderText={`Unknown Content fragment: ${fragment.part.pt}`}
|
||||
messageRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
showAsDanger
|
||||
/>
|
||||
);
|
||||
}
|
||||
}).filter(Boolean)}
|
||||
</Box>;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import { BlocksContainer } from '~/modules/blocks/BlocksContainer';
|
||||
import { RenderImageRefDBlob, showImageDataRefInNewTab } from '~/modules/blocks/image/RenderImageRefDBlob';
|
||||
import { RenderImageURL } from '~/modules/blocks/image/RenderImageURL';
|
||||
|
||||
import type { DMessageContentFragment, DMessageFragmentId, DMessageImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
|
||||
|
||||
|
||||
export function ContentPartImageRef(props: {
|
||||
imageRefPart: DMessageImageRefPart,
|
||||
fragmentId: DMessageFragmentId,
|
||||
contentScaling: ContentScaling,
|
||||
onFragmentDelete?: (fragmentId: DMessageFragmentId) => void,
|
||||
onFragmentReplace?: (fragmentId: DMessageFragmentId, newFragment: DMessageContentFragment) => void,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const { fragmentId, imageRefPart, onFragmentDelete, onFragmentReplace } = props;
|
||||
const { dataRef } = imageRefPart;
|
||||
|
||||
// event handlers
|
||||
const handleDeleteFragment = React.useCallback(() => {
|
||||
onFragmentDelete?.(fragmentId);
|
||||
}, [fragmentId, onFragmentDelete]);
|
||||
|
||||
const handleReplaceFragment = React.useCallback((newImageFragment: DMessageContentFragment) => {
|
||||
onFragmentReplace?.(fragmentId, newImageFragment);
|
||||
}, [fragmentId, onFragmentReplace]);
|
||||
|
||||
const handleOpenInNewTab = React.useCallback(() => {
|
||||
void showImageDataRefInNewTab(dataRef); // fire/forget
|
||||
}, [dataRef]);
|
||||
|
||||
|
||||
// memo the scaled image style
|
||||
const scaledImageSx = React.useMemo((): SxProps => ({
|
||||
// overflowX: 'auto', // <- this would make the right side margin scrollable
|
||||
fontSize: themeScalingMap[props.contentScaling]?.blockFontSize ?? undefined,
|
||||
lineHeight: themeScalingMap[props.contentScaling]?.blockLineHeight ?? 1.75,
|
||||
marginBottom: themeScalingMap[props.contentScaling]?.blockImageGap ?? 1.5,
|
||||
}), [props.contentScaling]);
|
||||
|
||||
return (
|
||||
<BlocksContainer>
|
||||
{dataRef.reftype === 'dblob' ? (
|
||||
<RenderImageRefDBlob
|
||||
dataRefDBlobAssetId={dataRef.dblobAssetId}
|
||||
dataRefMimeType={dataRef.mimeType}
|
||||
imageAltText={imageRefPart.altText}
|
||||
imageWidth={imageRefPart.width}
|
||||
imageHeight={imageRefPart.height}
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onDeleteFragment={onFragmentDelete ? handleDeleteFragment : undefined}
|
||||
onReplaceFragment={onFragmentReplace ? handleReplaceFragment : undefined}
|
||||
scaledImageSx={scaledImageSx}
|
||||
variant='content-part'
|
||||
/>
|
||||
) : dataRef.reftype === 'url' ? (
|
||||
<RenderImageURL
|
||||
imageURL={dataRef.url}
|
||||
expandableText={imageRefPart.altText}
|
||||
scaledImageSx={scaledImageSx}
|
||||
variant='content-part'
|
||||
/>
|
||||
) : (
|
||||
<Box>
|
||||
ContentPartImageRef: unknown reftype
|
||||
</Box>
|
||||
)}
|
||||
</BlocksContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { AutoBlocksRenderer } from '~/modules/blocks/AutoBlocksRenderer';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
|
||||
|
||||
export function ContentPartPlaceholder(props: {
|
||||
placeholderText: string,
|
||||
messageRole: DMessageRole,
|
||||
contentScaling: ContentScaling,
|
||||
showAsDanger?: boolean,
|
||||
showAsItalic?: boolean,
|
||||
// showAsProgress?: boolean,
|
||||
}) {
|
||||
// const placeholder = (
|
||||
return (
|
||||
<AutoBlocksRenderer
|
||||
text={props.placeholderText}
|
||||
fromRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
fitScreen={false}
|
||||
showAsDanger={props.showAsDanger}
|
||||
showAsItalic={props.showAsItalic}
|
||||
renderTextAsMarkdown={false}
|
||||
/>
|
||||
);
|
||||
//
|
||||
// return props.showAsProgress ? (
|
||||
// <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
// <CircularProgress color='neutral' size='sm' sx={{ ml: 1.5, '--CircularProgress-size': '16px', '--CircularProgress-trackThickness': '2px' }} /> {placeholder}
|
||||
// </Box>
|
||||
// ) : (
|
||||
// placeholder
|
||||
// );
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react';
|
||||
import type { Diff as TextDiff } from '@sanity/diff-match-patch';
|
||||
|
||||
import { AutoBlocksRenderer } from '~/modules/blocks/AutoBlocksRenderer';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
|
||||
import { explainServiceErrors } from '../explainServiceErrors';
|
||||
|
||||
/**
|
||||
* The OG part, comprised of text, which can be markdown, have code blocks, etc.
|
||||
* Uses BlocksRenderer to render the markdown/code/html/text, etc.
|
||||
*/
|
||||
export function ContentPartTextAutoBlocks(props: {
|
||||
textPartText: string,
|
||||
|
||||
messageRole: DMessageRole,
|
||||
messageOriginLLM?: string,
|
||||
|
||||
contentScaling: ContentScaling,
|
||||
fitScreen: boolean,
|
||||
renderTextAsMarkdown: boolean,
|
||||
renderTextDiff?: TextDiff[];
|
||||
|
||||
showUnsafeHtml?: boolean,
|
||||
showTopWarning: string | undefined,
|
||||
optiAllowSubBlocksMemo: boolean,
|
||||
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
onDoubleClick?: (event: React.MouseEvent) => void;
|
||||
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const messageText = props.textPartText;
|
||||
const fromAssistant = props.messageRole === 'assistant';
|
||||
|
||||
const errorMessage = React.useMemo(
|
||||
() => explainServiceErrors(messageText, fromAssistant, props.messageOriginLLM),
|
||||
[fromAssistant, messageText, props.messageOriginLLM],
|
||||
);
|
||||
|
||||
// if errored, render an Auto-Error message
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<GoodTooltip placement='top' arrow title={messageText}>
|
||||
<div><InlineError error={`${errorMessage}. Hover this message for more details.`} /></div>
|
||||
</GoodTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoBlocksRenderer
|
||||
text={messageText || ''}
|
||||
fromRole={props.messageRole}
|
||||
contentScaling={props.contentScaling}
|
||||
fitScreen={props.fitScreen}
|
||||
showUnsafeHtml={props.showUnsafeHtml}
|
||||
showTopWarning={props.showTopWarning}
|
||||
renderTextAsMarkdown={props.renderTextAsMarkdown}
|
||||
renderTextDiff={props.renderTextDiff}
|
||||
optiAllowSubBlocksMemo={props.optiAllowSubBlocksMemo}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDoubleClick={props.onDoubleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { BlocksTextarea } from '~/modules/blocks/BlocksContainer';
|
||||
|
||||
import type { ContentScaling } from '~/common/app.theme';
|
||||
import type { DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
|
||||
/**
|
||||
* Very similar to <InlineTextArea /> but with externally controlled state rather than internal.
|
||||
* Made it for as the editing alternative for <ContentPartText />.
|
||||
*/
|
||||
export function ContentPartTextEditor(props: {
|
||||
// current value
|
||||
textPartText: string,
|
||||
fragmentId: DMessageFragmentId,
|
||||
|
||||
// visual
|
||||
contentScaling: ContentScaling,
|
||||
|
||||
// edited value
|
||||
editedText?: string,
|
||||
setEditedText: (fragmentId: DMessageFragmentId, value: string) => void,
|
||||
|
||||
// events
|
||||
onEnterPressed: () => void,
|
||||
onEscapePressed: () => void,
|
||||
}) {
|
||||
|
||||
// external
|
||||
// NOTE: we disabled `useUIPreferencesStore(state => state.enterIsNewline)` on 2024-06-19, as it's
|
||||
// not a good pattern for this kind of editing and we have buttons to take care of Save/Cancel
|
||||
const enterIsNewline = true;
|
||||
|
||||
// derived state
|
||||
const { fragmentId, setEditedText, onEnterPressed, onEscapePressed } = props;
|
||||
|
||||
// handlers
|
||||
const handleEditTextChanged = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
(e.target.value !== undefined) && setEditedText(fragmentId, e.target.value);
|
||||
}, [fragmentId, setEditedText]);
|
||||
|
||||
const handleEditKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
const shiftOrAlt = e.shiftKey || e.altKey;
|
||||
if (enterIsNewline ? shiftOrAlt : !shiftOrAlt) {
|
||||
e.preventDefault();
|
||||
onEnterPressed();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onEscapePressed();
|
||||
}
|
||||
}, [enterIsNewline, onEnterPressed, onEscapePressed]);
|
||||
|
||||
return (
|
||||
<BlocksTextarea
|
||||
variant={/*props.invertedColors ? 'plain' :*/ 'soft'}
|
||||
color={/*props.decolor ? undefined : props.invertedColors ? 'primary' :*/ 'warning'}
|
||||
autoFocus
|
||||
size={props.contentScaling !== 'md' ? 'sm' : undefined}
|
||||
value={(props.editedText !== undefined)
|
||||
? props.editedText /* self-text */
|
||||
: props.textPartText /* DMessageTextPart text */
|
||||
}
|
||||
onChange={handleEditTextChanged}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
// onBlur={props.disableAutoSaveOnBlur ? undefined : handleEditBlur}
|
||||
slotProps={{
|
||||
textarea: {
|
||||
enterKeyHint: enterIsNewline ? 'enter' : 'done',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Avatar, Box } from '@mui/joy';
|
||||
import Face6Icon from '@mui/icons-material/Face6';
|
||||
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
|
||||
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
|
||||
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import type { DMessageRole } from '~/common/stores/chat/chat.message';
|
||||
import { animationColorRainbow } from '~/common/util/animUtils';
|
||||
|
||||
|
||||
// Animations
|
||||
const ANIM_BUSY_DOWNLOADING = 'https://i.giphy.com/26u6dIwIphLj8h10A.webp'; // hourglass: https://i.giphy.com/TFSxpAIYz5inJGuY8f.webp, small-lq: https://i.giphy.com/131tNuGktpXGhy.webp, floppy: https://i.giphy.com/RxR1KghIie2iI.webp
|
||||
const ANIM_BUSY_PAINTING = 'https://i.giphy.com/media/5t9ujj9cMisyVjUZ0m/giphy.webp';
|
||||
const ANIM_BUSY_THINKING = 'https://i.giphy.com/media/l44QzsOLXxcrigdgI/giphy.webp';
|
||||
export const ANIM_BUSY_TYPING = 'https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp';
|
||||
|
||||
|
||||
export const messageAsideColumnSx: SxProps = {
|
||||
// make this stick to the top of the screen
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
|
||||
// flexBasis: 0, // this won't let the item grow
|
||||
minWidth: { xs: 50, md: 64 },
|
||||
maxWidth: 80,
|
||||
textAlign: 'center',
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
export const avatarIconSx = {
|
||||
width: 36,
|
||||
height: 36,
|
||||
} as const;
|
||||
|
||||
|
||||
export function makeMessageAvatar(
|
||||
messageAvatarUrl: string | null,
|
||||
messageRole: DMessageRole | string,
|
||||
messageOriginLLM: string | undefined,
|
||||
messagePurposeId: SystemPurposeId | string | undefined,
|
||||
messageIncomplete: boolean,
|
||||
larger?: boolean,
|
||||
): React.JSX.Element {
|
||||
const nameByRole = messageRole === 'user' ? 'You' : messageRole === 'assistant' ? 'Assistant' : 'System';
|
||||
if (typeof messageAvatarUrl === 'string' && messageAvatarUrl)
|
||||
return <Avatar alt={nameByRole} src={messageAvatarUrl} />;
|
||||
|
||||
const mascotSx = larger ? { width: 48, height: 48 } : avatarIconSx;
|
||||
switch (messageRole) {
|
||||
case 'system':
|
||||
return <SettingsSuggestIcon sx={avatarIconSx} />; // https://em-content.zobj.net/thumbs/120/apple/325/robot_1f916.png
|
||||
|
||||
case 'user':
|
||||
return <Face6Icon sx={avatarIconSx} />; // https://www.svgrepo.com/show/306500/openai.svg
|
||||
|
||||
case 'assistant':
|
||||
const isDownload = messageOriginLLM === 'web';
|
||||
const isTextToImage = messageOriginLLM === 'DALL·E' || messageOriginLLM === 'Prodia';
|
||||
const isReact = messageOriginLLM?.startsWith('react-');
|
||||
|
||||
// animation on incomplete messages
|
||||
if (messageIncomplete)
|
||||
return <Avatar
|
||||
alt={nameByRole} variant='plain'
|
||||
src={isDownload ? ANIM_BUSY_DOWNLOADING
|
||||
: isTextToImage ? ANIM_BUSY_PAINTING
|
||||
: isReact ? ANIM_BUSY_THINKING
|
||||
: ANIM_BUSY_TYPING}
|
||||
sx={{ ...mascotSx, borderRadius: 'sm' }}
|
||||
/>;
|
||||
|
||||
// icon: text-to-image
|
||||
if (isTextToImage)
|
||||
return <FormatPaintOutlinedIcon sx={{
|
||||
...avatarIconSx,
|
||||
animation: `${animationColorRainbow} 1s linear 2.66`,
|
||||
}} />;
|
||||
|
||||
// purpose symbol (if present)
|
||||
const symbol = SystemPurposes[messagePurposeId as SystemPurposeId]?.symbol;
|
||||
if (symbol)
|
||||
return <Box sx={{
|
||||
fontSize: '24px',
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
minWidth: `${avatarIconSx.width}px`,
|
||||
lineHeight: `${avatarIconSx.height}px`,
|
||||
}}>
|
||||
{symbol}
|
||||
</Box>;
|
||||
|
||||
// default assistant avatar
|
||||
return <SmartToyOutlinedIcon sx={avatarIconSx} />; // https://mui.com/static/images/avatar/2.jpg
|
||||
}
|
||||
return <Avatar alt={nameByRole} />;
|
||||
}
|
||||
|
||||
|
||||
export function messageBackground(messageRole: DMessageRole | string, wasEdited: boolean, isAssistantIssue: boolean): string {
|
||||
switch (messageRole) {
|
||||
case 'user':
|
||||
return 'primary.plainHoverBg'; // was .background.level1
|
||||
case 'assistant':
|
||||
return isAssistantIssue ? 'danger.softBg' : 'background.surface';
|
||||
case 'system':
|
||||
return wasEdited ? 'warning.softHoverBg' : 'neutral.softBg';
|
||||
default:
|
||||
return '#ff0000';
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import * as React from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { agiUuid } from '~/common/util/idUtils';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
|
||||
|
||||
// change this to increase/decrease the number history steps per pane
|
||||
@@ -54,7 +55,7 @@ interface AppChatPanesStore extends AppChatPanesState {
|
||||
|
||||
function createPane(conversationId: DConversationId | null = null): ChatPane {
|
||||
return {
|
||||
paneId: uuidv4(),
|
||||
paneId: agiUuid('chat-pane'),
|
||||
conversationId,
|
||||
history: conversationId ? [conversationId] : [],
|
||||
historyIndex: conversationId ? 0 : -1,
|
||||
@@ -63,7 +64,7 @@ function createPane(conversationId: DConversationId | null = null): ChatPane {
|
||||
|
||||
function duplicatePane(pane: ChatPane): ChatPane {
|
||||
return {
|
||||
paneId: uuidv4(),
|
||||
paneId: agiUuid('chat-pane'),
|
||||
conversationId: pane.conversationId,
|
||||
history: [...pane.history],
|
||||
historyIndex: pane.historyIndex,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Alert, Avatar, Box, Button, Card, CardContent, Checkbox, IconButton, Input, List, ListItem, ListItemButton, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
@@ -16,10 +15,12 @@ import { SystemPurposeData, SystemPurposeId, SystemPurposes } from '../../../../
|
||||
import { bareBonesPromptMixer } from '~/modules/persona/pmix/pmix';
|
||||
import { useChatLLM } from '~/modules/llms/store-llms';
|
||||
|
||||
import { DConversationId, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
import { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { ExpanderControlledBox } from '~/common/components/ExpanderControlledBox';
|
||||
import { createDMessageTextContent } from '~/common/stores/chat/chat.message';
|
||||
import { lineHeightTextareaMd } from '~/common/app.theme';
|
||||
import { navigateToPersonas } from '~/common/app.routes';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
import { useChipBoolean } from '~/common/components/useChipBoolean';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
@@ -158,7 +159,6 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
|
||||
// Handlers
|
||||
|
||||
// Modify the handlePurposeChanged function to check for the YouTube Transcriber
|
||||
const handlePurposeChanged = React.useCallback((purposeId: SystemPurposeId | null) => {
|
||||
if (purposeId) {
|
||||
if (purposeId === 'YouTubeTranscriber') {
|
||||
@@ -179,25 +179,14 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
}, [systemPurposeId]);
|
||||
|
||||
|
||||
// Implement handleAddMessage function
|
||||
const handleAddMessage = (messageText: string) => {
|
||||
const handleAppendTranscriptAsMessage = (messageText: string) => {
|
||||
// Retrieve the appendMessage action from the useChatStore
|
||||
const { appendMessage } = useChatStore.getState();
|
||||
|
||||
const conversationId = props.conversationId;
|
||||
|
||||
// Create a new message object
|
||||
const newMessage: DMessage = {
|
||||
id: uuidv4(),
|
||||
text: messageText,
|
||||
sender: 'Bot',
|
||||
avatar: null,
|
||||
typing: false,
|
||||
role: 'assistant' as 'assistant',
|
||||
tokenCount: 0,
|
||||
created: Date.now(),
|
||||
updated: null,
|
||||
};
|
||||
const newMessage = createDMessageTextContent('assistant', messageText); // [chat] append assistant:YouTube transcript
|
||||
|
||||
// Append the new message to the conversation
|
||||
appendMessage(conversationId, newMessage);
|
||||
@@ -467,7 +456,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
{/* [row -1] YouTube URL */}
|
||||
{isYouTubeTranscriberActive && (
|
||||
<YouTubeURLInput
|
||||
onSubmit={(url) => handleAddMessage(url)}
|
||||
onSubmit={(transcript) => handleAppendTranscriptAsMessage(transcript)}
|
||||
isFetching={false}
|
||||
sx={{
|
||||
gridColumn: '1 / -1',
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { getChatLLMId } from '~/modules/llms/store-llms';
|
||||
import { inlineUpdateHistoryForReplyTo } from '~/modules/aifn/replyto/replyTo';
|
||||
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import type { DMessage } from '~/common/stores/chat/chat.message';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { createTextContentFragment, isContentFragment, isTextPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { getConversationSystemPurposeId } from '~/common/stores/chat/store-chats';
|
||||
import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs';
|
||||
|
||||
import type { ChatExecuteMode } from '../execute-mode/execute-mode.types';
|
||||
import { getInstantAppChatPanesCount } from '../components/panes/usePanesManager';
|
||||
import { textToDrawCommand } from '../commands/CommandsDraw';
|
||||
|
||||
import { _handleExecuteCommand, RET_NO_CMD } from './_handleExecuteCommand';
|
||||
import { runAssistantUpdatingStateV1 } from './chat-stream-v1';
|
||||
import { runImageGenerationUpdatingState } from './image-generate';
|
||||
import { runPersonaOnConversationHead } from './chat-persona';
|
||||
import { runReActUpdatingState } from './react-tangent';
|
||||
|
||||
|
||||
export async function _handleExecute(chatExecuteMode: ChatExecuteMode, conversationId: DConversationId, executeCallerNameDebug: string) {
|
||||
|
||||
// Handle missing conversation
|
||||
if (!conversationId)
|
||||
return 'err-no-conversation';
|
||||
|
||||
const chatLLMId = getChatLLMId();
|
||||
const cHandler = ConversationsManager.getHandler(conversationId);
|
||||
const initialHistory = cHandler.historyViewHead(executeCallerNameDebug) as Readonly<DMessage[]>;
|
||||
|
||||
// Update the system message from the active persona to the history
|
||||
// NOTE: this does NOT call setMessages anymore (optimization). make sure to:
|
||||
// 1. all the callers need to pass a new array
|
||||
// 2. all the exit points need to call setMessages
|
||||
const _inplaceEditableHistory = [...initialHistory];
|
||||
cHandler.inlineUpdatePurposeInHistory(_inplaceEditableHistory, chatLLMId || undefined);
|
||||
|
||||
// FIXME: shouldn't do this for all the code paths. The advantage for having it here (vs Composer output only) is re-executing history
|
||||
// TODO: move this to the server side after transferring metadata?
|
||||
inlineUpdateHistoryForReplyTo(_inplaceEditableHistory);
|
||||
|
||||
// Set the history - note that 'history' objects become invalid after this, and you'd have to
|
||||
// re-read it from the store, such as with `cHandler.historyView()`
|
||||
cHandler.historyReplace(_inplaceEditableHistory);
|
||||
|
||||
|
||||
// Handle unconfigured
|
||||
if (!chatLLMId || !chatExecuteMode)
|
||||
return !chatLLMId ? 'err-no-chatllm' : 'err-no-chatmode';
|
||||
|
||||
// handle missing last user message (or fragment)
|
||||
// note that we use the initial history, as the user message could have been displaced on the edited versions
|
||||
const lastMessage = initialHistory.length >= 1 ? initialHistory.slice(-1)[0] : null;
|
||||
const firstFragment = lastMessage?.fragments[0];
|
||||
if (!lastMessage || !firstFragment)
|
||||
return 'err-no-last-message';
|
||||
|
||||
|
||||
// execute a command, if the last message has one
|
||||
if (lastMessage.role === 'user') {
|
||||
const cmdRC = await _handleExecuteCommand(lastMessage.id, firstFragment, cHandler, chatLLMId);
|
||||
if (cmdRC !== RET_NO_CMD) return cmdRC;
|
||||
}
|
||||
|
||||
// get the system purpose (note: we don't react to it, or it would invalidate half UI components..)
|
||||
// TODO: change this massively
|
||||
if (!getConversationSystemPurposeId(conversationId)) {
|
||||
cHandler.messageAppendAssistantText('Issue: no Persona selected.', 'issue');
|
||||
return 'err-no-persona';
|
||||
}
|
||||
|
||||
// synchronous long-duration tasks, which update the state as they go
|
||||
switch (chatExecuteMode) {
|
||||
case 'generate-content':
|
||||
return await runPersonaOnConversationHead(chatLLMId, conversationId);
|
||||
|
||||
case 'generate-text-v1':
|
||||
return await runAssistantUpdatingStateV1(conversationId, cHandler.historyViewHead('generate-text-v1'), chatLLMId, getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount());
|
||||
|
||||
case 'beam-content':
|
||||
cHandler.beamInvoke(cHandler.historyViewHead('beam-content'), [], null);
|
||||
return true;
|
||||
|
||||
case 'append-user':
|
||||
return true;
|
||||
|
||||
case 'generate-image':
|
||||
// verify we were called with a single DMessageTextContent
|
||||
if (!isContentFragment(firstFragment) || !isTextPart(firstFragment.part))
|
||||
return false;
|
||||
const imagePrompt = firstFragment.part.text;
|
||||
cHandler.messageFragmentReplace(lastMessage.id, firstFragment.fId, createTextContentFragment(textToDrawCommand(imagePrompt)), true);
|
||||
return await runImageGenerationUpdatingState(cHandler, imagePrompt);
|
||||
|
||||
case 'react-content':
|
||||
// verify we were called with a single DMessageTextContent
|
||||
if (!isContentFragment(firstFragment) || !isTextPart(firstFragment.part))
|
||||
return false;
|
||||
const reactPrompt = firstFragment.part.text;
|
||||
cHandler.messageFragmentReplace(lastMessage.id, firstFragment.fId, createTextContentFragment(textToDrawCommand(reactPrompt)), true);
|
||||
return await runReActUpdatingState(cHandler, reactPrompt, chatLLMId);
|
||||
|
||||
default:
|
||||
console.log('Chat execute: issue running', chatExecuteMode, conversationId, lastMessage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { getChatLLMId } from '~/modules/llms/store-llms';
|
||||
import { updateHistoryForReplyTo } from '~/modules/aifn/replyto/replyTo';
|
||||
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { createDMessage, DConversationId, DMessage, getConversationSystemPurposeId } from '~/common/state/store-chats';
|
||||
import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { extractChatCommand, findAllChatCommands } from '../commands/commands.registry';
|
||||
import { getInstantAppChatPanesCount } from '../components/panes/usePanesManager';
|
||||
|
||||
import { runAssistantUpdatingState } from './chat-stream';
|
||||
import { runBrowseGetPageUpdatingState } from './browse-load';
|
||||
import { runImageGenerationUpdatingState } from './image-generate';
|
||||
import { runReActUpdatingState } from './react-tangent';
|
||||
|
||||
import type { ChatModeId } from '../AppChat';
|
||||
|
||||
|
||||
export async function _handleExecute(chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) {
|
||||
|
||||
// Handle missing conversation
|
||||
if (!conversationId)
|
||||
return 'err-no-conversation';
|
||||
|
||||
const chatLLMId = getChatLLMId();
|
||||
|
||||
// Update the system message from the active persona to the history
|
||||
// NOTE: this does NOT call setMessages anymore (optimization). make sure to:
|
||||
// 1. all the callers need to pass a new array
|
||||
// 2. all the exit points need to call setMessages
|
||||
const cHandler = ConversationsManager.getHandler(conversationId);
|
||||
cHandler.inlineUpdatePurposeInHistory(history, chatLLMId || undefined);
|
||||
|
||||
// FIXME: shouldn't do this for all the code paths. The advantage for having it here (vs Composer output only) is re-executing history
|
||||
// TODO: move this to the server side after transferring metadata?
|
||||
updateHistoryForReplyTo(history);
|
||||
|
||||
// Handle unconfigured
|
||||
if (!chatLLMId || !chatModeId) {
|
||||
// set the history (e.g. the updated system prompt and the user prompt) at least, see #523
|
||||
cHandler.messagesReplace(history);
|
||||
return !chatLLMId ? 'err-no-chatllm' : 'err-no-chatmode';
|
||||
}
|
||||
|
||||
// Valid /commands are intercepted here, and override chat modes, generally for mechanics or sidebars
|
||||
const lastMessage = history.length > 0 ? history[history.length - 1] : null;
|
||||
if (lastMessage?.role === 'user') {
|
||||
const chatCommand = extractChatCommand(lastMessage.text)[0];
|
||||
if (chatCommand && chatCommand.type === 'cmd') {
|
||||
switch (chatCommand.providerId) {
|
||||
case 'ass-browse':
|
||||
cHandler.messagesReplace(history); // show command
|
||||
return await runBrowseGetPageUpdatingState(cHandler, chatCommand.params);
|
||||
|
||||
case 'ass-t2i':
|
||||
cHandler.messagesReplace(history); // show command
|
||||
return await runImageGenerationUpdatingState(cHandler, chatCommand.params);
|
||||
|
||||
case 'ass-react':
|
||||
cHandler.messagesReplace(history); // show command
|
||||
return await runReActUpdatingState(cHandler, chatCommand.params, chatLLMId);
|
||||
|
||||
case 'chat-alter':
|
||||
// /clear
|
||||
if (chatCommand.command === '/clear') {
|
||||
if (chatCommand.params === 'all') {
|
||||
cHandler.messagesReplace([]);
|
||||
} else {
|
||||
cHandler.messagesReplace(history);
|
||||
cHandler.messageAppendAssistant('Issue: this command requires the \'all\' parameter to confirm the operation.', undefined, 'issue', false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// /assistant, /system
|
||||
Object.assign(lastMessage, {
|
||||
role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user',
|
||||
sender: 'Bot',
|
||||
text: chatCommand.params || '',
|
||||
} satisfies Partial<DMessage>);
|
||||
cHandler.messagesReplace(history);
|
||||
return true;
|
||||
|
||||
case 'cmd-help':
|
||||
const chatCommandsText = findAllChatCommands()
|
||||
.map(cmd => ` - ${cmd.primary}` + (cmd.alternatives?.length ? ` (${cmd.alternatives.join(', ')})` : '') + `: ${cmd.description}`)
|
||||
.join('\n');
|
||||
cHandler.messagesReplace(history);
|
||||
cHandler.messageAppendAssistant('Available Chat Commands:\n' + chatCommandsText, undefined, 'help', false);
|
||||
return true;
|
||||
|
||||
case 'mode-beam':
|
||||
if (chatCommand.isError) {
|
||||
cHandler.messagesReplace(history);
|
||||
return false;
|
||||
}
|
||||
// remove '/beam ', as we want to be a user chat message
|
||||
Object.assign(lastMessage, { text: chatCommand.params || '' });
|
||||
cHandler.messagesReplace(history);
|
||||
ConversationsManager.getHandler(conversationId).beamInvoke(history, [], null);
|
||||
return true;
|
||||
|
||||
default:
|
||||
cHandler.messagesReplace([...history, createDMessage('assistant', 'This command is not supported.')]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// get the system purpose (note: we don't react to it, or it would invalidate half UI components..)
|
||||
if (!getConversationSystemPurposeId(conversationId)) {
|
||||
cHandler.messagesReplace(history);
|
||||
cHandler.messageAppendAssistant('Issue: no Persona selected.', undefined, 'issue', false);
|
||||
return 'err-no-persona';
|
||||
}
|
||||
|
||||
// synchronous long-duration tasks, which update the state as they go
|
||||
switch (chatModeId) {
|
||||
case 'generate-text':
|
||||
cHandler.messagesReplace(history);
|
||||
return await runAssistantUpdatingState(conversationId, history, chatLLMId, getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount());
|
||||
|
||||
case 'generate-text-beam':
|
||||
cHandler.messagesReplace(history);
|
||||
cHandler.beamInvoke(history, [], null);
|
||||
return true;
|
||||
|
||||
case 'append-user':
|
||||
cHandler.messagesReplace(history);
|
||||
return true;
|
||||
|
||||
case 'generate-image':
|
||||
if (!lastMessage?.text) break;
|
||||
// also add a 'fake' user message with the '/draw' command
|
||||
cHandler.messagesReplace(history.map(message => (message.id !== lastMessage.id) ? message : {
|
||||
...message,
|
||||
text: `/draw ${lastMessage.text}`,
|
||||
}));
|
||||
return await runImageGenerationUpdatingState(cHandler, lastMessage.text);
|
||||
|
||||
case 'generate-react':
|
||||
if (!lastMessage?.text) break;
|
||||
cHandler.messagesReplace(history);
|
||||
return await runReActUpdatingState(cHandler, lastMessage.text, chatLLMId);
|
||||
}
|
||||
|
||||
// ISSUE: if we're here, it means we couldn't do the job, at least sync the history
|
||||
console.log('Chat execute: issue running', chatModeId, conversationId, lastMessage);
|
||||
cHandler.messagesReplace(history);
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { DLLMId } from '~/modules/llms/store-llms';
|
||||
|
||||
import type { DMessageId } from '~/common/stores/chat/chat.message';
|
||||
import { ConversationHandler } from '~/common/chats/ConversationHandler';
|
||||
import { createTextContentFragment, DMessageFragment, isContentFragment, isTextPart } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
import { extractChatCommand, helpPrettyChatCommands } from '../commands/commands.registry';
|
||||
import { runBrowseGetPageUpdatingState } from './browse-load';
|
||||
import { runImageGenerationUpdatingState } from './image-generate';
|
||||
import { runReActUpdatingState } from './react-tangent';
|
||||
|
||||
|
||||
export const RET_NO_CMD = 'no-cmd';
|
||||
|
||||
|
||||
export async function _handleExecuteCommand(lastMessageId: DMessageId, lastMessageFirstFragment: DMessageFragment, cHandler: ConversationHandler, chatLLMId: DLLMId) {
|
||||
|
||||
// commands must have a first Content DMessageTextPart
|
||||
if (!isContentFragment(lastMessageFirstFragment) || !isTextPart(lastMessageFirstFragment.part))
|
||||
return RET_NO_CMD;
|
||||
|
||||
// check if we have a command
|
||||
const chatCommand = extractChatCommand(lastMessageFirstFragment.part.text)[0];
|
||||
if (chatCommand?.type !== 'cmd')
|
||||
return RET_NO_CMD;
|
||||
|
||||
// Valid /commands are intercepted here, and override chat modes, generally for mechanics or sidebars
|
||||
switch (chatCommand.providerId) {
|
||||
|
||||
case 'cmd-ass-browse':
|
||||
return await runBrowseGetPageUpdatingState(cHandler, chatCommand.params);
|
||||
|
||||
case 'cmd-ass-t2i':
|
||||
return await runImageGenerationUpdatingState(cHandler, chatCommand.params);
|
||||
|
||||
case 'cmd-chat-alter':
|
||||
// clear command
|
||||
if (chatCommand.command === '/clear') {
|
||||
if (chatCommand.params === 'all')
|
||||
cHandler.historyClear();
|
||||
else
|
||||
cHandler.messageAppendAssistantText('Issue: this command requires the \'all\' parameter to confirm the operation.', 'issue');
|
||||
return true;
|
||||
}
|
||||
// assistant/system command: change role and remove the /command
|
||||
cHandler.messageEdit(lastMessageId, { role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user' }, false, false);
|
||||
cHandler.messageFragmentReplace(lastMessageId, lastMessageFirstFragment.fId, createTextContentFragment(chatCommand.params || ''), true);
|
||||
return true;
|
||||
|
||||
case 'cmd-help':
|
||||
cHandler.messageAppendAssistantText(`Available Chat Commands:\n${helpPrettyChatCommands()}`, 'help');
|
||||
return true;
|
||||
|
||||
case 'cmd-mode-beam':
|
||||
if (chatCommand.isErrorNoArgs || !chatCommand.params)
|
||||
return false;
|
||||
// remove '/beam ', as we want to be a user chat message
|
||||
cHandler.messageFragmentReplace(lastMessageId, lastMessageFirstFragment.fId, createTextContentFragment(chatCommand.params), true);
|
||||
cHandler.beamInvoke(cHandler.historyViewHead('cmd-mode-beam'), [], null);
|
||||
return true;
|
||||
|
||||
case 'cmd-mode-react':
|
||||
return await runReActUpdatingState(cHandler, chatCommand.params, chatLLMId);
|
||||
|
||||
default:
|
||||
cHandler.messageAppendAssistantText('This command is not supported', 'help');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,35 @@
|
||||
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
|
||||
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
|
||||
import { createErrorContentFragment, createTextContentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
|
||||
export const runBrowseGetPageUpdatingState = async (cHandler: ConversationHandler, url?: string) => {
|
||||
if (!url) {
|
||||
cHandler.messageAppendAssistant('Issue: no URL provided.', undefined, 'issue', false);
|
||||
cHandler.messageAppendAssistantText('Issue: no URL provided.', 'issue');
|
||||
return false;
|
||||
}
|
||||
|
||||
// noinspection HttpUrlsUsage
|
||||
const shortUrl = url.replace('https://www.', '').replace('https://', '').replace('http://', '').replace('www.', '');
|
||||
const assistantMessageId = cHandler.messageAppendAssistant(`Loading page at ${shortUrl}...`, undefined, 'web', true);
|
||||
const { assistantMessageId, placeholderFragmentId } = cHandler.messageAppendAssistantPlaceholder(
|
||||
`Loading page at ${shortUrl}...`,
|
||||
{ originLLM: 'web' },
|
||||
);
|
||||
|
||||
try {
|
||||
const page = await callBrowseFetchPage(url);
|
||||
const pageContent = page.content.markdown || page.content.text || page.content.html || 'Issue: page load did not produce an answer: no text found';
|
||||
cHandler.messageEdit(assistantMessageId, { text: pageContent, typing: false }, true);
|
||||
|
||||
const pageContent = page.content.markdown || page.content.text || page.content.html || 'Issue: Browsing did not produce a page content.';
|
||||
cHandler.messageFragmentReplace(assistantMessageId, placeholderFragmentId, createTextContentFragment(pageContent), true);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
cHandler.messageEdit(assistantMessageId, { text: 'Issue: browse did not produce an answer (error: ' + (error?.message || error?.toString() || 'unknown') + ').', typing: false }, true);
|
||||
|
||||
const pageError = 'Issue: Browsing did not produce a page.\n(error: ' + (error?.message || error?.toString() || 'unknown') + ').';
|
||||
cHandler.messageFragmentReplace(assistantMessageId, placeholderFragmentId, createErrorContentFragment(pageError), true);
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { DLLMId } from '~/modules/llms/store-llms';
|
||||
import type { VChatContextRef, VChatMessageIn, VChatStreamContextName } from '~/modules/llms/llm.client';
|
||||
import { aixStreamingChatGenerate, StreamingClientUpdate } from '~/modules/aix/client/aix.client';
|
||||
import { autoConversationTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
import { autoSuggestions } from '~/modules/aifn/autosuggestions/autoSuggestions';
|
||||
import { PersonaChatMessageSpeak } from './persona/PersonaChatMessageSpeak';
|
||||
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { DMessage, messageFragmentsReplaceLastContentText, messageSingleTextOrThrow } from '~/common/stores/chat/chat.message';
|
||||
import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs';
|
||||
import { isContentFragment, isTextPart } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
import { getChatAutoAI } from '../store-app-chat';
|
||||
import { getInstantAppChatPanesCount } from '../components/panes/usePanesManager';
|
||||
|
||||
|
||||
/**
|
||||
* The main "chat" function.
|
||||
*/
|
||||
export async function runPersonaOnConversationHead(
|
||||
assistantLlmId: DLLMId,
|
||||
conversationId: DConversationId,
|
||||
): Promise<boolean> {
|
||||
|
||||
const cHandler = ConversationsManager.getHandler(conversationId);
|
||||
|
||||
const history = cHandler.historyViewHead('runPersonaOnConversationHead') as Readonly<DMessage[]>;
|
||||
|
||||
const parallelViewCount = getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount();
|
||||
|
||||
// ai follow-up operations (fire/forget)
|
||||
const { autoSpeak, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
|
||||
|
||||
// assistant placeholder
|
||||
const { assistantMessageId } = cHandler.messageAppendAssistantPlaceholder(
|
||||
'...',
|
||||
{ originLLM: assistantLlmId, purposeId: history[0].purposeId },
|
||||
);
|
||||
|
||||
// AutoSpeak
|
||||
const autoSpeaker = autoSpeak !== 'off' ? new PersonaChatMessageSpeak(autoSpeak) : null;
|
||||
|
||||
// when an abort controller is set, the UI switches to the "stop" mode
|
||||
const abortController = new AbortController();
|
||||
cHandler.setAbortController(abortController);
|
||||
|
||||
// stream the assistant's messages directly to the state store
|
||||
let instructions: VChatMessageIn[];
|
||||
try {
|
||||
instructions = history.map((m): VChatMessageIn => ({ role: m.role, content: messageSingleTextOrThrow(m) /* BIG FIXME */ }));
|
||||
} catch (error) {
|
||||
console.error('runAssistantUpdatingState: error:', error, history);
|
||||
throw error;
|
||||
}
|
||||
const messageStatus = await llmGenerateContentStream(
|
||||
assistantLlmId,
|
||||
instructions,
|
||||
'conversation',
|
||||
conversationId,
|
||||
parallelViewCount,
|
||||
abortController.signal,
|
||||
(accumulatedMessage: Partial<StreamMessageUpdate>, messageComplete: boolean) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
|
||||
// typing sound
|
||||
// if (messageComplete)
|
||||
// AudioGenerator.basicAstralChimes({ volume: 0.4 }, 0, 2, 250);
|
||||
|
||||
cHandler.messageEdit(assistantMessageId, accumulatedMessage, messageComplete, false);
|
||||
|
||||
if (autoSpeaker && accumulatedMessage.fragments?.length && isContentFragment(accumulatedMessage.fragments[0]) && isTextPart(accumulatedMessage.fragments[0].part)) {
|
||||
if (messageComplete)
|
||||
autoSpeaker.finalizeText(accumulatedMessage.fragments[0].part.text);
|
||||
else
|
||||
autoSpeaker.handleTextSoFar(accumulatedMessage.fragments[0].part.text);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// check if aborted
|
||||
const hasBeenAborted = abortController.signal.aborted;
|
||||
|
||||
// clear to send, again
|
||||
// FIXME: race condition? (for sure!)
|
||||
cHandler.setAbortController(null);
|
||||
|
||||
if (autoTitleChat) {
|
||||
// fire/forget, this will only set the title if it's not already set
|
||||
void autoConversationTitle(conversationId, false);
|
||||
}
|
||||
|
||||
if (!hasBeenAborted && (autoSuggestDiagrams || autoSuggestHTMLUI || autoSuggestQuestions))
|
||||
autoSuggestions(null, conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions);
|
||||
|
||||
return messageStatus.outcome === 'success';
|
||||
}
|
||||
|
||||
|
||||
type StreamMessageOutcome = 'success' | 'aborted' | 'errored';
|
||||
type StreamMessageStatus = { outcome: StreamMessageOutcome, errorMessage?: string };
|
||||
type StreamMessageUpdate = Pick<DMessage, 'fragments' | 'originLLM' | 'pendingIncomplete'>;
|
||||
|
||||
export async function llmGenerateContentStream(
|
||||
llmId: DLLMId,
|
||||
messagesHistory: VChatMessageIn[],
|
||||
contextName: VChatStreamContextName,
|
||||
contextRef: VChatContextRef,
|
||||
parallelViewCount: number, // 0: disable, 1: default throttle (12Hz), 2+ reduce frequency with the square root
|
||||
abortSignal: AbortSignal,
|
||||
onMessageUpdated: (incrementalMessage: Partial<StreamMessageUpdate>, messageComplete: boolean) => void,
|
||||
): Promise<StreamMessageStatus> {
|
||||
|
||||
const returnStatus: StreamMessageStatus = { outcome: 'success', errorMessage: undefined };
|
||||
|
||||
const throttler = new ThrottleFunctionCall(parallelViewCount);
|
||||
|
||||
// TODO: should clean this up once we have multi-fragment streaming/recombination
|
||||
const incrementalAnswer: StreamMessageUpdate = {
|
||||
fragments: [],
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
await aixStreamingChatGenerate(llmId, messagesHistory, contextName, contextRef, null, null, abortSignal,
|
||||
(update: StreamingClientUpdate, done: boolean) => {
|
||||
|
||||
// grow the incremental message
|
||||
if (update.textSoFar) incrementalAnswer.fragments = messageFragmentsReplaceLastContentText(incrementalAnswer.fragments, update.textSoFar);
|
||||
if (update.originLLM) incrementalAnswer.originLLM = update.originLLM;
|
||||
if (update.typing !== undefined)
|
||||
incrementalAnswer.pendingIncomplete = update.typing ? true : undefined;
|
||||
|
||||
// throttle the update
|
||||
throttler.handleUpdate(() => {
|
||||
onMessageUpdated(incrementalAnswer, false);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
console.error('Fetch request error:', error);
|
||||
const errorText = ` [Issue: ${error.message || (typeof error === 'string' ? error : 'Chat stopped.')}]`;
|
||||
incrementalAnswer.fragments = messageFragmentsReplaceLastContentText(incrementalAnswer.fragments, errorText, true);
|
||||
returnStatus.outcome = 'errored';
|
||||
returnStatus.errorMessage = error.message;
|
||||
} else
|
||||
returnStatus.outcome = 'aborted';
|
||||
}
|
||||
|
||||
// Ensure the last content is flushed out, and mark as complete
|
||||
throttler.finalize(() => {
|
||||
onMessageUpdated({ ...incrementalAnswer, pendingIncomplete: undefined }, true);
|
||||
});
|
||||
|
||||
return returnStatus;
|
||||
}
|
||||
|
||||
|
||||
export class ThrottleFunctionCall {
|
||||
private readonly throttleDelay: number;
|
||||
private lastCallTime: number = 0;
|
||||
|
||||
constructor(throttleUnits: number) {
|
||||
// 12 messages per second works well for 60Hz displays (single chat, and 24 in 4 chats, see the square root below)
|
||||
const baseDelayMs = 1000 / 12;
|
||||
this.throttleDelay = throttleUnits === 0 ? 0
|
||||
: throttleUnits > 1 ? Math.round(baseDelayMs * Math.sqrt(throttleUnits))
|
||||
: baseDelayMs;
|
||||
}
|
||||
|
||||
handleUpdate(fn: () => void): void {
|
||||
const now = Date.now();
|
||||
if (this.throttleDelay === 0 || this.lastCallTime === 0 || now - this.lastCallTime >= this.throttleDelay) {
|
||||
fn();
|
||||
this.lastCallTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
finalize(fn: () => void): void {
|
||||
fn(); // Always execute the final update
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,90 @@
|
||||
import type { DLLMId } from '~/modules/llms/store-llms';
|
||||
import type { StreamingClientUpdate } from '~/modules/llms/vendors/unifiedStreamingClient';
|
||||
import { autoSuggestions } from '~/modules/aifn/autosuggestions/autoSuggestions';
|
||||
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
import { autoConversationTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
import { llmStreamingChatGenerate, VChatContextRef, VChatMessageIn, VChatStreamContextName } from '~/modules/llms/llm.client';
|
||||
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
|
||||
import type { DMessage } from '~/common/state/store-chats';
|
||||
import { ConversationsManager } from '~/common/chats/ConversationsManager';
|
||||
import { DMessage, messageFragmentsReduceText, messageFragmentsReplaceLastContentText, messageSingleTextOrThrow } from '~/common/stores/chat/chat.message';
|
||||
|
||||
import { ChatAutoSpeakType, getChatAutoAI } from '../store-app-chat';
|
||||
|
||||
|
||||
export const STREAM_TEXT_INDICATOR = '...';
|
||||
|
||||
|
||||
/**
|
||||
* 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, parallelViewCount: number) {
|
||||
export async function runAssistantUpdatingStateV1(
|
||||
conversationId: string,
|
||||
history: Readonly<DMessage[]>,
|
||||
assistantLlmId: DLLMId,
|
||||
parallelViewCount: number,
|
||||
) {
|
||||
const cHandler = ConversationsManager.getHandler(conversationId);
|
||||
|
||||
// ai follow-up operations (fire/forget)
|
||||
const { autoSpeak, autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
|
||||
const { autoSpeak, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
|
||||
|
||||
// create a blank and 'typing' message for the assistant
|
||||
const assistantMessageId = cHandler.messageAppendAssistant(STREAM_TEXT_INDICATOR, history[0].purposeId, assistantLlmId, true);
|
||||
// assistant placeholder
|
||||
const { assistantMessageId } = cHandler.messageAppendAssistantPlaceholder(
|
||||
'...',
|
||||
{ originLLM: assistantLlmId, purposeId: history[0].purposeId },
|
||||
);
|
||||
|
||||
// when an abort controller is set, the UI switches to the "stop" mode
|
||||
const abortController = new AbortController();
|
||||
cHandler.setAbortController(abortController);
|
||||
|
||||
// stream the assistant's messages
|
||||
const messageStatus = await streamAssistantMessage(
|
||||
// stream the assistant's messages directly to the state store
|
||||
const overwriteMessageParts = (incrementalMessage: Partial<StreamMessageUpdate>, messageComplete: boolean) => {
|
||||
cHandler.messageEdit(assistantMessageId, incrementalMessage, messageComplete, false);
|
||||
};
|
||||
let instructions: VChatMessageIn[];
|
||||
try {
|
||||
instructions = history.map((m): VChatMessageIn => ({ role: m.role, content: messageSingleTextOrThrow(m) /* BIG FIXME */ }));
|
||||
} catch (error) {
|
||||
console.error('runAssistantUpdatingState: error:', error, history);
|
||||
throw error;
|
||||
}
|
||||
const messageStatus = await streamAssistantMessageV1(
|
||||
assistantLlmId,
|
||||
history.map((m): VChatMessageIn => ({ role: m.role, content: m.text })),
|
||||
instructions,
|
||||
'conversation',
|
||||
conversationId,
|
||||
parallelViewCount,
|
||||
autoSpeak,
|
||||
(update) => cHandler.messageEdit(assistantMessageId, update, false),
|
||||
overwriteMessageParts,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// clear to send, again
|
||||
// FIXME: race condition?
|
||||
// FIXME: race condition? (for sure!)
|
||||
cHandler.setAbortController(null);
|
||||
|
||||
if (autoTitleChat) {
|
||||
// fire/forget, this will only set the title if it's not already set
|
||||
void conversationAutoTitle(conversationId, false);
|
||||
void autoConversationTitle(conversationId, false);
|
||||
}
|
||||
|
||||
if (autoSuggestDiagrams || autoSuggestQuestions)
|
||||
autoSuggestions(conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestQuestions);
|
||||
if (autoSuggestDiagrams || autoSuggestHTMLUI || autoSuggestQuestions)
|
||||
autoSuggestions(null, conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions);
|
||||
|
||||
return messageStatus.outcome === 'success';
|
||||
}
|
||||
|
||||
|
||||
type StreamMessageOutcome = 'success' | 'aborted' | 'errored';
|
||||
type StreamMessageStatus = { outcome: StreamMessageOutcome, errorMessage?: string };
|
||||
type StreamMessageUpdate = Pick<DMessage, 'fragments' | 'originLLM' | 'pendingIncomplete'>;
|
||||
|
||||
export async function streamAssistantMessage(
|
||||
export async function streamAssistantMessageV1(
|
||||
llmId: DLLMId,
|
||||
messagesHistory: VChatMessageIn[],
|
||||
contextName: VChatStreamContextName,
|
||||
contextRef: VChatContextRef,
|
||||
throttleUnits: number, // 0: disable, 1: default throttle (12Hz), 2+ reduce the message frequency with the square root
|
||||
autoSpeak: ChatAutoSpeakType,
|
||||
editMessage: (update: Partial<DMessage>) => void,
|
||||
onMessageUpdated: (incrementalMessage: Partial<StreamMessageUpdate>, messageComplete: boolean) => void,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<StreamMessageStatus> {
|
||||
|
||||
@@ -85,24 +102,28 @@ export async function streamAssistantMessage(
|
||||
if (throttleUnits > 1)
|
||||
throttleDelay = Math.round(throttleDelay * Math.sqrt(throttleUnits));
|
||||
|
||||
function throttledEditMessage(updatedMessage: Partial<DMessage>) {
|
||||
function throttledEditMessage(updatedMessage: Partial<StreamMessageUpdate>) {
|
||||
const now = Date.now();
|
||||
if (throttleUnits === 0 || now - lastCallTime >= throttleDelay) {
|
||||
editMessage(updatedMessage);
|
||||
onMessageUpdated(updatedMessage, false);
|
||||
lastCallTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
const incrementalAnswer: Partial<DMessage> = { text: '' };
|
||||
// TODO: should clean this up once we have multi-fragment streaming/recombination
|
||||
const incrementalAnswer: StreamMessageUpdate = {
|
||||
fragments: [],
|
||||
};
|
||||
|
||||
try {
|
||||
await llmStreamingChatGenerate(llmId, messagesHistory, contextName, contextRef, null, null, abortSignal, (update: StreamingClientUpdate) => {
|
||||
const textSoFar = update.textSoFar;
|
||||
|
||||
// grow the incremental message
|
||||
if (textSoFar) incrementalAnswer.fragments = messageFragmentsReplaceLastContentText(incrementalAnswer.fragments, textSoFar);
|
||||
if (update.originLLM) incrementalAnswer.originLLM = update.originLLM;
|
||||
if (textSoFar) incrementalAnswer.text = textSoFar;
|
||||
if (update.typing !== undefined) incrementalAnswer.typing = update.typing;
|
||||
if (update.typing !== undefined)
|
||||
incrementalAnswer.pendingIncomplete = update.typing ? true : undefined;
|
||||
|
||||
// Update the data store, with optional max-frequency throttling (e.g. OpenAI is downsamped 50 -> 12Hz)
|
||||
// This can be toggled from the settings
|
||||
@@ -125,21 +146,22 @@ export async function streamAssistantMessage(
|
||||
if (error?.name !== 'AbortError') {
|
||||
console.error('Fetch request error:', error);
|
||||
const errorText = ` [Issue: ${error.message || (typeof error === 'string' ? error : 'Chat stopped.')}]`;
|
||||
incrementalAnswer.text = (incrementalAnswer.text || '') + errorText;
|
||||
incrementalAnswer.fragments = messageFragmentsReplaceLastContentText(incrementalAnswer.fragments, errorText, true);
|
||||
returnStatus.outcome = 'errored';
|
||||
returnStatus.errorMessage = error.message;
|
||||
} else
|
||||
returnStatus.outcome = 'aborted';
|
||||
}
|
||||
|
||||
// Optimized:
|
||||
// 1 - stop the typing animation
|
||||
// 2 - ensure the last content is flushed out
|
||||
editMessage({ ...incrementalAnswer, typing: false });
|
||||
// Ensure the last content is flushed out, and mark as complete
|
||||
onMessageUpdated({ ...incrementalAnswer, pendingIncomplete: undefined }, true);
|
||||
|
||||
// 📢 TTS: all
|
||||
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && incrementalAnswer.text && !spokenLine && !abortSignal.aborted)
|
||||
void speakText(incrementalAnswer.text);
|
||||
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && !spokenLine && !abortSignal.aborted) {
|
||||
const incrementalText = messageFragmentsReduceText(incrementalAnswer.fragments);
|
||||
if (incrementalText.length > 0)
|
||||
void speakText(incrementalText);
|
||||
}
|
||||
|
||||
return returnStatus;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { getActiveTextToImageProviderOrThrow, t2iGenerateImageOrThrow } from '~/modules/t2i/t2i.client';
|
||||
import type { DBlobAssetId } from '~/modules/dblobs/dblobs.types';
|
||||
import { gcDBImageAssets } from '~/modules/dblobs/dblobs.images';
|
||||
import { getActiveTextToImageProviderOrThrow, t2iGenerateImageContentFragments } from '~/modules/t2i/t2i.client';
|
||||
|
||||
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
|
||||
import type { TextToImageProvider } from '~/common/components/useCapabilities';
|
||||
import { createErrorContentFragment, isContentOrAttachmentFragment, isImageRefPart } from '~/common/stores/chat/chat.fragments';
|
||||
import { useChatStore } from '~/common/stores/chat/store-chats';
|
||||
|
||||
|
||||
/**
|
||||
@@ -9,7 +13,7 @@ import type { TextToImageProvider } from '~/common/components/useCapabilities';
|
||||
*/
|
||||
export async function runImageGenerationUpdatingState(cHandler: ConversationHandler, imageText?: string) {
|
||||
if (!imageText) {
|
||||
cHandler.messageAppendAssistant('Issue: no image description provided.', undefined, 'issue', false);
|
||||
cHandler.messageAppendAssistantText('Issue: no image description provided.', 'issue');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -18,7 +22,7 @@ export async function runImageGenerationUpdatingState(cHandler: ConversationHand
|
||||
try {
|
||||
t2iProvider = getActiveTextToImageProviderOrThrow();
|
||||
} catch (error: any) {
|
||||
cHandler.messageAppendAssistant(`[Issue] Sorry, I can't generate images right now. ${error?.message || error?.toString() || 'Unknown error'}.`, undefined, 'issue', false);
|
||||
cHandler.messageAppendAssistantText(`[Issue] Sorry, I can't generate images right now. ${error?.message || error?.toString() || 'Unknown error'}.`, 'issue');
|
||||
return 'err-t2i-unconfigured';
|
||||
}
|
||||
|
||||
@@ -28,18 +32,55 @@ export async function runImageGenerationUpdatingState(cHandler: ConversationHand
|
||||
if (repeat > 1)
|
||||
imageText = imageText.replace(/x(\d+)$|\[(\d+)]$/, '').trim(); // Remove the "xN" or "[N]" part from the imageText
|
||||
|
||||
const assistantMessageId = cHandler.messageAppendAssistant(
|
||||
`Give me ${t2iProvider.vendor === 'openai' ? 'a dozen' : 'a few'} seconds while I draw ${imageText?.length > 20 ? 'that' : '"' + imageText + '"'}...`,
|
||||
undefined, t2iProvider.painter, true,
|
||||
const { assistantMessageId, placeholderFragmentId } = cHandler.messageAppendAssistantPlaceholder(
|
||||
`Give me ${t2iProvider.vendor === 'openai' ? 'a minute' : 'a few seconds'} while I draw ${imageText?.length > 20 ? 'that' : '"' + imageText + '"'} with ${t2iProvider.painter}...`,
|
||||
{ originLLM: t2iProvider.painter },
|
||||
);
|
||||
|
||||
try {
|
||||
const imageUrls = await t2iGenerateImageOrThrow(t2iProvider, imageText, repeat);
|
||||
cHandler.messageEdit(assistantMessageId, { text: imageUrls.join('\n'), typing: false }, true);
|
||||
const imageContentFragments = await t2iGenerateImageContentFragments(t2iProvider, imageText, repeat, 'global', 'app-chat');
|
||||
|
||||
// add the image content fragments to the message
|
||||
for (const imageContentFragment of imageContentFragments)
|
||||
cHandler.messageFragmentAppend(assistantMessageId, imageContentFragment, false, false);
|
||||
|
||||
cHandler.messageFragmentDelete(assistantMessageId, placeholderFragmentId, true, true);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
const errorMessage = error?.message || error?.toString() || 'Unknown error';
|
||||
cHandler.messageEdit(assistantMessageId, { text: `[Issue] Sorry, I couldn't create an image for you. ${errorMessage}`, typing: false }, false);
|
||||
|
||||
|
||||
const drawError = `Issue: Sorry, I couldn't create an image for you.\n${error?.message || error?.toString() || 'Unknown error'}`;
|
||||
cHandler.messageFragmentReplace(assistantMessageId, placeholderFragmentId, createErrorContentFragment(drawError), true);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage collect unreferenced dblobs in global chats
|
||||
*/
|
||||
export async function gcChatImageAssets() {
|
||||
|
||||
// find all the dblob references in all chats
|
||||
const chatsAssetIDs: Set<DBlobAssetId> = new Set();
|
||||
const chatStore = useChatStore.getState();
|
||||
for (const chat of chatStore.conversations) {
|
||||
for (const message of chat.messages) {
|
||||
for (const fragment of message.fragments) {
|
||||
if (!isContentOrAttachmentFragment(fragment) || !isImageRefPart(fragment.part))
|
||||
continue;
|
||||
if (fragment.part.dataRef.reftype !== 'dblob')
|
||||
continue;
|
||||
chatsAssetIDs.add(fragment.part.dataRef.dblobAssetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sanity check: if no blobs are referenced, do nothing; in case we have a state bug and we don't wipe the db
|
||||
if (!chatsAssetIDs.size)
|
||||
return;
|
||||
|
||||
// perform the GC (set to array)
|
||||
await gcDBImageAssets('global', 'app-chat', Array.from(chatsAssetIDs));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
|
||||
|
||||
export type AutoSpeakType = 'off' | 'firstLine' | 'all';
|
||||
|
||||
|
||||
export class PersonaChatMessageSpeak {
|
||||
private spokenLine: boolean = false;
|
||||
|
||||
constructor(private autoSpeakType: AutoSpeakType) {
|
||||
}
|
||||
|
||||
|
||||
handleTextSoFar(textSoFar: string): void {
|
||||
if (this.spokenLine || this.autoSpeakType === 'off') return;
|
||||
|
||||
// 📢 TTS: first-line
|
||||
if (this.autoSpeakType === 'firstLine') {
|
||||
const cutPoint = this.findLastCutPoint(textSoFar);
|
||||
if (cutPoint > 100 && cutPoint < 400) {
|
||||
this.spokenLine = true;
|
||||
const firstParagraph = textSoFar.substring(0, cutPoint);
|
||||
this.speak(firstParagraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalizeText(fullText: string): void {
|
||||
if (!this.spokenLine && this.autoSpeakType !== 'off' && fullText.length > 0) {
|
||||
this.speak(fullText);
|
||||
}
|
||||
}
|
||||
|
||||
private findLastCutPoint(text: string): number {
|
||||
let cutPoint = text.lastIndexOf('\n');
|
||||
if (cutPoint < 0)
|
||||
cutPoint = text.lastIndexOf('. ');
|
||||
return cutPoint;
|
||||
}
|
||||
|
||||
private speak(text: string) {
|
||||
console.log('📢 TTS:', text);
|
||||
// fire/forget: we don't want to stall this loop
|
||||
void speakText(text);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import { DLLMId } from '~/modules/llms/store-llms';
|
||||
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
|
||||
import { createErrorContentFragment, createTextContentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
import { STREAM_TEXT_INDICATOR } from './chat-stream';
|
||||
|
||||
// configuration
|
||||
const EPHEMERAL_DELETION_DELAY = 5 * 1000;
|
||||
|
||||
|
||||
@@ -14,13 +14,16 @@ const EPHEMERAL_DELETION_DELAY = 5 * 1000;
|
||||
*/
|
||||
export async function runReActUpdatingState(cHandler: ConversationHandler, question: string | undefined, assistantLlmId: DLLMId) {
|
||||
if (!question) {
|
||||
cHandler.messageAppendAssistant('Issue: no question provided.', undefined, 'issue', false);
|
||||
cHandler.messageAppendAssistantText('Issue: no question provided.', 'issue');
|
||||
return false;
|
||||
}
|
||||
|
||||
// create a blank and 'typing' message for the assistant - to be filled when we're done
|
||||
// create an assistant placeholder message - to be filled when we're done
|
||||
const assistantModelLabel = 'react-' + assistantLlmId; //.slice(4, 7); // HACK: this is used to change the Avatar animation
|
||||
const assistantMessageId = cHandler.messageAppendAssistant(STREAM_TEXT_INDICATOR, undefined, assistantModelLabel, true);
|
||||
const { assistantMessageId, placeholderFragmentId } = cHandler.messageAppendAssistantPlaceholder(
|
||||
'...',
|
||||
{ originLLM: assistantModelLabel },
|
||||
);
|
||||
const { enableReactTool: enableBrowse } = useBrowseStore.getState();
|
||||
|
||||
// create an ephemeral space
|
||||
@@ -39,14 +42,19 @@ export async function runReActUpdatingState(cHandler: ConversationHandler, quest
|
||||
const agent = new Agent();
|
||||
const reactResult = await agent.reAct(question, assistantLlmId, 5, enableBrowse, logToEphemeral, showStateInEphemeral);
|
||||
|
||||
cHandler.messageEdit(assistantMessageId, { text: reactResult, typing: false }, false);
|
||||
cHandler.messageFragmentReplace(assistantMessageId, placeholderFragmentId, createTextContentFragment(reactResult), true);
|
||||
|
||||
setTimeout(() => eHandler.delete(), EPHEMERAL_DELETION_DELAY);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
console.error('ReAct error', error);
|
||||
|
||||
logToEphemeral(ephemeralText + `\nIssue: ${error || 'unknown'}`);
|
||||
cHandler.messageEdit(assistantMessageId, { text: 'Issue: ReAct did not produce an answer.', typing: false }, false);
|
||||
|
||||
const reactError = `Issue: ReAct couldn't answer your question. ${error?.message || error?.toString() || 'Unknown error'}`;
|
||||
cHandler.messageFragmentReplace(assistantMessageId, placeholderFragmentId, createErrorContentFragment(reactError), true);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, MenuItem, Radio, Typography } from '@mui/joy';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { KeyStroke, platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import type { ChatExecuteMode } from './execute-mode.types';
|
||||
import { ExecuteModeItems } from './execute-mode.items';
|
||||
|
||||
|
||||
export function ExecuteModeMenu(props: {
|
||||
isMobile: boolean,
|
||||
hasCapabilityT2I: boolean,
|
||||
anchorEl: HTMLAnchorElement | null,
|
||||
onClose: () => void,
|
||||
chatExecuteMode: ChatExecuteMode,
|
||||
onSetChatExecuteMode: (chatExecuteMode: ChatExecuteMode) => void,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
|
||||
return (
|
||||
<CloseableMenu
|
||||
placement='top-end'
|
||||
open anchorEl={props.anchorEl} onClose={props.onClose}
|
||||
sx={{ minWidth: 320 }}
|
||||
>
|
||||
|
||||
{/*<MenuItem color='neutral' selected>*/}
|
||||
{/* Conversation Mode*/}
|
||||
{/*</MenuItem>*/}
|
||||
{/**/}
|
||||
{/*<ListDivider />*/}
|
||||
|
||||
{/* Items */}
|
||||
{Object.entries(ExecuteModeItems)
|
||||
.filter(([_key, data]) => !data.hideOnDesktop || props.isMobile)
|
||||
.map(([key, data]) =>
|
||||
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatExecuteMode(key as ChatExecuteMode)}>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
|
||||
<Radio color={data.highlight ? 'success' : undefined} checked={key === props.chatExecuteMode} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography>{data.label}</Typography>
|
||||
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.hasCapabilityT2I) ? 'Unconfigured' : ''}</Typography>
|
||||
</Box>
|
||||
{(key === props.chatExecuteMode || !!data.shortcut) && (
|
||||
<KeyStroke combo={platformAwareKeystrokes(
|
||||
newLineShortcut(
|
||||
(key === props.chatExecuteMode) ? 'ENTER'
|
||||
: data.shortcut ? data.shortcut
|
||||
: 'ENTER',
|
||||
enterIsNewline,
|
||||
),
|
||||
)} />
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>,
|
||||
)}
|
||||
|
||||
</CloseableMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function newLineShortcut(shortcut: string, enterIsNewLine: boolean) {
|
||||
if (shortcut === 'ENTER')
|
||||
return enterIsNewLine ? 'Shift + Enter' : 'Enter';
|
||||
return shortcut;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { ColorPaletteProp } from '@mui/joy/styles/types';
|
||||
|
||||
import type { ChatExecuteMode } from './execute-mode.types';
|
||||
|
||||
|
||||
interface ModeDescription {
|
||||
// menu data
|
||||
label: string;
|
||||
description: string | React.JSX.Element;
|
||||
canAttach?: boolean;
|
||||
highlight?: boolean;
|
||||
shortcut?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
requiresTTI?: boolean;
|
||||
// button data
|
||||
sendColor: ColorPaletteProp;
|
||||
sendText: string;
|
||||
}
|
||||
|
||||
|
||||
export const ExecuteModeItems: { [key in ChatExecuteMode]: ModeDescription } = {
|
||||
'generate-content': {
|
||||
label: 'Chat',
|
||||
description: 'Persona replies',
|
||||
canAttach: true,
|
||||
sendColor: 'primary',
|
||||
sendText: 'Chat · DEV',
|
||||
},
|
||||
'generate-text-v1': {
|
||||
label: 'Chat (Stable)',
|
||||
description: 'Model replies (stable)',
|
||||
canAttach: true,
|
||||
sendColor: 'primary',
|
||||
sendText: 'Chat · Stable',
|
||||
},
|
||||
'beam-content': {
|
||||
label: 'Beam', // Best of, Auto-Prime, Top Pick, Select Best
|
||||
description: 'Combine multiple models', // Smarter: combine...
|
||||
shortcut: 'Ctrl + Enter',
|
||||
canAttach: true,
|
||||
hideOnDesktop: true,
|
||||
sendColor: 'primary',
|
||||
sendText: 'Beam',
|
||||
},
|
||||
'append-user': {
|
||||
label: 'Write',
|
||||
description: 'Append a message',
|
||||
shortcut: 'Alt + Enter',
|
||||
canAttach: true,
|
||||
sendColor: 'primary',
|
||||
sendText: 'Write',
|
||||
},
|
||||
'generate-image': {
|
||||
label: 'Draw',
|
||||
description: 'AI Image Generation',
|
||||
requiresTTI: true,
|
||||
sendColor: 'warning',
|
||||
sendText: 'Draw',
|
||||
},
|
||||
'react-content': {
|
||||
label: 'Reason + Act', // · α
|
||||
description: 'Answer questions in multiple steps',
|
||||
sendColor: 'success',
|
||||
sendText: 'ReAct',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Mode: how to treat the input from the Composer
|
||||
* Was: ChatModeId
|
||||
*/
|
||||
export type ChatExecuteMode =
|
||||
| 'append-user'
|
||||
| 'beam-content'
|
||||
| 'generate-content'
|
||||
| 'generate-image'
|
||||
| 'generate-text-v1'
|
||||
| 'react-content'
|
||||
;
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { ChatExecuteMode } from './execute-mode.types';
|
||||
import { ExecuteModeMenu } from './ExecuteModeMenu';
|
||||
import { ExecuteModeItems } from './execute-mode.items';
|
||||
|
||||
|
||||
export function chatExecuteModeCanAttach(chatExecuteMode: ChatExecuteMode) {
|
||||
return !!ExecuteModeItems[chatExecuteMode]?.canAttach;
|
||||
}
|
||||
|
||||
|
||||
export function useChatExecuteMode(capabilityHasT2I: boolean, isMobile: boolean) {
|
||||
|
||||
// state
|
||||
const [chatExecuteMode, setChatExecuteMode] = React.useState<ChatExecuteMode>('generate-content');
|
||||
const [chatExecuteModeMenuAnchor, setChatExecuteModeMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
|
||||
|
||||
|
||||
const handleMenuHide = React.useCallback(() => setChatExecuteModeMenuAnchor(null), []);
|
||||
|
||||
const handleMenuShow = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
setChatExecuteModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
}, []);
|
||||
|
||||
const handleChangeMode = React.useCallback((mode: ChatExecuteMode) => {
|
||||
handleMenuHide();
|
||||
setChatExecuteMode(mode);
|
||||
}, [handleMenuHide]);
|
||||
|
||||
|
||||
const chatExecuteMenuComponent = React.useMemo(() => !!chatExecuteModeMenuAnchor && (
|
||||
<ExecuteModeMenu
|
||||
isMobile={isMobile}
|
||||
hasCapabilityT2I={capabilityHasT2I}
|
||||
anchorEl={chatExecuteModeMenuAnchor}
|
||||
onClose={handleMenuHide}
|
||||
chatExecuteMode={chatExecuteMode}
|
||||
onSetChatExecuteMode={handleChangeMode}
|
||||
/>
|
||||
), [capabilityHasT2I, chatExecuteMode, chatExecuteModeMenuAnchor, handleMenuHide, handleChangeMode, isMobile]);
|
||||
|
||||
|
||||
return {
|
||||
chatExecuteMode,
|
||||
chatExecuteMenuComponent,
|
||||
chatExecuteModeSendColor: ExecuteModeItems[chatExecuteMode]?.sendColor || 'primary',
|
||||
chatExecuteModeSendLabel: ExecuteModeItems[chatExecuteMode]?.sendText || 'Send',
|
||||
chatExecuteMenuShown: !!chatExecuteModeMenuAnchor,
|
||||
showChatExecuteMenu: handleMenuShow,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { create } from 'zustand';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
@@ -19,6 +18,9 @@ interface AppChatStore {
|
||||
autoSuggestDiagrams: boolean,
|
||||
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => void;
|
||||
|
||||
autoSuggestHTMLUI: boolean;
|
||||
setAutoSuggestHTMLUI: (autoSuggestHTMLUI: boolean) => void;
|
||||
|
||||
autoSuggestQuestions: boolean,
|
||||
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => void;
|
||||
|
||||
@@ -30,6 +32,9 @@ interface AppChatStore {
|
||||
filterHasStars: boolean;
|
||||
setFilterHasStars: (filterHasStars: boolean) => void;
|
||||
|
||||
filterHasImageAssets: boolean;
|
||||
setFilterHasImageAssets: (filterHasImageAssets: boolean) => void;
|
||||
|
||||
micTimeoutMs: number;
|
||||
setMicTimeoutMs: (micTimeoutMs: number) => void;
|
||||
|
||||
@@ -57,6 +62,9 @@ const useAppChatStore = create<AppChatStore>()(persist(
|
||||
autoSuggestDiagrams: false,
|
||||
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => _set({ autoSuggestDiagrams }),
|
||||
|
||||
autoSuggestHTMLUI: false,
|
||||
setAutoSuggestHTMLUI: (autoSuggestHTMLUI: boolean) => _set({ autoSuggestHTMLUI }),
|
||||
|
||||
autoSuggestQuestions: false,
|
||||
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => _set({ autoSuggestQuestions }),
|
||||
|
||||
@@ -66,6 +74,9 @@ const useAppChatStore = create<AppChatStore>()(persist(
|
||||
filterHasStars: false,
|
||||
setFilterHasStars: (filterHasStars: boolean) => _set({ filterHasStars }),
|
||||
|
||||
filterHasImageAssets: false,
|
||||
setFilterHasImageAssets: (filterHasImageAssets: boolean) => _set({ filterHasImageAssets }),
|
||||
|
||||
micTimeoutMs: 2000,
|
||||
setMicTimeoutMs: (micTimeoutMs: number) => _set({ micTimeoutMs }),
|
||||
|
||||
@@ -102,49 +113,57 @@ const useAppChatStore = create<AppChatStore>()(persist(
|
||||
));
|
||||
|
||||
|
||||
export const useChatAutoAI = () => useAppChatStore(state => ({
|
||||
export const useChatAutoAI = () => useAppChatStore(useShallow(state => ({
|
||||
autoSpeak: state.autoSpeak,
|
||||
autoSuggestDiagrams: state.autoSuggestDiagrams,
|
||||
autoSuggestHTMLUI: state.autoSuggestHTMLUI,
|
||||
autoSuggestQuestions: state.autoSuggestQuestions,
|
||||
autoTitleChat: state.autoTitleChat,
|
||||
setAutoSpeak: state.setAutoSpeak,
|
||||
setAutoSuggestDiagrams: state.setAutoSuggestDiagrams,
|
||||
setAutoSuggestHTMLUI: state.setAutoSuggestHTMLUI,
|
||||
setAutoSuggestQuestions: state.setAutoSuggestQuestions,
|
||||
setAutoTitleChat: state.setAutoTitleChat,
|
||||
}), shallow);
|
||||
})));
|
||||
|
||||
export const getChatAutoAI = (): {
|
||||
autoSpeak: ChatAutoSpeakType,
|
||||
autoSuggestDiagrams: boolean,
|
||||
autoSuggestHTMLUI: boolean,
|
||||
autoSuggestQuestions: boolean,
|
||||
autoTitleChat: boolean,
|
||||
} => useAppChatStore.getState();
|
||||
|
||||
export const useChatAutoSuggestHTMLUI = (): boolean =>
|
||||
useAppChatStore(state => state.autoSuggestHTMLUI);
|
||||
|
||||
export const useChatMicTimeoutMsValue = (): number =>
|
||||
useAppChatStore(state => state.micTimeoutMs);
|
||||
|
||||
export const useChatMicTimeoutMs = (): [number, (micTimeoutMs: number) => void] =>
|
||||
useAppChatStore(state => [state.micTimeoutMs, state.setMicTimeoutMs], shallow);
|
||||
useAppChatStore(useShallow(state => [state.micTimeoutMs, state.setMicTimeoutMs]));
|
||||
|
||||
export const useChatDrawerFilters = () => {
|
||||
const values = useAppChatStore(useShallow(state => ({
|
||||
filterHasStars: state.filterHasStars,
|
||||
filterHasImageAssets: state.filterHasImageAssets,
|
||||
showPersonaIcons: state.showPersonaIcons,
|
||||
showRelativeSize: state.showRelativeSize,
|
||||
})));
|
||||
return {
|
||||
...values,
|
||||
toggleFilterHasStars: () => useAppChatStore.getState().setFilterHasStars(!values.filterHasStars),
|
||||
toggleFilterHasImageAssets: () => useAppChatStore.getState().setFilterHasImageAssets(!values.filterHasImageAssets),
|
||||
toggleShowPersonaIcons: () => useAppChatStore.getState().setShowPersonaIcons(!values.showPersonaIcons),
|
||||
toggleShowRelativeSize: () => useAppChatStore.getState().setShowRelativeSize(!values.showRelativeSize),
|
||||
};
|
||||
};
|
||||
|
||||
export const useChatShowTextDiff = (): [boolean, (showDiff: boolean) => void] =>
|
||||
useAppChatStore(state => [state.showTextDiff, state.setShowTextDiff], shallow);
|
||||
useAppChatStore(useShallow(state => [state.showTextDiff, state.setShowTextDiff]));
|
||||
|
||||
export const getChatShowSystemMessages = (): boolean =>
|
||||
useAppChatStore.getState().showSystemMessages;
|
||||
|
||||
export const useChatShowSystemMessages = (): [boolean, (showSystemMessages: boolean) => void] =>
|
||||
useAppChatStore(state => [state.showSystemMessages, state.setShowSystemMessages], shallow);
|
||||
useAppChatStore(useShallow(state => [state.showSystemMessages, state.setShowSystemMessages]));
|
||||
|
||||
+43
-48
@@ -1,73 +1,68 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
|
||||
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { useRouterQuery } from '~/common/app.routes';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { useProcessingQueue } from '~/common/logic/ProcessingQueue';
|
||||
|
||||
import { DrawHeading } from './components/DrawHeading';
|
||||
import { DrawUnconfigured } from './components/DrawUnconfigured';
|
||||
import { TextToImage } from './TextToImage';
|
||||
import { DrawCreate } from './DrawCreate';
|
||||
import { DrawGallery } from './DrawGallery';
|
||||
import { drawCreateQueue } from './queue-draw-create';
|
||||
import { useDrawSectionDropdown } from './useDrawSectionDropdown';
|
||||
|
||||
|
||||
export interface AppDrawIntent {
|
||||
backTo: 'app-chat';
|
||||
}
|
||||
// export interface AppDrawIntent {
|
||||
// backTo: 'app-chat';
|
||||
// }
|
||||
|
||||
|
||||
export function AppDraw() {
|
||||
|
||||
// state
|
||||
const [showHeading, setShowHeading] = React.useState<boolean>(true);
|
||||
const [_drawIntent, setDrawIntent] = React.useState<AppDrawIntent | null>(null);
|
||||
const [section, setSection] = React.useState<number>(0);
|
||||
|
||||
const [showHeader, setShowHeader] = React.useState(true);
|
||||
// const [_drawIntent, setDrawIntent] = React.useState<AppDrawIntent | null>(null);
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const query = useRouterQuery<Partial<AppDrawIntent>>();
|
||||
const { queueState, queueAddItem, queueCancelAll } = useProcessingQueue(drawCreateQueue);
|
||||
const { activeProviderId, mayWork, providers, setActiveProviderId } = useCapabilityTextToImage();
|
||||
|
||||
// const query = useRouterQuery<Partial<AppDrawIntent>>();
|
||||
|
||||
// [effect] set intent from the query parameters
|
||||
React.useEffect(() => {
|
||||
if (query.backTo) {
|
||||
setDrawIntent({
|
||||
backTo: query.backTo || 'app-chat',
|
||||
});
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
// React.useEffect(() => {
|
||||
// if (query.backTo) {
|
||||
// setDrawIntent({
|
||||
// backTo: query.backTo || 'app-chat',
|
||||
// });
|
||||
// }
|
||||
// }, [query]);
|
||||
// const hasIntent = !!drawIntent && !!drawIntent.backTo;
|
||||
|
||||
// usePluggableOptimaLayout(null, null, null, 'aa');
|
||||
// pluggable layout
|
||||
const { drawSection, drawSectionDropdown } = useDrawSectionDropdown(queueState.items.length, queueCancelAll);
|
||||
usePluggableOptimaLayout(null, drawSectionDropdown, null, 'aa');
|
||||
|
||||
return <>
|
||||
switch (drawSection) {
|
||||
case 'create':
|
||||
return (
|
||||
<DrawCreate
|
||||
queue={drawCreateQueue}
|
||||
isMobile={isMobile}
|
||||
showHeader={showHeader}
|
||||
onHideHeader={() => setShowHeader(false)}
|
||||
mayWork={mayWork}
|
||||
providers={providers}
|
||||
activeProviderId={activeProviderId}
|
||||
setActiveProviderId={setActiveProviderId}
|
||||
/>
|
||||
);
|
||||
|
||||
{/* The container is a 100dvh, flex column with App bg (see `pageCoreSx`) */}
|
||||
case 'browse':
|
||||
return <DrawGallery domain='draw' />;
|
||||
|
||||
{showHeading && <DrawHeading
|
||||
section={section}
|
||||
setSection={setSection}
|
||||
showSections
|
||||
onRemoveHeading={() => setShowHeading(false)}
|
||||
sx={{
|
||||
px: { xs: 1, md: 2 },
|
||||
py: { xs: 1, md: 6 },
|
||||
}}
|
||||
/>}
|
||||
|
||||
{!mayWork && <DrawUnconfigured />}
|
||||
|
||||
{/*{mayWork && <Gallery />}*/}
|
||||
|
||||
{mayWork && (
|
||||
<TextToImage
|
||||
isMobile={isMobile}
|
||||
providers={providers}
|
||||
activeProviderId={activeProviderId}
|
||||
setActiveProviderId={setActiveProviderId}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
case 'media':
|
||||
return <DrawGallery domain='app' />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import type { TextToImageProvider } from '~/common/components/useCapabilities';
|
||||
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
|
||||
|
||||
import { DesignerPrompt, PromptComposer } from './create/PromptComposer';
|
||||
import { DrawCreateQueue } from './queue-draw-create';
|
||||
import { DrawSectionHeading } from './create/DrawSectionHeading';
|
||||
import { ProviderConfigure } from './create/ProviderConfigure';
|
||||
import { ZeroDrawConfig } from './create/ZeroDrawConfig';
|
||||
import { ZeroGenerations } from './create/ZeroGenerations';
|
||||
import { useProcessingQueue } from '~/common/logic/ProcessingQueue';
|
||||
|
||||
|
||||
const imagineWorkspaceSx: SxProps = {
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
|
||||
// style
|
||||
backgroundColor: 'background.level3',
|
||||
boxShadow: 'inset 0 0 4px 0px rgba(0, 0, 0, 0.2)',
|
||||
|
||||
// layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
};
|
||||
|
||||
const imagineScrollContainerSx: SxProps = {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
position: 'relative',
|
||||
minHeight: 128,
|
||||
};
|
||||
|
||||
|
||||
/*async function queryActiveGenerateImageVector(singlePrompt: string, vectorSize: number = 1) {
|
||||
const imageContentFragments = await t2iGenerateImageContentFragments(null, singlePrompt, vectorSize, 'global', 'app-draw');
|
||||
|
||||
for (const imageContentFragment of imageContentFragments) {
|
||||
console.log('TODO: notImplemented: imagePartDataRef: CRUD and View of blobs as ImageBlocks', imageContentFragment.part);
|
||||
}
|
||||
// TODO continue...
|
||||
|
||||
return [];
|
||||
}*/
|
||||
|
||||
/*
|
||||
function TempPromptImageGen(props: { prompt: DesignerPrompt, sx?: SxProps }) {
|
||||
|
||||
// NOTE: we shall consider a multidimensional shape-based design
|
||||
|
||||
// derived state
|
||||
const { prompt: dp } = props;
|
||||
|
||||
// external state
|
||||
const { data: imageBlocks, error, isPending } = useQuery<ImageBlock[], Error>({
|
||||
enabled: !!dp.prompt,
|
||||
queryKey: ['draw-dpid', dp.uuid],
|
||||
queryFn: () => queryActiveGenerateImageVector(dp.prompt, dp._repeatCount),
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
return <>
|
||||
|
||||
{error && <InlineError error={error} />}
|
||||
|
||||
{Array.from({ length: dp._repeatCount }).map((_, index) => {
|
||||
const imgUid = `gen-img-${index}`;
|
||||
const imageBlock = imageBlocks?.[index] || null;
|
||||
return imageBlock
|
||||
// ? <RenderImage key={imgUid} imageBlock={imageBlock} noTooltip />
|
||||
? <Box sx={{
|
||||
|
||||
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', position: 'relative',
|
||||
mx: 'auto', my: 'auto', // mt: (index > 0 || !props.isFirst) ? 1.5 : 0,
|
||||
boxShadow: 'lg',
|
||||
backgroundColor: 'neutral.solidBg',
|
||||
|
||||
'& picture': { display: 'flex' },
|
||||
'& img': { maxWidth: '100%', maxHeight: '100%' },
|
||||
|
||||
}}>
|
||||
<picture><img src={imageBlock.url} alt={imageBlock.alt} /></picture>
|
||||
</Box>
|
||||
: <Card key={imgUid} sx={{ mb: 'auto' }}>
|
||||
<Skeleton animation='wave' variant='rectangular' sx={{ minWidth: 128, width: '100%', aspectRatio: 1 }} />
|
||||
</Card>;
|
||||
})}
|
||||
|
||||
</>;
|
||||
}
|
||||
*/
|
||||
|
||||
export function DrawCreate(props: {
|
||||
queue: DrawCreateQueue,
|
||||
isMobile: boolean,
|
||||
showHeader: boolean,
|
||||
onHideHeader: () => void,
|
||||
mayWork: boolean,
|
||||
providers: TextToImageProvider[],
|
||||
activeProviderId: string | null,
|
||||
setActiveProviderId: (providerId: (string | null)) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [prompts, setPrompts] = React.useState<DesignerPrompt[]>([]);
|
||||
|
||||
|
||||
// external state
|
||||
const { queueState } = useProcessingQueue(props.queue);
|
||||
console.log('DrawCreate', { queueState });
|
||||
|
||||
// handlers
|
||||
const handleStopDrawing = React.useCallback(() => {
|
||||
setPrompts([]);
|
||||
}, []);
|
||||
|
||||
const { queue } = props;
|
||||
|
||||
const handlePromptEnqueue = React.useCallback((designerPrompts: DesignerPrompt[]) => {
|
||||
for (const designerPrompt of designerPrompts) {
|
||||
void queue.enqueueItem(designerPrompt); // fire/forget
|
||||
}
|
||||
}, [queue]);
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
{/* The container is a '100dvh flex column' with App background (see `pageCoreSx`) */}
|
||||
|
||||
{/* Embossed Imagine Workspace */}
|
||||
<Box sx={imagineWorkspaceSx}>
|
||||
|
||||
{/* This box is here to let ScrollToBottomButton anchor to this (relative) insted of the scroll-dependent ScrollToBottom */}
|
||||
<Box sx={imagineScrollContainerSx}>
|
||||
|
||||
{/* [overlay] Welcoming header - Closeable */}
|
||||
{props.showHeader && (
|
||||
<DrawSectionHeading
|
||||
isBeta
|
||||
title='Imagine'
|
||||
subTitle={props.mayWork ? 'Model, Prompts, Go!' : 'No AI providers configured :('}
|
||||
chipText='Multi-model, AI Text-to-Image'
|
||||
highlight={props.mayWork}
|
||||
onRemoveHeading={props.onHideHeader}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0, top: 0, right: 0,
|
||||
zIndex: 1,
|
||||
m: { xs: 1, md: 2 },
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScrollToBottom
|
||||
bootToBottom
|
||||
stickToBottomInitial
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
p: { xs: 1, md: 2 },
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Gallery/Placeholders Grid */}
|
||||
<Box sx={{
|
||||
// my: 'auto',
|
||||
mt: 'auto',
|
||||
mx: 'auto',
|
||||
border: '1px solid purple',
|
||||
minHeight: '300px',
|
||||
|
||||
// layout
|
||||
display: 'grid',
|
||||
gridTemplateColumns: props.isMobile
|
||||
? 'repeat(auto-fit, minmax(320px, 1fr))'
|
||||
: 'repeat(auto-fit, minmax(max(min(100%, 400px), 100%/5), 1fr))',
|
||||
gap: { xs: 2, md: 2 },
|
||||
}}>
|
||||
|
||||
{/* {prompts.map((prompt, _index) => {*/}
|
||||
{/* return (*/}
|
||||
{/* <TempPromptImageGen*/}
|
||||
{/* key={prompt.dpId}*/}
|
||||
{/* prompt={prompt}*/}
|
||||
{/* sx={{*/}
|
||||
{/* border: DEBUG_LAYOUT ? '1px solid green' : undefined,*/}
|
||||
{/* }}*/}
|
||||
{/* />*/}
|
||||
{/* );*/}
|
||||
|
||||
|
||||
<Box sx={{background:'red'}}>a</Box>
|
||||
<Box>a</Box>
|
||||
<Box>a</Box>
|
||||
<Box>a</Box>
|
||||
<Box>a</Box>
|
||||
<Box>a</Box>
|
||||
</Box>
|
||||
|
||||
{/* Fallback */}
|
||||
<ZeroGenerations />
|
||||
|
||||
{/* End with this Unconfigured message */}
|
||||
{!props.mayWork && <ZeroDrawConfig />}
|
||||
|
||||
|
||||
{/* Visibility and actions are handled via Context */}
|
||||
<ScrollToBottomButton />
|
||||
|
||||
</ScrollToBottom>
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Prompt Composer - inside the workspace for root-scrollability */}
|
||||
<PromptComposer
|
||||
isMobile={props.isMobile}
|
||||
queueLength={prompts.length}
|
||||
onDrawingStop={handleStopDrawing}
|
||||
onPromptEnqueue={handlePromptEnqueue}
|
||||
sx={{
|
||||
flex: 0,
|
||||
backgroundColor: 'background.level2',
|
||||
borderTop: `1px solid`,
|
||||
borderTopColor: 'divider',
|
||||
p: { xs: 1, md: 2 },
|
||||
}}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* AI Service Provider Options */}
|
||||
<ProviderConfigure
|
||||
providers={props.providers}
|
||||
activeProviderId={props.activeProviderId}
|
||||
setActiveProviderId={props.setActiveProviderId}
|
||||
sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
borderTop: `1px solid`,
|
||||
borderTopColor: 'divider',
|
||||
p: { xs: 1, md: 2 },
|
||||
}}
|
||||
/>
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Table } from '@mui/joy';
|
||||
|
||||
import { DBlobAssetType, DBlobImageAsset } from '~/modules/dblobs/dblobs.types';
|
||||
import { useDBAssetsByScopeAndType } from '~/modules/dblobs/dblobs.hooks';
|
||||
|
||||
import { ZeroGallery } from './gallery/ZeroGallery';
|
||||
|
||||
|
||||
export function DrawGallery(props: { domain: 'draw' | 'app' }) {
|
||||
const [items] = useDBAssetsByScopeAndType<DBlobImageAsset>(
|
||||
DBlobAssetType.IMAGE,
|
||||
'global',
|
||||
props.domain === 'draw' ? 'app-draw' : 'app-chat',
|
||||
);
|
||||
|
||||
|
||||
const boxStyles = {
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
p: { xs: 2, md: 6 },
|
||||
};
|
||||
|
||||
const cellStyles = {
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'break-spaces',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={boxStyles}>
|
||||
<Table borderAxis='both' size='sm' stripe='odd' variant='plain'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Origin</th>
|
||||
<th>Metadata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(items || []).map(({ id, label, cache, data, origin, metadata, createdAt, updatedAt }) => (
|
||||
<tr key={id}>
|
||||
<td>
|
||||
<Box sx={cellStyles}>
|
||||
<picture style={{ display: 'flex', maxWidth: 256, maxHeight: 256 }}>
|
||||
<img
|
||||
src={cache.thumb256?.base64 ? `data:${cache.thumb256?.mimeType};base64,${cache.thumb256?.base64}` : `data:${data.mimeType};base64,${data.base64}`}
|
||||
alt={label}
|
||||
style={{
|
||||
boxShadow: '0 0 4px 1px rgba(0, 0, 0, 0.1)',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
opacity: cache.thumb256?.base64 ? 1 : 0.5,
|
||||
}}
|
||||
/>
|
||||
</picture>
|
||||
{label}
|
||||
</Box>
|
||||
</td>
|
||||
<td>
|
||||
<Box sx={cellStyles}>{JSON.stringify(origin, null, 2)}</Box>
|
||||
</td>
|
||||
<td>
|
||||
<Box sx={cellStyles}>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
<br />
|
||||
{createdAt ? new Date(createdAt).toLocaleString() : 'no creation'}
|
||||
<br />
|
||||
{updatedAt && updatedAt !== createdAt ? new Date(updatedAt).toLocaleString() : null}
|
||||
</Box>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
{(!items || items.length === 0) && <ZeroGallery domain={props.domain} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { AppPlaceholder } from '../AppPlaceholder';
|
||||
import * as React from 'react';
|
||||
|
||||
export function Gallery() {
|
||||
return (
|
||||
|
||||
<AppPlaceholder text='Drawing App is under development. v1.16.' />
|
||||
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Card, Skeleton } from '@mui/joy';
|
||||
|
||||
import type { ImageBlock } from '~/modules/blocks/blocks';
|
||||
import { getActiveTextToImageProviderOrThrow, t2iGenerateImageOrThrow } from '~/modules/t2i/t2i.client';
|
||||
import { heuristicMarkdownImageReferenceBlocks } from '~/modules/blocks/RenderImage';
|
||||
|
||||
import type { TextToImageProvider } from '~/common/components/useCapabilities';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { themeBgAppChatComposer } from '~/common/app.theme';
|
||||
|
||||
import { DesignerPrompt, PromptDesigner } from './components/PromptDesigner';
|
||||
import { ProviderConfigure } from './components/ProviderConfigure';
|
||||
|
||||
|
||||
const STILL_LAYOUTING = false;
|
||||
|
||||
|
||||
/**
|
||||
* @returns up-to `vectorSize` image URLs
|
||||
*/
|
||||
async function queryActiveGenerateImageVector(singlePrompt: string, vectorSize: number = 1) {
|
||||
const t2iProvider = getActiveTextToImageProviderOrThrow();
|
||||
|
||||
const mdStringsVector = await t2iGenerateImageOrThrow(t2iProvider, singlePrompt, vectorSize);
|
||||
if (!mdStringsVector?.length)
|
||||
throw new Error('No image generated');
|
||||
|
||||
const block = heuristicMarkdownImageReferenceBlocks(mdStringsVector.join('\n'));
|
||||
if (!block?.length)
|
||||
throw new Error('No URLs in the generated images');
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
|
||||
function TempPromptImageGen(props: { prompt: DesignerPrompt, sx?: SxProps }) {
|
||||
|
||||
// NOTE: we shall consider a multidimensional shape-based design
|
||||
|
||||
// derived state
|
||||
const { prompt: dp } = props;
|
||||
|
||||
// external state
|
||||
const { data: imageBlocks, error, isLoading } = useQuery<ImageBlock[], Error>({
|
||||
enabled: !!dp.prompt,
|
||||
queryKey: ['draw-uuid', dp.uuid],
|
||||
queryFn: () => queryActiveGenerateImageVector(dp.prompt, dp._repeatCount),
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
return <>
|
||||
|
||||
{error && <InlineError error={error} />}
|
||||
|
||||
{Array.from({ length: dp._repeatCount }).map((_, index) => {
|
||||
const imgUid = `gen-img-${index}`;
|
||||
const imageBlock = imageBlocks?.[index] || null;
|
||||
return imageBlock
|
||||
// ? <RenderImage key={imgUid} imageBlock={imageBlock} noTooltip />
|
||||
? <Box sx={{
|
||||
|
||||
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', position: 'relative',
|
||||
mx: 'auto', my: 'auto', // mt: (index > 0 || !props.isFirst) ? 1.5 : 0,
|
||||
boxShadow: 'lg',
|
||||
backgroundColor: 'neutral.solidBg',
|
||||
|
||||
'& picture': { display: 'flex' },
|
||||
'& img': { maxWidth: '100%', maxHeight: '100%' },
|
||||
|
||||
}}>
|
||||
<picture><img src={imageBlock.url} alt={imageBlock.alt} /></picture>
|
||||
</Box>
|
||||
: <Card key={imgUid} sx={{ mb: 'auto' }}>
|
||||
<Skeleton animation='wave' variant='rectangular' sx={{ minWidth: 128, width: '100%', aspectRatio: 1 }} />
|
||||
</Card>;
|
||||
})}
|
||||
|
||||
</>;
|
||||
};
|
||||
|
||||
|
||||
export function TextToImage(props: {
|
||||
isMobile: boolean,
|
||||
providers: TextToImageProvider[],
|
||||
activeProviderId: string | null,
|
||||
setActiveProviderId: (providerId: (string | null)) => void
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [prompts, setPrompts] = React.useState<DesignerPrompt[]>([]);
|
||||
|
||||
|
||||
const handleStopDrawing = React.useCallback(() => {
|
||||
setPrompts([]);
|
||||
}, []);
|
||||
|
||||
const handlePromptEnqueue = React.useCallback((prompts: DesignerPrompt[]) => {
|
||||
setPrompts((prevPrompts) => [...prompts, ...prevPrompts]);
|
||||
}, []);
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
<ProviderConfigure
|
||||
providers={props.providers}
|
||||
activeProviderId={props.activeProviderId}
|
||||
setActiveProviderId={props.setActiveProviderId}
|
||||
sx={{
|
||||
p: { xs: 1, md: 2 },
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{/* TMP Body */}
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
|
||||
// style
|
||||
backgroundColor: 'background.level2',
|
||||
border: STILL_LAYOUTING ? '1px solid blue' : undefined,
|
||||
p: { xs: 1, md: 2 },
|
||||
}}>
|
||||
<Box sx={{
|
||||
// my: 'auto',
|
||||
// display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
border: STILL_LAYOUTING ? '1px solid purple' : undefined,
|
||||
minHeight: '300px',
|
||||
|
||||
// layout
|
||||
display: 'grid',
|
||||
gridTemplateColumns: props.isMobile
|
||||
? 'repeat(auto-fit, minmax(320px, 1fr))'
|
||||
: 'repeat(auto-fit, minmax(max(min(100%, 400px), 100%/5), 1fr))',
|
||||
gap: { xs: 2, md: 2 },
|
||||
}}>
|
||||
{prompts.map((prompt, index) => {
|
||||
return (
|
||||
<TempPromptImageGen
|
||||
key={prompt.uuid}
|
||||
prompt={prompt}
|
||||
sx={{
|
||||
border: STILL_LAYOUTING ? '1px solid green' : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<PromptDesigner
|
||||
isMobile={props.isMobile}
|
||||
queueLength={prompts.length}
|
||||
onDrawingStop={handleStopDrawing}
|
||||
onPromptEnqueue={handlePromptEnqueue}
|
||||
sx={{
|
||||
backgroundColor: themeBgAppChatComposer,
|
||||
borderTop: `1px solid`,
|
||||
borderTopColor: 'divider',
|
||||
p: { xs: 1, md: 2 },
|
||||
}}
|
||||
/>
|
||||
|
||||
</>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user