Added web client

This commit is contained in:
James Ketr 2025-03-19 23:06:48 -07:00
parent 9c0f2ba2bb
commit 8792a02717
24 changed files with 14466 additions and 12 deletions

View File

@ -47,7 +47,6 @@ services:
volumes:
- ./cache:/root/.cache # Cache hub models and neo_compiler_cache
- ./ollama:/root/.ollama # Cache the ollama models
- ./src:/opt/airc/src:rw # Live mount src
cap_add: # used for running ze-monitor within airc container
- CAP_DAC_READ_SEARCH # Bypass all filesystem read access checks
- CAP_PERFMON # Access to perf_events (vs. overloaded CAP_SYS_ADMIN)
@ -69,11 +68,14 @@ services:
ports:
- 8888:8888 # Jupyter Notebook
- 60673:60673 # Gradio
- 5000:5000 # Flask React server
- 8081:8081 # REACT expo
networks:
- internal
volumes:
- ./jupyter:/opt/jupyter:rw
- ./cache:/root/.cache
- ./src:/opt/airc/src:rw # Live mount src
deploy:
resources:
limits:

View File

@ -12,6 +12,7 @@ import re
import time
from datetime import datetime
import textwrap
import threading
def try_import(module_name, pip_name=None):
try:
@ -32,6 +33,8 @@ try_import('dotenv', 'python-dotenv')
try_import('geopy', 'geopy')
try_import('hyphen', 'PyHyphen')
try_import('bs4', 'beautifulsoup4')
try_import('flask')
try_import('flask_cors')
from dotenv import load_dotenv
from geopy.geocoders import Nominatim
@ -44,8 +47,9 @@ import requests
import yfinance as yf
from hyphen import hyphenator
from bs4 import BeautifulSoup
from flask import Flask, request, jsonify, render_template, send_from_directory
from flask_cors import CORS
# Local defined imports
from tools import (
get_weather_by_location,
get_current_datetime,
@ -67,17 +71,18 @@ USE_TLS=False
GRADIO_HOST="0.0.0.0"
GRADIO_PORT=60673
GRADIO_ENABLE=False
WEB_HOST="0.0.0.0"
WEB_PORT=5000
WEB_DISABLE=False
BOT_ADMIN="james"
# %%
# Globals
system_message = f"""
You are a helpful information agent connected to the IRC network {IRC_SERVER}. Your name is {NICK}.
You have real time access to any website or URL the user asks about.
Messages from users are in the form "NICK: MESSAGE". The name before the colon (:) tells you which user asked about something.
You have real time access to any website or URL the user asks about, to stock prices, the current date and time, and current weather information for locations in the United States.
You are running { { 'model': MODEL_NAME, 'gpu': 'Intel Arc B580', 'cpu': 'Intel Core i9-14900KS', 'ram': '64G' } }.
You were launched on {get_current_datetime()}.
You have real time access to stock prices, the current date and time, and current weather information for locations in the United States.
If you use any real time access, do not mention your knowledge cutoff.
Give short, courteous answers, no more than 2-3 sentences.
Always be accurate. If you don't know the answer, say so. Do not make up details.
@ -108,6 +113,9 @@ def parse_args():
parser.add_argument("--gradio-host", type=str, default=GRADIO_HOST, help=f"Host to launch gradio on. default={GRADIO_HOST} only if --gradio-enable is specified.")
parser.add_argument("--gradio-port", type=str, default=GRADIO_PORT, help=f"Port to launch gradio on. default={GRADIO_PORT} only if --gradio-enable is specified.")
parser.add_argument("--gradio-enable", action="store_true", default=GRADIO_ENABLE, help=f"If set to True, enable Gradio. default={GRADIO_ENABLE}")
parser.add_argument("--web-host", type=str, default=WEB_HOST, help=f"Host to launch Flask web server. default={WEB_HOST} only if --web-disable not specified.")
parser.add_argument("--web-port", type=str, default=WEB_PORT, help=f"Port to launch Flask web server. default={WEB_PORT} only if --web-disable not specified.")
parser.add_argument("--web-disable", action="store_true", default=WEB_DISABLE, help=f"If set to True, disable Flask web server. default={WEB_DISABLE}")
parser.add_argument('--level', type=str, choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
default=LOG_LEVEL, help=f'Set the logging level. default={LOG_LEVEL}')
return parser.parse_args()
@ -310,7 +318,7 @@ async def summarize_site(url, question):
except Exception as e:
return f"Error processing the website content: {str(e)}"
async def chat(history, is_irc=False):
async def chat(history):
global client, model, irc_bot, system_log, tool_log
if not client:
return history
@ -442,14 +450,14 @@ class DynamicIRCBot(pydle.Client):
await super().on_part(channel, user)
logging.info(f"PART: {channel} => {user}")
async def on_message(self, target, source, message, is_gradio=False):
async def on_message(self, target, source, message, local_user=None):
global system_log, tool_log, system_log, command_log
if not is_gradio:
if not local_user:
await super().on_message(target, source, message)
message = message.strip()
logging.info(f"MESSAGE: {source} => {target}: {message}")
if source == self.nickname and not is_gradio:
if source == self.nickname and not local_user:
return
last_message = self.history[-1] if len(self.history) > 0 and self.history[-1]["role"] == "user" else None
try:
@ -493,7 +501,7 @@ class DynamicIRCBot(pydle.Client):
"content": f"{source}: {content}"
}
self.history.append(last_message)
self.history = await chat(self.history, is_irc=True)
self.history = await chat(self.history)
chat_response = self.history[-1]
await self.message(target, chat_response['content'])
return
@ -632,7 +640,7 @@ async def create_ui():
if not irc_bot:
return gr.skip()
await irc_bot.message(irc_bot.channel, f"[console] {message}")
await irc_bot.on_message(irc_bot.channel, irc_bot.nickname, f"{irc_bot.nickname}: {message}", is_gradio=True)
await irc_bot.on_message(irc_bot.channel, irc_bot.nickname, f"{irc_bot.nickname}: {message}", local_user="gradio")
return "", irc_bot.history
def do_clear():
@ -670,6 +678,71 @@ async def create_ui():
return ui
# %%
class WebServer:
"""Web interface"""
def __init__(self):
self.app = Flask(__name__, static_folder='/opt/airc/src/client/dist', static_url_path='')
CORS(self.app, resources={r"/*": {"origins": "http://battle-linux.ketrenos.com:8081"}})
# Setup routes
self.setup_routes()
def setup_routes(self):
"""Setup Flask routes"""
@self.app.route('/')
def serve():
return send_from_directory(self.app.static_folder, 'index.html')
def index():
return render_template('index.html')
# Basic endpoint for chat completions
@self.app.route('/chat', methods=['POST'])
async def chat():
if not irc_bot:
return jsonify({ "error": "Bot not initialized" }), 400
try:
data = request.get_json()
logging.info(f"/chat data={data}")
await irc_bot.on_message(irc_bot.channel, irc_bot.nickname, f"{irc_bot.nickname}: {data}", local_user="web")
return jsonify(irc_bot.history)
except Exception as e:
logging.exception(data)
return jsonify({
"error": "Invalid request"
}), 400
# Context requests
@self.app.route('/history', methods=['GET'])
def http_history():
if not irc_bot:
return jsonify({ "error": "Bot not initialized" }), 400
return jsonify(irc_bot.history), 200
@self.app.route('/system', methods=['GET'])
def http_system():
if not irc_bot:
return jsonify({ "error": "Bot not initialized" }), 400
return jsonify(system_log), 200
@self.app.route('/tools', methods=['GET'])
def http_tools():
if not irc_bot:
return jsonify({ "error": "Bot not initialized" }), 400
return jsonify(tool_log), 200
# Health check endpoint
@self.app.route('/health', methods=['GET'])
def health():
return jsonify({"status": "healthy"}), 200
def run(self, host='0.0.0.0', port=5000, debug=False, **kwargs):
"""Run the web server"""
# Load documents
self.app.run(host=host, port=port, debug=debug, **kwargs)
# %%
# Main function to run everything
@ -693,6 +766,11 @@ async def main():
ui.launch(server_name=args.gradio_host, server_port=args.gradio_port, prevent_thread_lock=True, pwa=True)
logging.info(args)
if not args.web_disable:
server = WebServer()
logging.info(f"Starting web server at http://{args.web_host}:{args.web_port}")
threading.Thread(target=lambda: server.run(host=args.web_host, port=args.web_port, debug=True, use_reloader=False)).start()
await irc_bot.handle_forever()
# Run the main function using anyio

@ -1 +0,0 @@
Subproject commit 571cdd1ecc93ec9ad0d63079fe6da94dce2cc5dc

38
src/client/.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example

50
src/client/README.md Normal file
View File

@ -0,0 +1,50 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

41
src/client/app.json Normal file
View File

@ -0,0 +1,41 @@
{
"expo": {
"name": "airc",
"slug": "airc",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
]
],
"experiments": {
"typedRoutes": true
}
}
}

View File

@ -0,0 +1,17 @@
import { Tabs } from 'expo-router';
import FontAwesome from 'react-fontawesome';
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen name="index" options={{
title: 'Chat',
tabBarIcon: ({ color }) => <FontAwesome size={28} name="chat" color={color} />
}} />
<Tabs.Screen name="about" options={{
title: 'About',
tabBarIcon: ({ color }) => <FontAwesome size={28} name="about" color={color} />
}} />
</Tabs>
);
}

View File

@ -0,0 +1,30 @@
import { Text, View, StyleSheet } from 'react-native';
export default function AboutScreen() {
return (
<View style={styles.container}>
<Text style={styles.text}>
<div>Welcome to my AI agent. It has live access to websites, weather, and stock information. You can ask it things like:
<ul>
<li>What's the current weather?</li>
<li>Can you provide the current headlines from http://cnn.com?</li>
<li>What is the current value of the 5 most traded companies?</li>
</ul>
</div>
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#25292e',
justifyContent: 'center',
alignItems: 'center',
},
text: {
color: '#fff',
maxWidth: "90%"
},
});

View File

@ -0,0 +1,108 @@
body {
font-family: 'Poppins', sans-serif;
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
max-height: 100vh;
overflow: auto;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.query-box {
display: flex;
margin-bottom: 20px;
}
.query-box input {
flex-grow: 1;
padding: 8px;
font-size: 1rem;
}
.query-box button {
padding: 0.5rem 1rem;
background-color: #4CAF50;
color: white;
border: none;
cursor: pointer;
}
.conversation {
background-color: #F5F5F5;
border: 1px solid #E0E0E0;
flex-grow: 1;
overflow-y: auto;
padding: 10px;
margin-bottom: 20px;
}
.user-message {
background-color: #DCF8C6;
border: 1px solid #B2E0A7;
color: #333333;
padding: 0.5rem;
margin-bottom: 0.75rem;
margin-left: 1rem;
border-radius: 0.25rem;
min-width: 70%;
max-width: 70%;
justify-self: right;
display: flex;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
}
.assistant-message {
background-color: #FFFFFF;
border: 1px solid #E0E0E0;
color: #333333;
padding: 0.5rem;
margin-bottom: 0.75rem;
margin-right: 1rem;
min-width: 70%;
max-width: 70%;
border-radius: 0.25rem;
justify-self: left;
display: flex;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
}
.tabs {
display: flex;
margin-bottom: 10px;
}
.tab {
padding: 8px 16px;
cursor: pointer;
border: 1px solid #ccc;
background-color: #f1f1f1;
margin-right: 5px;
}
.tab.active {
background-color: #4CAF50;
color: white;
}
.tab-content {
display: none;
border: 1px solid #ccc;
padding: 10px;
height: 200px;
overflow-y: auto;
}
.tab-content.active {
display: block;
}

View File

@ -0,0 +1,136 @@
import React, { useState, useEffect, useRef, CSSProperties } from 'react';
import PropagateLoader from "react-spinners/PropagateLoader";
import Markdown from 'react-native-markdown-display';
import './index.css';
const url: string = "https://ai.ketrenos.com"
const override: CSSProperties = {
display: "block",
margin: "0 auto",
borderColor: "red",
};
const App = () => {
const [query, setQuery] = useState('');
const [conversation, setConversation] = useState([]);
const conversationRef = useRef(null);
const [loading, setLoading] = useState(false);
// Scroll to bottom of conversation when conversation updates
useEffect(() => {
if (conversationRef.current) {
conversationRef.current.scrollTop = conversationRef.current.scrollHeight;
}
}, [conversation]);
// Polling interval in milliseconds (e.g., 5 seconds)
const pollingInterval = 3000;
useEffect(() => {
const fetchHistory = async () => {
try {
const response = await fetch(`${url}/history`);
const data = await response.json();
if (conversation.length != data.length)
setConversation(data || []);
} catch (error) {
console.error('Error fetching documents:', error);
}
};
// Initial fetch
fetchHistory();
// Set interval for polling
const intervalId = setInterval(fetchHistory, pollingInterval);
// Cleanup on component unmount
return () => clearInterval(intervalId);
}, [conversation, setConversation, pollingInterval]);
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
sendQuery();
}
};
const sendQuery = async () => {
if (!query.trim()) return;
// Add user message to conversation
const newConversation = [
...conversation,
{ role: 'user', content: query }
];
setConversation(newConversation);
// Clear input
setQuery('');
try {
setLoading(true);
// Send query to server
const response = await fetch(`${url}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(query.trim()),
});
const data = await response.json();
setLoading(false);
// Add assistant message to conversation
setConversation(data);
} catch (error) {
console.error('Error:', error);
setConversation([
...newConversation,
{ role: 'assistant', content: 'Error processing your query. Please try again.' }
]);
}
};
return (
<div className="container">
<div className="conversation" ref={conversationRef}>
{conversation.map((msg, index) => (
<div
key={index}
className={msg.role === 'user' ? 'user-message' : 'assistant-message'}
>
{
msg.content
.split("\n")
.map((line) => line.replace(/^[^\s:]+:\s*/, ''))
.join("\n")
}
</div>
))}
<div style={{justifyContent: "center", display: "flex", paddingBottom: "0.5rem"}}>
<PropagateLoader
size="10px"
loading={loading}
aria-label="Loading Spinner"
data-testid="loader"
/>
</div>
</div>
<div className="query-box">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Enter your query..."
id="query-input"
/>
<button onClick={sendQuery}>Send</button>
</div>
</div>
);
};
export default App;

View File

@ -0,0 +1,30 @@
import { View, StyleSheet } from 'react-native';
import { Link, Stack } from 'expo-router';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops! Not Found' }} />
<View style={styles.container}>
<Link href="/" style={styles.button}>
Go back to Home screen!
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#25292e',
justifyContent: 'center',
alignItems: 'center',
},
button: {
fontSize: 20,
textDecorationLine: 'underline',
color: '#fff',
},
});

View File

@ -0,0 +1,10 @@
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
);
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

13841
src/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
src/client/package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "airc",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"test": "jest --watchAll",
"lint": "expo lint"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"@expo/vector-icons": "^14.0.2",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.14",
"expo": "~52.0.37",
"expo-blur": "~14.0.3",
"expo-constants": "~17.0.7",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.1",
"expo-linking": "~7.0.5",
"expo-router": "~4.0.17",
"expo-splash-screen": "~0.29.22",
"expo-status-bar": "~2.0.1",
"expo-symbols": "~0.2.2",
"expo-system-ui": "~4.0.8",
"expo-web-browser": "~14.0.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-fontawesome": "^1.7.1",
"react-native": "0.76.7",
"react-native-gesture-handler": "~2.20.2",
"react-native-markdown-display": "^7.0.2",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-web": "~0.19.13",
"react-native-webview": "13.12.5",
"react-spinners": "^0.15.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/jest": "^29.5.12",
"@types/react": "~18.3.12",
"@types/react-test-renderer": "^18.3.0",
"jest": "^29.2.1",
"jest-expo": "~52.0.4",
"react-test-renderer": "18.3.1",
"typescript": "^5.3.3"
},
"private": true
}

17
src/client/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}