This commit is contained in:
geoffsee
2025-05-22 23:14:01 -04:00
commit 33679583af
242 changed files with 15090 additions and 0 deletions

9
.dev.vars Normal file
View File

@@ -0,0 +1,9 @@
OPENAI_API_KEY=
EVENTSOURCE_HOST=
GROQ_API_KEY=
ANTHROPIC_API_KEY=
FIREWORKS_API_KEY=
XAI_API_KEY=
CEREBRAS_API_KEY=
CLOUDFLARE_API_KEY=
CLOUDFLARE_ACCOUNT_ID=

View File

@@ -0,0 +1,34 @@
name: "Update VPN Blocklist"
on:
# uncomment to deploy on next push
# push:
# branches:
# - main
workflow_dispatch: # Manual trigger
schedule:
- cron: "57 8 * * *"
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
# Step 5: Update block-list-ipv4.txt
- name: Update block-list-ipv4.txt
run: -|
curl https://raw.githubusercontent.com/X4BNet/lists_vpn/refs/heads/main/output/vpn/ipv4.txt > workers/session-proxy/block-list-ipv4.txt
# Step 6: Deploy application
- name: Deploy application
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: bun deploy:session-proxy:production && bun deploy:session-proxy:staging && bun deploy:session-proxy:dev

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
**/node_modules/
/dist/
**/.wrangler/
/.idea/
public/sitemap.xml

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Geoff Seemueller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
## open-geoff-seemueller-io
I am making this available for others to learn from. It is a downstream fork of the source code powering my personal website. Search and attachments are not implemented. I have several more mature variants of this repository which have extended capabilities.
### Stack:
- vike
- react
- cloudflare workers
- openai sdk
## Quickstart
1. `bun i`
2. `bun run build`
3. Configure .dev.vars
4. In isolated shells, run `bun run worker:dev` and `bun run vite:dev`
### Further Documentation
Upstream versions contain further documentation, tests, and features. Any of the latter can be made available upon request.
History
---
### **May 2025**
| Hash | Change |
| ------- | --------------------------------------------------------------------- |
| 049bf97 | **Add** *seemueller.ai* sidebar link and constrain Hero heading width |
| 6be5f68 | **Consolidate** configuration files (CI, bundler, environment) |
| a047f19 | **Expand** Markdown usage guide for endusers |
---
### **April 2025**
| Hash | Change |
| ----------------- | --------------------------------------------------------------------------- |
| ce3457a | **Introduce** custom error page and purge dead code |
| 806c933 | **Fix** duplicate`robots.txt` entries (SEO) |
| 4bbe8ea · e909e0b | **Restore** bundlesize safeguards and **switch** toBun as package manager |
| 7f1520b·aa71f86 | **Automate** VPN blocklist deployment; retire legacy pull script |
| b332c93 | **Repair** CI job for blocklist updates |
| d506e7d | **Deprecate** experimental **Mixtral** model |
---
### **March 2025**
| Hash | Change |
| ----------------- | ------------------------------------------------------------------------ |
| 8b9e9eb | **Add** permodel `max_tokens` limits |
| cb0d912 | **Expose** Cloudflare AI models for staging |
| 85de6ed·cec4f70 | **Shrink** production bundles: reenable minifier and drop unused assets |
| 4805c7e · 9709f61 | **Refresh** landingpage copy (“Welcomehome”) |
---
### **February 2025**
| Hash | Change |
| ----------------- | --------------------------------------------------------------------------- |
| 8d70eef·886d45a | **Ship** runtime theme switching with dynamic navigation colors |
| 4efaa93/194b168 | **Polish** resume & selector styling (padding, borders) |
| 7f925d1·0b9088a | **Refine** responsive chat: correct breakpoints, input scaling, MobX typing |
| 0865897 | **Remove** deprecated DocumentAPI |
| e355540 | **Fix** background rendering issues |
---
### **January 2025**
| Hash | Change |
| ----------------- | --------------------------------------------------------------------------- |
| d8b47c9 ·361a523 | **Enable** full LaTeX/KaTeX math rendering |
| 64a0513·6ecc4f5 | **Set** default model to *llamav3p170binstruct* and **limit** model list |
| 0ad9dc4 | **Add** ratelimit middleware |
| 42f371b·1f526ce | **Launch** VPN blocker with live CIDR validation and CI workflow |
| f7464a1 | **Remove** useruploaded attachments to cut storage costs |
| e9c3a12 | **Rotate** Fireworks API credentials |
---
### **Late 2024 Highlights**
| Area | Notable Work |
| ----------------- | ---------------------------------------------------------------------- |
| **Generative UX** | Imagegeneration pipeline; modelselection UI; seasonal prompt packs |
| **Analytics** | Workerbased metrics engine, event capture, tail helpers |
| **Model Support** | GROQ & Anthropic streaming integrations with attachment handling |
| **Feedback Loop** | Modaldriven userfeedback feature with dedicated store |
| **Payments** | Onchain ETH/DOGE processor with dynamic deposit addresses |
| **Performance** | Tokenizer limits, LightningCSS minifier, esbuild migration |
| **Mobile & A11y** | Dynamic textarea sizing, cookieconsent banner, iMessagestyle bubbles |
### August 2024 - December 2024
History is available by request.

1962
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb Executable file

Binary file not shown.

1
gitleaks-report.json Normal file
View File

@@ -0,0 +1 @@
[]

106
package.json Normal file
View File

@@ -0,0 +1,106 @@
{
"type": "module",
"scripts": {
"clean": "rm -rf node_modules && rm -rf .wrangler && rm -rf dist",
"build": "pnpm client:build && pnpm worker:build",
"vite:dev": "pnpm vite dev --host 0.0.0.0",
"worker:dev": "pnpm run build && pnpm wrangler dev",
"client": "pnpm vite:dev",
"client:build": "vite build",
"worker:build": "WRANGLER_LOG=info wrangler build",
"agents:dev": "(cd ../web-agent-rs; cargo run)",
"agents:docker": "(cd ../web-agent-rs; docker compose up --build)",
"dev:session-proxy": "wrangler dev -c workers/session-proxy/wrangler-session-proxy.toml",
"dev:image-generation-service": "wrangler dev -c workers/image-generation-service/wrangler-image-generation-service.toml",
"dev:email-service": "wrangler dev -c workers/email/wrangler-email.toml",
"dev:analytics-service": "wrangler dev -c workers/analytics/wrangler-analytics.toml",
"deploy:dev": "CI=true vite build && wrangler deploy --keep-vars=true --minify=true --env dev && pnpm deploy:session-proxy:dev",
"deploy:staging": "CI=true vite build && wrangler deploy --minify --env staging && pnpm deploy:session-proxy:staging",
"deploy:production": "CI=true vite build && wrangler deploy --minify --env production",
"deploy:production:full": "CI=true vite build && wrangler deploy --minify --env production && pnpm deploy:session-proxy:production && ./scripts/update_vpn_blocklist.sh && watch gh run list --workflow=update-vpn-blocklist.yaml",
"deploy:session-proxy:dev": "CI=true wrangler deploy --minify -c workers/session-proxy/wrangler-session-proxy.toml --env dev",
"deploy:session-proxy:staging": "CI=true wrangler deploy --minify -c workers/session-proxy/wrangler-session-proxy.toml --env staging",
"deploy:session-proxy:production": "CI=true wrangler deploy --minify -c workers/session-proxy/wrangler-session-proxy.toml --env production",
"deploy:rate-limiter": "CI=true wrangler deploy --minify -c workers/rate-limiter/wrangler-rate-limiter.toml",
"deploy:image-generation-service": "wrangler deploy -c workers/image-generation-service/wrangler-image-generation-service.toml",
"deploy:email-service": "wrangler deploy -c workers/email/wrangler-email.toml",
"deploy:analytics-service": "wrangler deploy -c workers/analytics/wrangler-analytics.toml",
"deploy:next": "pnpm clean && pnpm install --frozen-lockfile && pnpm deploy:staging && pnpm deploy:production",
"deploy:all": "pnpm deploy:dev && pnpm deploy:staging && pnpm deploy:production",
"tail:dev": "wrangler tail",
"tail:staging": "wrangler tail --env staging",
"tail:production": "wrangler tail --env production",
"tail:email-service": "wrangler tail -c workers/email/wrangler-email.toml",
"tail:analytics-service": "wrangler tail -c workers/analytics/wrangler-analytics.toml",
"tail:image-generation-service": "wrangler tail -c workers/image-generation-service/wrangler-image-generation-service.toml",
"tail:session-proxy": "wrangler tail -c workers/session-proxy/wrangler-session-proxy.toml --env production"
},
"devDependencies": {
"@babel/runtime-corejs3": "^7.26.0",
"babel-plugin-inferno": "^6.7.2",
"compression": "^1.7.5",
"express": "^4.21.2",
"kill-port": "^2.0.1",
"llama3-tokenizer-js": "^1.2.0",
"mimetext": "^3.0.24",
"replicate": "^1.0.1",
"scheduler": "^0.23.2",
"suspend-react": "^0.1.3",
"together-ai": "^0.7.0",
"@anthropic-ai/sdk": "^0.32.1",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-react-jsx": "^7.25.9",
"@babel/plugin-transform-runtime": "^7.25.9",
"@babel/preset-env": "^7.26.0",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"@babel/runtime": "^7.26.9",
"@chakra-ui/react": "^2.10.6",
"@cloudflare/workers-types": "^4.20241205.0",
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@mdxeditor/editor": "^3.20.0",
"@types/marked": "^6.0.0",
"@vitejs/plugin-react": "^4.3.4",
"chokidar": "^4.0.1",
"framer-motion": "^11.13.1",
"gpt-tokenizer": "^2.7.0",
"hastscript": "^9.0.0",
"isomorphic-dompurify": "^2.19.0",
"itty-router": "^5.0.18",
"js-cookie": "^3.0.5",
"katex": "^0.16.20",
"lucide-react": "^0.436.0",
"manifold-workflow-engine": "^2.0.2",
"marked": "^15.0.4",
"marked-extended-latex": "^1.1.0",
"marked-footnote": "^1.2.4",
"marked-katex-extension": "^5.1.4",
"mobx": "^6.13.5",
"mobx-react-lite": "^4.0.7",
"mobx-state-tree": "^6.0.1",
"moo": "^0.5.2",
"openai": "^4.76.0",
"qrcode.react": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-streaming": "^0.3.44",
"react-textarea-autosize": "^8.5.5",
"rehype-katex": "^7.0.1",
"rehype-react": "^8.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"shiki": "^1.24.0",
"terser": "^5.39.0",
"typescript": "^5.7.2",
"vike": "0.4.193",
"vite": "^5.4.11",
"wrangler": "^4.14.4",
"zod": "^3.23.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

44
public/cfga.min.js vendored Normal file
View File

@@ -0,0 +1,44 @@
!(function (t, e, n) {
var a = t.screen,
r = encodeURIComponent,
o = Math.max,
i = t.performance,
d = i && i.timing,
c = function (t) {
return isNaN(t) || t == 1 / 0 || t < 0 ? void 0 : t;
},
g = function (t) {
return Math.random().toString(36).slice(-t);
},
m = function (t) {
return Math.ceil(Math.random() * (t - 1)) + 1;
};
function s() {
var i = [
g(m(4)) + "=" + g(m(6)),
"ga=" + t.ga_tid,
"dt=" + r(e.title),
"de=" + r(e.characterSet || e.charset),
"dr=" + r(e.referrer),
"ul=" + (n.language || n.browserLanguage || n.userLanguage),
"sd=" + a.colorDepth + "-bit",
"sr=" + a.width + "x" + a.height,
"vp=" +
o(e.documentElement.clientWidth, t.innerWidth || 0) +
"x" +
o(e.documentElement.clientHeight, t.innerHeight || 0),
"plt=" + c(d.loadEventStart - d.navigationStart || 0),
"dns=" + c(d.domainLookupEnd - d.domainLookupStart || 0),
"pdt=" + c(d.responseEnd - d.responseStart || 0),
"rrt=" + c(d.redirectEnd - d.redirectStart || 0),
"tcp=" + c(d.connectEnd - d.connectStart || 0),
"srt=" + c(d.responseStart - d.requestStart || 0),
"dit=" + c(d.domInteractive - d.domLoading || 0),
"clt=" + c(d.domContentLoadedEventStart - d.navigationStart || 0),
"z=" + Date.now(),
];
(t.__ga_img = new Image()), (t.__ga_img.src = t.ga_api + "?" + i.join("&"));
}
(t.cfga = s),
"complete" === e.readyState ? s() : t.addEventListener("load", s);
})(window, document, navigator);

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

BIN
public/me.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
public/rehoboam.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

7
public/robots.txt Normal file
View File

@@ -0,0 +1,7 @@
User-agent: *
Allow: /
Allow: /connect
Disallow: /api
Disallow: /assets
Sitemap: https://geoff.seemueller.io/sitemap.xml

19
public/site.webmanifest Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#fffff0",
"background_color": "#000000",
"display": "standalone"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,31 @@
const TOKEN = "";
const ACCOUNT_ID = "";
async function showTables() {
const url = `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/analytics_engine/sql`;
const options = {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "SHOW TABLES",
};
try {
console.log("Sending request to Cloudflare Analytics Engine...");
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log("Response received:", JSON.stringify(data, null, 2));
} catch (error) {
console.error("Error occurred:", error.message);
}
}
showTables();

30
scripts/gen_sitemap.js Executable file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bun
import fs from "fs";
const currentDate = new Date().toISOString().split("T")[0];
const sitemapTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 ">
<url>
<loc>https://geoff.seemueller.io/</loc>
<lastmod>${currentDate}</lastmod>
<priority>1.0</priority>
</url>
<url>
<loc>https://geoff.seemueller.io/connect</loc>
<lastmod>${currentDate}</lastmod>
<priority>0.7</priority>
</url>
</urlset>`;
const sitemapPath = "./public/sitemap.xml";
fs.writeFile(sitemapPath, sitemapTemplate, (err) => {
if (err) {
console.error("Error writing sitemap file:", err);
process.exit(1);
}
console.log("Sitemap updated successfully with current date:", currentDate);
});

View File

@@ -0,0 +1,22 @@
(async () => {
// Run the script with bun so it automatically picks up the env
const apiKey = process.env.GROQ_API_KEY;
try {
const response = await fetch("https://api.groq.com/openai/v1/models", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
console.log(JSON.stringify(data));
} catch (error) {
console.error("Error fetching data:", error);
}
})();

36
scripts/killport.js Normal file
View File

@@ -0,0 +1,36 @@
import * as child_process from "node:child_process";
export const killProcessOnPort = (port) => {
return new Promise((resolve, reject) => {
child_process.exec(`lsof -t -i :${port}`.trim(), (err, stdout) => {
if (err) {
if (err.code !== 1) {
console.error(`Error finding process on port ${port}:`, err);
return reject(err);
} else {
console.log(`No process found on port ${port}`);
return resolve();
}
}
const pid = stdout.trim();
if (!pid) {
console.log(`No process is currently running on port ${port}`);
return resolve();
}
child_process.exec(`kill -9 ${pid}`.trim(), (killErr) => {
if (killErr) {
console.error(
`Failed to kill process ${pid} on port ${port}`,
killErr,
);
return reject(killErr);
}
console.log(`Successfully killed process ${pid} on port ${port}`);
resolve();
});
});
});
};

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
gh workflow run "Update VPN Blocklist"

12
secrets.json Normal file
View File

@@ -0,0 +1,12 @@
{
"OPENAI_API_KEY": "",
"OPENAI_API_ENDPOINT": "",
"PERIGON_API_KEY": "",
"EVENTSOURCE_HOST": "",
"GROQ_API_KEY": "",
"ANTHROPIC_API_KEY": "",
"FIREWORKS_API_KEY": "",
"GEMINI_API_KEY": "",
"XAI_API_KEY": "",
"CEREBRAS_API_KEY": ""
}

View File

@@ -0,0 +1,26 @@
import React from "react";
import { IconButton } from "@chakra-ui/react";
import { LucideHammer } from "lucide-react";
import { toolbarButtonZIndex } from "./toolbar/Toolbar";
export default function BuiltWithButton() {
return (
<IconButton
aria-label="Build Info"
icon={<LucideHammer />}
size="md"
bg="transparent"
stroke="text.accent"
color="text.accent"
onClick={() => alert("Built by Geoff Seemueller")}
_hover={{
bg: "transparent",
svg: {
stroke: "accent.secondary",
transition: "stroke 0.3s ease-in-out",
},
}}
zIndex={toolbarButtonZIndex}
/>
);
}

View File

@@ -0,0 +1,53 @@
import { getColorThemes } from "../layout/theme/color-themes";
import { Center, IconButton, VStack } from "@chakra-ui/react";
import userOptionsStore from "../stores/UserOptionsStore";
import { Circle } from "lucide-react";
import { toolbarButtonZIndex } from "./toolbar/Toolbar";
import React from "react";
import { useIsMobile } from "./contexts/MobileContext";
export function ThemeSelectionOptions() {
const children = [];
const isMobile = useIsMobile();
for (const theme of getColorThemes()) {
children.push(
<IconButton
as="div"
key={theme.name}
onClick={() => userOptionsStore.selectTheme(theme.name)}
size="xs"
icon={
<Circle
size={!isMobile ? 16 : 20}
stroke="transparent"
style={{
background: `conic-gradient(${theme.colors.background.primary.startsWith("#") ? theme.colors.background.primary : theme.colors.background.secondary} 0 50%, ${theme.colors.text.secondary} 50% 100%)`,
borderRadius: "50%",
boxShadow: "0 0 0.5px 0.25px #fff",
cursor: "pointer",
transition: "background 0.2s",
}}
/>
}
bg="transparent"
borderRadius="50%" // Ensures the button has a circular shape
stroke="transparent"
color="transparent"
_hover={{
svg: {
transition: "stroke 0.3s ease-in-out", // Smooth transition effect
},
}}
zIndex={toolbarButtonZIndex}
/>,
);
}
return (
<VStack align={!isMobile ? "end" : "start"} p={1.2}>
<Center>{children}</Center>
</VStack>
);
}

View File

@@ -0,0 +1,84 @@
import { motion } from "framer-motion";
import { Box, Center, VStack } from "@chakra-ui/react";
import {
welcome_home_text,
welcome_home_tip,
} from "../static-data/welcome_home_text";
import CustomMarkdownRenderer, {
WelcomeHomeMarkdownRenderer,
} from "./chat/CustomMarkdownRenderer";
function WelcomeHomeMessage({ visible }) {
const containerVariants = {
visible: {
transition: {
staggerChildren: 0.15,
},
},
hidden: {
transition: {
staggerChildren: 0.05,
staggerDirection: -1,
},
},
};
const textVariants = {
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.165, 0.84, 0.44, 1],
},
},
hidden: {
opacity: 0,
y: 20,
transition: {
duration: 0.3,
ease: [0.165, 0.84, 0.44, 1],
},
},
};
return (
<Center>
<VStack spacing={8} align="center" maxW="400px">
{/* Welcome Message */}
<Box
fontSize="sm"
fontStyle="italic"
textAlign="center"
color="text.secondary"
mt={4}
>
<motion.div
variants={containerVariants}
initial="hidden"
animate={visible ? "visible" : "hidden"}
>
<Box userSelect={"none"}>
<motion.div variants={textVariants}>
<WelcomeHomeMarkdownRenderer markdown={welcome_home_text} />
</motion.div>
</Box>
</motion.div>
</Box>
<motion.div variants={textVariants}>
<Box
fontSize="sm"
fontStyle="italic"
textAlign="center"
color="text.secondary"
mt={1}
>
<CustomMarkdownRenderer markdown={welcome_home_tip} />
</Box>
</motion.div>
</VStack>
</Center>
);
}
export default WelcomeHomeMessage;

View File

@@ -0,0 +1,44 @@
import React from "react";
import { Grid, GridItem, Image, Text } from "@chakra-ui/react";
const fontSize = "md";
function AboutComponent() {
return (
<Grid
templateColumns="1fr"
gap={4}
maxW={["100%", "100%", "100%"]}
mx="auto"
className="about-container"
>
<GridItem colSpan={1} justifySelf="center" mb={[6, 6, 8]}>
<Image
src="/me.png"
alt="Geoff Seemueller"
borderRadius="full"
boxSize={["120px", "150px"]}
objectFit="cover"
/>
</GridItem>
<GridItem
colSpan={1}
maxW={["100%", "100%", "container.md"]}
justifySelf="center"
minH={"100%"}
>
<Grid templateColumns="1fr" gap={4} overflowY={"auto"}>
<GridItem>
<Text fontSize={fontSize}>
If you're interested in collaborating on innovative projects that
push technological boundaries and create real value, I'd be keen
to connect and explore potential opportunities.
</Text>
</GridItem>
</Grid>
</GridItem>
</Grid>
);
}
export default AboutComponent;

View File

@@ -0,0 +1,40 @@
import React from "react";
import { IconButton, Tag, TagCloseButton, TagLabel } from "@chakra-ui/react";
import { PaperclipIcon } from "lucide-react";
// Add a new component for UploadedItem
export const UploadedItem: React.FC<{
url: string;
onRemove: () => void;
name: string;
}> = ({ url, onRemove, name }) => (
<Tag size="md" borderRadius="full" variant="solid" colorScheme="teal">
<TagLabel>{name || url.split("/").pop()}</TagLabel>
<TagCloseButton onClick={onRemove} />
</Tag>
);
export const AttachmentButton: React.FC<{
onClick: () => void;
disabled: boolean;
}> = ({ onClick, disabled }) => (
<IconButton
aria-label="Attach"
title="Attach"
bg="transparent"
color="text.tertiary"
icon={<PaperclipIcon size={"1.3337rem"} />}
onClick={onClick}
_hover={{
bg: "transparent",
svg: {
stroke: "accent.secondary",
transition: "stroke 0.3s ease-in-out",
},
}}
variant="ghost"
size="sm"
isDisabled={disabled}
_focus={{ boxShadow: "none" }}
/>
);

View File

@@ -0,0 +1,75 @@
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Box, Grid, GridItem } from "@chakra-ui/react";
import ChatMessages from "./ChatMessages";
import ChatInput from "./ChatInput";
import chatStore from "../../stores/ClientChatStore";
import menuState from "../../stores/AppMenuStore";
import WelcomeHomeMessage from "../WelcomeHomeMessage";
const Chat = observer(({ height, width }) => {
const scrollRef = useRef();
const [isAndroid, setIsAndroid] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
setIsAndroid(/android/i.test(window.navigator.userAgent));
}
}, []);
return (
<Grid
templateRows="1fr auto"
templateColumns="1fr"
height={height}
width={width}
gap={0}
>
<GridItem alignSelf="center" hidden={!(chatStore.messages.length < 1)}>
<WelcomeHomeMessage visible={chatStore.messages.length < 1} />
</GridItem>
<GridItem
overflow="auto"
width="100%"
maxH="100%"
ref={scrollRef}
// If there are attachments, use "100px". Otherwise, use "128px" on Android, "73px" elsewhere.
pb={
chatStore.attachments.length > 0
? "100px"
: isAndroid
? "128px"
: "73px"
}
alignSelf="flex-end"
>
<ChatMessages scrollRef={scrollRef} />
</GridItem>
<GridItem
position="relative"
bg="background.primary"
zIndex={1000}
width="100%"
>
<Box
w="100%"
display="flex"
justifyContent="center"
mx="auto"
hidden={menuState.isOpen}
>
<ChatInput
input={chatStore.input}
setInput={(value) => chatStore.setInput(value)}
handleSendMessage={chatStore.sendMessage}
isLoading={chatStore.isLoading}
/>
</Box>
</GridItem>
</Grid>
);
});
export default Chat;

View File

@@ -0,0 +1,157 @@
import React, { useEffect, useRef, useState } from "react";
import {
Box,
Button,
Grid,
GridItem,
useBreakpointValue,
} from "@chakra-ui/react";
import { observer } from "mobx-react-lite";
import chatStore from "../../stores/ClientChatStore";
import InputMenu from "./flyoutmenu/InputMenu";
import InputTextarea from "./ChatInputTextArea";
import SendButton from "./ChatInputSendButton";
import { useMaxWidth } from "../../layout/useMaxWidth";
import userOptionsStore from "../../stores/UserOptionsStore";
const ChatInput = observer(() => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const maxWidth = useMaxWidth();
const [inputValue, setInputValue] = useState<string>("");
const [containerHeight, setContainerHeight] = useState(56);
const [containerBorderRadius, setContainerBorderRadius] = useState(9999);
const [shouldFollow, setShouldFollow] = useState<boolean>(
userOptionsStore.followModeEnabled,
);
const [couldFollow, setCouldFollow] = useState<boolean>(chatStore.isLoading);
const [inputWidth, setInputWidth] = useState<string>("50%");
useEffect(() => {
setShouldFollow(chatStore.isLoading && userOptionsStore.followModeEnabled);
setCouldFollow(chatStore.isLoading);
}, [chatStore.isLoading, userOptionsStore.followModeEnabled]);
useEffect(() => {
inputRef.current?.focus();
setInputValue(chatStore.input);
}, [chatStore.input]);
useEffect(() => {
if (containerRef.current) {
const observer = new ResizeObserver((entries) => {
for (let entry of entries) {
const newHeight = entry.target.clientHeight;
setContainerHeight(newHeight);
const newBorderRadius = Math.max(28 - (newHeight - 56) * 0.2, 16);
setContainerBorderRadius(newBorderRadius);
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
chatStore.sendMessage();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
chatStore.sendMessage();
}
};
const inputMaxWidth = useBreakpointValue(
{ base: "50rem", lg: "50rem", md: "80%", sm: "100vw" },
{ ssr: true },
);
const inputMinWidth = useBreakpointValue({ lg: "40rem" }, { ssr: true });
useEffect(() => {
setInputWidth("100%");
}, [inputMaxWidth, inputMinWidth]);
return (
<Box
width={inputWidth}
maxW={inputMaxWidth}
minWidth={inputMinWidth}
mx="auto"
p={2}
pl={2}
pb={`calc(env(safe-area-inset-bottom) + 16px)`}
bottom={0}
position="fixed"
zIndex={1000}
>
{couldFollow && (
<Box
position="absolute"
top={-8}
right={0}
zIndex={1001}
display="flex"
justifyContent="flex-end"
>
<Button
size="sm"
variant="ghost"
colorScheme="blue"
onClick={(_) => {
userOptionsStore.toggleFollowMode();
}}
isDisabled={!chatStore.isLoading}
>
{shouldFollow ? "Disable Follow Mode" : "Enable Follow Mode"}
</Button>
</Box>
)}
<Grid
ref={containerRef}
p={2}
bg="background.secondary"
borderRadius={`${containerBorderRadius}px`}
templateColumns="auto 1fr auto"
gap={2}
alignItems="center"
style={{
transition: "border-radius 0.2s ease",
}}
>
<GridItem>
<InputMenu
selectedModel={chatStore.model}
onSelectModel={chatStore.setModel}
isDisabled={chatStore.isLoading}
/>
</GridItem>
<GridItem>
<InputTextarea
inputRef={inputRef}
value={chatStore.input}
onChange={chatStore.setInput}
onKeyDown={handleKeyDown}
isLoading={chatStore.isLoading}
/>
</GridItem>
<GridItem>
<SendButton
isLoading={chatStore.isLoading}
isDisabled={chatStore.isLoading || !chatStore.input.trim()}
onClick={handleSubmit}
/>
</GridItem>
</Grid>
</Box>
);
});
export default ChatInput;

View File

@@ -0,0 +1,55 @@
import React from "react";
import { Button } from "@chakra-ui/react";
import clientChatStore from "../../stores/ClientChatStore";
import { CirclePause, Send } from "lucide-react";
import { motion } from "framer-motion";
interface SendButtonProps {
isLoading: boolean;
isDisabled: boolean;
onClick: (e: React.FormEvent) => void;
onStop?: () => void;
}
const SendButton: React.FC<SendButtonProps> = ({ onClick }) => {
const isDisabled =
clientChatStore.input.trim().length === 0 && !clientChatStore.isLoading;
return (
<Button
onClick={(e) =>
clientChatStore.isLoading
? clientChatStore.stopIncomingMessage()
: onClick(e)
}
bg="transparent"
color={
clientChatStore.input.trim().length <= 1 ? "brand.700" : "text.primary"
}
borderRadius="full"
p={2}
isDisabled={isDisabled}
_hover={{ bg: !isDisabled ? "rgba(255, 255, 255, 0.2)" : "inherit" }}
_active={{ bg: !isDisabled ? "rgba(255, 255, 255, 0.3)" : "inherit" }}
_focus={{ boxShadow: "none" }}
>
{clientChatStore.isLoading ? <MySpinner /> : <Send size={20} />}
</Button>
);
};
const MySpinner = ({ onClick }) => (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{
duration: 0.4,
ease: "easeInOut",
}}
>
<CirclePause color={"#F0F0F0"} size={24} onClick={onClick} />
</motion.div>
);
export default SendButton;

View File

@@ -0,0 +1,149 @@
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import {
Alert,
AlertIcon,
Box,
chakra,
HStack,
InputGroup,
} from "@chakra-ui/react";
import fileUploadStore from "../../stores/FileUploadStore";
import { UploadedItem } from "./Attachments";
import AutoResize from "react-textarea-autosize";
const AutoResizeTextArea = chakra(AutoResize);
interface InputTextAreaProps {
inputRef: React.RefObject<HTMLTextAreaElement>;
value: string;
onChange: (value: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
isLoading: boolean;
}
const InputTextArea: React.FC<InputTextAreaProps> = observer(
({ inputRef, value, onChange, onKeyDown, isLoading }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAttachmentClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
fileUploadStore.uploadFile(file, "/api/documents");
}
};
const handleRemoveUploadedItem = (url: string) => {
fileUploadStore.removeUploadedFile(url);
};
const [heightConstraint, setHeightConstraint] = useState<
number | undefined
>(10);
useEffect(() => {
if (value.length > 10) {
setHeightConstraint();
}
}, [value]);
return (
<Box
position="relative"
width="100%"
height={heightConstraint}
display="flex"
flexDirection="column"
>
{/* Attachments Section */}
{fileUploadStore.uploadResults.length > 0 && (
<HStack
spacing={2}
mb={2}
overflowX="auto"
css={{ "&::-webkit-scrollbar": { display: "none" } }}
// Ensure attachments wrap if needed
flexWrap="wrap"
>
{fileUploadStore.uploadResults.map((result) => (
<UploadedItem
key={result.url}
url={result.url}
name={result.name}
onRemove={() => handleRemoveUploadedItem(result.url)}
/>
))}
</HStack>
)}
{/* Input Area */}
<InputGroup position="relative">
<AutoResizeTextArea
fontFamily="Arial, sans-serif"
ref={inputRef}
value={value}
height={heightConstraint}
autoFocus
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
p={2}
pr="8px"
pl="17px"
bg="rgba(255, 255, 255, 0.15)"
color="text.primary"
borderRadius="20px" // Set a consistent border radius
border="none"
placeholder="Free my mind..."
_placeholder={{ color: "gray.400" }}
_focus={{
outline: "none",
}}
disabled={isLoading}
minRows={1}
maxRows={12}
style={{
touchAction: "none",
resize: "none",
overflowY: "auto",
width: "100%",
transition: "height 0.2s ease-in-out",
}}
/>
{/*<InputRightElement*/}
{/* position="absolute"*/}
{/* right={0}*/}
{/* top={0}*/}
{/* bottom={0}*/}
{/* width="40px"*/}
{/* height="100%"*/}
{/* display="flex"*/}
{/* alignItems="center"*/}
{/* justifyContent="center"*/}
{/*>*/}
{/*<EnableSearchButton />*/}
{/*</InputRightElement>*/}
</InputGroup>
<input
type="file"
ref={fileInputRef}
style={{ display: "none" }}
onChange={handleFileChange}
/>
{fileUploadStore.uploadError && (
<Alert status="error" mt={2}>
<AlertIcon />
{fileUploadStore.uploadError}
</Alert>
)}
</Box>
);
},
);
export default InputTextArea;

View File

@@ -0,0 +1,9 @@
import React from "react";
import CustomMarkdownRenderer from "./CustomMarkdownRenderer";
const ChatMessageContent = ({ content }) => {
return <CustomMarkdownRenderer markdown={content} />;
};
export default React.memo(ChatMessageContent);

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