├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── .npmrc ├── .prettierrc ├── Dockerfile ├── README.md ├── app └── api │ ├── elevenlabs │ └── speech │ │ └── route.ts │ ├── llms │ └── stream │ │ └── route.ts │ ├── trpc-edge │ └── [trpc] │ │ └── route.ts │ └── trpc-node │ └── [trpc] │ └── route.ts ├── docker-compose.yaml ├── middleware_BASIC_AUTH.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── call.tsx ├── index.tsx ├── link │ ├── callback_openrouter.tsx │ ├── chat │ │ └── [chatLinkId].tsx │ └── share_target.tsx ├── news.tsx └── personas.tsx ├── prisma └── schema.prisma ├── public ├── favicon.ico ├── icons │ ├── apple-touch-icon.png │ ├── card-dark-1200.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── icon-1024x1024.png │ ├── icon-192x192.png │ └── icon-512x512.png ├── manifest.json ├── sounds │ ├── chat-begin.mp3 │ ├── chat-end.mp3 │ ├── chat-ringtone.mp3 │ ├── mic-off-mid.mp3 │ └── mic-off.mp3 └── workers │ └── pdf.worker.min.mjs ├── src ├── apps │ ├── call │ │ ├── AppCall.tsx │ │ ├── CallUI.tsx │ │ ├── CallWizard.tsx │ │ └── components │ │ │ ├── CallAvatar.tsx │ │ │ ├── CallButton.tsx │ │ │ ├── CallMessage.tsx │ │ │ └── CallStatus.tsx │ ├── chat │ │ ├── AppChat.tsx │ │ ├── components │ │ │ ├── ChatMessageList.tsx │ │ │ ├── Ephemerals.tsx │ │ │ ├── applayout │ │ │ │ ├── ChatDrawerItems.tsx │ │ │ │ ├── ChatDropdowns.tsx │ │ │ │ ├── ChatMenuItems.tsx │ │ │ │ ├── ChatNavigationItem.tsx │ │ │ │ ├── useLLMDropdown.tsx │ │ │ │ └── usePersonaDropdown.tsx │ │ │ ├── composer │ │ │ │ ├── ButtonAttachCamera.tsx │ │ │ │ ├── ButtonAttachClipboard.tsx │ │ │ │ ├── ButtonAttachFile.tsx │ │ │ │ ├── ButtonCall.tsx │ │ │ │ ├── ButtonMic.tsx │ │ │ │ ├── ButtonMicContinuation.tsx │ │ │ │ ├── ButtonOptionsDraw.tsx │ │ │ │ ├── CameraCaptureModal.tsx │ │ │ │ ├── ChatModeMenu.tsx │ │ │ │ ├── Composer.tsx │ │ │ │ ├── TokenBadge.tsx │ │ │ │ ├── TokenProgressbar.tsx │ │ │ │ ├── attachments │ │ │ │ │ ├── AttachmentItem.tsx │ │ │ │ │ ├── AttachmentMenu.tsx │ │ │ │ │ ├── Attachments.tsx │ │ │ │ │ ├── pipeline.tsx │ │ │ │ │ ├── port.ts │ │ │ │ │ ├── store-attachments.tsx │ │ │ │ │ ├── useAttachments.tsx │ │ │ │ │ └── useLLMAttachments.ts │ │ │ │ ├── composer.types.ts │ │ │ │ └── store-composer.ts │ │ │ ├── message │ │ │ │ ├── ChatMessage.tsx │ │ │ │ ├── CleanerMessage.tsx │ │ │ │ ├── OpenInCodepen.tsx │ │ │ │ ├── OpenInReplit.tsx │ │ │ │ ├── RenderCode.tsx │ │ │ │ ├── RenderCodeMermaid.tsx │ │ │ │ ├── RenderHtml.tsx │ │ │ │ ├── RenderImage.tsx │ │ │ │ ├── RenderLatex.tsx │ │ │ │ ├── RenderMarkdown.tsx │ │ │ │ ├── RenderText.tsx │ │ │ │ ├── RenderTextDiff.tsx │ │ │ │ ├── blocks.ts │ │ │ │ └── codePrism.ts │ │ │ ├── persona-selector │ │ │ │ ├── PersonaSelector.tsx │ │ │ │ └── store-purposes.ts │ │ │ └── usePanesManager.ts │ │ ├── editors │ │ │ ├── browse-load.ts │ │ │ ├── chat-stream.ts │ │ │ ├── commands.ts │ │ │ ├── editors.ts │ │ │ ├── image-generate.ts │ │ │ └── react-tangent.ts │ │ └── store-app-chat.ts │ ├── link │ │ ├── AppChatLink.tsx │ │ ├── AppChatLinkDrawerItems.tsx │ │ ├── AppChatLinkMenuItems.tsx │ │ └── ViewChatLink.tsx │ ├── models-modal │ │ ├── LLMOptionsModal.tsx │ │ ├── ModelsList.tsx │ │ ├── ModelsModal.tsx │ │ └── ModelsSourceSelector.tsx │ ├── news │ │ ├── AppNews.tsx │ │ ├── news.data.tsx │ │ └── news.hooks.ts │ ├── personas │ │ ├── AppPersonas.tsx │ │ ├── YTPersonaCreator.tsx │ │ ├── useLLMChain.ts │ │ └── ytpersona.router.ts │ └── settings-modal │ │ ├── AppChatSettingsAI.tsx │ │ ├── AppChatSettingsUI.tsx │ │ ├── SettingsModal.tsx │ │ ├── ShortcutsModal.tsx │ │ ├── UxLabsSettings.tsx │ │ └── VoiceSettings.tsx ├── common │ ├── app.config.ts │ ├── app.routes.ts │ ├── app.theme.ts │ ├── components │ │ ├── CloseableMenu.tsx │ │ ├── ConfirmationModal.tsx │ │ ├── GoodModal.tsx │ │ ├── GoodTooltip.tsx │ │ ├── InlineError.tsx │ │ ├── InlineTextarea.tsx │ │ ├── KeyStroke.tsx │ │ ├── LanguageSelect.tsx │ │ ├── Languages.json │ │ ├── Link.tsx │ │ ├── LogoProgress.tsx │ │ ├── LogoSquircle.tsx │ │ ├── NoSSR.tsx │ │ ├── Section.tsx │ │ ├── forms │ │ │ ├── FormInputKey.tsx │ │ │ ├── FormLabelStart.tsx │ │ │ ├── FormRadioControl.tsx │ │ │ ├── FormSliderControl.tsx │ │ │ ├── FormSwitchControl.tsx │ │ │ ├── FormTextField.tsx │ │ │ ├── SetupFormRefetchButton.tsx │ │ │ ├── useFormRadio.tsx │ │ │ └── useFormRadioLlmType.tsx │ │ ├── icons │ │ │ ├── AnthropicIcon.tsx │ │ │ ├── AzureIcon.tsx │ │ │ ├── CommuneIcon.tsx │ │ │ ├── MistralIcon.tsx │ │ │ ├── OllamaIcon.tsx │ │ │ ├── OobaboogaIcon.tsx │ │ │ ├── OpenAIIcon.tsx │ │ │ └── OpenRouterIcon.tsx │ │ ├── useCameraCapture.tsx │ │ ├── useCapabilities.ts │ │ ├── useDebouncer.ts │ │ ├── useDebugHook.ts │ │ ├── useGlobalShortcut.ts │ │ ├── useMatchMedia.ts │ │ ├── useSingleTabEnforcer.ts │ │ ├── useSnackbarsStore.ts │ │ └── useSpeechRecognition.ts │ ├── layout │ │ ├── AppBar.tsx │ │ ├── AppBarDropdown.tsx │ │ ├── AppBarSupportItem.tsx │ │ ├── AppBarSwitcherItem.tsx │ │ ├── AppLayout.tsx │ │ └── store-applayout.ts │ ├── state │ │ ├── ProviderBackend.tsx │ │ ├── ProviderSingleTab.tsx │ │ ├── ProviderSnacks.tsx │ │ ├── ProviderTRPCQueryClient.tsx │ │ ├── ProviderTheming.tsx │ │ ├── globalStoredList.ts │ │ ├── store-appstate.ts │ │ ├── store-chats.ts │ │ ├── store-ui.ts │ │ └── store-ux-labs.ts │ ├── styles │ │ ├── CodePrism.css │ │ └── GithubMarkdown.css │ ├── types │ │ └── next.page.d.ts │ └── util │ │ ├── AudioLivePlayer.ts │ │ ├── audioUtils.ts │ │ ├── clipboardUtils.ts │ │ ├── dropTextUtils.ts │ │ ├── htmlTableToMarkdown.ts │ │ ├── idbUtils.ts │ │ ├── modelUtils.ts │ │ ├── pdfUtils.ts │ │ ├── pwaUtils.ts │ │ ├── textUtils.ts │ │ ├── token-counter.ts │ │ ├── trpc.client.ts │ │ ├── urlUtils.ts │ │ └── useToggleableBoolean.tsx ├── data.ts ├── modules │ ├── aifn │ │ ├── autosuggestions │ │ │ └── autoSuggestions.ts │ │ ├── autotitle │ │ │ └── autoTitle.ts │ │ ├── digrams │ │ │ ├── DiagramsModal.tsx │ │ │ └── diagrams.data.ts │ │ ├── flatten │ │ │ ├── FlattenerModal.tsx │ │ │ └── flatten.data.ts │ │ ├── imagine │ │ │ └── imaginePromptFromText.ts │ │ ├── react │ │ │ └── react.ts │ │ ├── summarize │ │ │ ├── ContentReducer.tsx │ │ │ └── summerize.ts │ │ └── useStreamChatText.ts │ ├── backend │ │ ├── backend.analytics.ts │ │ ├── backend.router.ts │ │ └── state-backend.ts │ ├── browse │ │ ├── BrowseSettings.tsx │ │ ├── browse.client.ts │ │ ├── browse.router.ts │ │ └── store-module-browsing.tsx │ ├── elevenlabs │ │ ├── ElevenlabsSettings.tsx │ │ ├── elevenlabs.client.ts │ │ ├── elevenlabs.router.ts │ │ ├── store-module-elevenlabs.ts │ │ └── useElevenLabsVoiceDropdown.tsx │ ├── google │ │ ├── GoogleSearchSettings.tsx │ │ ├── search.client.ts │ │ ├── search.router.ts │ │ ├── search.types.ts │ │ └── store-module-google.ts │ ├── llms │ │ ├── store-llms.ts │ │ ├── transports │ │ │ ├── chatGenerate.ts │ │ │ ├── server │ │ │ │ ├── anthropic │ │ │ │ │ ├── anthropic.models.ts │ │ │ │ │ ├── anthropic.router.ts │ │ │ │ │ └── anthropic.wiretypes.ts │ │ │ │ ├── ollama │ │ │ │ │ ├── ollama.models.ts │ │ │ │ │ ├── ollama.router.ts │ │ │ │ │ └── ollama.wiretypes.ts │ │ │ │ ├── openai │ │ │ │ │ ├── mistral.wiretypes.ts │ │ │ │ │ ├── models.data.ts │ │ │ │ │ ├── openai.router.ts │ │ │ │ │ ├── openai.streaming.ts │ │ │ │ │ └── openai.wiretypes.ts │ │ │ │ └── server.schemas.ts │ │ │ └── streamChat.ts │ │ └── vendors │ │ │ ├── IModelVendor.ts │ │ │ ├── anthropic │ │ │ ├── AnthropicSourceSetup.tsx │ │ │ └── anthropic.vendor.ts │ │ │ ├── azure │ │ │ ├── AzureSourceSetup.tsx │ │ │ └── azure.vendor.ts │ │ │ ├── commune │ │ │ ├── CommuneSourceSetup.tsx │ │ │ └── commune.vendor.ts │ │ │ ├── localai │ │ │ ├── LocalAISourceSetup.tsx │ │ │ └── localai.vendor.ts │ │ │ ├── mistral │ │ │ ├── MistralSourceSetup.tsx │ │ │ └── mistral.vendor.ts │ │ │ ├── ollama │ │ │ ├── OllamaAdministration.tsx │ │ │ ├── OllamaSourceSetup.tsx │ │ │ └── ollama.vendor.ts │ │ │ ├── oobabooga │ │ │ ├── OobaboogaSourceSetup.tsx │ │ │ └── oobabooga.vendor.ts │ │ │ ├── openai │ │ │ ├── OpenAILLMOptions.tsx │ │ │ ├── OpenAISourceSetup.tsx │ │ │ └── openai.vendor.ts │ │ │ ├── openrouter │ │ │ ├── OpenRouterSourceSetup.tsx │ │ │ └── openrouter.vendor.ts │ │ │ └── vendors.registry.ts │ ├── prodia │ │ ├── ProdiaSettings.tsx │ │ ├── prodia.client.ts │ │ ├── prodia.models.ts │ │ ├── prodia.router.ts │ │ └── store-module-prodia.ts │ └── trade │ │ ├── ExportChats.tsx │ │ ├── ExportedChatLink.tsx │ │ ├── ExportedPublish.tsx │ │ ├── ImportChats.tsx │ │ ├── ImportOutcomeModal.tsx │ │ ├── TradeModal.tsx │ │ ├── server │ │ ├── chatgpt.ts │ │ ├── link.ts │ │ ├── pastegg.ts │ │ └── trade.router.ts │ │ ├── store-module-trade.ts │ │ └── trade.client.ts └── server │ ├── api │ ├── trpc.router-edge.ts │ ├── trpc.router-node.ts │ ├── trpc.server.ts │ └── trpc.serverutils.ts │ ├── db.ts │ ├── env.mjs │ └── wire.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # Non-code files 2 | /docs/ 3 | README.md 4 | 5 | # Node build artifacts 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # next.js 11 | /.next/ 12 | /out/ 13 | 14 | # production 15 | /build 16 | 17 | # versioning 18 | .git/ 19 | .github/ 20 | 21 | # IDEs 22 | .idea/ 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | .pnpm-debug.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env 30 | .env.* 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # other 40 | .idea/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | overrides=@mui/material@^5.0.0: 2 | dependencies: 3 | @mui/material: replaced-by=@mui/joy 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleAttributePerLine": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "endOfLine": "lf", 6 | "printWidth": 160 7 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Base 2 | FROM node:18-alpine AS base 3 | ENV NEXT_TELEMETRY_DISABLED 1 4 | 5 | # Dependencies 6 | FROM base AS deps 7 | WORKDIR /app 8 | 9 | # Dependency files 10 | COPY package*.json ./ 11 | COPY prisma ./prisma 12 | 13 | # Install dependencies, including dev (release builds should use npm ci) 14 | ENV NODE_ENV development 15 | RUN npm ci 16 | 17 | # Builder 18 | FROM base AS builder 19 | WORKDIR /app 20 | 21 | # Copy development deps and source 22 | COPY --from=deps /app/node_modules ./node_modules 23 | COPY . . 24 | 25 | # Build the application 26 | ENV NODE_ENV production 27 | RUN npm run build 28 | 29 | # Reduce installed packages to production-only 30 | RUN npm prune --production 31 | 32 | # Runner 33 | FROM base AS runner 34 | WORKDIR /app 35 | 36 | # As user 37 | RUN addgroup --system --gid 1001 nodejs 38 | RUN adduser --system --uid 1001 nextjs 39 | 40 | # Copy Built app 41 | COPY --from=builder --chown=nextjs:nodejs /app/public public 42 | COPY --from=builder --chown=nextjs:nodejs /app/.next .next 43 | COPY --from=builder --chown=nextjs:nodejs /app/node_modules node_modules 44 | 45 | # Minimal ENV for production 46 | ENV NODE_ENV production 47 | ENV PATH $PATH:/app/node_modules/.bin 48 | 49 | # Run as non-root user 50 | USER nextjs 51 | 52 | # Expose port 3000 for the application to listen on 53 | EXPOSE 3000 54 | 55 | # Start the application 56 | CMD ["next", "start"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 🛠 Develop 2 | 3 | ![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=&logo=typescript&logoColor=white) 4 | ![React](https://img.shields.io/badge/React-61DAFB?style=&logo=react&logoColor=black) 5 | ![Next.js](https://img.shields.io/badge/Next.js-000000?style=&logo=vercel&logoColor=white) 6 | 7 | Clone this repo, install the dependencies (all locally), and run the development server (which auto-watches the 8 | files for changes): 9 | 10 | ```bash 11 | npm install 12 | npm run dev 13 | ``` 14 | 15 | The development app will be running on `http://localhost:3000`. Development builds have the advantage of not requiring 16 | a build step, but can be slower than production builds. Also, development builds won't have timeout on edge functions. 17 | 18 | ## 🌐 Deploy manually 19 | 20 | The _production_ build of the application is optimized for performance and is performed by the `npm run build` command, 21 | after installing the required dependencies. 22 | 23 | ```bash 24 | # .. repeat the steps above up to `npm install`, then: 25 | npm run build 26 | npm run start --port 3000 27 | ``` 28 | 29 | The app will be running on the specified port, e.g. `http://localhost:3000`. 30 | -------------------------------------------------------------------------------- /app/api/elevenlabs/speech/route.ts: -------------------------------------------------------------------------------- 1 | import { createEmptyReadableStream, safeErrorString, serverFetchOrThrow } from '~/server/wire'; 2 | 3 | import { elevenlabsAccess, elevenlabsVoiceId, ElevenlabsWire, speechInputSchema } from '~/modules/elevenlabs/elevenlabs.router'; 4 | 5 | 6 | /* NOTE: Why does this file even exist? 7 | 8 | This file is a workaround for a limitation in tRPC; it does not support ArrayBuffer responses, 9 | and that would force us to use base64 encoding for the audio data, which would be a waste of 10 | bandwidth. So instead, we use this file to make the request to ElevenLabs, and then return the 11 | response as an ArrayBuffer. Unfortunately this means duplicating the code in the server-side 12 | and client-side vs. the tRPC implementation. So at lease we recycle the input structures. 13 | 14 | */ 15 | const handler = async (req: Request) => { 16 | try { 17 | 18 | // construct the upstream request 19 | const { 20 | elevenKey, text, voiceId, nonEnglish, 21 | streaming, streamOptimization, 22 | } = speechInputSchema.parse(await req.json()); 23 | const path = `/v1/text-to-speech/${elevenlabsVoiceId(voiceId)}` + (streaming ? `/stream?optimize_streaming_latency=${streamOptimization || 1}` : ''); 24 | const { headers, url } = elevenlabsAccess(elevenKey, path); 25 | const body: ElevenlabsWire.TTSRequest = { 26 | text: text, 27 | ...(nonEnglish && { model_id: 'eleven_multilingual_v1' }), 28 | }; 29 | 30 | // elevenlabs POST 31 | const upstreamResponse: Response = await serverFetchOrThrow(url, 'POST', headers, body); 32 | 33 | // NOTE: this is disabled, as we pass-through what we get upstream for speed, as it is not worthy 34 | // to wait for the entire audio to be downloaded before we send it to the client 35 | // if (!streaming) { 36 | // const audioArrayBuffer = await upstreamResponse.arrayBuffer(); 37 | // return new NextResponse(audioArrayBuffer, { status: 200, headers: { 'Content-Type': 'audio/mpeg' } }); 38 | // } 39 | 40 | // stream the data to the client 41 | const audioReadableStream = upstreamResponse.body || createEmptyReadableStream(); 42 | return new Response(audioReadableStream, { status: 200, headers: { 'Content-Type': 'audio/mpeg' } }); 43 | 44 | } catch (error: any) { 45 | const fetchOrVendorError = safeErrorString(error) + (error?.cause ? ' · ' + error.cause : ''); 46 | console.log(`api/elevenlabs/speech: fetch issue: ${fetchOrVendorError}`); 47 | return new Response(`[Issue] elevenlabs: ${fetchOrVendorError}`, { status: 500 }); 48 | } 49 | }; 50 | 51 | export const runtime = 'edge'; 52 | export { handler as POST }; -------------------------------------------------------------------------------- /app/api/llms/stream/route.ts: -------------------------------------------------------------------------------- 1 | export const runtime = 'edge'; 2 | export { openaiStreamingRelayHandler as POST } from '~/modules/llms/transports/server/openai/openai.streaming'; -------------------------------------------------------------------------------- /app/api/trpc-edge/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; 2 | 3 | import { appRouterEdge } from '~/server/api/trpc.router-edge'; 4 | import { createTRPCFetchContext } from '~/server/api/trpc.server'; 5 | 6 | const handlerEdgeRoutes = (req: Request) => 7 | fetchRequestHandler({ 8 | router: appRouterEdge, 9 | endpoint: '/api/trpc-edge', 10 | req, 11 | createContext: createTRPCFetchContext, 12 | onError: 13 | process.env.NODE_ENV === 'development' 14 | ? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? ''}:`, error) 15 | : undefined, 16 | }); 17 | 18 | export const runtime = 'edge'; 19 | export { handlerEdgeRoutes as GET, handlerEdgeRoutes as POST }; -------------------------------------------------------------------------------- /app/api/trpc-node/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; 2 | 3 | import { appRouterNode } from '~/server/api/trpc.router-node'; 4 | import { createTRPCFetchContext } from '~/server/api/trpc.server'; 5 | 6 | const handlerNodeRoutes = (req: Request) => 7 | fetchRequestHandler({ 8 | router: appRouterNode, 9 | endpoint: '/api/trpc-node', 10 | req, 11 | createContext: createTRPCFetchContext, 12 | onError: 13 | process.env.NODE_ENV === 'development' 14 | ? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? ''}:`, error) 15 | : undefined, 16 | }); 17 | 18 | export const runtime = 'nodejs'; 19 | export { handlerNodeRoutes as GET, handlerNodeRoutes as POST }; -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # Very simple docker-compose file to run the app on http://localhost:3000 (or http://127.0.0.1:3000). 2 | # 3 | # For more examples, such runnin com-chat alongside a web browsing service, see the `docs/docker` folder. 4 | 5 | version: '3.9' 6 | 7 | services: 8 | com-chat: 9 | image: ghcr.io/smart-window/com-chat:latest 10 | ports: 11 | - "3000:3000" 12 | env_file: 13 | - .env 14 | command: [ "next", "start", "-p", "3000" ] -------------------------------------------------------------------------------- /middleware_BASIC_AUTH.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Middleware to protect `com-chat` with HTTP Basic Authentication 3 | * 4 | * For more information on how to deploy with HTTP Basic Authentication, see: 5 | * - [deploy-authentication.md](docs/deploy-authentication.md) 6 | */ 7 | 8 | import type { NextRequest } from 'next/server'; 9 | import { NextResponse } from 'next/server'; 10 | 11 | 12 | // noinspection JSUnusedGlobalSymbols 13 | export function middleware(request: NextRequest) { 14 | 15 | // Validate deployment configuration 16 | if (!process.env.HTTP_BASIC_AUTH_USERNAME || !process.env.HTTP_BASIC_AUTH_PASSWORD) { 17 | console.warn('HTTP Basic Authentication is enabled but not configured'); 18 | return new Response('Unauthorized/Unconfigured', unauthResponse); 19 | } 20 | 21 | // Request client authentication if no credentials are provided 22 | const authHeader = request.headers.get('authorization'); 23 | if (!authHeader?.startsWith('Basic ')) 24 | return new Response('Unauthorized', unauthResponse); 25 | 26 | // Request authentication if credentials are invalid 27 | const base64Credentials = authHeader.split(' ')[1]; 28 | const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii'); 29 | const [username, password] = credentials.split(':'); 30 | if ( 31 | !username || !password || 32 | username !== process.env.HTTP_BASIC_AUTH_USERNAME || 33 | password !== process.env.HTTP_BASIC_AUTH_PASSWORD 34 | ) 35 | return new Response('Unauthorized', unauthResponse); 36 | 37 | return NextResponse.next(); 38 | } 39 | 40 | 41 | // Response to send when authentication is required 42 | const unauthResponse: ResponseInit = { 43 | status: 401, 44 | headers: { 45 | 'WWW-Authenticate': 'Basic realm="Secure com-chat"', 46 | }, 47 | }; 48 | 49 | export const config = { 50 | matcher: [ 51 | // Include root 52 | '/', 53 | // Include pages 54 | '/(call|index|news|personas|link)(.*)', 55 | // Include API routes 56 | '/api(.*)', 57 | // Note: this excludes _next, /images etc.. 58 | ], 59 | }; -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | let nextConfig = { 3 | reactStrictMode: true, 4 | 5 | // Note: disabled to chech whether the project becomes slower with this 6 | // modularizeImports: { 7 | // '@mui/icons-material': { 8 | // transform: '@mui/icons-material/{{member}}', 9 | // }, 10 | // }, 11 | 12 | // [puppeteer] https://github.com/puppeteer/puppeteer/issues/11052 13 | experimental: { 14 | serverComponentsExternalPackages: ['puppeteer-core'], 15 | }, 16 | 17 | webpack: (config, _options) => { 18 | // @mui/joy: anything material gets redirected to Joy 19 | config.resolve.alias['@mui/material'] = '@mui/joy'; 20 | 21 | // @dqbd/tiktoken: enable asynchronous WebAssembly 22 | config.experiments = { 23 | asyncWebAssembly: true, 24 | layers: true, 25 | }; 26 | 27 | return config; 28 | }, 29 | }; 30 | 31 | // Validate environment variables, if set at build time. Will be actually read and used at runtime. 32 | // This is the reason both this file and the servr/env.mjs files have this extension. 33 | await import('./src/server/env.mjs'); 34 | 35 | // conditionally enable the nextjs bundle analyzer 36 | if (process.env.ANALYZE_BUNDLE) { 37 | const { default: withBundleAnalyzer } = await import('@next/bundle-analyzer'); 38 | nextConfig = withBundleAnalyzer({ openAnalyzer: true })(nextConfig); 39 | } 40 | 41 | export default nextConfig; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "com-chat", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "env:pull": "npx vercel env pull .env.development.local", 11 | "postinstall": "prisma generate", 12 | "db:push": "prisma db push", 13 | "db:studio": "prisma studio" 14 | }, 15 | "dependencies": { 16 | "@dqbd/tiktoken": "^1.0.7", 17 | "@emotion/cache": "^11.11.0", 18 | "@emotion/react": "^11.11.1", 19 | "@emotion/server": "^11.11.0", 20 | "@emotion/styled": "^11.11.0", 21 | "@mui/icons-material": "^5.15.0", 22 | "@mui/joy": "^5.0.0-beta.18", 23 | "@next/bundle-analyzer": "^14.0.4", 24 | "@prisma/client": "^5.7.0", 25 | "@sanity/diff-match-patch": "^3.1.1", 26 | "@t3-oss/env-nextjs": "^0.7.1", 27 | "@tanstack/react-query": "~4.36.1", 28 | "@trpc/client": "^10.44.1", 29 | "@trpc/next": "^10.44.1", 30 | "@trpc/react-query": "^10.44.1", 31 | "@trpc/server": "^10.44.1", 32 | "@vercel/analytics": "^1.1.1", 33 | "browser-fs-access": "^0.35.0", 34 | "eventsource-parser": "^1.1.1", 35 | "idb-keyval": "^6.2.1", 36 | "next": "^14.0.4", 37 | "pdfjs-dist": "^4.7.76", 38 | "plantuml-encoder": "^1.4.0", 39 | "prismjs": "^1.29.0", 40 | "react": "^18.2.0", 41 | "react-dom": "^18.2.0", 42 | "react-katex": "^3.0.1", 43 | "react-markdown": "^9.0.1", 44 | "react-timeago": "^7.2.0", 45 | "remark-gfm": "^4.0.0", 46 | "superjson": "^2.2.1", 47 | "tesseract.js": "^5.0.3", 48 | "uuid": "^9.0.1", 49 | "zod": "^3.22.4", 50 | "zustand": "^4.4.7" 51 | }, 52 | "devDependencies": { 53 | "@cloudflare/puppeteer": "^0.0.5", 54 | "@types/node": "^20.10.4", 55 | "@types/plantuml-encoder": "^1.4.2", 56 | "@types/prismjs": "^1.26.3", 57 | "@types/react": "^18.2.45", 58 | "@types/react-dom": "^18.2.17", 59 | "@types/react-katex": "^3.0.4", 60 | "@types/react-timeago": "^4.1.6", 61 | "@types/uuid": "^9.0.7", 62 | "eslint": "^8.55.0", 63 | "eslint-config-next": "^14.0.4", 64 | "prettier": "^3.1.1", 65 | "prisma": "^5.7.0", 66 | "typescript": "^5.3.3" 67 | }, 68 | "engines": { 69 | "node": "^20.0.0 || ^18.0.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Head from 'next/head'; 3 | import { MyAppProps } from 'next/app'; 4 | import { Analytics as VercelAnalytics } from '@vercel/analytics/react'; 5 | 6 | import { Brand } from '~/common/app.config'; 7 | import { apiQuery } from '~/common/util/trpc.client'; 8 | 9 | import 'katex/dist/katex.min.css'; 10 | import '~/common/styles/CodePrism.css'; 11 | import '~/common/styles/GithubMarkdown.css'; 12 | 13 | import { ProviderBackend } from '~/common/state/ProviderBackend'; 14 | import { ProviderSingleTab } from '~/common/state/ProviderSingleTab'; 15 | import { ProviderSnacks } from '~/common/state/ProviderSnacks'; 16 | import { ProviderTRPCQueryClient } from '~/common/state/ProviderTRPCQueryClient'; 17 | import { ProviderTheming } from '~/common/state/ProviderTheming'; 18 | 19 | 20 | const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) => 21 | <> 22 | 23 | 24 | {Brand.Title.Common} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ; 43 | 44 | // enables the React Query API invocation 45 | export default apiQuery.withTRPC(MyApp); -------------------------------------------------------------------------------- /pages/call.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { AppCall } from '../src/apps/call/AppCall'; 4 | 5 | import { AppLayout } from '~/common/layout/AppLayout'; 6 | 7 | 8 | export default function CallPage() { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | } -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { AppChat } from '../src/apps/chat/AppChat'; 4 | import { useShowNewsOnUpdate } from '../src/apps/news/news.hooks'; 5 | 6 | import { AppLayout } from '~/common/layout/AppLayout'; 7 | 8 | 9 | export default function ChatPage() { 10 | // show the News page on updates 11 | useShowNewsOnUpdate(); 12 | 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } -------------------------------------------------------------------------------- /pages/link/chat/[chatLinkId].tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | 4 | import { AppChatLink } from '../../../src/apps/link/AppChatLink'; 5 | 6 | import { AppLayout } from '~/common/layout/AppLayout'; 7 | 8 | 9 | export default function ChatLinkPage() { 10 | const { query } = useRouter(); 11 | const chatLinkId = query?.chatLinkId as string ?? ''; 12 | 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } -------------------------------------------------------------------------------- /pages/news.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { AppNews } from '../src/apps/news/AppNews'; 4 | import { useMarkNewsAsSeen } from '../src/apps/news/news.hooks'; 5 | 6 | import { AppLayout } from '~/common/layout/AppLayout'; 7 | 8 | 9 | export default function NewsPage() { 10 | // update the last seen news version 11 | useMarkNewsAsSeen(); 12 | 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } -------------------------------------------------------------------------------- /pages/personas.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { AppPersonas } from '../src/apps/personas/AppPersonas'; 4 | 5 | import { AppLayout } from '~/common/layout/AppLayout'; 6 | 7 | 8 | export default function PersonasPage() { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | } -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // Prisma is the ORM for server-side (API) access to the database 2 | // 3 | // This file defines the schema for the database. 4 | // - make sure to run 'prisma generate' after making changes to this file 5 | // - make sure to run 'prisma db push' to sync the remote database with the schema 6 | // 7 | // Database is optional: when the environment variables are not set, the database is not used at all, 8 | // and the storage of data in com-chat is limited to client-side (browser) storage. 9 | // 10 | // The database is used for: 11 | // - the 'sharing' function, to let users share the chats with each other 12 | 13 | generator client { 14 | provider = "prisma-client-js" 15 | } 16 | 17 | datasource db { 18 | provider = "postgresql" 19 | url = env("POSTGRES_PRISMA_URL") // uses connection pooling 20 | directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection 21 | } 22 | 23 | // 24 | // Storage of Linked Data 25 | // 26 | model LinkStorage { 27 | id String @id @default(uuid()) 28 | 29 | ownerId String 30 | visibility LinkStorageVisibility 31 | 32 | dataType LinkStorageDataType 33 | dataTitle String? 34 | dataSize Int 35 | data Json 36 | 37 | upVotes Int @default(0) 38 | downVotes Int @default(0) 39 | flagsCount Int @default(0) 40 | readCount Int @default(0) 41 | writeCount Int @default(1) 42 | 43 | // time-based expiration 44 | expiresAt DateTime? 45 | 46 | // manual deletion 47 | deletionKey String 48 | isDeleted Boolean @default(false) 49 | deletedAt DateTime? 50 | 51 | createdAt DateTime @default(now()) 52 | updatedAt DateTime @updatedAt 53 | } 54 | 55 | enum LinkStorageVisibility { 56 | PUBLIC 57 | UNLISTED 58 | PRIVATE 59 | } 60 | 61 | enum LinkStorageDataType { 62 | CHAT_V1 63 | } 64 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/favicon.ico -------------------------------------------------------------------------------- /public/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/icons/card-dark-1200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/icons/card-dark-1200.png -------------------------------------------------------------------------------- /public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/icons/icon-1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/icons/icon-1024x1024.png -------------------------------------------------------------------------------- /public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "com-chat", 3 | "short_name": "com-chat", 4 | "theme_color": "#32383E", 5 | "background_color": "#9FA6AD", 6 | "description": "Personal AGI App", 7 | "display": "standalone", 8 | "start_url": "/", 9 | "icons": [ 10 | { 11 | "src": "/icons/icon-192x192.png", 12 | "sizes": "192x192", 13 | "type": "image/png", 14 | "purpose": "maskable" 15 | }, 16 | { 17 | "src": "/icons/icon-512x512.png", 18 | "sizes": "512x512", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/icons/icon-1024x1024.png", 23 | "sizes": "1024x1024", 24 | "type": "image/png" 25 | } 26 | ], 27 | "share_target": { 28 | "action": "/link/share_target", 29 | "method": "GET", 30 | "enctype": "application/x-www-form-urlencoded", 31 | "params": { 32 | "title": "title", 33 | "text": "text", 34 | "url": "url" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/sounds/chat-begin.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/sounds/chat-begin.mp3 -------------------------------------------------------------------------------- /public/sounds/chat-end.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/sounds/chat-end.mp3 -------------------------------------------------------------------------------- /public/sounds/chat-ringtone.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/sounds/chat-ringtone.mp3 -------------------------------------------------------------------------------- /public/sounds/mic-off-mid.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/sounds/mic-off-mid.mp3 -------------------------------------------------------------------------------- /public/sounds/mic-off.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AIBlockchainGuru/Chat-app/e04e7203151ab7476faa97f42675b1eb404f3bce/public/sounds/mic-off.mp3 -------------------------------------------------------------------------------- /src/apps/call/AppCall.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | 4 | import { Container, Sheet } from '@mui/joy'; 5 | 6 | import { AppCallQueryParams } from '~/common/app.routes'; 7 | import { InlineError } from '~/common/components/InlineError'; 8 | 9 | import { CallUI } from './CallUI'; 10 | import { CallWizard } from './CallWizard'; 11 | 12 | 13 | export function AppCall() { 14 | // external state 15 | const { query } = useRouter(); 16 | 17 | // derived state 18 | const { conversationId, personaId } = query as any as AppCallQueryParams; 19 | const validInput = !!conversationId && !!personaId; 20 | 21 | return ( 22 | 28 | 29 | 35 | 36 | {!validInput && } 37 | 38 | {validInput && ( 39 | 40 | 41 | 42 | )} 43 | 44 | 45 | 46 | 47 | ); 48 | } -------------------------------------------------------------------------------- /src/apps/call/components/CallAvatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { keyframes } from '@emotion/react'; 3 | 4 | import { Avatar, Box } from '@mui/joy'; 5 | 6 | 7 | const cssScaleKeyframes = keyframes` 8 | 0% { 9 | transform: scale(1); 10 | } 11 | 50% { 12 | transform: scale(1.2); 13 | } 14 | 100% { 15 | transform: scale(1); 16 | }`; 17 | 18 | 19 | export function CallAvatar(props: { symbol: string, imageUrl?: string, isRinging: boolean, onClick: () => void }) { 20 | return ( 21 | 32 | 33 | {/* As fallback, show the large Persona Symbol */} 34 | {!props.imageUrl && ( 35 | 42 | {props.symbol} 43 | 44 | )} 45 | 46 | 47 | ); 48 | } -------------------------------------------------------------------------------- /src/apps/call/components/CallButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, ColorPaletteProp, IconButton, Typography, VariantProp } from '@mui/joy'; 4 | 5 | 6 | /** 7 | * Large button to operate the call, e.g. 8 | * -------- 9 | * | 🎤 | 10 | * | Mute | 11 | * -------- 12 | */ 13 | export function CallButton(props: { 14 | Icon: React.FC, text: string, 15 | variant?: VariantProp, color?: ColorPaletteProp, disabled?: boolean, 16 | onClick?: () => void, 17 | }) { 18 | return ( 19 | !props.disabled && props.onClick?.()} 21 | sx={{ 22 | display: 'flex', flexDirection: 'column', alignItems: 'center', 23 | gap: { xs: 1, md: 2 }, 24 | }} 25 | > 26 | 27 | 34 | 35 | 36 | 37 | 38 | {props.text} 39 | 40 | 41 | 42 | ); 43 | } -------------------------------------------------------------------------------- /src/apps/call/components/CallMessage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Chip, ColorPaletteProp, VariantProp } from '@mui/joy'; 4 | import { SxProps } from '@mui/joy/styles/types'; 5 | 6 | import { VChatMessageIn } from '~/modules/llms/transports/chatGenerate'; 7 | 8 | 9 | export function CallMessage(props: { 10 | text?: string | React.JSX.Element, 11 | variant?: VariantProp, color?: ColorPaletteProp, 12 | role: VChatMessageIn['role'], 13 | sx?: SxProps, 14 | }) { 15 | return ( 16 | 28 | 29 | {props.text} 30 | 31 | 32 | ); 33 | } -------------------------------------------------------------------------------- /src/apps/call/components/CallStatus.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, Typography } from '@mui/joy'; 4 | 5 | import { InlineError } from '~/common/components/InlineError'; 6 | 7 | 8 | /** 9 | * A status message for the call, such as: 10 | * 11 | * $Name 12 | * "Connecting..." or "Call ended", 13 | * re: $Regarding 14 | */ 15 | export function CallStatus(props: { 16 | callerName?: string, 17 | statusText: string, 18 | regardingText?: string, 19 | micError: boolean, speakError: boolean, 20 | // llmComponent?: React.JSX.Element, 21 | }) { 22 | return ( 23 | 24 | 25 | {!!props.callerName && 26 | {props.callerName} 27 | } 28 | 29 | {/*{props.llmComponent}*/} 30 | 31 | 32 | {props.statusText} 33 | 34 | 35 | {!!props.regardingText && 36 | re: {props.regardingText} 37 | } 38 | 39 | {props.micError && } 41 | 42 | {props.speakError && } 44 | 45 | 46 | ); 47 | } -------------------------------------------------------------------------------- /src/apps/chat/components/applayout/ChatDropdowns.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import type { DConversationId } from '~/common/state/store-chats'; 4 | 5 | import { useChatLLMDropdown } from './useLLMDropdown'; 6 | import { usePersonaIdDropdown } from './usePersonaDropdown'; 7 | 8 | 9 | export function ChatDropdowns(props: { 10 | conversationId: DConversationId | null 11 | }) { 12 | 13 | // state 14 | const { chatLLMDropdown } = useChatLLMDropdown(); 15 | const { personaDropdown } = usePersonaIdDropdown(props.conversationId); 16 | 17 | return <> 18 | 19 | {/* Model selector */} 20 | {chatLLMDropdown} 21 | 22 | {/* Persona selector */} 23 | {personaDropdown} 24 | 25 | ; 26 | } 27 | -------------------------------------------------------------------------------- /src/apps/chat/components/composer/ButtonAttachCamera.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, Button, IconButton, Tooltip } from '@mui/joy'; 4 | import AddAPhotoIcon from '@mui/icons-material/AddAPhoto'; 5 | 6 | import { CameraCaptureModal } from './CameraCaptureModal'; 7 | 8 | 9 | const attachCameraLegend = (isMobile: boolean) => 10 | 11 | Attach photo
12 | {isMobile ? 'Auto-OCR to read text' : 'See the world, on the go'} 13 |
; 14 | 15 | 16 | export const ButtonAttachCameraMemo = React.memo(ButtonAttachCamera); 17 | 18 | function ButtonAttachCamera(props: { isMobile?: boolean, onAttachImage: (file: File) => void }) { 19 | // state 20 | const [open, setOpen] = React.useState(false); 21 | 22 | return <> 23 | 24 | {/* The Button */} 25 | {props.isMobile ? ( 26 | setOpen(true)}> 27 | 28 | 29 | ) : ( 30 | 31 | 35 | 36 | )} 37 | 38 | {/* The actual capture dialog, which will stream the video */} 39 | {open && ( 40 | setOpen(false)} 42 | onAttachImage={props.onAttachImage} 43 | /> 44 | )} 45 | 46 | ; 47 | } -------------------------------------------------------------------------------- /src/apps/chat/components/composer/ButtonAttachClipboard.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, Button, IconButton, Tooltip } from '@mui/joy'; 4 | import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo'; 5 | 6 | import { KeyStroke } from '~/common/components/KeyStroke'; 7 | 8 | 9 | const pasteClipboardLegend = 10 | 11 | Attach clipboard 📚
12 | Auto-converts to the best types
13 | 14 |
; 15 | 16 | 17 | export const ButtonAttachClipboardMemo = React.memo(ButtonAttachClipboard); 18 | 19 | function ButtonAttachClipboard(props: { isMobile?: boolean, onClick: () => void }) { 20 | return props.isMobile ? ( 21 | 22 | 23 | 24 | ) : ( 25 | 26 | 30 | 31 | ); 32 | } -------------------------------------------------------------------------------- /src/apps/chat/components/composer/ButtonAttachFile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, Button, IconButton, Tooltip } from '@mui/joy'; 4 | import AttachFileOutlinedIcon from '@mui/icons-material/AttachFileOutlined'; 5 | 6 | 7 | const attachFileLegend = 8 | 9 | Attach files
10 | Drag & drop in chat for faster loads ⚡ 11 |
; 12 | 13 | 14 | export const ButtonAttachFileMemo = React.memo(ButtonAttachFile); 15 | 16 | function ButtonAttachFile(props: { isMobile?: boolean, onAttachFilePicker: () => void }) { 17 | return props.isMobile ? ( 18 | 19 | 20 | 21 | ) : ( 22 | 23 | 27 | 28 | ); 29 | } -------------------------------------------------------------------------------- /src/apps/chat/components/composer/ButtonCall.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, Button, IconButton, Tooltip } from '@mui/joy'; 4 | import { SxProps } from '@mui/joy/styles/types'; 5 | import CallIcon from '@mui/icons-material/Call'; 6 | 7 | 8 | const callConversationLegend = 9 | 10 | Quick call regarding this chat 11 | ; 12 | 13 | export function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onClick: () => void, sx?: SxProps }) { 14 | return props.isMobile ? ( 15 | 16 | 17 | 18 | ) : ( 19 | 20 | 23 | 24 | ); 25 | } -------------------------------------------------------------------------------- /src/apps/chat/components/composer/ButtonMic.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, IconButton } from '@mui/joy'; 4 | import { ColorPaletteProp, VariantProp } from '@mui/joy/styles/types'; 5 | import MicIcon from '@mui/icons-material/Mic'; 6 | 7 | import { GoodTooltip } from '~/common/components/GoodTooltip'; 8 | import { KeyStroke } from '~/common/components/KeyStroke'; 9 | 10 | 11 | const micLegend = 12 | 13 | Voice input
14 | 15 |
; 16 | 17 | 18 | export const ButtonMicMemo = React.memo(ButtonMic); 19 | 20 | function ButtonMic(props: { variant: VariantProp, color: ColorPaletteProp, noBackground?: boolean, onClick: () => void }) { 21 | return 22 | 23 | 24 | 25 | ; 26 | } -------------------------------------------------------------------------------- /src/apps/chat/components/composer/ButtonMicContinuation.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, IconButton, Tooltip } from '@mui/joy'; 4 | import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types'; 5 | import AutoModeIcon from '@mui/icons-material/AutoMode'; 6 | 7 | 8 | const micContinuationLegend = 9 | 10 | Voice Continuation 11 | ; 12 | 13 | 14 | export const ButtonMicContinuationMemo = React.memo(ButtonMicContinuation); 15 | 16 | function ButtonMicContinuation(props: { variant: VariantProp, color: ColorPaletteProp, onClick: () => void, sx?: SxProps }) { 17 | return 18 | 19 | 20 | 21 | ; 22 | } -------------------------------------------------------------------------------- /src/apps/chat/components/composer/ButtonOptionsDraw.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Button, IconButton } from '@mui/joy'; 4 | import { SxProps } from '@mui/joy/styles/types'; 5 | import FormatPaintIcon from '@mui/icons-material/FormatPaint'; 6 | 7 | 8 | export function ButtonOptionsDraw(props: { isMobile?: boolean, onClick: () => void, sx?: SxProps }) { 9 | return props.isMobile ? ( 10 | 11 | 12 | 13 | ) : ( 14 | 17 | ); 18 | } -------------------------------------------------------------------------------- /src/apps/chat/components/composer/attachments/port.ts: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | /// REDUCER 4 | 5 | import { ContentReducer } from '~/modules/aifn/summarize/ContentReducer'; 6 | 7 | const [reducerText, setReducerText] = React.useState(''); 8 | const [reducerTextTokens, setReducerTextTokens] = React.useState(0); 9 | 10 | {reducerText?.length >= 1 && 11 | 15 | } 16 | const handleReducerClose = () => setReducerText(''); 17 | 18 | const handleReducedText = (text: string) => { 19 | handleReducerClose(); 20 | setComposeText(_t => _t + text); 21 | }; 22 | 23 | const handleAttachFiles = async (files: FileList, overrideFileNames?: string[]): Promise => { 24 | 25 | // see how we fare on budget 26 | if (chatLLMId) { 27 | const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger'); 28 | 29 | // simple trigger for the reduction dialog 30 | if (newTextTokens > remainingTokens) { 31 | setReducerTextTokens(newTextTokens); 32 | setReducerText(newText); 33 | return; 34 | } 35 | } 36 | 37 | // within the budget, so just append 38 | setComposeText(text => expandPromptTemplate(PromptTemplates.Concatenate, { text: newText })(text)); 39 | 40 | 41 | 42 | */ -------------------------------------------------------------------------------- /src/apps/chat/components/composer/composer.types.ts: -------------------------------------------------------------------------------- 1 | export type ComposerOutputPartType = 'text-block' | 'image-part'; 2 | 3 | export type ComposerOutputPart = { 4 | type: 'text-block', 5 | text: string, 6 | title: string | null, 7 | collapsible: boolean, 8 | } | { 9 | // TODO: not implemented yet 10 | type: 'image-part', 11 | base64Url: string, 12 | collapsible: false, 13 | }; 14 | 15 | export type ComposerOutputMultiPart = ComposerOutputPart[]; 16 | -------------------------------------------------------------------------------- /src/apps/chat/components/composer/store-composer.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { persist } from 'zustand/middleware'; 3 | import { shallow } from 'zustand/shallow'; 4 | 5 | 6 | /// Composer Store 7 | 8 | interface ComposerStore { 9 | 10 | startupText: string | null; // if not null, the composer will load this text at startup 11 | setStartupText: (text: string | null) => void; 12 | 13 | } 14 | 15 | const useComposerStore = create()( 16 | persist((set, _get) => ({ 17 | 18 | startupText: null, 19 | setStartupText: (text: string | null) => set({ startupText: text }), 20 | 21 | }), 22 | { 23 | name: 'app-composer', 24 | version: 1, 25 | }), 26 | ); 27 | 28 | export const setComposerStartupText = (text: string | null) => 29 | useComposerStore.getState().setStartupText(text); 30 | 31 | export const useComposerStartupText = (): [string | null, (text: string | null) => void] => 32 | useComposerStore(state => [state.startupText, state.setStartupText], shallow); -------------------------------------------------------------------------------- /src/apps/chat/components/message/OpenInCodepen.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Button, Tooltip } from '@mui/joy'; 4 | 5 | interface CodeBlockProps { 6 | codeBlock: { 7 | code: string; 8 | language?: string; 9 | }; 10 | } 11 | 12 | export function OpenInCodepen({ codeBlock }: CodeBlockProps): React.JSX.Element { 13 | const { code, language } = codeBlock; 14 | const hasCSS = language === 'css'; 15 | const hasJS = ['javascript', 'json', 'typescript'].includes(language || ''); 16 | const hasHTML = !hasCSS && !hasJS; // use HTML as fallback if an unanticipated frontend language is used 17 | 18 | const handleOpenInCodepen = () => { 19 | const data = { 20 | title: `GPT ${new Date().toISOString()}`, // eg "GPT 2021-08-31T15:00:00.000Z" 21 | css: hasCSS ? code : '', 22 | html: hasHTML ? code : '', 23 | js: hasJS ? code : '', 24 | editors: `${hasHTML ? 1 : 0}${hasCSS ? 1 : 0}${hasJS ? 1 : 0}` // eg '101' for HTML, JS 25 | }; 26 | 27 | const form = document.createElement('form'); 28 | form.method = 'POST'; 29 | form.action = 'https://codepen.io/pen/define'; 30 | form.target = '_blank'; 31 | 32 | const input = document.createElement('input'); 33 | input.type = 'hidden'; 34 | input.name = 'data'; 35 | input.value = JSON.stringify(data); 36 | 37 | form.appendChild(input); 38 | document.body.appendChild(form); 39 | form.submit(); 40 | document.body.removeChild(form); 41 | }; 42 | 43 | return ( 44 | 45 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/apps/chat/components/message/OpenInReplit.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Button, Tooltip } from '@mui/joy'; 4 | 5 | interface CodeBlockProps { 6 | codeBlock: { 7 | code: string; 8 | language?: string; 9 | }; 10 | } 11 | 12 | export function OpenInReplit({ codeBlock }: CodeBlockProps): React.JSX.Element { 13 | const { language } = codeBlock; 14 | 15 | const replitLanguageMap: Record = { 16 | python: 'python3', 17 | csharp: 'csharp', 18 | java: 'java', 19 | }; 20 | 21 | const handleOpenInReplit = () => { 22 | const replitLanguage = replitLanguageMap[language || 'python']; 23 | const url = new URL(`https://replit.com/languages/${replitLanguage}`); 24 | window.open(url.toString(), '_blank'); 25 | }; 26 | 27 | return ( 28 | 29 | 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/apps/chat/components/message/RenderImage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, IconButton, Tooltip } from '@mui/joy'; 4 | import ReplayIcon from '@mui/icons-material/Replay'; 5 | import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap'; 6 | 7 | import { Link } from '~/common/components/Link'; 8 | 9 | import { ImageBlock } from './blocks'; 10 | import { overlayButtonsSx } from './RenderCode'; 11 | 12 | 13 | export const RenderImage = (props: { imageBlock: ImageBlock, allowRunAgain: boolean, onRunAgain?: (e: React.MouseEvent) => void }) => { 14 | const imageUrls = props.imageBlock.url.split('\n'); 15 | 16 | return imageUrls.map((url, index) => ( 17 | 0 ? 1.5 : 0, 22 | // p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1, 23 | minWidth: 64, minHeight: 64, boxShadow: 'lg', 24 | backgroundColor: 'neutral.solidBg', 25 | '& picture': { display: 'flex' }, 26 | '& img': { maxWidth: '100%', maxHeight: '100%' }, 27 | '&:hover > .overlay-buttons': { opacity: 1 }, 28 | }}> 29 | 30 | {/* External Image */} 31 | Generated Image 32 | 33 | {/* Image Buttons */} 34 | 35 | {props.allowRunAgain && !!props.onRunAgain && ( 36 | 37 | 38 | 39 | 40 | 41 | )} 42 | 43 | 44 | 45 | 46 | 47 | )); 48 | }; -------------------------------------------------------------------------------- /src/apps/chat/components/message/RenderLatex.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box } from '@mui/joy'; 4 | import { SxProps } from '@mui/joy/styles/types'; 5 | 6 | import { LatexBlock } from './blocks'; 7 | 8 | 9 | // Dynamically import the Katex functions 10 | const RenderLatexDynamic = React.lazy(async () => { 11 | const { InlineMath } = await import('react-katex'); 12 | return { 13 | default: (props: { latex: string }) => , 14 | }; 15 | }); 16 | 17 | export const RenderLatex = ({ latexBlock, sx }: { latexBlock: LatexBlock; sx?: SxProps; }) => 18 | 24 | }> 25 | 26 | 27 | ; -------------------------------------------------------------------------------- /src/apps/chat/components/message/RenderMarkdown.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, useTheme } from '@mui/joy'; 4 | 5 | import { TextBlock } from './blocks'; 6 | 7 | 8 | // Dynamically import ReactMarkdown using React.lazy 9 | const ReactMarkdown = React.lazy(async () => { 10 | const [markdownModule, remarkGfmModule] = await Promise.all([ 11 | import('react-markdown'), 12 | import('remark-gfm') 13 | ]); 14 | 15 | // Pass the dynamically imported remarkGfm as children 16 | const ReactMarkdownWithRemarkGfm = (props: any) => ( 17 | 18 | ); 19 | 20 | return { default: ReactMarkdownWithRemarkGfm }; 21 | }); 22 | 23 | 24 | export const RenderMarkdown = ({ textBlock }: { textBlock: TextBlock }) => { 25 | const theme = useTheme(); 26 | return ( 27 | 36 | 37 | {/* Using React.Suspense / React.Lazy loading this */} 38 | Loading...}> 39 | {textBlock.content} 40 | 41 | 42 | ); 43 | }; -------------------------------------------------------------------------------- /src/apps/chat/components/message/RenderText.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Chip, Typography } from '@mui/joy'; 4 | import { SxProps } from '@mui/joy/styles/types'; 5 | 6 | import { extractCommands } from '../../editors/commands'; 7 | 8 | import { TextBlock } from './blocks'; 9 | 10 | 11 | export const RenderText = ({ textBlock, sx }: { textBlock: TextBlock; sx?: SxProps; }) => { 12 | const elements = extractCommands(textBlock.content); 13 | return ( 14 | 24 | {elements.map((element, index) => 25 | element.type === 'cmd' 26 | ? {element.value} 27 | : {element.value}, 28 | )} 29 | 30 | ); 31 | }; -------------------------------------------------------------------------------- /src/apps/chat/components/message/RenderTextDiff.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Diff as TextDiff, DIFF_DELETE, DIFF_INSERT } from '@sanity/diff-match-patch'; 3 | 4 | import { Box, Typography, useTheme } from '@mui/joy'; 5 | import { SxProps } from '@mui/joy/styles/types'; 6 | 7 | import { DiffBlock } from './blocks'; 8 | 9 | 10 | export const RenderTextDiff = ({ diffBlock, sx }: { diffBlock: DiffBlock; sx?: SxProps; }) => { 11 | 12 | // external state 13 | const theme = useTheme(); 14 | 15 | // derived state 16 | const textDiffs: TextDiff[] = diffBlock.textDiffs; 17 | 18 | // text added 19 | const styleAdd = { 20 | // backgroundColor: theme.vars.palette.success.softBg, 21 | backgroundColor: `rgba(${theme.palette.mode === 'light' ? theme.vars.palette.success.lightChannel : theme.vars.palette.success.darkChannel} / 1)`, 22 | color: theme.vars.palette.success.softColor, 23 | padding: '0.1rem 0.1rem', margin: '0 -0.1rem', 24 | }; 25 | 26 | // text removed (strike-through) 27 | const styleSub = { 28 | backgroundColor: `rgba(${theme.vars.palette.danger.darkChannel} / 0.05)`, 29 | color: theme.vars.palette.danger.plainColor, 30 | padding: '0 0.25rem', margin: '0 -0.25rem', 31 | textDecoration: 'line-through', 32 | }; 33 | 34 | const styleUnchanged = { 35 | // backgroundColor: `rgba(${theme.vars.palette.neutral.mainChannel} / 0.05)`, 36 | }; 37 | 38 | return ( 39 | 51 | {textDiffs.map(([op, text], index) => 52 | {text})} 53 | 54 | ); 55 | }; -------------------------------------------------------------------------------- /src/apps/chat/components/persona-selector/store-purposes.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { persist } from 'zustand/middleware'; 3 | 4 | 5 | interface PurposeStore { 6 | 7 | // state 8 | hiddenPurposeIDs: string[]; 9 | 10 | // actions 11 | toggleHiddenPurposeId: (purposeId: string) => void; 12 | 13 | } 14 | 15 | 16 | export const usePurposeStore = create()( 17 | persist( 18 | (set) => ({ 19 | 20 | // default state 21 | hiddenPurposeIDs: ['Designer'], 22 | 23 | toggleHiddenPurposeId: (purposeId: string) => { 24 | set(state => { 25 | const hiddenPurposeIDs = state.hiddenPurposeIDs.includes(purposeId) 26 | ? state.hiddenPurposeIDs.filter((id) => id !== purposeId) 27 | : [...state.hiddenPurposeIDs, purposeId]; 28 | return { 29 | hiddenPurposeIDs, 30 | }; 31 | }); 32 | }, 33 | 34 | }), 35 | { 36 | name: 'app-purpose', 37 | }), 38 | ); -------------------------------------------------------------------------------- /src/apps/chat/editors/browse-load.ts: -------------------------------------------------------------------------------- 1 | import { callBrowseFetchPage } from '~/modules/browse/browse.client'; 2 | 3 | import { DMessage, useChatStore } from '~/common/state/store-chats'; 4 | 5 | import { createAssistantTypingMessage } from './editors'; 6 | 7 | 8 | export const runBrowseUpdatingState = async (conversationId: string, url: string) => { 9 | 10 | const { editMessage } = useChatStore.getState(); 11 | 12 | // create a blank and 'typing' message for the assistant - to be filled when we're done 13 | // const assistantModelStr = 'react-' + assistantModelId.slice(4, 7); // HACK: this is used to change the Avatar animation 14 | // noinspection HttpUrlsUsage 15 | const shortUrl = url.replace('https://www.', '').replace('https://', '').replace('http://', '').replace('www.', ''); 16 | const assistantMessageId = createAssistantTypingMessage(conversationId, 'web', undefined, `Loading page at ${shortUrl}...`); 17 | const updateAssistantMessage = (update: Partial) => editMessage(conversationId, assistantMessageId, update, false); 18 | 19 | try { 20 | 21 | const page = await callBrowseFetchPage(url); 22 | if (!page.content) { 23 | // noinspection ExceptionCaughtLocallyJS 24 | throw new Error('No text found.'); 25 | } 26 | updateAssistantMessage({ 27 | text: page.content, 28 | typing: false, 29 | }); 30 | 31 | } catch (error: any) { 32 | console.error(error); 33 | updateAssistantMessage({ 34 | text: 'Issue: browse did not produce an answer (error: ' + (error?.message || error?.toString() || 'unknown') + ').', 35 | typing: false, 36 | }); 37 | } 38 | }; -------------------------------------------------------------------------------- /src/apps/chat/editors/commands.ts: -------------------------------------------------------------------------------- 1 | import { CmdRunBrowse } from '~/modules/browse/browse.client'; 2 | import { CmdRunProdia } from '~/modules/prodia/prodia.client'; 3 | import { CmdRunReact } from '~/modules/aifn/react/react'; 4 | import { CmdRunSearch } from '~/modules/google/search.client'; 5 | import { Brand } from '~/common/app.config'; 6 | import { createDMessage, DMessage } from '~/common/state/store-chats'; 7 | 8 | 9 | export const CmdAddRoleMessage: string[] = ['/assistant', '/a', '/system', '/s']; 10 | 11 | export const CmdHelp: string[] = ['/help', '/h', '/?']; 12 | 13 | export const commands = [...CmdRunBrowse, ...CmdRunProdia, ...CmdRunReact, ...CmdRunSearch, ...CmdAddRoleMessage, ...CmdHelp]; 14 | 15 | export interface SentencePiece { 16 | type: 'text' | 'cmd'; 17 | value: string; 18 | } 19 | 20 | /** 21 | * Sentence to pieces (must have a leading slash) from the provided text 22 | * Used by rendering functions, as well as input processing functions. 23 | */ 24 | export function extractCommands(input: string): SentencePiece[] { 25 | // 'help' commands are the only without a space and text after 26 | if (CmdHelp.includes(input)) 27 | return [{ type: 'cmd', value: input }, { type: 'text', value: '' }]; 28 | const regexFromTags = commands.map(tag => `^\\${tag} `).join('\\b|') + '\\b'; 29 | const pattern = new RegExp(regexFromTags, 'g'); 30 | const result: SentencePiece[] = []; 31 | let lastIndex = 0; 32 | let match: RegExpExecArray | null; 33 | 34 | while ((match = pattern.exec(input)) !== null) { 35 | if (match.index !== lastIndex) 36 | result.push({ type: 'text', value: input.substring(lastIndex, match.index) }); 37 | result.push({ type: 'cmd', value: match[0].trim() }); 38 | lastIndex = pattern.lastIndex; 39 | 40 | // Remove the space after the matched tag 41 | if (input[lastIndex] === ' ') 42 | lastIndex++; 43 | } 44 | 45 | if (lastIndex !== input.length) 46 | result.push({ type: 'text', value: input.substring(lastIndex) }); 47 | 48 | return result; 49 | } 50 | 51 | export function createCommandsHelpMessage(): DMessage { 52 | let text = 'Available Chat Commands:\n'; 53 | text += commands.map(c => ` - ${c}`).join('\n'); 54 | const helpMessage = createDMessage('assistant', text); 55 | helpMessage.originLLM = Brand.Title.Base; 56 | return helpMessage; 57 | } -------------------------------------------------------------------------------- /src/apps/chat/editors/editors.ts: -------------------------------------------------------------------------------- 1 | import { DLLMId } from '~/modules/llms/store-llms'; 2 | import { SystemPurposeId, SystemPurposes } from '../../../data'; 3 | 4 | import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats'; 5 | 6 | 7 | export function createAssistantTypingMessage(conversationId: string, assistantLlmLabel: DLLMId | 'prodia' | 'react-...' | 'web', assistantPurposeId: SystemPurposeId | undefined, text: string): string { 8 | const assistantMessage: DMessage = createDMessage('assistant', text); 9 | assistantMessage.typing = true; 10 | assistantMessage.purposeId = assistantPurposeId; 11 | assistantMessage.originLLM = assistantLlmLabel; 12 | useChatStore.getState().appendMessage(conversationId, assistantMessage); 13 | return assistantMessage.id; 14 | } 15 | 16 | 17 | export function updatePurposeInHistory(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, purposeId: SystemPurposeId): DMessage[] { 18 | const systemMessageIndex = history.findIndex(m => m.role === 'system'); 19 | const systemMessage: DMessage = systemMessageIndex >= 0 ? history.splice(systemMessageIndex, 1)[0] : createDMessage('system', ''); 20 | if (!systemMessage.updated && purposeId && SystemPurposes[purposeId]?.systemMessage) { 21 | systemMessage.purposeId = purposeId; 22 | systemMessage.text = SystemPurposes[purposeId].systemMessage 23 | .replaceAll('{{Cutoff}}', assistantLlmId.includes('1106') ? '2023-04' : '2021-09') 24 | .replaceAll('{{Today}}', new Date().toISOString().split('T')[0]); 25 | 26 | // HACK: this is a special case for the "Custom" persona, to set the message in stone (so it doesn't get updated when switching to another persona) 27 | if (purposeId === 'Custom') 28 | systemMessage.updated = Date.now(); 29 | } 30 | history.unshift(systemMessage); 31 | useChatStore.getState().setMessages(conversationId, history); 32 | return history; 33 | } -------------------------------------------------------------------------------- /src/apps/chat/editors/image-generate.ts: -------------------------------------------------------------------------------- 1 | import { prodiaGenerateImage } from '~/modules/prodia/prodia.client'; 2 | 3 | import { useChatStore } from '~/common/state/store-chats'; 4 | 5 | import { createAssistantTypingMessage } from './editors'; 6 | 7 | 8 | /** 9 | * The main 'image generation' function - for now specialized to the 'imagine' command. 10 | */ 11 | export async function runImageGenerationUpdatingState(conversationId: string, imageText: string) { 12 | 13 | // if the imageText ends with " xN" or " [N]" (where N is a number), then we'll generate N images 14 | const match = imageText.match(/\sx(\d+)$|\s\[(\d+)]$/); 15 | const count = match ? parseInt(match[1] || match[2], 10) : 1; 16 | if (count > 1) 17 | imageText = imageText.replace(/x(\d+)$|\[(\d+)]$/, '').trim(); // Remove the "xN" or "[N]" part from the imageText 18 | 19 | // create a blank and 'typing' message for the assistant 20 | const assistantMessageId = createAssistantTypingMessage(conversationId, 'prodia', undefined, 21 | `Give me a few seconds while I draw ${imageText?.length > 20 ? 'that' : '"' + imageText + '"'}...`); 22 | 23 | // reference the state editing functions 24 | const { editMessage } = useChatStore.getState(); 25 | 26 | try { 27 | const imageUrls = await prodiaGenerateImage(count, imageText); 28 | 29 | // Concatenate all the resulting URLs and update the assistant message with these URLs 30 | const allImageUrls = imageUrls.join('\n'); 31 | editMessage(conversationId, assistantMessageId, { text: allImageUrls, typing: false }, false); 32 | 33 | } catch (error: any) { 34 | const errorMessage = error?.message || error?.toString() || 'Unknown error'; 35 | editMessage(conversationId, assistantMessageId, { text: `Sorry, I couldn't create an image for you. ${errorMessage}`, typing: false }, false); 36 | } 37 | } -------------------------------------------------------------------------------- /src/apps/chat/editors/react-tangent.ts: -------------------------------------------------------------------------------- 1 | import { Agent } from '~/modules/aifn/react/react'; 2 | import { DLLMId } from '~/modules/llms/store-llms'; 3 | import { useBrowseStore } from '~/modules/browse/store-module-browsing'; 4 | 5 | import { createDEphemeral, DMessage, useChatStore } from '~/common/state/store-chats'; 6 | 7 | import { createAssistantTypingMessage } from './editors'; 8 | 9 | 10 | /** 11 | * Synchronous ReAct chat function - TODO: event loop, auto-ui, cleanups, etc. 12 | */ 13 | export async function runReActUpdatingState(conversationId: string, question: string, assistantLlmId: DLLMId) { 14 | 15 | const { enableReactTool: enableBrowse } = useBrowseStore.getState(); 16 | const { appendEphemeral, updateEphemeralText, updateEphemeralState, deleteEphemeral, editMessage } = useChatStore.getState(); 17 | 18 | // create a blank and 'typing' message for the assistant - to be filled when we're done 19 | const assistantModelLabel = 'react-' + assistantLlmId.slice(4, 7); // HACK: this is used to change the Avatar animation 20 | const assistantMessageId = createAssistantTypingMessage(conversationId, assistantModelLabel, undefined, '...'); 21 | const updateAssistantMessage = (update: Partial) => 22 | editMessage(conversationId, assistantMessageId, update, false); 23 | 24 | 25 | // create an ephemeral space 26 | const ephemeral = createDEphemeral(`Reason+Act`, 'Initializing ReAct..'); 27 | appendEphemeral(conversationId, ephemeral); 28 | 29 | let ephemeralText = ''; 30 | const logToEphemeral = (text: string) => { 31 | console.log(text); 32 | ephemeralText += (text.length > 300 ? text.slice(0, 300) + '...' : text) + '\n'; 33 | updateEphemeralText(conversationId, ephemeral.id, ephemeralText); 34 | }; 35 | const showStateInEphemeral = (state: object) => updateEphemeralState(conversationId, ephemeral.id, state); 36 | 37 | try { 38 | 39 | // react loop 40 | const agent = new Agent(); 41 | const reactResult = await agent.reAct(question, assistantLlmId, 5, enableBrowse, logToEphemeral, showStateInEphemeral); 42 | 43 | setTimeout(() => deleteEphemeral(conversationId, ephemeral.id), 2 * 1000); 44 | updateAssistantMessage({ text: reactResult, typing: false }); 45 | 46 | } catch (error: any) { 47 | console.error(error); 48 | logToEphemeral(ephemeralText + `\nIssue: ${error || 'unknown'}`); 49 | updateAssistantMessage({ text: 'Issue: ReAct did not produce an answer.', typing: false }); 50 | } 51 | } -------------------------------------------------------------------------------- /src/apps/link/AppChatLinkDrawerItems.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import TimeAgo from 'react-timeago'; 3 | 4 | import { Box, ListDivider, ListItem, ListItemDecorator, MenuItem, Typography } from '@mui/joy'; 5 | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; 6 | 7 | import { useChatLinkItems } from '~/modules/trade/store-module-trade'; 8 | 9 | import { Brand } from '~/common/app.config'; 10 | import { Link } from '~/common/components/Link'; 11 | import { closeLayoutDrawer } from '~/common/layout/store-applayout'; 12 | import { getChatLinkRelativePath, ROUTE_INDEX } from '~/common/app.routes'; 13 | 14 | 15 | /** 16 | * Drawer Items are all the links already shared, for quick access. 17 | * This is stores in the Trade Store (local storage). 18 | */ 19 | export function AppChatLinkDrawerItems() { 20 | 21 | // external state 22 | const chatLinkItems = useChatLinkItems() 23 | .slice() 24 | .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); 25 | const notEmpty = chatLinkItems.length > 0; 26 | 27 | return <> 28 | 29 | 33 | 34 | {Brand.Title.Base} 35 | 36 | 37 | {notEmpty && } 38 | 39 | {notEmpty && 40 | 41 | Links shared by you 42 | 43 | } 44 | 45 | {notEmpty && 46 | {chatLinkItems.map(item => ( 47 | 48 | 56 | 57 | {item.chatTitle || 'Untitled Chat'} 58 | 59 | 60 | 61 | 62 | 63 | 64 | ))} 65 | } 66 | ; 67 | 68 | } -------------------------------------------------------------------------------- /src/apps/link/AppChatLinkMenuItems.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'zustand/shallow'; 3 | 4 | import { MenuItem, Switch, Typography } from '@mui/joy'; 5 | 6 | import { useUIPreferencesStore } from '~/common/state/store-ui'; 7 | 8 | import { useChatShowSystemMessages } from '../chat/store-app-chat'; 9 | 10 | 11 | /** 12 | * Menu Items are the settings for the chat. 13 | */ 14 | export function AppChatLinkMenuItems() { 15 | 16 | // external state 17 | const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages(); 18 | const { 19 | renderMarkdown, setRenderMarkdown, 20 | zenMode, setZenMode, 21 | } = useUIPreferencesStore(state => ({ 22 | renderMarkdown: state.renderMarkdown, setRenderMarkdown: state.setRenderMarkdown, 23 | zenMode: state.zenMode, setZenMode: state.setZenMode, 24 | }), shallow); 25 | 26 | 27 | const handleRenderSystemMessageChange = (event: React.ChangeEvent) => setShowSystemMessages(event.target.checked); 28 | const handleRenderMarkdownChange = (event: React.ChangeEvent) => setRenderMarkdown(event.target.checked); 29 | const handleZenModeChange = (event: React.ChangeEvent) => setZenMode(event.target.checked ? 'cleaner' : 'clean'); 30 | 31 | const zenOn = zenMode === 'cleaner'; 32 | 33 | 34 | return <> 35 | 36 | setShowSystemMessages(!showSystemMessages)} sx={{ justifyContent: 'space-between' }}> 37 | 38 | System message 39 | 40 | 45 | 46 | 47 | setRenderMarkdown(!renderMarkdown)} sx={{ justifyContent: 'space-between' }}> 48 | 49 | Markdown 50 | 51 | 56 | 57 | 58 | setZenMode(zenOn ? 'clean' : 'cleaner')} sx={{ justifyContent: 'space-between' }}> 59 | 60 | Zen 61 | 62 | 67 | 68 | 69 | ; 70 | } -------------------------------------------------------------------------------- /src/apps/news/news.hooks.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'zustand/shallow'; 3 | 4 | import { navigateToNews } from '~/common/app.routes'; 5 | import { useAppStateStore } from '~/common/state/store-appstate'; 6 | 7 | import { incrementalVersion } from './news.data'; 8 | 9 | 10 | export function useShowNewsOnUpdate() { 11 | const { usageCount, lastSeenNewsVersion } = useAppStateStore(state => ({ 12 | usageCount: state.usageCount, 13 | lastSeenNewsVersion: state.lastSeenNewsVersion, 14 | }), shallow); 15 | React.useEffect(() => { 16 | const isNewsOutdated = (lastSeenNewsVersion || 0) < incrementalVersion; 17 | if (isNewsOutdated && usageCount > 2) { 18 | // Disable for now 19 | void navigateToNews(); 20 | } 21 | }, [lastSeenNewsVersion, usageCount]); 22 | } 23 | 24 | export function useMarkNewsAsSeen() { 25 | React.useEffect(() => { 26 | useAppStateStore.getState().setLastSeenNewsVersion(incrementalVersion); 27 | }, []); 28 | } -------------------------------------------------------------------------------- /src/apps/personas/AppPersonas.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, Container, ListDivider, Sheet, Typography } from '@mui/joy'; 4 | 5 | import { YTPersonaCreator } from './YTPersonaCreator'; 6 | import ScienceIcon from '@mui/icons-material/Science'; 7 | 8 | 9 | export function AppPersonas() { 10 | return ( 11 | 17 | 18 | 19 | 20 | 21 | Advanced AI Personas 22 | 23 | 24 | 25 | 26 | Experimental 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } -------------------------------------------------------------------------------- /src/apps/settings-modal/ShortcutsModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { ChatMessage } from '../chat/components/message/ChatMessage'; 4 | 5 | import { GoodModal } from '~/common/components/GoodModal'; 6 | import { closeLayoutShortcuts, useLayoutShortcuts } from '~/common/layout/store-applayout'; 7 | import { createDMessage } from '~/common/state/store-chats'; 8 | import { platformAwareKeystrokes } from '~/common/components/KeyStroke'; 9 | 10 | 11 | const shortcutsMd = ` 12 | 13 | | Shortcut | Description | 14 | |---------------------|-------------------------------------------------| 15 | | **Edit** | | 16 | | Shift + Enter | Newline | 17 | | Alt + Enter | Append (no response) | 18 | | Ctrl + Shift + R | Regenerate answer | 19 | | Ctrl + Shift + V | Attach clipboard (better than Ctrl + V) | 20 | | Ctrl + M | Microphone (voice typing) | 21 | | **Chats** | | 22 | | Ctrl + Alt + Left | **Previous** chat (in history) | 23 | | Ctrl + Alt + Right | **Next** chat (in history) | 24 | | Ctrl + Alt + N | **New** chat | 25 | | Ctrl + Alt + X | **Reset** chat | 26 | | Ctrl + Alt + D | **Delete** chat | 27 | | Ctrl + Alt + B | **Branch** chat | 28 | | **Settings** | | 29 | | Ctrl + Shift + P | ⚙️ Preferences | 30 | | Ctrl + Shift + M | 🧠 Models | 31 | | Ctrl + Shift + O | Options (current Chat Model) | 32 | | Ctrl + Shift + ? | Shortcuts | 33 | 34 | `.trim(); 35 | 36 | const shortcutsMessage = createDMessage('assistant', platformAwareKeystrokes(shortcutsMd)); 37 | 38 | 39 | export function ShortcutsModal() { 40 | 41 | // external state 42 | const showShortcuts = useLayoutShortcuts(); 43 | 44 | return ( 45 | 50 | 51 | 52 | ); 53 | } -------------------------------------------------------------------------------- /src/apps/settings-modal/VoiceSettings.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { FormControl } from '@mui/joy'; 4 | 5 | import { useChatAutoAI, useChatMicTimeoutMs } from '../chat/store-app-chat'; 6 | 7 | import { useElevenLabsVoices } from '~/modules/elevenlabs/useElevenLabsVoiceDropdown'; 8 | 9 | import { FormLabelStart } from '~/common/components/forms/FormLabelStart'; 10 | import { FormRadioControl } from '~/common/components/forms/FormRadioControl'; 11 | import { LanguageSelect } from '~/common/components/LanguageSelect'; 12 | import { useIsMobile } from '~/common/components/useMatchMedia'; 13 | 14 | 15 | export function VoiceSettings() { 16 | 17 | // external state 18 | const isMobile = useIsMobile(); 19 | const { autoSpeak, setAutoSpeak } = useChatAutoAI(); 20 | const { hasVoices } = useElevenLabsVoices(); 21 | const [chatTimeoutMs, setChatTimeoutMs] = useChatMicTimeoutMs(); 22 | 23 | 24 | // this converts from string keys to numbers and vice versa 25 | const chatTimeoutValue: string = '' + chatTimeoutMs; 26 | const setChatTimeoutValue = (value: string) => value && setChatTimeoutMs(parseInt(value)); 27 | 28 | return <> 29 | 30 | {/* LanguageSelect: moved from the UI settings (where it logically belongs), just to group things better from an UX perspective */} 31 | 32 | 35 | 36 | 37 | 38 | {!isMobile && 5000 ? 'Best for thinking' : 'Standard'} 41 | options={[ 42 | { value: '600', label: '.6s' }, 43 | { value: '2000', label: '2s' }, 44 | { value: '15000', label: '15s' }, 45 | ]} 46 | value={chatTimeoutValue} onChange={setChatTimeoutValue} 47 | />} 48 | 49 | 61 | 62 | ; 63 | } -------------------------------------------------------------------------------- /src/common/app.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Application Identity (Brand) 3 | * 4 | * Also note that the 'Brand' is used in the following places: 5 | * - README.md all over 6 | * - package.json app-slug and version 7 | * - [public/manifest.json] name, short_name, description, theme_color, background_color 8 | */ 9 | export const Brand = { 10 | Title: { 11 | Base: 'com-chat', 12 | Common: (process.env.NODE_ENV === 'development' ? '[DEV] ' : '') + 'com-chat', 13 | }, 14 | Meta: { 15 | Description: 'Leading open-source AI web interface to help you learn, think, and do. AI personas, superior privacy, advanced features, and fun UX.', 16 | SiteName: 'com-chat | Harnessing AI for You', 17 | ThemeColor: '#32383E', 18 | TwitterSite: '@enricoros', 19 | }, 20 | URIs: { 21 | Home: 'https://com-chat.com', 22 | CardImage: 'https://com-chat.com/icons/card-dark-1200.png', 23 | OpenRepo: 'https://github.com/smart-window/com-chat', 24 | OpenProject: 'https://github.com/smart-window', 25 | SupportInvite: 'https://discord.gg/wczEJaPRxq', 26 | PrivacyPolicy: 'https://com-chat.com/privacy', 27 | }, 28 | }; -------------------------------------------------------------------------------- /src/common/app.routes.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Application Routes 3 | // 4 | // We will centralize them here, for UI and routing purposes. 5 | // 6 | 7 | import Router from 'next/router'; 8 | 9 | import type { DConversationId } from '~/common/state/store-chats'; 10 | import { isBrowser } from './util/pwaUtils'; 11 | 12 | 13 | export const ROUTE_INDEX = '/'; 14 | export const ROUTE_APP_CHAT = '/'; 15 | export const ROUTE_APP_LINK_CHAT = '/link/chat/:linkId'; 16 | export const ROUTE_APP_NEWS = '/news'; 17 | const ROUTE_CALLBACK_OPENROUTER = '/link/callback_openrouter'; 18 | 19 | 20 | // Get Paths 21 | 22 | export const getCallbackUrl = (source: 'openrouter') => { 23 | const callbackUrl = new URL(window.location.href); 24 | switch (source) { 25 | case 'openrouter': 26 | callbackUrl.pathname = ROUTE_CALLBACK_OPENROUTER; 27 | break; 28 | default: 29 | throw new Error(`Unknown source: ${source}`); 30 | } 31 | return callbackUrl.toString(); 32 | }; 33 | 34 | export const getChatLinkRelativePath = (chatLinkId: string) => ROUTE_APP_LINK_CHAT.replace(':linkId', chatLinkId); 35 | 36 | 37 | /// Simple Navigation 38 | 39 | export const navigateToIndex = navigateFn(ROUTE_INDEX); 40 | 41 | export const navigateToChat = async (conversationId?: DConversationId) => { 42 | if (conversationId) { 43 | await Router.push( 44 | { 45 | pathname: ROUTE_APP_CHAT, 46 | query: { 47 | conversationId, 48 | }, 49 | }, 50 | ROUTE_APP_CHAT, 51 | ); 52 | } else { 53 | await Router.push(ROUTE_APP_CHAT, ROUTE_APP_CHAT); 54 | } 55 | }; 56 | export const navigateToNews = navigateFn(ROUTE_APP_NEWS); 57 | 58 | export const navigateBack = Router.back; 59 | 60 | export const reloadPage = () => isBrowser && window.location.reload(); 61 | 62 | function navigateFn(path: string) { 63 | return (replace?: boolean): Promise => Router[replace ? 'replace' : 'push'](path); 64 | } 65 | 66 | 67 | /// Launch Apps 68 | 69 | export interface AppCallQueryParams { 70 | conversationId: string; 71 | personaId: string; 72 | } 73 | 74 | export function launchAppCall(conversationId: string, personaId: string) { 75 | void Router.push( 76 | { 77 | pathname: `/call`, 78 | query: { 79 | conversationId, 80 | personaId, 81 | } satisfies AppCallQueryParams, 82 | }, 83 | // '/call', 84 | ).then(); 85 | } -------------------------------------------------------------------------------- /src/common/components/ConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, Button, Divider, Typography } from '@mui/joy'; 4 | import WarningRoundedIcon from '@mui/icons-material/WarningRounded'; 5 | 6 | import { GoodModal } from '~/common/components/GoodModal'; 7 | 8 | 9 | /** 10 | * A confirmation dialog (Joy Modal) 11 | * Pass the question and the positive answer, and get called when it's time to close the dialog, or when the positive action is taken 12 | */ 13 | export function ConfirmationModal(props: { 14 | open?: boolean, onClose: () => void, onPositive: () => void, 15 | title?: string | React.JSX.Element, 16 | confirmationText: string | React.JSX.Element, 17 | positiveActionText: string 18 | }) { 19 | return ( 20 | } 24 | onClose={props.onClose} 25 | hideBottomClose 26 | > 27 | 28 | 29 | {props.confirmationText} 30 | 31 | 32 | 35 | 38 | 39 | 40 | ); 41 | } -------------------------------------------------------------------------------- /src/common/components/GoodModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Box, Button, Divider, Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'; 4 | import { SxProps } from '@mui/joy/styles/types'; 5 | 6 | 7 | /** 8 | * Base for our Modal components (Preferences, Models Setup, etc.) 9 | */ 10 | export function GoodModal(props: { 11 | title?: string | React.JSX.Element, 12 | titleStartDecorator?: React.JSX.Element, 13 | strongerTitle?: boolean, 14 | noTitleBar?: boolean, 15 | dividers?: boolean, 16 | open: boolean, 17 | onClose?: () => void, 18 | hideBottomClose?: boolean, 19 | startButton?: React.JSX.Element, 20 | sx?: SxProps, 21 | children: React.ReactNode, 22 | }) { 23 | const showBottomClose = !!props.onClose && props.hideBottomClose !== true; 24 | return ( 25 | 26 | 27 | 34 | 35 | {!props.noTitleBar && 36 | 37 | {props.title || ''} 38 | 39 | {!!props.onClose && } 40 | } 41 | 42 | {props.dividers === true && } 43 | 44 | {props.children} 45 | 46 | {props.dividers === true && } 47 | 48 | {(!!props.startButton || showBottomClose) && 49 | {props.startButton} 50 | {showBottomClose && } 53 | } 54 | 55 | 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/common/components/GoodTooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Tooltip } from '@mui/joy'; 4 | import { SxProps } from '@mui/joy/styles/types'; 5 | 6 | 7 | /** 8 | * Tooltip with text that wraps to multiple lines (doesn't go too long) 9 | */ 10 | export const GoodTooltip = (props: { 11 | title: string | React.JSX.Element | null, 12 | placement?: 'top' | 'bottom' | 'top-start', 13 | isError?: boolean, isWarning?: boolean, 14 | children: React.JSX.Element, 15 | sx?: SxProps 16 | }) => 17 | 24 | {props.children} 25 | ; 26 | -------------------------------------------------------------------------------- /src/common/components/InlineError.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Alert, Typography } from '@mui/joy'; 4 | import { SxProps } from '@mui/joy/styles/types'; 5 | 6 | export function InlineError(props: { error: React.JSX.Element | null | any, severity?: 'warning' | 'danger' | 'info', sx?: SxProps }) { 7 | const color = props.severity === 'info' ? 'primary' : props.severity || 'warning'; 8 | return ( 9 | 10 | 11 | {props.error?.message || props.error || 'Unknown error'} 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/common/components/InlineTextarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Textarea } from '@mui/joy'; 4 | import { ColorPaletteProp, SxProps } from '@mui/joy/styles/types'; 5 | 6 | import { useUIPreferencesStore } from '~/common/state/store-ui'; 7 | 8 | 9 | export function InlineTextarea(props: { initialText: string, color?: ColorPaletteProp, onEdit: (text: string) => void, sx?: SxProps }) { 10 | 11 | const [text, setText] = React.useState(props.initialText); 12 | const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline); 13 | 14 | const handleEditTextChanged = (e: React.ChangeEvent) => setText(e.target.value); 15 | 16 | const handleEditKeyDown = (e: React.KeyboardEvent) => { 17 | if (e.key === 'Enter') { 18 | const shiftOrAlt = e.shiftKey || e.altKey; 19 | if (enterIsNewline ? shiftOrAlt : !shiftOrAlt) { 20 | e.preventDefault(); 21 | props.onEdit(text); 22 | } 23 | } 24 | }; 25 | 26 | const handleEditBlur = () => props.onEdit(text); 27 | 28 | return ( 29 |