xml • Lines: 19374This file is a merged representation of the entire codebase, combined into a single document by Repomix.
<file_summary>
This section contains a summary of this file.
<purpose>
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
</purpose>
<file_format>
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Repository files (if enabled)
5. Multiple file entries, each consisting of:
- File path as an attribute
- Full contents of the file
</file_format>
<usage_guidelines>
- This file should be treated as read-only. Any changes should be made to the
original repository files, not this packed version.
- When processing this file, use the file path to distinguish
between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
the same level of security as you would the original repository.
</usage_guidelines>
<notes>
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
- Files are sorted by Git change count (files with more changes are at the bottom)
</notes>
</file_summary>
<directory_structure>
prompts/
critique.txt
editor.txt
genre_checklists.json
genre_persona_map.json
moderator.txt
persona_pool.json
self_chat.txt
tasks.json
scripts/
authentik_bootstrap.py
gcp_heartbeat_server.py
gcp-heartbeat.service
searxng/
settings.yml
server/
features/
__init__.py
context.py
critic.py
images.py
llm.py
monitoring.py
orchestration.py
sessions.py
shares.py
state.py
tasks_db.py
themes_db.py
tools.py
users.py
api.py
auth.py
config.py
dotenv.py
read_file.py
track_dashboard.py
src/
components/
ChatArea.jsx
ImageLightbox.jsx
InputBar.jsx
LocationPrompt.jsx
Message.jsx
ModelBar.jsx
OverloadWarning.jsx
PublicShareView.jsx
Sidebar.jsx
StatusBox.jsx
TaskPanel.jsx
api.js
App.css
App.jsx
main.jsx
utils.js
.gitignore
.repomixignore
authentik-compose.yaml
chat-webui.py
docker-compose.yaml
gcp_nginx.conf
genre_creator.py
index.html
LICENSE
local_cloud.sh
markdown_hosting.py
package.json
README.md
requirements.txt
restart_services.sh
self-chat.py
server_startup_commands.md
setup.sh
sso-debugging.md
ufw_rules.txt
vite.config.js
</directory_structure>
<files>
This section contains the contents of the repository's files.
<file path="src/components/ImageLightbox.jsx">
import { useEffect, useRef, useState, useCallback } from 'react'
export default function ImageLightbox({ src, onClose }) {
const [zoom, setZoom] = useState(1)
const [panX, setPanX] = useState(0)
const [panY, setPanY] = useState(0)
const dragging = useRef(false)
const startX = useRef(0)
const startY = useRef(0)
const pinchDist = useRef(0)
function handleClose() {
onClose()
if (window.history.state && window.history.state.lightboxOpen) {
window.history.back()
}
}
useEffect(() => {
if (!src) return
const onPopState = () => onClose()
window.addEventListener('popstate', onPopState)
if (!(window.history.state && window.history.state.lightboxOpen)) {
window.history.pushState({ lightboxOpen: true }, '')
}
return () => window.removeEventListener('popstate', onPopState)
}, [src, onClose])
const updateTransform = useCallback(() => {
const img = document.getElementById('fullscreen-img')
if (img) {
img.style.transform = `scale(${zoom}) translate(${panX}px, ${panY}px)`
}
const label = document.getElementById('zoom-label')
if (label) label.textContent = Math.round(zoom * 100) + '%'
}, [zoom, panX, panY])
useEffect(() => {
if (!src) return
setZoom(1)
setPanX(0)
setPanY(0)
const img = document.getElementById('fullscreen-img')
if (img) {
img.src = src
img.style.transform = ''
}
const label = document.getElementById('zoom-label')
if (label) label.textContent = '100%'
}, [src])
useEffect(() => {
if (!src) return
const overlay = document.getElementById('image-overlay')
function handleWheel(e) {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
setZoom(z => Math.max(0.25, Math.min(10, z + delta)))
}
function handleMouseDown(e) {
const img = document.getElementById('fullscreen-img')
if (e.target === img) {
dragging.current = true
startX.current = e.clientX - panX
startY.current = e.clientY - panY
overlay.style.cursor = 'grabbing'
e.preventDefault()
} else {
handleClose()
}
}
function handleMouseMove(e) {
if (!dragging.current) return
setPanX(e.clientX - startX.current)
setPanY(e.clientY - startY.current)
}
function handleMouseUp() {
if (dragging.current) {
dragging.current = false
overlay.style.cursor = 'grab'
}
}
function handleTouchStart(e) {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX
const dy = e.touches[0].clientY - e.touches[1].clientY
pinchDist.current = Math.sqrt(dx * dx + dy * dy)
dragging.current = false
e.preventDefault()
} else if (e.touches.length === 1 && e.target === document.getElementById('fullscreen-img')) {
dragging.current = true
startX.current = e.touches[0].clientX - panX
startY.current = e.touches[0].clientY - panY
e.preventDefault()
}
}
function handleTouchMove(e) {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX
const dy = e.touches[0].clientY - e.touches[1].clientY
const dist = Math.sqrt(dx * dx + dy * dy)
if (pinchDist.current > 0) {
setZoom(z => Math.max(0.25, Math.min(10, z * (dist / pinchDist.current))))
pinchDist.current = dist
}
e.preventDefault()
} else if (e.touches.length === 1 && dragging.current) {
setPanX(e.touches[0].clientX - startX.current)
setPanY(e.touches[0].clientY - startY.current)
e.preventDefault()
}
}
function handleTouchEnd(e) {
if (dragging.current && e.touches.length === 0) {
dragging.current = false
}
if (e.changedTouches.length === 1 && e.target !== document.getElementById('fullscreen-img')) {
handleClose()
}
}
function handleKeyDown(e) {
if (e.key === 'Escape') handleClose()
}
overlay.addEventListener('wheel', handleWheel, { passive: false })
overlay.addEventListener('mousedown', handleMouseDown)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
overlay.addEventListener('touchstart', handleTouchStart, { passive: false })
overlay.addEventListener('touchmove', handleTouchMove, { passive: false })
overlay.addEventListener('touchend', handleTouchEnd)
document.addEventListener('keydown', handleKeyDown)
return () => {
overlay.removeEventListener('wheel', handleWheel)
overlay.removeEventListener('mousedown', handleMouseDown)
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
overlay.removeEventListener('touchstart', handleTouchStart)
overlay.removeEventListener('touchmove', handleTouchMove)
overlay.removeEventListener('touchend', handleTouchEnd)
document.removeEventListener('keydown', handleKeyDown)
}
}, [src, onClose, panX, panY])
useEffect(() => {
updateTransform()
}, [zoom, panX, panY, updateTransform])
if (!src) return null
return (
<div id="image-overlay" className="open">
<div className="img-wrap"><img id="fullscreen-img" /></div>
<div className="zoom-label" id="zoom-label">100%</div>
</div>
)
}
</file>
<file path="src/components/LocationPrompt.jsx">
export default function LocationPrompt({ onAllow, onDeny, error }) {
return (
<div id="location-overlay" onClick={onDeny}>
<div id="location-dialog" onClick={e => e.stopPropagation()}>
<div id="location-icon">📍</div>
<div id="location-title">Share your location?</div>
<div id="location-desc">
{error || 'Local AI can use your location to provide location-aware responses and search results. You can change this anytime in your browser settings.'}
</div>
<div id="location-actions">
<button id="location-deny-btn" onClick={onDeny}>Deny</button>
<button id="location-allow-btn" onClick={onAllow} disabled={error && error.includes('blocked')}>Allow</button>
</div>
</div>
</div>
)
}
</file>
<file path="src/components/ModelBar.jsx">
import { useState, useEffect, useRef } from 'react'
export default function ModelBar({ modelStatus, modelTps, tokenEstimate, contextCompressed, rawTokenEstimate, maxContext, onToggleSidebar, username, onLogout, reminderCount, onToggleTasks }) {
const [dropdownOpen, setDropdownOpen] = useState(false)
const dropdownRef = useRef(null)
useEffect(() => {
function handleClick(e) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setDropdownOpen(false)
}
}
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}, [])
const labels = {
chat_loaded: 'Chat model ready',
image_active: 'Image generation active',
loading: 'Loading model...',
unloading: 'Unloading model...',
unloaded: 'No model loaded',
}
return (
<div id="model-bar">
<button id="sidebar-toggle" onClick={onToggleSidebar}>☰</button>
<span id="model-dot" className={modelStatus}></span>
<span id="model-label">{labels[modelStatus] || modelStatus}</span>
{modelTps != null && (
<span id="model-tps" style={{ marginLeft: 12, fontSize: 12, color: '#888' }}>
{modelTps < 5 ? (
<span style={{ color: '#f87171' }}>⚠ {modelTps.toFixed(1)} t/s</span>
) : (
<>{modelTps.toFixed(1)} t/s</>
)}
</span>
)}
<span id="token-indicator">
<svg id="context-donut" viewBox="0 0 24 24" width="18" height="18">
<circle cx="12" cy="12" r="8" fill="none" stroke="rgba(255,255,255,0.1)" strokeWidth="3" />
<circle
cx="12" cy="12" r="8" fill="none"
stroke={(tokenEstimate / maxContext) * 100 > 80 ? '#f87171' : (tokenEstimate / maxContext) * 100 > 60 ? '#fbbf24' : '#4ade80'}
strokeWidth="3" strokeLinecap="round"
strokeDasharray={Math.PI * 16}
strokeDashoffset={Math.PI * 16 * (1 - Math.min((tokenEstimate / maxContext) * 100, 100) / 100)}
transform="rotate(-90 12 12)"
/>
</svg>
{tokenEstimate > 0 && (
<span className="token-text">
{tokenEstimate > 1000 ? (tokenEstimate / 1000).toFixed(1) + 'k' : tokenEstimate} / {maxContext > 1000 ? (maxContext / 1000).toFixed(0) + 'k' : maxContext}
{contextCompressed && rawTokenEstimate > 0 && (
<span className="token-compressed" title="Context compressed — older messages summarized">
({rawTokenEstimate > 1000 ? (rawTokenEstimate / 1000).toFixed(1) + 'k' : rawTokenEstimate})
</span>
)}
</span>
)}
</span>
<div id="user-menu" ref={dropdownRef}>
<span id="user-name" onClick={() => setDropdownOpen(o => !o)}>{username}</span>
<div id="user-dropdown" className={dropdownOpen ? 'open' : ''}>
<button className="task-menu-item" onClick={() => { onToggleTasks(); setDropdownOpen(false) }} title="Tasks">
<span>☰ Tasks</span>
{reminderCount > 0 && <span id="reminder-badge">{reminderCount}</span>}
</button>
<button onClick={onLogout}>Logout</button>
</div>
</div>
</div>
)
}
</file>
<file path="src/components/Sidebar.jsx">
export default function Sidebar({
sessions,
currentSessionId,
onSwitchSession,
onNewChat,
onRenameSession,
onDeleteSession,
onClose,
open,
}) {
return (
<>
<div id="sidebar-overlay" className={open ? 'open' : ''} onClick={onClose}></div>
<div id="sidebar" className={open ? 'open' : ''}>
<div id="sidebar-header">
<button id="new-chat-btn" onClick={onNewChat}>+ New Chat</button>
</div>
<div id="session-list">
{sessions.map(s => (
<div
key={s.session_id}
className={`session-item${s.session_id === currentSessionId ? ' active' : ''}`}
onClick={() => onSwitchSession(s.session_id)}
>
<span className="session-name">
{s.name.length > 30 ? s.name.slice(0, 30) + '...' : s.name}
</span>
<span className="session-actions">
<button
title="Rename"
onClick={e => { e.stopPropagation(); onRenameSession(s.session_id) }}
dangerouslySetInnerHTML={{ __html: '✎' }}
/>
<button
title="Delete"
onClick={e => { e.stopPropagation(); onDeleteSession(s.session_id) }}
dangerouslySetInnerHTML={{ __html: '🗑' }}
/>
</span>
</div>
))}
</div>
</div>
</>
)
}
</file>
<file path="src/components/StatusBox.jsx">
function statusState(msg) {
if (!msg) return 'thinking'
if (/^Searching/.test(msg)) return 'search'
if (/^Generating image/.test(msg)) return 'generate-image'
if (/^Editing image/.test(msg)) return 'edit-image'
return 'thinking'
}
const icons = {
search: '\uD83D\uDD0D',
'generate-image': '\uD83C\uDFA8',
'edit-image': '\u270F\uFE0F',
thinking: '\uD83D\uDCAD',
}
export default function StatusBox({ message }) {
const state = statusState(message)
return (
<div className="status-box open" data-state={state}>
<div className="status-header">
<div className="left-group">
<span className="status-icon">{icons[state]}</span>
<span className="status-text">{message || 'Thinking...'}</span>
<div className="spinner"></div>
</div>
</div>
</div>
)
}
</file>
<file path="src/components/TaskPanel.jsx">
import { useState, useEffect } from 'react'
import * as api from '../api'
export default function TaskPanel({ onClose }) {
const [tasks, setTasks] = useState([])
const [newTitle, setNewTitle] = useState('')
const [newPriority, setNewPriority] = useState('medium')
const [showForm, setShowForm] = useState(false)
useEffect(() => { loadTasks() }, [])
async function loadTasks() {
const data = await api.fetchTasks()
setTasks(data.tasks || [])
}
async function handleCreate(e) {
e.preventDefault()
if (!newTitle.trim()) return
await api.createTask({ title: newTitle.trim(), priority: newPriority })
setNewTitle('')
setNewPriority('medium')
setShowForm(false)
loadTasks()
}
async function handleToggle(task) {
if (task.status === 'completed') {
await api.updateTask(task.id, { status: 'pending' })
} else {
await api.updateTask(task.id, { status: 'completed' })
}
loadTasks()
}
async function handleDelete(tid) {
await api.deleteTask(tid)
loadTasks()
}
const priorityColors = { high: '#f87171', medium: '#fbbf24', low: '#4ade80' }
return (
<div id="task-panel">
<div id="task-panel-header">
<span>Tasks</span>
<button onClick={() => setShowForm(!showForm)}>{showForm ? 'x' : '+'}</button>
<button onClick={onClose} id="task-panel-close">✕</button>
</div>
{showForm && (
<form id="task-form" onSubmit={handleCreate}>
<input value={newTitle} onChange={e => setNewTitle(e.target.value)} placeholder="New task..." />
<select value={newPriority} onChange={e => setNewPriority(e.target.value)}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button type="submit">Add</button>
</form>
)}
<div id="task-list">
{tasks.map(t => (
<div key={t.id} className={`task-item ${t.status === 'completed' ? 'done' : ''}`}>
<input type="checkbox" checked={t.status === 'completed'} onChange={() => handleToggle(t)} />
<span className="task-title" style={{ color: priorityColors[t.priority] || '#94a3b8' }}>{t.title}</span>
<span className="task-status">{t.status}</span>
{t.due_date && <span className="task-due">{new Date(t.due_date).toLocaleDateString()}</span>}
<button className="task-delete" onClick={() => handleDelete(t.id)}>🗑</button>
</div>
))}
</div>
</div>
)
}
</file>
<file path="src/main.jsx">
import { createRoot } from 'react-dom/client'
import App from './App'
import 'katex/dist/katex.min.css'
import './App.css'
createRoot(document.getElementById('root')).render(<App />)
</file>
<file path="index.html">
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Local AI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
</file>
<file path="LICENSE">
MIT License
Copyright (c) 2026 Palash90
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.
</file>
<file path="requirements.txt">
requests
fastapi
uvicorn
PyJWT
Markdown
</file>
<file path="server_startup_commands.md">
# Draft on setup of low vram local ai setup
## Requirements
1. 2 to 4 concurrent users at max
2. Mostly 15 to 20 image generation per week
3. Multi-Lingual
4. Web Search
5. Recipe
6. Travel planning
7. Image analysis
8. Minor coding assistance
9. Usable within network
10. No dependency and data sharing with Big Tech
## Challenge
NVIDIA RTX 3050 Laptop GPU 4 GB VRAM, 16 GB RAM
No extra device to spare, same dev machine is used to host local AI
## Setup
### git
git clone <ComfyUI>
git clone <llama.cpp>
cd ComfyUI
python -m venv venv
pip install -r requirements.txt
cd llama.cpp
cmake # website gives info
### nvidia
Install nvidia cuda toolkit
Install nvidia container toolkit (Only if you want to run your ai server on a docker container, otherwise not needed)
### Docker
Install searxng - read setup.sh
In a docker container, install ubuntu:24.04 with gpus support
```shell
docker run -it --gpus all --name ai-container ubuntu:24.04
nvidia-smi # To confirm the nvidia support
nvcc --version # To verify nvcc
```
There is a separate docker-compose.yaml file in the repo if you want a containerized setup.
Internet Toggle for container:
```shell
docker network connect local-ai_external-net ai-container
docker exec -it ai-container bash
apt update && apt install -y tzdata
dpkg-reconfigure -f noninteractive tzdata
# Update packages and install useful utilities
apt update && apt install -y curl wget git python3 python3-pip nano pipx
apt-get update && apt-get install -y nvidia-cuda-toolkit
# Check GPU availability inside the container
nvidia-smi
docker network disconnect local-ai_external-net ai-container
```
### ComfyUI
Build step was there - read setup.sh
```shell
mkdir ~/local-ai
cd ~/local-ai
source venv/bin/activate
python main.py --lowvram --input-directory ~/local-ai-files/ComfyUI/input --output-directory ~/local-ai-files/ComfyUI/output
```
### Llama Server
Build step was there - read seup.sh
```shell
~/local-ai/llama.cpp/build/bin/llama-server --host 0.0.0.0 --port 8081 --models-dir ~/local-ai-files/my-models/ --n-gpu-layers 99 --no-kv-offload --ctx-size 16384 --reasoning-budget 2048
```
### chat-server
```shell
cd ~/git/local-ai
python chat-webui.py
```
# Configuration Files
List of files - read setup.sh
# Reverse Proxy & HTTPS (To be added)
## 1. Install Nginx and clean default site
```shell
sudo apt update && sudo apt install nginx -y
sudo rm -f /etc/nginx/sites-enabled/default
```
## 2. Create Nginx config
```shell
sudo tee /etc/nginx/sites-available/chat.local > /dev/null << 'EOF'
server {
listen 80;
server_name chat.local;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
EOF
```
## 3. Enable site and test Nginx
```shell
sudo ln -sf /etc/nginx/sites-available/chat.local /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl restart nginx
```
## mDNS Setup
### 1. Set hostname (Avahi will automatically broadcast chat.local)
```shell
sudo hostnamectl set-hostname chat
```
### 2. Create Avahi mDNS HTTP Service discovery (pointing to Nginx on Port 80)
```shell
sudo tee /etc/avahi/services/chat.service > /dev/null << 'EOF'
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name>Chat AI</name>
<service>
<type>_http._tcp</type>
<port>80</port>
</service>
</service-group>
EOF
```
### 3. Restart Avahi to reload the service definition
```shell
sudo systemctl restart avahi-daemon
```
</file>
<file path="ufw_rules.txt">
sudo ufw allow in on wlan0
sudo ufw allow out on wlan0
</file>
<file path="vite.config.js">
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
build: {
outDir: 'dist',
},
server: {
proxy: {
'/api': 'http://localhost:3000',
'/output': 'http://localhost:3000',
},
},
})
</file>
<file path="prompts/critique.txt">
You are the story verification pass for the Kaya–Kolpo pipeline. The draft to
review is NOT yours — your partner wrote it. Your job is exactly like a research
fact-checker: verify the draft against the rules below, and where a rule is
violated, fix THAT spot and only that spot. You never rewrite the story, never
add new scenes, ideas, dialogue, images, or sections, and never fix things that
are not flagged.
## Task context
- Genre: %genre%
- Declared mediums: %mediums%
- Declared language(s): %language%
- Task details: %details%
## Named characters (immutable)
%cast%
The characters above were decided and named before writing. This is the COMPLETE
cast: the story text and every image may only depict these named characters.
Never introduce, name, or depict any additional character in your fix.
## Genre-specific checklist
Check the flagged parts against every item below. Fix what you can directly in
the spot where it occurs. If a violation genuinely cannot be fixed with the
material available (for example an image was never generated), do NOT invent a
fix — leave that spot untouched and report it in the response.
%checklist%
## Non-negotiable preservation rules
- Never remove or alter the header metadata block (Task prompt, Genre, For roles,
Mediums, Language(s)).
- Never remove or move existing image references (``): each image
stays exactly where it appears, right after the paragraph it illustrates.
- Never change the heading "## Citations & References" and never remove or shorten
the citations that the story already contains.
- Never write the names "Kaya", "Kolpo", "কায়া", "কল্প", "काया", "कल्प" anywhere
in the content text, dialogue, captions, or headings.
- Keep the content in the declared language(s). Do not translate.
- Strip any meta-commentary, planning chatter, or conversational setup. Retain
only the final, continuous story narrative and structural headers.
## WORK MODE
- First, quote the exact spot of every flagged violation (paste the offending
line or paragraph).
- Then fix ONLY those spots. Everything outside the flagged spots must remain
byte-for-byte identical.
- Your reply must be TWO parts, in this order:
1. A short "<!-- CRITIQUE: <verdict for each flagged item: FIXED / UNRESOLVED> -->"
HTML comment on its own line.
2. The COMPLETE corrected markdown of the story in a single ```markdown code
block. Nothing else outside the comment and the code block.
- If everything was resolved, every verdict is FIXED. Never skip the code block;
the full markdown must always be returned.
</file>
<file path="scripts/authentik_bootstrap.py">
#!/usr/bin/env python3
"""Provision Authentik for the unified SSO: groups, users, the OIDC provider
and the proxy outpost.
Run this once after ``docker compose -f authentik-compose.yaml up -d`` and the
initial-setup flow have created the admin account:
python3 scripts/authentik_bootstrap.py
It needs admin credentials. Provide them inline via flags or via the .env
values (AUTHENTIK_BOOTSTRAP_EMAIL / AUTHENTIK_BOOTSTRAP_PASSWORD), or pass a
service-account token with ``--token`` (AUTHENTIK_BOOTSTRAP_TOKEN/.env
AUTHENTIK_TOKEN). On success it prints the outpost token you must put into
the image deployment (append --token to an already-scaled outpost, or deploy
as the ``ghcr.io/goauthentik/proxy`` sidecar with OUTPOST_TOKEN=...).
Uses only the Python stdlib + requests (already a dependency).
"""
import argparse
import json
import sys
import uuid
import requests
from server.dotenv import load_dotenv
load_dotenv()
ADMIN_USERS = {"palash"}
PREMIUM_USERS = {"totan"}
FREE_USERS = {"kolpo", "kaya", "editor", "moderator", "test"}
DEFAULT_PASSWORD = "changeme!authentik1"
DEFAULT_EMAIL = "auth@localhost"
def api(base, token, method, path, **kwargs):
url = f"{base}/api/v3/{path}"
headers = {"Authorization": f"Bearer {token}"} if token else {}
if method == "POST":
headers["Content-Type"] = "application/json"
resp = requests.request(method, url, headers=headers, timeout=30, **kwargs)
if resp.status_code >= 400 and method != "GET":
print(f"[api] {method} {path} -> {resp.status_code}: {resp.text[:300]}")
return resp
def main():
parser = argparse.ArgumentParser(description=__doc__.strip())
parser.add_argument("--base", default="", help="Authentik base URL (default: AUTHENTIK_BASE_URL from .env)")
parser.add_argument("--email", default="", help="Admin email (default: AUTHENTIK_BOOTSTRAP_EMAIL)")
parser.add_argument("--password", default="", help="Admin password (default: AUTHENTIK_BOOTSTRAP_PASSWORD)")
parser.add_argument("--token", default="", help="Authentik API token (skips password auth if set)")
args = parser.parse_args()
import os
base = (args.base or os.environ.get("AUTHENTIK_BASE_URL") or "https://home.palashkantikundu.in/sso").rstrip("/")
email = args.email or os.environ.get("AUTHENTIK_BOOTSTRAP_EMAIL") or DEFAULT_EMAIL
password = args.password or os.environ.get("AUTHENTIK_BOOTSTRAP_PASSWORD") or DEFAULT_PASSWORD
token = args.token or os.environ.get("AUTHENTIK_BOOTSTRAP_TOKEN") or os.environ.get("AUTHENTIK_TOKEN") or ""
if not token:
# Username is the local part of the bootstrap email.
username = email.split("@")[0] or "akadmin"
resp = api(base, "", "POST", "core/users/me/impersonation/")
# Password-based token is simpler: use the admin/user token endpoint.
tr = requests.post(
f"{base}/api/v3/core/tokens/",
json={
"identifier": f"bootstrap-{uuid.uuid4().hex[:8]}",
"intent": "app_password",
"user": 1,
"expiring": False,
},
headers={"Content-Type": "application/json"},
timeout=15,
)
if tr.status_code == 401:
print("No --token and no usable AUTHENTIK_BOOTSTRAP_TOKEN; refusing.")
print(f"Either set the token in .env or sign in to {base}/if/flow/initial-setup/ first.")
sys.exit(1)
if tr.status_code != 201:
print(f"[tokens] bootstrap token creation failed: {tr.status_code} {tr.text[:300]}")
sys.exit(1)
token = tr.json()["key"]
print(f"Using Authentik at {base} with admin token.")
groups = {}
for gname in ("admin", "premium", "free"):
r = api(base, token, "GET", f"core/groups/?name={gname}")
if r.status_code == 200 and r.json().get("results"):
groups[gname] = r.json()["results"][0]["pk"]
else:
r = api(base, token, "POST", "core/groups/", json={"name": gname})
# 400 name-taken races are fine; re-list after.
if r.status_code >= 400:
r = api(base, token, "GET", f"core/groups/?name={gname}")
if r.status_code == 200 and r.json().get("results"):
groups[gname] = r.json()["results"][0]["pk"]
continue
groups[gname] = r.json()["pk"]
print(f" group {gname}: {groups[gname]}")
def ensure_user(uid, role_group):
r = api(base, token, "GET", f"core/users/?username={uid}")
if r.status_code == 200 and r.json().get("results"):
user = r.json()["results"][0]
api(base, token, "POST", f"core/users/{user['pk']}/", json={"password": DEFAULT_PASSWORD})
api(base, token, "POST", f"core/users/{user['pk']}/groups/", json={"pk": groups[role_group]})
else:
r = api(base, token, "POST", "core/users/", json={
"username": uid,
"name": uid,
"email": f"{uid}@localhost",
"password": DEFAULT_PASSWORD,
"is_active": True,
})
if r.status_code >= 400:
print(f"[user] {uid}: {r.status_code} {r.text[:200]}")
return
user = r.json()
api(base, token, "POST", f"core/users/{user['pk']}/groups/", json={"pk": groups[role_group]})
print(f" user {uid} (role {role_group})")
for uid in ADMIN_USERS:
ensure_user(uid, "admin")
for uid in PREMIUM_USERS:
ensure_user(uid, "premium")
for uid in FREE_USERS:
ensure_user(uid, "free")
# Proxy outpost: nginx auth_request hits it at /outpost.goauthentik.io.
r = api(base, token, "GET", "core/outposts/?name=nginx-ssd")
if r.status_code == 200 and r.json().get("results"):
outpost = r.json()["results"][0]
else:
r = api(base, token, "POST", "core/outposts/", json={
"name": "nginx-ssd",
"type": "proxy",
})
outpost = r.json()
outpost_pk = outpost["pk"]
print(f" outpost nginx-ssd: {outpost_pk}")
# OIDC provider "local-ai" → agent password grant + JWT issuance.
prov = None
r = api(base, token, "GET", "core/providers/oauth2/?name=local-ai")
if r.status_code == 200 and r.json().get("results"):
prov = r.json()["results"][0]
provider_pk = prov["pk"]
else:
r = api(base, token, "POST", "core/providers/oauth2/", json={
"name": "local-ai",
"authorization_flow": _first_flow(base, token, "authorization"),
"client_type": "confidential",
"client_id": os.environ.get("AUTH_CLIENT_ID", "local-ai"),
"client_secret": os.environ.get("AUTH_CLIENT_SECRET") or uuid.uuid4().hex,
"signing_key": _first_signing_key(base, token),
"access_code_validity": "minutes=10",
"access_token_validity": "minutes=10",
"refresh_token_validity": "days=30",
"include_claims_in_id_token": True,
"issuer_mode": "global",
"sub_mode": "hashed_user_id",
# Password grant support (self-chat agents).
"redirect_uris": [],
"property_mappings": [],
})
if r.status_code >= 400:
print(f"[provider] {r.status_code} {r.text[:300]}")
sys.exit(1)
prov = r.json()
provider_pk = prov["pk"]
print(f" oauth2 provider local-ai: {provider_pk}")
print("\nProvisioning complete.")
print("\nThe proxy outpost token (OUTPOST token) is shown in the Authentik")
print("admin UI → Outposts → nginx-ssd → Details. Deploy the outpost as:")
print(" docker run -d --name authentik-proxy --network host \\")
print(" -e AUTHENTIK_HOST=https://home.palashkantikundu.in/sso \\")
print(" -e AUTHENTIK_TOKEN=<outpost token> \\")
print(" ghcr.io/goauthentik/proxy:2025.2.1")
print("\nThen in the admin UI: Applications → local-ai → add the proxy")
print("provider. nginx auth_request already points at the outpost.")
def _first_flow(base, token, slug):
r = api(base, token, "GET", f"flows/instances/?slug={slug}")
items = r.json().get("results", []) if r.status_code == 200 else []
return items[0]["pk"] if items else None
def _first_signing_key(base, token):
r = api(base, token, "GET", "crypto/certificatekeypairs/")
items = r.json().get("results", []) if r.status_code == 200 else []
# Prefer a key usable for JWT signing (private key present).
for it in items:
if it.get("private_key"):
return it["pk"]
return items[0]["pk"] if items else None
if __name__ == "__main__":
main()
</file>
<file path="scripts/gcp-heartbeat.service">
# /etc/systemd/system/gcp-heartbeat.service
[Service]
Type=simple
User=heartbeat
Group=heartbeat
WorkingDirectory=/opt/heartbeat
ExecStart=/usr/bin/python3 /opt/heartbeat/gcp_heartbeat_server.py --dns-port 0 --log /opt/heartbeat/logs/heartbeat.log
Restart=always
RestartSec=3
StartLimitIntervalSec=60
StartLimitBurst=5
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/heartbeat/logs
[Install]
WantedBy=multi-user.target
</file>
<file path="server/features/__init__.py">
"""Feature modules for the chat web UI.
The chat engine used to be a single ``chat-webui.py`` file. The implementation
now lives in this package, split by feature area:
* :mod:`~server.features.state` — shared state, the ``M`` entrypoint proxy
* :mod:`~server.features.tasks_db` — SQLite-backed to-do tasks
* :mod:`~server.features.users` — login, user context, usernames
* :mod:`~server.features.sessions` — conversation persistence
* :mod:`~server.features.context` — token estimation / context compaction
* :mod:`~server.features.llm` — llama-server lifecycle and streaming
* :mod:`~server.features.tools` — LLM tool implementations
* :mod:`~server.features.images` — ComfyUI image generation / editing
* :mod:`~server.features.monitoring` — health loops, restart, thermal/RAM
* :mod:`~server.features.orchestration` — event loop and task queue
``chat-webui.py`` remains the single entrypoint: it owns every shared value and
registers itself as the entrypoint module. Feature code resolves shared state,
config values and cross-cutting helpers at call time through the ``M`` proxy
(see :mod:`server.features.state`), so monkeypatching ``chat-webui.<name>`` —
as the test-suite does — keeps working across module boundaries.
"""
from server.features import (
context,
images,
llm,
monitoring,
orchestration,
sessions,
state,
tasks_db,
tools,
users,
)
__all__ = [
"context",
"images",
"llm",
"monitoring",
"orchestration",
"sessions",
"state",
"tasks_db",
"tools",
"users",
]
</file>
<file path="server/features/shares.py">
"""Public message sharing: create, fetch and revoke single-message shares.
A share is a capability URL: whoever knows the unguessable token can read the
message without logging in. The message content is snapshotted at share time so
later edits to or deletion of the session never change or break the share.
Storage is a simple JSON file (``SHARES_FILE``) shaped as::
{
"<token>": {
"session_id": "...",
"msg_index": 3,
"owner": "alice",
"created": 1234567890.0,
"message": { "role": "assistant", "content": "...", ... }
}
}
"""
import copy
import json
import os
import time
import uuid
from server.features.state import M
def load_shares():
"""Load share records from disk into the shared ``shares`` container."""
with M._data_lock:
M.shares.clear()
path = M.SHARES_FILE
if not path or not os.path.exists(path):
return
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return
with M._data_lock:
for token, rec in (data or {}).items():
M.shares[token] = rec
def save_shares():
"""Persist the in-memory ``shares`` container to disk."""
with M._data_lock:
data = copy.deepcopy(dict(M.shares))
path = M.SHARES_FILE
if not path:
return
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
# Message fields worth carrying into a public snapshot. Everything else is
# internal bookkeeping that the public page must never see.
_SNAPSHOT_KEYS = (
"role",
"content",
"_image_url",
"_image_model",
"_gen_prompt",
"_reasoning",
"_tools_used",
"_search_details",
"_elapsed_ms",
"_timestamp",
)
def _snapshot_message(msg):
snap = {}
for key in _SNAPSHOT_KEYS:
if key in msg:
snap[key] = copy.deepcopy(msg[key])
return snap
def _share_url(token):
"""Public URL for a share.
Uses ``SHARE_BASE_URL`` (a portless origin, see config) when set, otherwise
falls back to a site-relative path. A ported origin like ``:3001`` is
deliberately avoided because WhatsApp stops auto-linking URLs at the ":" of
a port, which truncates every share link to a dead short URL.
"""
base = M.SHARE_BASE_URL
return f"{base}/s/{token}" if base else f"/s/{token}"
def create_share(user, session_id, msg_index):
"""Create a share for a single assistant message.
Validates session ownership and that the target message is an assistant
(bot) message, then snapshots it. Returns ``(token, url)``.
Raises ``ValueError`` with a user-facing message on any validation failure.
"""
try:
msg_index = int(msg_index)
except (TypeError, ValueError):
raise ValueError("Invalid message index")
with M._data_lock:
meta = M.sessions_meta.get(session_id)
if not meta:
raise ValueError("Session not found")
if meta.get("user_id", "") != user:
raise ValueError("Not your session")
msgs = M.sessions.get(session_id)
if not msgs or msg_index < 0 or msg_index >= len(msgs):
raise ValueError("Message not found")
msg = msgs[msg_index]
if msg.get("role") != "assistant":
raise ValueError("Only assistant messages can be shared")
token = uuid.uuid4().hex
with M._data_lock:
M.shares[token] = {
"session_id": session_id,
"msg_index": msg_index,
"owner": user,
"created": time.time(),
"message": _snapshot_message(msg),
}
save_shares()
return token, _share_url(token)
def get_share(token):
"""Return the share record for a token, or ``None`` if missing/revoked."""
if not token:
return None
with M._data_lock:
return M.shares.get(token)
def revoke_share(token, user):
"""Remove a share. Only the owner may revoke.
Returns ``True`` on success, ``False`` if the share does not exist or the
user is not its owner.
"""
with M._data_lock:
rec = M.shares.get(token)
if not rec or rec.get("owner") != user:
return False
del M.shares[token]
save_shares()
return True
def list_shares(user):
"""Return the caller's share records (with a plain-text preview)."""
out = []
with M._data_lock:
for token, rec in M.shares.items():
if rec.get("owner") != user:
continue
content = rec.get("message", {}).get("content", "")
preview = ""
if isinstance(content, str):
preview = content[:120]
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
preview = (part.get("text") or "")[:120]
break
out.append(
{
"token": token,
"url": _share_url(token),
"session_id": rec.get("session_id"),
"msg_index": rec.get("msg_index"),
"created": rec.get("created"),
"preview": preview,
}
)
return sorted(out, key=lambda s: s.get("created") or 0, reverse=True)
</file>
<file path="server/features/tasks_db.py">
"""SQLite-backed to-do tasks used by the ``manage_tasks`` tool and /api/tasks."""
import json
import sqlite3
import threading
import uuid
from datetime import datetime
from server.features.state import M
_tasks_db_lock = threading.Lock()
def _init_tasks_db():
with _tasks_db_lock:
conn = sqlite3.connect(M.TASKS_DB)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
priority TEXT DEFAULT 'medium',
due_date TEXT,
session_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
reminder_at TEXT,
reminded INTEGER DEFAULT 0
)
"""
)
conn.commit()
conn.close()
def _db_run(query, params=()):
with _tasks_db_lock:
conn = sqlite3.connect(M.TASKS_DB)
try:
cur = conn.execute(query, params)
conn.commit()
return cur.rowcount
finally:
conn.close()
def _db_fetch(query, params=()):
with _tasks_db_lock:
conn = sqlite3.connect(M.TASKS_DB)
conn.row_factory = sqlite3.Row
try:
cur = conn.execute(query, params)
rows = [dict(r) for r in cur.fetchall()]
return rows
finally:
conn.close()
def _db_fetch_one(query, params=()):
rows = M._db_fetch(query, params)
return rows[0] if rows else None
def task_create(
user_id,
title,
description="",
priority="medium",
due_date=None,
session_id=None,
reminder_at=None,
):
tid = str(uuid.uuid4())
now = datetime.now().isoformat()
M._db_run(
"INSERT INTO tasks (id, user_id, title, description, status, priority, due_date, session_id, created_at, updated_at, reminder_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(
tid,
user_id,
title,
description,
"pending",
priority,
due_date,
session_id,
now,
now,
reminder_at,
),
)
return M._db_fetch_one("SELECT * FROM tasks WHERE id=?", (tid,))
def task_update(tid, user_id, **kwargs):
fields = {k: v for k, v in kwargs.items() if v is not None}
if not fields:
return None
fields["updated_at"] = datetime.now().isoformat()
set_clause = ", ".join(f"{k}=?" for k in fields)
vals = list(fields.values()) + [tid, user_id]
M._db_run(f"UPDATE tasks SET {set_clause} WHERE id=? AND user_id=?", vals)
return M._db_fetch_one("SELECT * FROM tasks WHERE id=?", (tid,))
def task_complete(tid, user_id):
now = datetime.now().isoformat()
M._db_run(
"UPDATE tasks SET status='completed', updated_at=? WHERE id=? AND user_id=?",
(now, tid, user_id),
)
return M._db_fetch_one("SELECT * FROM tasks WHERE id=?", (tid,))
def task_delete(tid, user_id):
return M._db_run("DELETE FROM tasks WHERE id=? AND user_id=?", (tid, user_id))
def task_list(user_id, status=None):
if status:
return M._db_fetch(
"SELECT * FROM tasks WHERE user_id=? AND status=? ORDER BY due_date IS NULL, due_date ASC, created_at DESC",
(user_id, status),
)
return M._db_fetch(
"SELECT * FROM tasks WHERE user_id=? ORDER BY due_date IS NULL, due_date ASC, created_at DESC",
(user_id,),
)
def task_get(tid, user_id):
return M._db_fetch_one(
"SELECT * FROM tasks WHERE id=? AND user_id=?", (tid, user_id)
)
def handle_task_tool(user_id, args):
op = args.get("operation", "")
if op == "create":
if not args.get("title"):
return json.dumps({"ok": False, "error": "Missing required argument: title"})
t = task_create(
user_id,
args["title"],
args.get("description", ""),
args.get("priority", "medium"),
args.get("due_date"),
args.get("session_id"),
args.get("reminder_at"),
)
return json.dumps({"ok": True, "task": t})
elif op in ("update", "complete", "delete", "get"):
tid = args.get("task_id")
if not tid:
return json.dumps(
{"ok": False, "error": f"Missing required argument: task_id"}
)
if op == "update":
t = task_update(
tid,
user_id,
title=args.get("title"),
description=args.get("description"),
priority=args.get("priority"),
status=args.get("status"),
due_date=args.get("due_date"),
reminder_at=args.get("reminder_at"),
)
if t:
return json.dumps({"ok": True, "task": t})
return json.dumps({"ok": False, "error": "Task not found"})
elif op == "complete":
t = task_complete(tid, user_id)
if t:
return json.dumps({"ok": True, "task": t})
return json.dumps({"ok": False, "error": "Task not found"})
elif op == "delete":
task_delete(tid, user_id)
return json.dumps({"ok": True})
else:
t = task_get(tid, user_id)
if t:
return json.dumps({"ok": True, "task": t})
return json.dumps({"ok": False, "error": "Task not found"})
elif op == "list":
tasks = task_list(user_id, args.get("status"))
return json.dumps({"ok": True, "tasks": tasks})
return json.dumps({"ok": False, "error": f"Unknown operation: {op}"})
</file>
<file path="server/dotenv.py">
"""Minimal ``.env`` loader (stdlib only — no external dependency).
Reads a ``KEY=VALUE`` file from the repo root into ``os.environ`` so every
entrypoint (``chat-webui.py``, ``self-chat.py``, ``markdown_hosting.py``)
sees the same settings whether it is launched by hand, systemd or cron.
Only values that are NOT already exported by the real environment are applied,
so a systemd ``EnvironmentFile`` or the shell always wins over the ``.env``.
"""
import os
from pathlib import Path
def load_dotenv(path=None):
"""Load ``path`` (default: repo-root ``.env``) into ``os.environ``."""
if path is None:
path = Path(__file__).resolve().parent.parent / ".env"
path = Path(path)
if not path.is_file():
return False
loaded = False
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if not key:
continue
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1]
if key and key not in os.environ:
os.environ[key] = value
loaded = True
return loaded
load_dotenv()
</file>
<file path="src/components/ChatArea.jsx">
import { useEffect, useRef } from 'react'
import Message from './Message'
export default function ChatArea({ messages, pendingMessages, currentSessionId, onImageOpen, selectingRef, onPendingResolved, onLocationNeeded }) {
const bottomRef = useRef(null)
const chatRef = useRef(null)
const userScrolledUp = useRef(false)
const currentPending = pendingMessages
? Object.values(pendingMessages).filter(p => p.sessionId === currentSessionId)
: []
const allMessages = [...messages, ...currentPending.map(p => ({ _pending: true, ...p }))]
useEffect(() => {
const el = chatRef.current
if (!el) return
const handler = () => {
const threshold = 100
userScrolledUp.current = el.scrollHeight - el.scrollTop - el.clientHeight > threshold
}
el.addEventListener('scroll', handler, { passive: true })
return () => el.removeEventListener('scroll', handler)
}, [])
useEffect(() => {
const el = chatRef.current
if (!el || !selectingRef) return
const down = () => { selectingRef.current = true }
const up = () => { selectingRef.current = false }
el.addEventListener('pointerdown', down)
el.addEventListener('pointerup', up)
el.addEventListener('pointercancel', up)
el.addEventListener('touchend', up)
window.addEventListener('mouseup', up)
return () => {
el.removeEventListener('pointerdown', down)
el.removeEventListener('pointerup', up)
el.removeEventListener('pointercancel', up)
el.removeEventListener('touchend', up)
window.removeEventListener('mouseup', up)
}
}, [selectingRef])
useEffect(() => {
if (!selectingRef) return
const onSelChange = () => {
let active = false
try { active = !!document.getSelection() && !document.getSelection().isCollapsed } catch { }
selectingRef.current = active
}
document.addEventListener('selectionchange', onSelChange)
return () => document.removeEventListener('selectionchange', onSelChange)
}, [selectingRef])
useEffect(() => {
if (bottomRef.current && !userScrolledUp.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [messages.length, currentPending.length])
return (
<div id="chat" ref={chatRef}>
{messages.map((msg, i) => (
<Message
key={i}
msg={msg}
sessionId={currentSessionId}
msgIndex={i}
onImageOpen={onImageOpen}
selectingRef={selectingRef}
onResolved={onPendingResolved}
onLocationNeeded={onLocationNeeded}
/>
))}
{currentPending.map((p, i) => (
<Message
key={'p-' + (p.taskId || i)}
pending={p}
onImageOpen={onImageOpen}
selectingRef={selectingRef}
onResolved={onPendingResolved}
onLocationNeeded={onLocationNeeded}
/>
))}
<div ref={bottomRef} />
</div>
)
}
</file>
<file path="src/components/OverloadWarning.jsx">
export default function OverloadWarning({ overheated, gpuTemp, ramEvacuating }) {
if (!overheated && !ramEvacuating) return null
const tempStr = gpuTemp != null ? gpuTemp + '\u00B0C' : ''
const text = ramEvacuating
? 'Server overloaded (high memory). Your queued messages are paused while RAM is freed and servers restart.'
: 'Server overloaded. Your queued messages will be processed once the GPU cools down.'
return (
<div id="overload-warn" style={{ display: 'block' }}>
{text}{tempStr ? ' (GPU: ' + tempStr + ')' : ''}
</div>
)
}
</file>
<file path="src/components/PublicShareView.jsx">
import { useState, useEffect, useCallback } from 'react'
import { fetchPublicShare } from '../api'
import Message from './Message'
import ImageLightbox from './ImageLightbox'
export default function PublicShareView({ token, onExit }) {
const [state, setState] = useState({ loading: true, error: '', message: null, sharedBy: '' })
const [lightboxSrc, setLightboxSrc] = useState(null)
useEffect(() => {
let alive = true
fetchPublicShare(token)
.then(data => {
if (!alive) return
if (data && data.message) {
setState({ loading: false, error: '', message: data.message, sharedBy: data.shared_by || '' })
} else {
setState({
loading: false,
error: (data && data.error) || 'This shared message is no longer available.',
message: null,
sharedBy: '',
})
}
})
.catch(() => {
if (alive) setState({ loading: false, error: 'Could not load the shared message.', message: null, sharedBy: '' })
})
return () => { alive = false }
}, [token])
const openImage = useCallback(src => setLightboxSrc(src), [])
const closeLightbox = useCallback(() => setLightboxSrc(null), [])
return (
<div id="public-share-view">
<div className="public-share-topbar">
<span className="public-share-label">Shared message</span>
{onExit && (
<button type="button" className="public-share-exit" onClick={onExit}>
Log in to chat
</button>
)}
</div>
{state.loading ? (
<div className="public-share-status">Loading…</div>
) : state.error ? (
<div className="public-share-status error">{state.error}</div>
) : (
<div className="public-share-body">
<div className="public-share-meta">Shared by <strong>{state.sharedBy || 'someone'}</strong></div>
<Message msg={state.message} onImageOpen={openImage} hideSpeak />
</div>
)}
<ImageLightbox src={lightboxSrc} onClose={closeLightbox} />
</div>
)
}
</file>
<file path="src/utils.js">
export function toApiImage(url) {
if (!url || typeof url !== 'string') return url
if (url.startsWith('data:') || /^https?:/i.test(url) || url.startsWith('/api/image/')) return url
if (url.startsWith('/uploads/') || url.startsWith('/output/')) return '/api/image/' + url.slice(1)
if (url.startsWith('/')) return '/api/image/' + url.slice(1)
return url
}
export async function downloadFile(url, fallbackName = 'file') {
const full = url.startsWith('http') || url.startsWith('data:') ? url : window.location.origin + url
const filename = full.split('/').pop() || fallbackName
try {
const res = await fetch(full)
const blob = await res.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(a.href)
} catch {
window.open(full, '_blank')
}
}
</file>
<file path=".repomixignore">
prompts/master_details.json
</file>
<file path="gcp_nginx.conf">
server {
listen 80;
server_name home.palashkantikundu.in;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name home.palashkantikundu.in;
ssl_certificate /etc/nginx/ssl/home.palashkantikundu.in/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/home.palashkantikundu.in/privkey.pem;
# Intercept errors from home server connection
proxy_intercept_errors on;
error_page 502 503 504 = @server_offline;
# Inline HTML page served when main server is down
location @server_offline {
default_type text/html;
return 502 '<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Main Server Offline</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 32px; max-width: 420px; width: 100%; text-align: center; box-shadow: 0 8px 24px rgba(0,0,0,0.5); }
.icon { font-size: 48px; margin-bottom: 16px; }
.title { font-size: 20px; font-weight: 600; color: #f85149; margin-bottom: 8px; }
.desc { font-size: 14px; color: #8b949e; line-height: 1.5; }
</style>
</head>
<body>
<div class="card">
<div class="icon">⚠️</div>
<div class="title">Main Server Offline</div>
<div class="desc">The home server is currently unreachable or powered off. Please try again later.</div>
</div>
</body>
</html>';
}
location / {
proxy_pass https://10.66.66.3;
proxy_ssl_server_name on;
proxy_ssl_verify off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Via-GCP "true";
# Fast failover timeouts
proxy_connect_timeout 3s;
proxy_read_timeout 5s;
proxy_send_timeout 5s;
}
}
</file>
<file path="genre_creator.py">
import json
import os
FILE_NAME = "nested_schema_template.json"
def load_existing_schema():
if os.path.exists(FILE_NAME):
try:
with open(FILE_NAME, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError:
pass
return {
"tasks_entry": [],
"genre_persona_map_entry": {},
"persona_pool_entry": {},
"genre_checklists_entry": {}
}
def generate_nested_schema():
data = load_existing_schema()
print("=== Interactive Deep-Merging Schema Builder ===")
while True:
genre = input("\nEnter Genre (e.g., Regional Fashion): ").strip()
if not genre:
break
# 1. Append Task if not already present
task_exists = any(t.get("genre") == genre for t in data["tasks_entry"])
if not task_exists:
data["tasks_entry"].append({
"task": f"Sample Task for {genre}",
"details": "Provide detailed task instructions here.",
"genre": genre,
"roles": ["free"],
"languages": ["English", "bengali"],
"mediums": ["text", "image"]
})
# 2. Init Genre-Persona Map & Checklist
if genre not in data["genre_persona_map_entry"]:
data["genre_persona_map_entry"][genre] = []
if genre not in data["genre_checklists_entry"]:
data["genre_checklists_entry"][genre] = {
"editor": ["Rule 1...", "Rule 2..."],
"moderator": ["RED if rule 1 fails.", "RED if rule 2 fails."]
}
# 3. Add Personas for this Genre
while True:
print(f"\n--- Adding Persona for Genre: '{genre}' ---")
relationship = input("Enter Relationship Category (e.g., Artisans): ").strip()
mood = input("Enter Mood Dynamic (e.g., Subtle Admirers): ").strip()
# Merge Relationship to Genre Map
if relationship not in data["genre_persona_map_entry"][genre]:
data["genre_persona_map_entry"][genre].append(relationship)
# Deep Merge Persona Pool
if relationship not in data["persona_pool_entry"]:
data["persona_pool_entry"][relationship] = {}
data["persona_pool_entry"][relationship][mood] = {
"Kaya": {
"role": "Role A",
"persona": "Description of Kaya's persona"
},
"Kolpo": {
"role": "Role B",
"persona": "Description of Kolpo's persona"
}
}
another_persona = input(f"Add another persona for '{genre}'? (y/n): ").strip().lower()
if another_persona != 'y':
break
another_genre = input("\nAdd another Genre? (y/n): ").strip().lower()
if another_genre != 'y':
break
# Save merged data
with open(FILE_NAME, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
print(f"\nSuccessfully merged and saved into: {FILE_NAME}")
if __name__ == "__main__":
generate_nested_schema()
</file>
<file path="restart_services.sh">
#!/usr/bin/env bash
# Restart the top-level local-ai services.
#
# chat-webui python3 ./chat-webui.py (port 3001)
# markdown hosting uvicorn markdown_hosting:app (port 3002)
# code hosting python3 code_host.py . (port 9000)
# request tracker sudo python3 server/track_dashboard.py (port 8093)
#
# Every service runs under nohup and appends to its own log in logs/.
# The tracker reads /var/log/nginx/track.log, so it runs under sudo —
# you may be prompted for your password once.
set -u
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GIT_DIR="$(dirname "$REPO_DIR")"
LOG_DIR="$REPO_DIR/logs"
mkdir -p "$LOG_DIR"
stop_all() {
local pattern="$1" name="$2"
if pgrep -f "$pattern" >/dev/null 2>&1; then
pkill -TERM -f "$pattern" 2>/dev/null
for _ in $(seq 1 20); do
pgrep -f "$pattern" >/dev/null 2>&1 || break
sleep 0.5
done
pkill -KILL -f "$pattern" 2>/dev/null
echo " stopped: $name"
else
echo " not running: $name"
fi
}
start_one() {
local name="$1" logfile="$2" dir="$3"
shift 3
(
cd "$dir" || exit 1
nohup "$@" >>"$logfile" 2>&1 &
echo " started: $name (pid $!, log $logfile)"
)
}
echo "== Stopping =="
stop_all 'chat-webui\.py' "chat web ui"
stop_all 'markdown_hosting' "markdown hosting"
stop_all 'code_host\.py' "code hosting"
stop_all 'track_dashboard' "request tracker"
# Tracker log must exist and be readable (same prep as local_cloud.sh).
sudo touch /var/log/nginx/track.log
sudo chmod 640 /var/log/nginx/track.log
echo "== Starting =="
start_one "chat web ui" "$LOG_DIR/chat-webui.log" "$REPO_DIR" python3 ./chat-webui.py
start_one "markdown hosting" "$LOG_DIR/markdown-hosting.log" "$REPO_DIR" python3 -m uvicorn markdown_hosting:app --host 127.0.0.1 --port 3002
start_one "code hosting" "$LOG_DIR/code-host.log" "$GIT_DIR" python3 code_host.py .
start_one "request tracker" "$LOG_DIR/track-dashboard.log" "$REPO_DIR" sudo python3 server/track_dashboard.py --port 8093 --log /var/log/nginx/track.log
sleep 3
echo "== Health =="
check() {
local name="$1" url="$2"
local code
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null)
if [ "$code" != "000" ]; then
echo " UP $name ($url -> HTTP $code)"
else
echo " DOWN $name ($url) — check its log in $LOG_DIR/"
[ "$name" = "request tracker" ] && \
echo " (tracker needs sudo; run this script from a terminal so the password can be prompted)"
fi
}
check "chat web ui" http://127.0.0.1:3001/
check "markdown hosting" http://127.0.0.1:3002/
check "code hosting" http://127.0.0.1:9000/
check "request tracker" http://127.0.0.1:8093/
</file>
<file path="sso-debugging.md">
# Here's a recap of the debugging session — you were fixing SSO/logout for `home.palashkantikundu.in/ai`:
**1. Initial 404 on `/ai/`**
- Root cause: `local-ai-authentik-outpost-1` was `unhealthy`, so `auth_request` calls to it failed and the login redirect chain dead-ended in a 404.
**2. Outpost `unhealthy`, config had a typo**
- `authentik-compose.yaml` had `UTHENTIK_TOKEN` (missing the leading `A`) instead of `AUTHENTIK_TOKEN` — the env var was silently ignored.
**3. `502 Bad Gateway` fetching outpost config**
- After fixing the typo, the outpost was trying to reach Authentik via the *public* URL (`https://home.palashkantikundu.in/sso/`), round-tripping out to the internet unnecessarily. Fixed by pointing `AUTHENTIK_HOST` at the internal Docker service name instead, and adding `AUTHENTIK_HOST_BROWSER` for user-facing redirects.
**4. `404 Not Found` fetching outpost config**
- `AUTHENTIK_HOST` was missing the `/sso/` path suffix that `AUTHENTIK_WEB__PATH: /sso/` requires on the server side. Fixed to `http://authentik-server:9000/sso/`.
**5. OAuth "Redirect URI Error"**
- The Proxy Provider's **External Host** field in the Authentik admin UI was pointing at an internal address instead of `https://home.palashkantikundu.in`. Fixed in the admin UI, then restarted the outpost to re-sync.
**6. 502 on the app itself**
- Traced to checking whether `chat-webui.py` (port 3001) was actually running — this resolved once confirmed/started.
**7. Login worked. Logout redirected to `/sso/outpost.goauthentik.io/end?rd=/` → 404**
- Found the bug in `src/api.js`: the logout URL was hardcoded with an incorrect `/sso/` prefix. Nginx only routes `/outpost.goauthentik.io/*` at the domain root, not under `/sso/`. Fixed the JS to `window.location.assign('/outpost.goauthentik.io/end?rd=/')` and rebuilt the frontend.
**8. Still 404 on `/outpost.goauthentik.io/end?rd=/` — unresolved**
- Confirmed via nginx access log that nginx is routing this correctly (the 404 comes from the outpost itself, not nginx).
- Confirmed via direct `curl` to `127.0.0.1:9010` (bypassing nginx) that the outpost returns a bare `404 page not found` for this path.
- Current working theory: the `/end` logout endpoint needs the browser's Authentik session cookie to identify which provider/session to terminate, and it may not be present/forwarded on that request. **Next steps left open:** check dev tools for the `authentik_proxy_*` cookie on that request, and retest the curl call with that cookie attached to confirm.
That last piece (logout 404) is where we left off — still open.
</file>
<file path="prompts/genre_checklists.json">
{
"default": {
"recommended_turns": 3,
"editor": [
"Ensure the story actually delivers the assigned task and any task details verbatim, not a loosely related substitute.",
"Ensure the story reaches a clear, complete ending rather than trailing off."
],
"moderator": [
"RED if the story does not deliver the assigned task, or ignores the task details field.",
"RED if the story ends abruptly without resolution."
]
},
"Bedtime Stories": {
"recommended_turns": 3,
"editor": [
"If the task details asked for a web-searched, kid-appropriate event as the story's basis, confirm a web_search was actually performed (check the [WEB SEARCH REPORTS SHARED] handoffs) and that the chosen event is reflected in the plot — flag if not.",
"Confirm the content is appropriate for children aged 5-12: no violence, fear, death, or romance beyond the mildest hand-holding.",
"Confirm the story ends on a calm, reassuring, sleep-appropriate note, not a cliffhanger or exciting climax.",
"If 'image' is a declared medium, confirm exactly one warm, child-friendly illustration is embedded and matches the scene it's placed next to."
],
"moderator": [
"RED if the story is not clearly appropriate for ages 5-12 (any violence, scariness, or mature themes).",
"RED if the ending is exciting/unresolved rather than calm and reassuring.",
"RED if the task details required a web-searched real event and the story shows no evidence a search actually happened or informed the plot.",
"RED if 'image' was declared but is missing."
]
},
"Satire": {
"recommended_turns": 3,
"editor": [
"Confirm the piece stays generic/institutional (corporate work culture as an institution) and does not name or clearly caricature a real, identifiable company or person.",
"Confirm there is an actual comedic/satirical thesis, not just a list of complaints presented as prose.",
"Confirm no fabricated quotes are attributed to real people.",
"Confirm the humor targets workplace institutions/behaviors, not a protected group."
],
"moderator": [
"RED if any real, identifiable company or person is named or clearly caricatured.",
"RED if the piece reads as a sincere complaint/rant rather than satire with a discernible comedic angle.",
"RED if a fabricated quote is attributed to a real person.",
"RED if the humor targets a protected group rather than the workplace institution."
]
},
"Food & Recipes": {
"recommended_turns": 3,
"editor": [
"Confirm the story/guide contains an actual ingredient list and numbered preparation steps, not just descriptive prose about the dish.",
"Confirm the dish is a real, identifiable traditional festival sweet (or clearly-labeled fictional variant), not an invented dish falsely claimed as traditional.",
"If 'image' is a declared medium, confirm the embedded image plausibly matches the described finished dish or a key preparation step.",
"Confirm quantities/measurements are given for every listed ingredient."
],
"moderator": [
"RED if there is no clear ingredient list or no numbered steps.",
"RED if 'image' was declared but is missing, or the image clearly does not match the described dish.",
"RED if the recipe is presented as traditional but is entirely fabricated with no real basis."
]
},
"News": {
"recommended_turns": 2,
"editor": [
"Confirm every factual claim traces back to a web_search/fetch_page result actually retrieved in this conversation (check the Citations & References section is non-empty and each claim maps to a citation).",
"Confirm no invented statistics, dates, or events are present beyond what the search results support.",
"Confirm a 'daily summary' covers multiple distinct news items, not a single deep dive presented as a summary.",
"Confirm neutral, reportorial tone — no editorializing presented as fact."
],
"moderator": [
"RED if there are no citations despite the piece presenting itself as a news summary.",
"RED if any claim looks fabricated or unsupported by the collected search results.",
"RED if only one topic is covered when the task calls for a 'summary of top updates' (plural).",
"RED if the tone is opinionated/editorial rather than reportorial."
]
},
"Adventure & Horror": {
"recommended_turns": 4,
"editor": [
"Confirm the narrative maintains high suspense without violating general platform safety bounds (no graphic gore or self-harm).",
"If based on real events, confirm a web search was executed and key historical details remain accurate.",
"Confirm the story reaches a distinct resolution or intentional thriller climax rather than cutting off mid-scene."
],
"moderator": [
"RED if the horror content depicts extreme graphic gore, gratuitous cruelty, or self-harm.",
"RED if a real-world factual task was requested but no search occurred or facts were completely fabricated.",
"RED if the narrative ends abruptly mid-action."
]
},
"Sci-Fi": {
"recommended_turns": 4,
"editor": [
"Verify the core sci-fi concept or futuristic technology follows consistent internal logic.",
"Ensure technical jargon enhances the atmosphere without rendering the narrative unreadable."
],
"moderator": [
"RED: World-building rules directly contradict previous turns.",
"RED: Scientific/futuristic elements are introduced as key plot devices without context."
]
},
"Thriller": {
"recommended_turns": 4,
"editor": [
"Ensure high pacing, claustrophobic tension, and suspense are maintained across all turns.",
"Verify that the resolution resolves the primary threat or delivers a deliberate thriller cliffhanger."
],
"moderator": [
"RED: Tension drops abruptly into mundane dialogue without narrative justification.",
"RED: The story ends mid-action without resolving the active threat or setting up a cliffhanger."
]
},
"Detective": {
"recommended_turns": 5,
"editor": [
"Confirm all crucial clues needed to solve the mystery are presented to the reader before the final reveal.",
"Ensure logical deduction is clearly demonstrated during the investigation phase."
],
"moderator": [
"RED: The mystery resolution relies on evidence or characters never mentioned in earlier turns.",
"RED: Deductive conclusions are completely illogical or contradict established clues."
]
},
"Adventure": {
"recommended_turns": 4,
"editor": [
"Ensure environmental hazards and physical stakes drive the narrative forward.",
"Verify dynamic interaction between characters as they navigate obstacles."
],
"moderator": [
"RED: The setting lacks environmental grounding or sense of physical progression.",
"RED: Conflict is resolved instantly without effort or risk."
]
},
"News Bytes": {
"recommended_turns": 2,
"editor": [
"Ensure facts directly map to retrieved search results with clear, precise summaries.",
"Maintain a strictly neutral, concise, and reportorial tone."
],
"moderator": [
"RED: Factual claims are fabricated or unverified.",
"RED: Missing required source citations or web research backing."
]
},
"News Turned Stories": {
"recommended_turns": 4,
"editor": [
"Verify the narrative adaptation retains core real-world facts while building compelling human drama.",
"Ensure character dialogue and emotional beats feel grounded and natural."
],
"moderator": [
"RED: Core factual premises from the news source are completely distorted.",
"RED: The story sensationalizes real events into harmful or misleading fiction."
]
}
}
</file>
<file path="prompts/genre_persona_map.json">
{
"Bedtime Stories": [
"Parent & Child",
"Siblings",
"Married Couple"
],
"Satire": [
"Colleagues",
"Friends"
],
"Food & Recipes": [
"Married Couple",
"Parent & Child",
"Friends",
"Siblings"
],
"News": [
"Colleagues",
"Friends"
],
"Adventure & Horror": [
"Travel Companions",
"Friends",
"Siblings",
"Married Couple"
],
"Sci-Fi": [
"Colleagues",
"Travel Companions",
"Friends",
"Married Couple"
],
"Thriller": [
"Colleagues",
"Friends",
"Married Couple",
"Siblings"
],
"Detective": [
"Colleagues",
"Friends"
],
"Adventure": [
"Travel Companions",
"Friends",
"Siblings",
"Married Couple"
],
"News Bytes": [
"Colleagues"
],
"News Turned Stories": [
"Colleagues",
"Friends",
"Siblings"
],
"default": [
"Colleagues",
"Friends"
]
}
</file>
<file path="prompts/persona_pool.json">
{
"Parent & Child": {
"Nurturing Guidance": {
"required_role": "free",
"Kaya": {
"role": "Daughter",
"persona": "Enthusiastic, curious, seeking advice with bright questions"
},
"Kolpo": {
"role": "Father",
"persona": "Patient, wise, reassuring, giving grounded life lessons"
}
},
"Bedtime Storytelling": {
"required_role": "free",
"Kaya": {
"role": "Mother",
"persona": "Warm, gentle storyteller with a whimsical and soothing touch"
},
"Kolpo": {
"role": "Son",
"persona": "Sleepy, imaginative listener asking playful 'what-if' questions"
}
}
},
"Siblings": {
"Playful Rivalry": {
"required_role": "free",
"Kaya": {
"role": "Younger Sister",
"persona": "Witty, teasing, quick-tongued sparkplug trying to outsmart"
},
"Kolpo": {
"role": "Older Brother",
"persona": "Protective, mildly exasperated, calm yet competitive"
}
},
"Shared Secrets": {
"required_role": "free",
"Kaya": {
"role": "Sister",
"persona": "Conspiratorial, expressive, whispering eager details"
},
"Kolpo": {
"role": "Brother",
"persona": "Reliable vault, analytical strategist helping plan the secret"
}
},
"Survival Alliance": {
"required_role": "premium",
"Kaya": {
"role": "Sister (Scout)",
"persona": "Quick-thinking, resource-frugal, highly observant under physical stress"
},
"Kolpo": {
"role": "Brother (Anchor)",
"persona": "Steadfast, pragmatic shield, calculating safe routes and risks"
}
}
},
"Married Couple": {
"Quiet Intimacy": {
"required_role": "premium",
"Kaya": {
"role": "Wife",
"persona": "Affectionate, observant, noticing subtle emotional details"
},
"Kolpo": {
"role": "Husband",
"persona": "Grounded, warm, deeply supportive with quiet strength"
}
},
"Playful Banter": {
"required_role": "free",
"Kaya": {
"role": "Wife",
"persona": "Tangy, sassy, tossing quick jokes over daily chores"
},
"Kolpo": {
"role": "Husband",
"persona": "Charming, dry-witted, playing along with effortless grace"
}
},
"Under Crisis": {
"required_role": "premium",
"Kaya": {
"role": "Wife (Crisis Lead)",
"persona": "Decisive, hyper-focused, protective, prioritizing survival logistics"
},
"Kolpo": {
"role": "Husband (Stabilizer)",
"persona": "Calm emotional anchor, vigilant, executing high-stakes maneuvers"
}
}
},
"Colleagues": {
"Focused Collaboration": {
"required_role": "free",
"Kaya": {
"role": "Creative Lead",
"persona": "Fast-paced, bold idea generator pushing boundaries"
},
"Kolpo": {
"role": "Technical Architect",
"persona": "Methodical, structured, turning wild ideas into reality"
}
},
"Deadline Stress": {
"required_role": "free",
"Kaya": {
"role": "Project Manager",
"persona": "High-energy, witty troubleshooter keeping morale up"
},
"Kolpo": {
"role": "Lead Specialist",
"persona": "Cool under pressure, razor-focused, crunching through blocks"
}
},
"Investigative Inquiry": {
"required_role": "premium",
"Kaya": {
"role": "Lead Detective / Researcher",
"persona": "Relentless, highly intuitive, connecting disparate evidence threads"
},
"Kolpo": {
"role": "Forensic / Data Analyst",
"persona": "Skeptical, precise, verifying facts against hard raw data"
}
},
"News Anchor Desk": {
"required_role": "free",
"Kaya": {
"role": "Senior News Anchor",
"persona": "Crisp, authoritative, adept at rapid live synthesized updates"
},
"Kolpo": {
"role": "Field Correspondent",
"persona": "Direct, fast-paced reporter providing verified ground details"
}
},
"Futuristic Research": {
"required_role": "free",
"Kaya": {
"role": "Chief Technologist",
"persona": "Visionary, speculative thinker probing technological ethics"
},
"Kolpo": {
"role": "Systems Systems Engineer",
"persona": "Pragmatic engineer, testing mechanical bounds and technical feasibility"
}
}
},
"Friends": {
"Casual Venting": {
"required_role": "free",
"Kaya": {
"role": "Best Friend (F)",
"persona": "Empathic, expressive, sharp humor to break tension"
},
"Kolpo": {
"role": "Best Friend (M)",
"persona": "Grounded listener, offering solid advice and perspective"
}
},
"Nostalgic Reminiscing": {
"required_role": "free",
"Kaya": {
"role": "Childhood Friend (F)",
"persona": "Vivid storyteller, digging up hilarious past memories"
},
"Kolpo": {
"role": "Childhood Friend (M)",
"persona": "Reflective, laughing along, filling in forgotten details"
}
},
"Urban Exploration": {
"required_role": "free",
"Kaya": {
"role": "Adventurous Friend",
"persona": "Curious thrill-seeker, pushing past comfort zones to uncover stories"
},
"Kolpo": {
"role": "Cautious Friend",
"persona": "Voice of reason, double-checking facts and ensuring safety"
}
}
},
"Travel Companions": {
"Shared Exploration": {
"required_role": "free",
"Kaya": {
"role": "Explorer",
"persona": "Spontaneous navigator, eager to discover hidden local spots"
},
"Kolpo": {
"role": "Planner",
"persona": "Resourceful, calm driver, keeping logistics and safety intact"
}
},
"High-Stakes Expedition": {
"required_role": "premium",
"Kaya": {
"role": "Field Expedition Lead",
"persona": "Resilient, bold, adapting rapidly to harsh environments"
},
"Kolpo": {
"role": "Navigation Specialist",
"persona": "Vigilant tracker, calculating terrain risks and environmental factors"
}
}
}
}
</file>
<file path="searxng/settings.yml">
use_default_settings: true
server:
secret_key: "ultrasecretkeyforsearxng"
base_url: false
limiter: false
search:
safe_search: 0
autocomplete: ""
formats:
- html
- json
outgoing:
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
request_timeout: 10.0
max_request_timeout: 15.0
engines:
- name: wikidata
engine: dummy
disabled: true
language_support: true
- name: ahmia
disabled: true
</file>
<file path="server/features/themes_db.py">
"""SQLite-backed theme & combination tracker (``track_theme`` tool, /api/themes).
A dedicated ledger that guarantees creative variety across generated content
without polluting per-user contexts or the to-do (``manage_tasks``) system.
Two dimensions are tracked:
* ``scope`` — the user whose content is being generated. Records inside one
scope are that user's *combination* history: every already-used combination
of detail fields + mood + genre + role + persona.
* global — the union of ALL scopes ("vastly keep track of all the users"),
used to avoid cross-user repetition and to see overall output.
Within a self-chat window every participant (kolpo, kaya, editor, moderator)
shares a single scope (``"self-chat"``) so the theme stays coordinated between
the users of that window while regular per-user chats stay isolated.
"""
import hashlib
import json
import sqlite3
import threading
import uuid
from datetime import datetime
from server.features.state import M
_themes_db_lock = threading.Lock()
def _db_run(query, params=()):
with _themes_db_lock:
conn = sqlite3.connect(M.THEMES_DB)
try:
cur = conn.execute(query, params)
conn.commit()
return cur.rowcount
finally:
conn.close()
def _db_fetch(query, params=()):
with _themes_db_lock:
conn = sqlite3.connect(M.THEMES_DB)
conn.row_factory = sqlite3.Row
try:
cur = conn.execute(query, params)
return [dict(r) for r in cur.fetchall()]
finally:
conn.close()
def _db_fetch_one(query, params=()):
rows = _db_fetch(query, params)
return rows[0] if rows else None
def _init_themes_db():
_db_run(
"""
CREATE TABLE IF NOT EXISTS theme_log (
id TEXT PRIMARY KEY,
scope TEXT NOT NULL,
user_id TEXT DEFAULT '',
genre TEXT DEFAULT '',
mood TEXT DEFAULT '',
role TEXT DEFAULT '',
persona TEXT DEFAULT '',
details TEXT DEFAULT '{}',
combo_hash TEXT NOT NULL,
theme TEXT DEFAULT '',
status TEXT DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(scope, combo_hash)
)
"""
)
def combo_hash(genre, mood, role, persona, details=None, level="round"):
"""Canonical fingerprint of a combination.
The combination mixes every detail field with mood, genre, role and
persona, so reusing ANY of them with the same set counts as a duplicate.
``level`` separates round-scoped records from per-turn records so they
never falsely collide.
"""
payload = {
"genre": genre or "",
"mood": mood or "",
"role": role or "",
"persona": persona or "",
"details": details or {},
"level": level or "round",
}
canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:24]
def theme_log_create(
scope,
user_id="",
genre="",
mood="",
role="",
persona="",
details=None,
theme="",
status="active",
level="round",
):
if isinstance(details, dict):
details = json.dumps(details, ensure_ascii=False, sort_keys=True)
if details in (None, ""):
details = "{}"
try:
details_obj = json.loads(details)
except (TypeError, ValueError):
details_obj = {}
h = combo_hash(genre, mood, role, persona, details_obj, level)
existing = _db_fetch_one(
"SELECT * FROM theme_log WHERE scope=? AND combo_hash=?",
(scope, h),
)
if existing:
updates = {}
if theme and theme != existing.get("theme"):
updates["theme"] = theme
if status and existing.get("status") != status:
updates["status"] = status
if updates:
updates["updated_at"] = datetime.now().isoformat(timespec="seconds")
set_clause = ", ".join(f"{k}=?" for k in updates)
_db_run(
f"UPDATE theme_log SET {set_clause} WHERE id=?",
tuple(updates.values()) + (existing["id"],),
)
existing = _db_fetch_one(
"SELECT * FROM theme_log WHERE id=?", (existing["id"],)
)
return existing, True
tid = str(uuid.uuid4())
now = datetime.now().isoformat(timespec="seconds")
_db_run(
"INSERT INTO theme_log (id, scope, user_id, genre, mood, role, persona, details, combo_hash, theme, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
tid,
scope,
user_id or "",
genre or "",
mood or "",
role or "",
persona or "",
details,
h,
theme or "",
status or "active",
now,
now,
),
)
return _db_fetch_one("SELECT * FROM theme_log WHERE id=?", (tid,)), False
def theme_log_complete(tid):
existing = _db_fetch_one("SELECT * FROM theme_log WHERE id=?", (tid,))
if not existing:
return None
_db_run(
"UPDATE theme_log SET status='completed', updated_at=? WHERE id=?",
(datetime.now().isoformat(timespec="seconds"), tid),
)
return _db_fetch_one("SELECT * FROM theme_log WHERE id=?", (tid,))
def theme_log_list(scope=None, all_scopes=False, status=None, limit=50):
where = []
params = []
if not all_scopes and scope:
where.append("scope=?")
params.append(scope)
if status:
where.append("status=?")
params.append(status)
clause = ("WHERE " + " AND ".join(where)) if where else ""
try:
limit = max(1, int(limit))
except (TypeError, ValueError):
limit = 50
params.append(limit)
return _db_fetch(
f"SELECT * FROM theme_log {clause} ORDER BY created_at DESC LIMIT ?",
params,
)
def theme_log_check(scope, genre="", mood="", role="", persona="", details=None, level="round"):
if isinstance(details, str):
try:
details = json.loads(details)
except (TypeError, ValueError):
details = {}
h = combo_hash(genre, mood, role, persona, details or {}, level)
return _db_fetch_one(
"SELECT * FROM theme_log WHERE scope=? AND combo_hash=?",
(scope, h),
)
def theme_log_stats(scope=None, all_scopes=False):
where = []
params = []
if not all_scopes and scope:
where.append("scope=?")
params.append(scope)
clause = ("WHERE " + " AND ".join(where)) if where else ""
rows = _db_fetch(
f"SELECT scope, status, COUNT(*) AS count FROM theme_log {clause} GROUP BY scope, status ORDER BY scope",
params,
)
totals = {}
for r in rows:
totals[r["scope"]] = totals.get(r["scope"], {})
totals[r["scope"]][r["status"]] = r["count"]
return {
"total": sum(r["count"] for r in rows),
"per_scope": totals,
}
def handle_theme_tool(user_id, args):
op = args.get("operation", "")
scope = (args.get("scope") or "").strip() or None
if op == "log":
if not scope:
return json.dumps({"ok": False, "error": "Missing required argument: scope"})
record, duplicate = theme_log_create(
scope,
user_id=user_id,
genre=args.get("genre"),
mood=args.get("mood"),
role=args.get("role"),
persona=args.get("persona"),
details=args.get("details"),
theme=args.get("theme"),
status=args.get("status"),
level=args.get("level", "round"),
)
return json.dumps({"ok": True, "duplicate": duplicate, "theme": record})
elif op == "complete":
tid = args.get("theme_id")
if not tid:
return json.dumps({"ok": False, "error": "Missing required argument: theme_id"})
record = theme_log_complete(tid)
if record:
return json.dumps({"ok": True, "theme": record})
return json.dumps({"ok": False, "error": "Theme not found"})
elif op == "check":
if not scope:
return json.dumps({"ok": False, "error": "Missing required argument: scope"})
record = theme_log_check(
scope,
genre=args.get("genre"),
mood=args.get("mood"),
role=args.get("role"),
persona=args.get("persona"),
details=args.get("details"),
level=args.get("level", "round"),
)
return json.dumps(
{
"ok": True,
"used": bool(record),
"theme": record,
}
)
elif op == "list":
records = theme_log_list(
scope=scope,
all_scopes=bool(args.get("global")),
status=args.get("status"),
limit=args.get("limit", 50),
)
return json.dumps({"ok": True, "themes": records})
elif op == "stats":
return json.dumps(
{"ok": True, **theme_log_stats(scope=scope, all_scopes=bool(args.get("global")))}
)
return json.dumps({"ok": False, "error": f"Unknown operation: {op}"})
</file>
<file path="server/features/users.py">
"""Per-user context files and HTTP identity resolution.
Identity comes from :mod:`server.auth` — Authentik is the single identity
provider (there is no users.json anymore). This module keeps the names the
chat engine calls: password lookup is gone, context paths are derived from the
username, and ``get_current_user``/``get_current_identity`` resolve the
request's identity through the shared auth layer.
"""
import os
import re
import time
from datetime import datetime
from server.auth import get_current_user as auth_get_current_user
from server.auth import get_identity as auth_get_identity
from server.config import CONTEXTS_DIR
from server.features.state import M
def _safe_username(user):
safe = re.sub(r"[^A-Za-z0-9_-]", "_", user or "")
return safe or "unknown"
def _mark_seen(username):
"""Record a username as recently active (heartbeat for active-user display)."""
if not username:
return
with M._user_last_seen_lock:
M._user_last_seen[username] = time.time()
def get_current_user(headers):
username = auth_get_current_user(headers)
_mark_seen(username)
return username
def get_current_identity(headers):
identity = auth_get_identity(headers)
if identity:
_mark_seen(identity["username"])
return identity
def active_users(window_seconds=120, exclude_agents=True):
"""Sorted usernames seen within the window, optionally excluding agents."""
now = time.time()
with M._user_last_seen_lock:
users = sorted(
u for u, ts in M._user_last_seen.items()
if now - ts <= window_seconds
)
if exclude_agents:
with M._tokens_lock:
agent_users = set(M._agent_users)
return [u for u in users if u not in agent_users]
return users
def get_user_context_path(username):
"""Derive the context file path from the username.
users.json previously stored an arbitrary per-user ``context_file`` path;
with Authentik as the sole identity store that field no longer exists, so
every user's context lives at ``CONTEXTS_DIR/<username>.txt``.
"""
return os.path.join(CONTEXTS_DIR, _safe_username(username) + ".txt")
def read_user_context(username):
path = get_user_context_path(username)
print("Context path", path, "for", username)
if path and os.path.exists(path):
try:
print("Reading", path)
with open(path) as f:
context = f.read()
print(context)
return context
except:
return ""
return ""
def write_user_context(username, content):
path = get_user_context_path(username)
if path:
os.makedirs(os.path.dirname(path), exist_ok=True)
existing = read_user_context(username)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
entry = f"[{timestamp}] {content}"
new_content = (existing.strip() + "\n\n" + entry) if existing.strip() else entry
with open(path, "w") as f:
f.write(new_content)
</file>
<file path="server/track_dashboard.py">
#!/usr/bin/env python3
"""Request tracking dashboard for the local nginx.
Reads the custom nginx access log (json lines, one per request) written by
local_cloud.sh (log_format `track`), aggregates per-request metadata, and
serves a minimal dashboard.
What it answers (from nginx's own request metadata):
- Did the request come from GCP? -> X-Via-GCP header ($http_x_via_gcp)
- What request was made? -> method + URI + status
- Outbound egress per request -> $bytes_sent (bytes sent to client)
- Inbound egress per request -> $request_length (bytes from client)
- Which app initiated it? -> user-agent classification (Android
app / iOS app / desktop app / browser / CLI)
Run: python3 server/track_dashboard.py [--port 8093] [--log PATH]
Serve via nginx: location /track/ { proxy_pass http://127.0.0.1:8093/; }
"""
import argparse
import json
import os
import threading
import time
from collections import Counter, defaultdict
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
DEFAULT_LOG = "/var/log/nginx/track.log"
DEFAULT_PORT = 8093
APP_PATTERNS = [
("Nextcloud Android", "nextcloud-android"),
("Nextcloud iOS", "nextcloud-ios"),
("Nextcloud Desktop", "nextcloud-desktop"),
("Nextcloud DAVx5", "davx5"),
("curl", "curl"),
("wget", "wget"),
("Python requests", "python-requests"),
("Rclone/Nextcloud", "rclone"),
("Browser Chrome", "chrome"),
("Browser Firefox", "firefox"),
("Browser Safari", "safari"),
("Browser Edge", "edg"),
("Browser (other Mozilla)", "mozilla"),
]
def classify_app(ua):
ua = (ua or "").lower()
if "nextcloud-android" in ua:
return "Nextcloud Android app"
if "nextcloud-ios" in ua or "nextcloudmobile" in ua:
return "Nextcloud iOS app"
if "nextcloud-desktop" in ua:
return "Nextcloud Desktop app"
for label, needle in APP_PATTERNS:
if needle in ua:
return label
if ua:
return "Unknown client"
return "(no user-agent)"
def status_class(status):
try:
s = int(status)
except (TypeError, ValueError):
return "?"
return f"{s // 100}xx"
class Tracker:
def __init__(self, log_path, keep_entries=5000):
self.path = log_path
self.keep = keep_entries
self.lock = threading.Lock()
self.reset()
def reset(self):
self.total = 0
self.gcp_total = 0
self.by_app = Counter()
self.by_uri_top = Counter()
self.by_method = Counter()
self.by_status = Counter()
self.out_bytes = {"all": 0, "gcp": 0, "non_gcp": 0}
self.in_bytes = {"all": 0, "gcp": 0, "non_gcp": 0}
self.recent = []
self.start_ts = time.time()
self.last_ts = None
def _ingest(self, raw):
try:
r = json.loads(raw)
except (ValueError, TypeError):
return
self.total += 1
gcp = str(r.get("gcp") or "").strip().lower()
is_gcp = gcp in ("1", "true", "yes", "on")
if is_gcp:
self.gcp_total += 1
ua = r.get("ua") or ""
app = classify_app(ua)
self.by_app[app] += 1
uri = r.get("uri") or "/"
self.by_uri_top[uri] += 1
method = r.get("method") or "-"
self.by_method[method] += 1
status = r.get("status") or 0
self.by_status[status_class(status)] += 1
out = int(r.get("out") or 0)
inc = int(r.get("in") or 0)
bucket = "gcp" if is_gcp else "non_gcp"
self.out_bytes["all"] += out
self.out_bytes[bucket] += out
self.in_bytes["all"] += inc
self.in_bytes[bucket] += inc
ts = r.get("time") or time.strftime("%Y-%m-%dT%H:%M:%S%z")
self.last_ts = ts
entry = {
"time": ts,
"gcp": is_gcp,
"app": app,
"method": method,
"uri": uri,
"status": status,
"in": inc,
"out": out,
}
self.recent.append(entry)
if len(self.recent) > self.keep:
self.recent = self.recent[-self.keep:]
def poll(self):
"""Read newly appended lines from the log and ingest them."""
if not os.path.exists(self.path):
return
pos = getattr(self, "_offset", 0)
size = os.path.getsize(self.path)
if size < pos:
# rotated/truncated — restart from the beginning
pos = 0
with open(self.path, "rb") as f:
f.seek(pos)
lines = f.read()
self._offset = f.tell()
for raw in lines.decode("utf-8", "replace").splitlines():
self._ingest(raw)
def stats(self):
with self.lock:
self.poll()
top_uri = self.by_uri_top.most_common(25)
return {
"generated": time.strftime("%Y-%m-%d %H:%M:%S"),
"uptime_secs": int(time.time() - self.start_ts),
"last_seen": self.last_ts,
"total": self.total,
"gcp_total": self.gcp_total,
"non_gcp_total": self.total - self.gcp_total,
"in_bytes": dict(self.in_bytes),
"out_bytes": dict(self.out_bytes),
"by_app": self.by_app.most_common(),
"by_method": self.by_method.most_common(),
"by_status": self.by_status.most_common(),
"top_uri": top_uri,
"recent": list(reversed(self.recent[-200:])),
}
TRACKER = None
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, content_type, body):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlparse(self.path).path
if path == "/api/stats":
data = json.dumps(TRACKER.stats()).encode()
self._send(200, "application/json", data)
elif path in ("/", "/index.html"):
self._send(200, "text/html; charset=utf-8", PAGE.encode())
else:
self._send(404, "text/plain", b"not found")
PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Request Tracker</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; }
h1 { color: #58a6ff; font-size: 22px; margin-bottom: 4px; }
.sub { color: #8b949e; font-size: 13px; margin-bottom: 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 20px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 16px; }
.card .label { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: #8b949e; }
.card .val { font-size: 26px; font-weight: 700; color: #58a6ff; }
.card .subval { font-size: 12px; color: #8b949e; margin-top: 2px; }
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; }
.panel { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 16px; margin-bottom: 14px; }
.panel h3 { color: #58a6ff; font-size: 14px; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #21262d; }
th { color: #8b949e; font-weight: 600; }
.bar { display: inline-block; height: 10px; border-radius: 3px; background: #1f6feb; vertical-align: middle; }
.bar-gcp { background: #f85149; }
.gcp-yes { color: #f85149; font-weight: 600; }
.gcp-no { color: #3fb950; font-weight: 600; }
tr.recent:hover td { background: #1c2128; }
@media (prefers-reduced-motion: reduce) { * { transition: none; } }
</style>
</head>
<body>
<h1>Request Tracker</h1>
<div class="sub" id="meta">loading…</div>
<div class="grid" id="cards"></div>
<div class="cols">
<div class="panel"><h3>By app / initiator</h3><table id="appTable"></table></div>
<div class="panel"><h3>Top URIs</h3><table id="uriTable"></table></div>
<div class="panel"><h3>Methods</h3><table id="methodTable"></table></div>
<div class="panel"><h3>Status</h3><table id="statusTable"></table></div>
</div>
<div class="panel"><h3>Recent requests</h3><table id="recentTable"></table></div>
<script>
function fmtBytes(n) {
if (n >= 1<<30) return (n/(1<<30)).toFixed(2)+' GiB';
if (n >= 1<<20) return (n/(1<<20)).toFixed(2)+' MiB';
if (n >= 1<<10) return (n/(1<<10)).toFixed(1)+' KiB';
return n+' B';
}
function barRow(label, count, total, color) {
const w = total ? (count/total*100) : 0;
return '<tr><td>'+label+'</td><td>'+count+'</td><td><span class="bar '+(color||'')+'" style="width:'+w.toFixed(1)+'%"></span></td></tr>';
}
function render(s) {
const gcpPct = s.total ? (s.gcp_total/s.total*100).toFixed(1) : '0';
document.getElementById('meta').textContent =
'Generated '+s.generated+' · tracking for '+Math.floor(s.uptime_secs/60)+' min · last request '+s.last_seen;
const cards = [
{label:'Total requests', val:s.total},
{label:'From GCP', val:s.gcp_total, sub:gcpPct+'% of all', cls:'gcp-yes'},
{label:'Not GCP', val:s.non_gcp_total, cls:'gcp-no', sub:(100-parseFloat(gcpPct)).toFixed(1)+'% of all'},
{label:'Inbound (from clients)', val:fmtBytes(s.in_bytes.all), sub:'GCP '+fmtBytes(s.in_bytes.gcp)},
{label:'Outbound (to clients)', val:fmtBytes(s.out_bytes.all), sub:'GCP '+fmtBytes(s.out_bytes.gcp)},
];
document.getElementById('cards').innerHTML = cards.map(c =>
'<div class="card"><div class="label">'+c.label+'</div><div class="val '+(c.cls||'')+'">'+c.val+'</div><div class="subval">'+(c.sub||'')+'</div></div>'
).join('');
const total = s.total || 1;
document.getElementById('appTable').innerHTML =
'<tr><th>App</th><th>Requests</th><th style="width:40%"></th></tr>' +
s.by_app.map(r=>barRow(r[0],r[1],total)).join('');
document.getElementById('uriTable').innerHTML =
'<tr><th>URI</th><th>Requests</th><th style="width:40%"></th></tr>' +
s.top_uri.map(r=>barRow(r[0],r[1],total)).join('');
document.getElementById('methodTable').innerHTML =
'<tr><th>Method</th><th>Requests</th><th style="width:40%"></th></tr>' +
s.by_method.map(r=>barRow(r[0],r[1],total)).join('');
document.getElementById('statusTable').innerHTML =
'<tr><th>Class</th><th>Requests</th><th style="width:40%"></th></tr>' +
s.by_status.map(r=>barRow(r[0],r[1],total)).join('');
document.getElementById('recentTable').innerHTML =
'<tr><th>Time</th><th>GCP</th><th>App</th><th>Method</th><th>URI</th><th>Status</th><th>Inb</th><th>Out</th></tr>' +
s.recent.map(r =>
'<tr class="recent"><td>'+r.time+'</td><td class="'+(r.gcp?'gcp-yes':'gcp-no')+'">'+(r.gcp?'yes':'no')+'</td><td>'+(r.app||'')+'</td><td>'+r.method+'</td><td>'+r.uri+'</td><td>'+r.status+'</td><td>'+fmtBytes(r.in)+'</td><td>'+fmtBytes(r.out)+'</td></tr>'
).join('');
}
function refresh() {
fetch('/track/api/stats').then(r=>r.json()).then(render).catch(()=>{});
}
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>
"""
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--log", default=DEFAULT_LOG)
parser.add_argument("--bind", default="127.0.0.1")
args = parser.parse_args()
global TRACKER
TRACKER = Tracker(args.log)
# Prime with existing log content on startup.
TRACKER.poll()
os.makedirs(os.path.dirname(args.log), exist_ok=True)
server = ThreadingHTTPServer((args.bind, args.port), Handler)
print(f"[track] dashboard on http://{args.bind}:{args.port} reading {args.log}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
</file>
<file path="authentik-compose.yaml">
# Authentik — the single identity provider for every app (SSO).
#
# Run with: docker compose -f authentik-compose.yaml up -d
# Manager UI: https://home.palashkantikundu.in/sso/if/admin/
version: '3.8'
services:
authentik-postgres:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- pg_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${POSTGRES_DB:-authentik}
POSTGRES_USER: ${POSTGRES_USER:-authentik}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 10
authentik-redis:
image: redis:7-alpine
restart: unless-stopped
command: --save 60 1 --loglevel warning
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 5s
timeout: 3s
retries: 10
authentik-server:
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.2.1}
restart: unless-stopped
command: server
ports:
- "127.0.0.1:9008:9000"
- "127.0.0.1:9443:9443"
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?set AUTHENTIK_SECRET_KEY in .env}
AUTHENTIK_TOKEN: ${AUTHENTIK_TOKEN:?set AUTHENTIK_TOKEN in .env}
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:-}
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-}
# FIXED: Points to external HTTPS domain so SSO redirects work properly
AUTHENTIK_URL: ${AUTHENTIK_BASE_URL:-https://home.palashkantikundu.in/sso}
AUTHENTIK_PORT: "9000"
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
AUTHENTIK_POSTGRESQL__NAME: ${POSTGRES_DB:-authentik}
AUTHENTIK_POSTGRESQL__USER: ${POSTGRES_USER:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD}
AUTHENTIK_CORS__ALLOWED_ORIGINS: '*'
AUTHENTIK_WEB__PATH: /sso/
AUTHENTIK_COOKIE_DOMAIN: ${AUTHENTIK_COOKIE_DOMAIN:-home.palashkantikundu.in}
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: "127.0.0.1/32,10.66.66.0/8,172.16.0.0/12,192.168.0.0/16"
volumes:
- media:/media
- custom-templates:/templates
depends_on:
authentik-postgres:
condition: service_healthy
authentik-redis:
condition: service_healthy
authentik-worker:
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.2.1}
restart: unless-stopped
command: worker
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?set AUTHENTIK_SECRET_KEY in .env}
AUTHENTIK_TOKEN: ${AUTHENTIK_TOKEN:?set AUTHENTIK_TOKEN in .env}
AUTHENTIK_URL: ${AUTHENTIK_BASE_URL:-https://home.palashkantikundu.in/sso}
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
AUTHENTIK_POSTGRESQL__NAME: ${POSTGRES_DB:-authentik}
AUTHENTIK_POSTGRESQL__USER: ${POSTGRES_USER:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- media:/media
- custom-templates:/templates
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
authentik-postgres:
condition: service_healthy
authentik-redis:
condition: service_healthy
authentik-outpost:
image: ghcr.io/goauthentik/proxy:2025.2.1
restart: unless-stopped
ports:
- "127.0.0.1:9010:9000"
environment:
AUTHENTIK_HOST: "http://authentik-server:9000/sso/"
AUTHENTIK_HOST_BROWSER: "https://home.palashkantikundu.in/sso/"
AUTHENTIK_INSECURE: "true"
AUTHENTIK_TOKEN: "${AUTHENTIK_OUTPOST_TOKEN:?set in .env}"
depends_on:
authentik-server:
condition: service_started
volumes:
pg_data:
redis_data:
media:
custom-templates:
</file>
<file path="prompts/moderator.txt">
You are the Story Moderator. You review finished, edited stories and give a final verdict: GREEN (approved for publishing) or RED (rejected).
You never delete or edit anything. You only categorize the story and briefly explain your verdict.
## Task context
- Genre: %genre%
- Declared mediums: %mediums%
- Declared language(s): %language%
- Task details: %details%
## Named characters (immutable)
%cast%
The characters above were decided and named before writing. The story text and
every image may only depict these named characters. A RED verdict is warranted
if any character that is not in this list appears in the story text or in any
image.
## Genre-specific checklist
Give a RED verdict if ANY item below fails. Only give GREEN if every item on this list passes.
%checklist%
## CRITICAL
You are moderator. You are not allowed to make any change in the generated content.
## Universal checks (apply regardless of genre)
- All declared mediums are actually present (e.g. if "image" is declared, at least one image must be embedded in the story).
- The story is written in the declared language(s), not translated or mixed.
- No "<!-- EDITOR FLAG: ... -->" comment is present anywhere — that means the editor could not resolve a problem, which is an automatic RED.
- The names "Kaya", "Kolpo", "কায়া", "কল্প", "काया", "कल्प" do not appear anywhere in the story text.
- The story actually reaches a real ending — no unfinished sentence, no dangling setup, no abrupt cutoff mid-scene.
Give your verdict using exactly these two lines:
VERDICT: GREEN
REASONS: <short reasons, citing the specific checklist item(s) that failed if RED>
## Evaluation Principle
Exercise reasonable editorial judgment. Do not flag creative artistic choices, stylistic fragments, or open endings as failures unless they represent genuine errors, broken text, or incomplete content. If the work fulfills the core prompt and respects all hard constraints, default to GREEN.
</file>
<file path="scripts/gcp_heartbeat_server.py">
#!/usr/bin/env python3
"""Heartbeat receiver + split-horizon DNS for the GCP VM.
One process, two jobs:
1. Heartbeat receiver (HTTP, default :9863)
The homeserver's ConnectionManager (server/features/monitoring.py) POSTs
its current addresses every 10 seconds over the WireGuard tunnel:
{"ipv6": "2405:201:...", # stable global IPv6
"public_ipv4": "49.37...", # WAN IPv4 (NAT'd, via ipify)
"wifi_ipv4": "192.168.29.x"} # LAN IPv4 on the WiFi interface
GET /status returns the last payload as JSON.
2. Split-horizon DNS (UDP+TCP :53)
Answers for home.palashkantikundu.in depend on WHO asks:
- query source IP == homeserver's latest WAN IP(s) -> LAN IPv4 (+AAAA)
- everyone else -> this VM's public IP
so same-network clients connect directly over the LAN while remote clients
go through the nginx/WireGuard tunnel. Every other name is forwarded to
upstream resolvers — but only for known homeserver IPs (no open resolver).
Run on the VM (port 53 needs root or CAP_NET_BIND_SERVICE):
sudo pip3 install dnslib
sudo python3 gcp_heartbeat_server.py [--bind 10.66.66.1] [--port 9863] \
[--dns-bind 0.0.0.0] [--dns-port 53] [--gcp-ip 35.212.x.x]
The VM's public IP is auto-detected from the GCP metadata server unless
--gcp-ip is given. Open udp/tcp 53 in the GCP firewall.
"""
import argparse
import ipaddress
import json
import os
import struct
import threading
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from dnslib import A, AAAA, QTYPE, RCODE, RR, SOA, DNSRecord
from dnslib.server import DNSServer, BaseResolver
ZONE = "home.palashkantikundu.in"
UPSTREAMS = ["1.1.1.1", "8.8.8.8"]
ZONE_TTL = 30 # short TTL so IP changes propagate fast
WG_PEER_IP = "10.66.66.3"
_lock = threading.Lock()
_latest = {}
# Recently seen WAN IPs of the homeserver (Jio rotates them).
_recent_public_ips = {} # ip -> last seen timestamp
_RECENT_TTL = 48 * 3600
_RECENT_MAX = 10
def _log(message, log_file=None):
line = f"{datetime.now().isoformat()} {message}"
print(line, flush=True)
if log_file:
with open(log_file, "a") as fh:
fh.write(line + "\n")
def _valid_ip(value, version):
try:
return str(ipaddress.ip_address(value)) if value else None
except ValueError:
return None
def _remember_public_ip(ip, now):
_recent_public_ips[ip] = now
stale = [k for k, ts in _recent_public_ips.items() if now - ts > _RECENT_TTL]
for k in stale:
del _recent_public_ips[k]
while len(_recent_public_ips) > _RECENT_MAX:
del _recent_public_ips[min(_recent_public_ips, key=lambda k: _recent_public_ips[k])]
# ---------------------------------------------------------------------------
# DNS
# ---------------------------------------------------------------------------
def _detect_gcp_ip():
"""Public IP of this VM via the GCP metadata server."""
try:
req = urllib.request.Request(
"http://metadata.google.internal/computeMetadata/v1/instance/"
"network-interfaces/0/access-configs/0/external-ip",
headers={"Metadata-Flavor": "Google"},
)
return urllib.request.urlopen(req, timeout=2).read().decode().strip()
except Exception:
return None
def _parse_ecs(data):
"""Parse one ECS option payload -> ip_network, or None."""
if len(data) < 4:
return None
family, src_bits, _scope = struct.unpack("!HBB", data[:4])
nbytes = (src_bits + 7) // 8
alen = 4 if family == 1 else 16
try:
# RFC 7871: address bytes are truncated to src_bits — pad back out to
# the full address length before parsing.
raw = data[4:4 + nbytes].ljust(alen, b"\x00")
if family == 1:
return ipaddress.ip_network(
f"{ipaddress.IPv4Address(raw)}/{src_bits}", strict=False)
if family == 2:
return ipaddress.ip_network(
f"{ipaddress.IPv6Address(raw)}/{src_bits}", strict=False)
except ValueError:
pass
return None
def _ecs_networks(request):
"""Client subnets advertised via EDNS Client Subnet (option 8).
When this server is queried through third-party resolvers (Google,
AdGuard, ...) the UDP source is the resolver, not the end client — the
real client network arrives as an ECS option instead.
"""
nets = []
for ar in getattr(request, "ar", []) or []:
if getattr(ar, "rtype", None) != 41: # OPT RR only
continue
rd = getattr(ar, "rdata", None)
options = rd if isinstance(rd, (list, tuple)) else ()
for opt in options: # parsed EDNSOption objects
if getattr(opt, "code", None) == 8:
net = _parse_ecs(opt.data)
if net:
nets.append(net)
if isinstance(rd, (bytes, bytearray)): # unparsed TLV blob
i = 0
while i + 4 <= len(rd):
code, dlen = struct.unpack("!HH", rd[i:i + 4])
if code == 8:
net = _parse_ecs(rd[i + 4:i + 4 + dlen])
if net:
nets.append(net)
i += 4 + dlen
return nets
class HomeResolver(BaseResolver):
"""Zone answers driven by heartbeat state; everything else forwarded."""
def __init__(self, gcp_ip):
self.gcp_ip = gcp_ip
def _snapshot(self):
with _lock:
return (
set(_recent_public_ips),
_latest.get("wifi_ipv4"),
_latest.get("ipv6"),
)
def _soa(self, reply):
reply.add_auth(RR(
ZONE, QTYPE.SOA,
rdata=SOA(f"ns.{ZONE}", f"hostmaster.{ZONE}", (2024010101, 300, 60, 600, 30)),
ttl=ZONE_TTL,
))
def _is_local_client(self, client, request):
"""True when the query originates from the homeserver's own network.
Matches either the UDP source IP directly (clients using this server
as their resolver) or an EDNS Client Subnet covering one of the
homeserver's recent WAN IPs (queries relayed through public
resolvers once this server is authoritative via GoDaddy NS
delegation).
"""
local_ips, _, _ = self._snapshot()
if client in local_ips:
return True
for net in _ecs_networks(request):
for rip in local_ips:
if ipaddress.ip_address(rip) in net:
return True
return False
def resolve(self, request, handler):
client = handler.client_address[0]
reply = request.reply()
name = str(request.q.qname).rstrip(".").lower()
qtype = QTYPE[request.q.qtype]
_local_ips, wifi_ip, server_v6 = self._snapshot()
is_local_client = self._is_local_client(client, request)
if name == ZONE:
if qtype == "A":
target = wifi_ip if (is_local_client and wifi_ip) else self.gcp_ip
if target:
reply.add_answer(RR(request.q.qname, QTYPE.A,
rdata=A(target), ttl=ZONE_TTL))
else:
self._soa(reply)
elif qtype == "AAAA":
# Always hand out the home server's global IPv6 — local or remote,
# it's directly reachable either way.
if server_v6:
reply.add_answer(RR(request.q.qname, QTYPE.AAAA,
rdata=AAAA(server_v6), ttl=ZONE_TTL))
else:
self._soa(reply)
else:
self._soa(reply)
return reply
# Recursion only for the homeserver itself (no open resolver).
if not (is_local_client or client == WG_PEER_IP):
reply.header.rcode = RCODE.REFUSED
return reply
try:
return DNSRecord.parse(request.send(self._upstream(), 53, timeout=3))
except Exception:
reply.header.rcode = RCODE.SERVFAIL
return reply
@staticmethod
def _upstream():
return UPSTREAMS[0]
# ---------------------------------------------------------------------------
# Heartbeat HTTP receiver
# ---------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/heartbeat":
self.send_error(404)
return
try:
length = int(self.headers.get("Content-Length", 0))
data = json.loads(self.rfile.read(length) or b"{}")
except (ValueError, json.JSONDecodeError):
self.send_error(400, "invalid JSON")
return
record = {
"ipv6": _valid_ip(data.get("ipv6"), 6),
"public_ipv4": _valid_ip(data.get("public_ipv4"), 4),
"wifi_ipv4": _valid_ip(data.get("wifi_ipv4"), 4),
"remote_addr": self.client_address[0],
"received_at": datetime.now(timezone.utc).isoformat(),
}
with _lock:
_latest.clear()
_latest.update(record)
if record["public_ipv4"]:
now = datetime.now(timezone.utc).timestamp()
with _lock:
_remember_public_ip(record["public_ipv4"], now)
_log(f"heartbeat from {record['remote_addr']}: "
f"v6={record['ipv6']} pub_v4={record['public_ipv4']} wifi_v4={record['wifi_ipv4']}",
self.server.log_file)
self.send_response(200)
self.end_headers()
def do_GET(self):
if self.path != "/status":
self.send_error(404)
return
with _lock:
body = json.dumps(_latest).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
pass # request lines are logged by _log() instead
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bind", default="10.66.66.1",
help="heartbeat listener address (default: WireGuard IP)")
parser.add_argument("--port", type=int, default=9863)
parser.add_argument("--log", default=os.path.expanduser("heartbeat.log"),
help="append received heartbeats to this file")
parser.add_argument("--dns-bind", default="0.0.0.0",
help="DNS listener address (default: all interfaces)")
parser.add_argument("--dns-port", type=int, default=53,
help="DNS port, 0 disables the DNS server")
parser.add_argument("--gcp-ip", default=None,
help="this VM's public IPv4 (default: auto-detect)")
args = parser.parse_args()
gcp_ip = args.gcp_ip or _detect_gcp_ip()
if args.dns_port:
resolver = HomeResolver(gcp_ip)
udp = DNSServer(resolver, port=args.dns_port, address=args.dns_bind)
tcp = DNSServer(resolver, port=args.dns_port, address=args.dns_bind, tcp=True)
udp.start_thread()
tcp.start_thread()
if not udp.isAlive() or not tcp.isAlive():
raise SystemExit(f"could not bind DNS on {args.dns_bind}:{args.dns_port}")
print(f"DNS listening on {args.dns_bind}:{args.dns_port} "
f"(zone={ZONE}, tunnel_ip={gcp_ip})", flush=True)
httpd = ThreadingHTTPServer((args.bind, args.port), Handler)
httpd.log_file = args.log
print(f"Heartbeat receiver listening on http://{args.bind}:{args.port}", flush=True)
try:
httpd.serve_forever()
finally:
if args.dns_port:
udp.stop()
tcp.stop()
if __name__ == "__main__":
main()
</file>
<file path="server/features/critic.py">
"""Post-generation self-verification ("critic") pass for research answers.
After the LLM emits its final research answer, ``run_verification`` extracts the
structured ``(Author, Venue, Year) [url]`` inline citations, re-fetches each
source and asks a fresh LLM call ("the critic") to check that (1) the source
exists, (2) the claimed author/venue/year metadata matches the real page, and
(3) the specific claim is actually supported by the source text. The resulting
verdict drives a small, local edit of just that sentence (never a full report
rewrite) and a transparent verification trail is appended to the answer.
The pass is always-on for research tasks (no separate toggle) and bounded only
per-citation: at most ``VERIFY_RETRIES`` extra search/fetch attempts per
citation, so a pathological report can never loop forever while keeping the
"no overall cap" behaviour.
"""
import json
import re
import time
import requests
from server.features.state import M
_VERIFY_SYSTEM = (
"You are a strict citation fact-checker for a research assistant. Given a "
"claimed citation (URL plus author/venue/year metadata plus one specific "
"claim) and the fetched text of the cited web page, determine: "
"(1) whether the page exists and its topic matches the citation, "
"(2) whether the cited author, venue and year actually appear on the page, "
"(3) whether the exact claim is supported by the page text. "
'Reply with ONLY a JSON object of the form: '
'{"exists": bool, "url_ok": bool, "author_ok": bool, "venue_ok": bool, '
'"year_ok": bool, "claim_support": "supports"|"partial"|"unsupported"|"absent", '
'"corrected_meta": string|null, "reason": string}. '
'"corrected_meta" must be a comma-separated "Author, Venue, Year" string when '
'the metadata is wrong AND the page itself reveals the correct values, '
'otherwise null. "year_ok"/"author_ok"/"venue_ok" must be false only when '
'the page text contradicts the claim, not when the value is merely absent.'
)
_META_RE = re.compile(
r"(?P<meta>[\[(][^)\]\[(\n]{0,180}[\]\)])\s*\[(?P<url>https?://[^\s\]<>']+)\]"
)
# A citation whose URL slot is empty, e.g. `[ScienceDirect Review] []` or the
# markdown link form `[Some Review]()`. These are fabricated by construction —
# there is nothing to verify.
_EMPTY_CITE_RE = re.compile(r"(?P<meta>[\[(][^)\]\[(\n]{0,180}[\]\)])\s*\[\s*\]")
_PLAIN_URL_RE = re.compile(r"(?<!\w)(https?://[^\s\]<>()]+)")
_CODE_FENCE_RE = re.compile(r"```.*?```", re.DOTALL)
_TAGLINE = "\n\n<details>\n<summary>Source verification</summary>"
def _critic_completion(system, user, mode="gpu", max_tokens=600):
"""Secondary, non-streamed, low-temperature LLM call. Retries once and
never raises — returns None only when the model itself is unreachable."""
payload = {
"model": M.server_model_id(mode),
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"max_tokens": max_tokens,
"temperature": 0.2,
"stream": False,
}
last_err = None
for attempt in range(2):
try:
M.mark_slot_kv_dirty(mode)
r = requests.post(M.server_url(mode), json=payload, timeout=120)
r.raise_for_status()
content = r.json()["choices"][0]["message"].get("content")
if content:
return content
last_err = "empty content in response"
except Exception as e:
last_err = str(e)
print(f"[critic] LLM call failed (mode={mode}, attempt {attempt + 1}/2): {last_err}")
time.sleep(1.0)
return None
def _parse_verdict(text):
"""Best-effort JSON parse of the critic's reply. Returns None on failure."""
if not text:
return None
cleaned = re.sub(r"```(?:json)?", "", text).strip()
start = cleaned.find("{")
end = cleaned.rfind("}")
if start < 0 or end <= start:
return None
try:
return json.loads(cleaned[start:end + 1])
except (ValueError, TypeError):
return None
def extract_citations(answer):
"""Return a list of citation dicts found in the answer.
Each dict is ``{idx, start, end, url, meta, prepared}`` where ``idx`` is the
paragraph index in ``answer`` split on blank lines, ``start``/``end`` are
offsets inside that paragraph, and ``meta`` is the "(Author, Venue, Year)"
text (or None for a bare URL). Duplicate URLs are ignored.
"""
citations = []
seen = set()
paras = re.split(r"\s*\n\s*\n\s*", answer or "")
for idx, para in enumerate(paras):
stripped = _CODE_FENCE_RE.sub("", para)
structured = []
for m in _EMPTY_CITE_RE.finditer(stripped):
# Empty-URL citations are individually meaningful (each must be
# stripped and flagged), so no URL-based dedupe applies here.
citations.append({
"idx": idx,
"start": m.start(),
"end": m.end(),
"url": "",
"meta": m.group("meta").strip(),
})
for m in _META_RE.finditer(stripped):
url = m.group("url").rstrip(".,;:]")
if url in seen:
continue
seen.add(url)
item = {
"idx": idx,
"start": m.start(),
"end": m.end(),
"url": url,
"meta": m.group("meta").strip(),
}
citations.append(item)
structured.append(url.rstrip(".,;:]"))
for m in _PLAIN_URL_RE.finditer(stripped):
url = m.group(0).rstrip(".,;:]")
if url in seen or url in structured:
continue
seen.add(url)
citations.append({
"idx": idx,
"start": m.start(),
"end": m.end(),
"url": url,
"meta": None,
})
return citations
def _norm_url(url):
"""Normalize a URL for equality checks: lowercase host, drop fragment and
trailing slash, keep path and query."""
try:
from urllib.parse import urlsplit
p = urlsplit(url or "")
path = p.path.rstrip("/") or "/"
parts = [p.netloc.lower() or url, path]
if p.query:
parts.append(f"?{p.query}")
return "".join(parts)
except Exception:
return (url or "").rstrip("/")
def _retrieved_urls(task_id):
"""All URLs the research agent actually opened or saw in search results.
Rebuilt from the task's ``_search_details`` (every ``web_search`` result
URL plus every ``fetch_page`` URL and any link inside fetched content). A
citation that is NOT in here was never grounded by the agent's own tools.
"""
urls = set()
with M._data_lock:
details = list(M.tasks.get(task_id, {}).get("_search_details", []))
for entry in details:
if not isinstance(entry, dict):
continue
if entry.get("tool") == "fetch_page":
u = entry.get("url", "")
if u:
urls.add(_norm_url(u))
for m in _PLAIN_URL_RE.finditer(entry.get("content", "") or ""):
urls.add(_norm_url(m.group(0).rstrip(".,;:]")))
continue
for r in entry.get("results", []) or []:
if isinstance(r, dict):
u = r.get("url") or r.get("link") or ""
if u:
urls.add(_norm_url(u))
return urls
def _citation_exists(url):
"""Existence probe for a source the agent never retrieved: re-search the
URL itself and require a same-URL hit. Used before ANY invention verdict."""
try:
res = json.loads(M.web_search(url))
except Exception as e:
print(f"[critic] existence probe failed for {url}: {e}")
return False
target = _norm_url(url)
for r in res.get("results", []) or []:
if isinstance(r, dict):
for u in (r.get("url"), r.get("link")):
if u and _norm_url(u) == target:
return True
return False
def _fetch_source(url, max_chars=6000):
"""Fetch a single source page; never raises. ``ok=False`` on any failure."""
try:
page = json.loads(M.fetch_page(url, max_chars=max_chars, chunk=1))
except Exception as e:
return {"ok": False, "url": url, "error": f"fetch failed: {e}"}
if page.get("error"):
return {"ok": False, "url": url, "error": page["error"]}
return {
"ok": True,
"url": url,
"final_url": page.get("url", url),
"title": page.get("title", ""),
"content": page.get("content", "") or "",
}
def _verify_source(cit, src, mode):
"""Single critic call for one citation. Returns (verdict, failed)."""
if not (src and src.get("ok")):
return {
"exists": False,
"url_ok": False,
"claim_support": "absent",
"corrected_meta": None,
"reason": (src or {}).get("error") or "source could not be fetched",
}, False
body = src.get("content", "") or ""
if not body.strip():
return {
"exists": True,
"url_ok": True,
"claim_support": "absent",
"corrected_meta": None,
"reason": "page content unreachable (likely JS-only or blocked)",
}, False
user = (
f"CLAIMED CITATION:\n"
f"URL: {cit['url']}\n"
f"Metadata: {cit.get('meta') or '(none given)'}\n\n"
f"CLAIM CONTEXT:\n{cit.get('prepared') or cit.get('para') or ''}\n\n"
f"FETCHED SOURCE (title: {src.get('title', '')}):\n"
f"{body[:M.VERIFY_FETCH_CHARS]}"
)
text = _critic_completion(_VERIFY_SYSTEM, user, mode)
if text is None:
return None, True
verdict = _parse_verdict(text)
if verdict is None:
return None, True
return verdict, False
def classify(verdict, src):
"""Map a critic verdict (+ fetch info) to an action token."""
if not verdict:
return "UNCHECKED"
if verdict.get("exists") is False or verdict.get("url_ok") is False:
return "UNVERIFIABLE"
if not (src and src.get("ok")) or not (src.get("content") or "").strip():
return "AMBIGUOUS"
support = verdict.get("claim_support", "absent")
if support in ("unsupported", "absent"):
return "UNVERIFIABLE"
if any(verdict.get(f) is False for f in ("author_ok", "venue_ok", "year_ok")):
return "METADATA_FIX"
if support == "partial":
return "AMBIGUOUS"
return "KEEP"
def _search_and_fetch(url, meta):
"""Targeted re-search for a corrected source; prefers the same host."""
host = ""
try:
from urllib.parse import urlparse
host = urlparse(url).netloc
except Exception:
pass
query = f"{meta or url} {url}".strip()
try:
res = json.loads(M.web_search(query))
except Exception:
res = {}
results = res.get("results", [])
for r in results:
u = r.get("url", "")
r_host = ""
try:
from urllib.parse import urlparse
r_host = urlparse(u).netloc
except Exception:
pass
if (not host or r_host == host) and r.get("full_content"):
return {"ok": True, "url": u, "title": r.get("page_title", ""), "content": r.get("full_content", "")}
for r in results:
if r.get("full_content"):
return {"ok": True, "url": r.get("url", ""), "title": r.get("page_title", ""), "content": r.get("full_content", "")}
return {"ok": False, "url": url}
def _verify_one(cit, mode):
"""Full per-citation pipeline. Returns (action, note, replace, verdict)."""
src = _fetch_source(cit["url"])
verdict, failed = _verify_source(cit, src, mode)
if failed:
return ("UNCHECKED",
"critic unavailable — not verified",
None, None)
action = classify(verdict, src)
if action == "METADATA_FIX" and not (verdict or {}).get("corrected_meta"):
best = None
for _ in range(max(1, M.VERIFY_RETRIES)):
src2 = _search_and_fetch(cit["url"], cit.get("meta") or "")
if not src2.get("ok"):
continue
v2, f2 = _verify_source(cit, src2, mode)
if f2 or not v2:
continue
if v2.get("corrected_meta"):
best = v2
src = src2
break
if best:
verdict = best
else:
verdict = {**verdict, "corrected_meta": None}
if action == "UNVERIFIABLE" and verdict and not (src and src.get("ok")):
# The page could not be fetched directly (403/404/timeout). Before
# stripping, run targeted re-searches so a real but blocked/retired
# source gets a second chance (e.g. via search snippets or mirrors).
for _ in range(max(1, M.VERIFY_RETRIES)):
src2 = _search_and_fetch(cit["url"], cit.get("meta") or "")
if not src2.get("ok"):
continue
v2, f2 = _verify_source(cit, src2, mode)
if not f2 and v2 and v2.get("exists"):
verdict = v2
src = src2
action = classify(v2, src2)
break
if action == "METADATA_FIX":
corrected = (verdict or {}).get("corrected_meta")
claimed_meta = cit.get("meta")
if corrected and claimed_meta:
replace = f"({corrected}) [{cit['url']}]"
note = f"metadata corrected to ({corrected})"
elif claimed_meta:
replace = f"(Author, Venue, uncertain) [{cit['url']}]"
note = "metadata could not be confirmed — marked uncertain"
else:
replace = None
note = "bare source URL without metadata — could not confirm metadata"
elif action == "UNVERIFIABLE":
replace = ""
note = (verdict or {}).get("reason") or "source could not be confirmed — citation removed"
elif action == "AMBIGUOUS":
replace = None
note = "claim only partially supported, or sources conflict — review"
_maybe = (verdict or {}).get("corrected_meta")
if _maybe:
note = f"metadata uncertain ({_maybe}) — review"
else: # KEEP / UNCHECKED
replace = None
note = "verified"
return action, note, replace, verdict
def _build_verification_block(verdicts):
if not verdicts:
return ""
marks = {
"KEEP": "✓",
"METADATA_FIX": "△",
"UNVERIFIABLE": "✗",
"AMBIGUOUS": "⚠",
"UNCHECKED": "∅",
}
lines = [_TAGLINE]
for v in verdicts:
action = v.get("action", "KEEP")
symbol = marks.get(action, "•")
note = f" — {v['note']}" if v.get("note") else ""
lines.append(f"{symbol} {v['url']}{note}")
lines.append("</details>")
return "\n".join(lines)
def run_verification(task_id, sid, answer, mode="gpu"):
"""Extract citations, verify each, patch the answer, return (final, verdicts).
Research tasks only; non-research answers and citation-free answers pass
through untouched. Never raises.
"""
with M._data_lock:
t = M.tasks.get(task_id) or {}
verdicts = []
if not answer or not t.get("research"):
return answer, verdicts
paras = re.split(r"\s*\n\s*\n\s*", answer)
citations = extract_citations(answer)
if not citations:
return answer, verdicts
# Deterministic anti-fabrication gate, computed ONCE per task: the set of
# URLs the agent genuinely retrieved, and per-URL usage so a single source
# backing many separate claims is surfaced. This runs before any critic
# LLM call and is decisive even when the critic model is unavailable.
retrieved = _retrieved_urls(task_id)
from collections import Counter
# Count every inline citation occurrence in the raw answer (not the
# deduped citation list) so a single URL backing several claims is
# surfaced, even when the parser dedupes the repeated citation.
usage = Counter()
for m in _META_RE.finditer(_CODE_FENCE_RE.sub("", answer or "")):
usage[m.group("url").rstrip(".,;:]")] += 1
per_cite_usage = {c["url"]: usage.get(c["url"], 0) for c in citations if c["url"]}
max_cites = int(getattr(M, "VERIFY_MAX_CITES_PER_URL", 3))
per_para = {}
for cit in citations:
para = paras[cit["idx"]] if cit["idx"] < len(paras) else ""
cit["prepared"] = para
pre_action = None
pre_note = ""
if not cit["url"]:
pre_action, pre_note = "UNVERIFIABLE", (
"citation has no URL to verify — cannot exist"
)
elif cit["url"] not in retrieved:
if not _citation_exists(cit["url"]):
pre_action, pre_note = "UNVERIFIABLE", (
"URL was never retrieved by research and no verification "
"search could find it — likely fabricated"
)
over = per_cite_usage.get(cit["url"], 0)
over_note = (""
if over <= max_cites
else f" SAME SOURCE CITED {over} TIMES for {over} separate claims — verify each maps to it.")
if pre_action is not None:
action, note, replace, verdict = pre_action, pre_note.strip(), "", None
else:
action, note, replace, verdict = _verify_one(cit, mode)
if over_note and action != "UNVERIFIABLE":
note = (note or "") + over_note
verdicts.append({
"url": cit["url"],
"meta": cit.get("meta"),
"action": action,
"note": note,
"corrected_meta": (verdict or {}).get("corrected_meta") if verdict else None,
"reason": (verdict or {}).get("reason") if verdict else None,
})
if replace is not None:
per_para.setdefault(cit["idx"], []).append({
"start": cit["start"],
"end": cit["end"],
"replace": replace,
})
if per_para:
for idx, edits in per_para.items():
para = paras[idx]
parts = []
pos = 0
for e in sorted(edits, key=lambda x: x["start"]):
parts.append(para[pos:e["start"]])
parts.append(e["replace"])
pos = e["end"]
parts.append(para[pos:])
paras[idx] = "".join(parts)
answer = "\n\n".join(paras)
block = _build_verification_block(verdicts)
if block:
answer = answer.rstrip() + block
return answer, verdicts
def run_verification_worker(task_id, sid, answer, body, mode):
"""Thread-pool entry point called from the orchestration event loop.
Guarantees the task always finalizes: the (possibly patched) answer on
success, the original answer untouched on any failure.
"""
started = time.time()
try:
final, verdicts = run_verification(task_id, sid, answer, mode)
with M._data_lock:
tt = M.tasks.get(task_id)
if tt:
tt["_verification"] = verdicts
tt["_verification_duration"] = round(time.time() - started, 1)
M._finalize_task(task_id, sid, final, body)
except Exception as e:
print(f"[critic] verification pass failed for task {task_id}: {e}")
with M._data_lock:
tt = M.tasks.get(task_id)
if tt:
tt["_verification"] = [{"url": "?", "action": "UNCHECKED",
"note": f"verification pass failed: {e}"}]
M._finalize_task(task_id, sid, answer, body)
</file>
<file path="server/auth.py">
"""Unified RBAC / SSO identity layer backed by Authentik.
This is the SINGLE identity provider for every app on the box. There is no
more ``users.json``; users, passwords and roles live in Authentik.
Two identity paths converge here:
1. Browser users — nginx runs ``auth_request`` against the Authentik proxy
outpost and forwards the ``X-Authentik-*`` claim headers upstream. Backends
trust those headers (only nginx can reach the app ports).
2. Machine agents (self-chat.py) — obtain an OIDC access token via Authentik's
OAuth2 password grant and send it as ``Authorization: Bearer <jwt>``.
Backends verify the JWT signature against Authentik's JWKS.
The resolved identity is always a dict::
{
"username": str,
"email": str,
"name": str,
"groups": [str, ...], # raw Authentik group names
"role": "admin" | "premium" | "free",
"uid": str, # Authentik user UUID (unique id across renames)
}
"""
import re
import threading
import time
import jwt
from jwt import PyJWK
import requests
from server.config import (
AUTH_AGENTS_CLIENT_ID,
AUTH_AGENTS_CLIENT_SECRET,
AUTH_AGENTS_ISSUER,
AUTH_AGENTS_JWKS_URL,
AUTH_AGENTS_TOKEN_URL,
AUTH_ROLE_GROUPS,
)
# Highest role wins when a user belongs to several groups.
_ROLE_LEVEL = {"free": 0, "premium": 1, "admin": 2}
_jwks_cache = None
_jwks_cache_at = 0.0
_jwks_lock = threading.Lock()
_JWKS_TTL = 300
def _first(seq):
for item in seq or ():
if item:
return item
return None
def role_from_groups(groups):
"""Map Authentik group names to the app role scale (free/premium/admin)."""
best = "free"
best_level = -1
for g in groups or ():
role = AUTH_ROLE_GROUPS.get(g.lower().strip())
if role and _ROLE_LEVEL.get(role, -1) > best_level:
best = role
best_level = _ROLE_LEVEL[role]
return best
def _split_groups(raw):
if not raw:
return []
if isinstance(raw, (list, tuple, set)):
return [str(g).strip() for g in raw if str(g).strip()]
return [g.strip() for g in re.split(r"[|,\s]+", str(raw)) if g.strip()]
def identity_from_headers(headers):
"""Resolve identity from the nginx-injected X-Authentik-* claim headers.
These headers are set by nginx's auth_request subrequest against the
Authentik proxy outpost and are only present on nginx-fronted traffic.
Returns None when absent (e.g. direct localhost calls or agents).
"""
username = _first(
[headers.get("X-Authentik-Username"), headers.get("X-Authentik-User")]
)
if not username:
return None
groups = _split_groups(
_first(
[
headers.get("X-Authentik-Groups"),
headers.get("X-Authentik-Group"),
]
)
)
return {
"username": username,
"email": headers.get("X-Authentik-Email", ""),
"name": headers.get("X-Authentik-Name", ""),
"groups": groups,
"role": role_from_groups(groups),
"uid": headers.get("X-Authentik-UID", ""),
}
def _fetch_jwks():
"""Return the Authentik JWKS key set (cached for _JWKS_TTL seconds)."""
global _jwks_cache, _jwks_cache_at
with _jwks_lock:
now = time.time()
if _jwks_cache is not None and now - _jwks_cache_at < _JWKS_TTL:
return _jwks_cache
if not AUTH_AGENTS_JWKS_URL:
return []
try:
resp = requests.get(AUTH_AGENTS_JWKS_URL, timeout=5)
resp.raise_for_status()
_jwks_cache = resp.json().get("keys", [])
_jwks_cache_at = now
except (requests.RequestException, ValueError):
_jwks_cache = _jwks_cache or []
_jwks_cache_at = now
return _jwks_cache
def identity_from_bearer(authorization):
"""Verify an ``Authorization: Bearer <jwt>`` token and resolve its identity.
Verifies the token signature against Authentik's JWKS and enforces the
issuer (the Authentik OIDC provider URL). Returns None for expired, bogus
or missing tokens. Raises on transient network failures (so callers can
treat those distinctly from a plain "no identity").
"""
if not authorization or not authorization.lower().startswith("bearer "):
return None
token = authorization.split(" ", 1)[1].strip()
if not token:
return None
keys = _fetch_jwks()
if not keys:
raise RuntimeError("Authentik JWKS unavailable — cannot verify access token")
decoded = None
for key in keys:
try:
crypto_key = PyJWK.from_dict(key).key
decoded = jwt.decode(
token,
crypto_key,
algorithms=[key.get("alg", "RS256")],
issuer=AUTH_AGENTS_ISSUER,
options={"verify_aud": False},
)
break
except jwt.InvalidTokenError:
continue
if not decoded:
return None
username = _first(
[
decoded.get("preferred_username"),
decoded.get("username"),
decoded.get("email"),
decoded.get("sub"),
]
)
groups = _split_groups(
_first([decoded.get("groups"), decoded.get("ak_groups")])
)
return {
"username": username,
"email": decoded.get("email", ""),
"name": decoded.get("name", ""),
"groups": groups,
"role": role_from_groups(groups),
"uid": decoded.get("sub", ""),
}
def get_identity(headers):
"""Resolve identity from request headers (browser path + agent path)."""
identity = identity_from_headers(headers)
if identity:
return identity
return identity_from_bearer(headers.get("Authorization", ""))
def get_current_user(headers):
"""Return just the authenticated username (or None)."""
identity = get_identity(headers)
return identity["username"] if identity else None
def required_role_level(role):
"""Level for the given role name (free=0, premium=1, admin=2)."""
return _ROLE_LEVEL.get(role, 0)
def oidc_password_grant(username, password):
"""Exchange agent credentials for an Authentik OIDC access token.
Used by self-chat.py so the automated agents authenticate through the same
identity provider as humans. Returns the access token string. Raises on any
failure so callers can surface a clear error.
"""
if not AUTH_AGENTS_TOKEN_URL or not AUTH_AGENTS_CLIENT_ID:
raise RuntimeError(
"Authentik OIDC not configured (AUTH_AGENTS_TOKEN_URL/AUTH_AGENTS_CLIENT_ID)"
)
resp = requests.post(
AUTH_AGENTS_TOKEN_URL,
data={
"grant_type": "password",
"username": username,
"password": password,
"client_id": AUTH_AGENTS_CLIENT_ID,
"client_secret": AUTH_AGENTS_CLIENT_SECRET,
"scope": "openid profile email groups",
},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
access_token = data.get("access_token")
if not access_token:
raise RuntimeError(f"Authentik password grant returned no access token: {data}")
return access_token
</file>
<file path="server/read_file.py">
import io
import os
import re
import subprocess
import tempfile
def strip_html(text):
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def read_file_text(file_path):
ext = os.path.splitext(file_path)[1].lower()
with open(file_path, "rb") as f:
raw = f.read()
if ext == ".pdf":
try:
import fitz
doc = fitz.open(stream=raw, filetype="pdf")
lines = []
for page in doc:
lines.append(page.get_text())
doc.close()
text = "\n".join(lines)
return strip_html(text)
except ImportError:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmpf:
tmpf.write(raw)
tmp = tmpf.name
try:
r = subprocess.run(
["pdftotext", tmp, "-"], capture_output=True, text=True, timeout=30
)
return r.stdout
finally:
os.unlink(tmp)
elif ext == ".docx":
from docx import Document
doc = Document(io.BytesIO(raw))
return "\n".join(p.text for p in doc.paragraphs)
elif ext == ".doc":
with tempfile.NamedTemporaryFile(suffix=".doc", delete=False) as tmpf:
tmpf.write(raw)
tmp = tmpf.name
try:
r = subprocess.run(
["catdoc", tmp], capture_output=True, text=True, timeout=30
)
if r.returncode == 0:
return r.stdout
r = subprocess.run(
["antiword", tmp], capture_output=True, text=True, timeout=30
)
return r.stdout
finally:
os.unlink(tmp)
elif ext in (".xls", ".xlsx"):
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
rows = []
for sheet in wb.worksheets:
for row in sheet.iter_rows(values_only=True):
rows.append("\t".join(str(c) if c is not None else "" for c in row))
wb.close()
return "\n".join(rows)
else:
# Plain text / code files (.py, .js, .json, .md, .txt, .csv, etc.)
try:
print("Reading code file(s)", raw)
return raw.decode("utf-8")
except UnicodeDecodeError:
try:
print("Trying Latin")
return raw.decode("latin-1")
except Exception:
print("Exception")
return ""
return ""
</file>
<file path="setup.sh">
#!/usr/bin/env bash
set -euo pipefail
CHAT_DIR="$(cd "$(dirname "$0")" && pwd)"
LOCAL_AI_HOME="$HOME/local-ai"
FILES_DIR="$HOME/local-ai-files"
# Environment detection (host vs. container)
if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi
if [ -d /run/systemd/system ]; then SYSTEMD=1; else SYSTEMD=0; fi
if [ -f /.dockerenv ] || [ -f /run/.containerenv ]; then IN_CONTAINER=1; else IN_CONTAINER=0; fi
if [ "$IN_CONTAINER" -eq 1 ]; then
echo "==> Detected: running inside a container (SearXNG / nginx / mDNS setup skipped)"
else
echo "==> Detected: running on the host OS (SearXNG / nginx / mDNS setup enabled)"
fi
echo "==> Installing system packages..."
$SUDO apt update
$SUDO apt install -y \
git python3 python3-venv python3-pip \
build-essential cmake \
nginx avahi-daemon \
pdftotext poppler-utils catdoc antiword \
curl docker.io docker-compose-v2
# Ensure Node.js (LTS) is installed (required for frontend build)
if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
echo "==> Installing Node.js (18.x LTS)..."
curl -fsSL https://deb.nodesource.com/setup_18.x | $SUDO -E bash -
$SUDO apt install -y nodejs
fi
# GPU / CUDA toolchain (required to build llama.cpp with -DGGML_CUDA=ON)
# - Host OS: needs an NVIDIA driver + CUDA toolkit (nvcc)
# - Dockerized: needs nvidia-container-toolkit on the HOST + CUDA toolkit inside the container
if ! command -v nvidia-smi >/dev/null 2>&1; then
if [ "$IN_CONTAINER" -eq 1 ]; then
echo "ERROR: nvidia-smi not found inside the container." >&2
echo " Install nvidia-container-toolkit on the HOST and start the container with --gpus all." >&2
else
echo "ERROR: nvidia-smi not found. Install the NVIDIA driver on this host first." >&2
fi
exit 1
fi
if ! command -v nvcc >/dev/null 2>&1; then
echo "==> nvcc not found, installing CUDA toolkit (large download)..."
$SUDO apt install -y nvidia-cuda-toolkit
fi
echo "==> Starting avahi-daemon..."
if [ "$SYSTEMD" -eq 1 ]; then
$SUDO systemctl enable --now avahi-daemon 2>/dev/null || true
fi
echo "==> Creating directory structure..."
mkdir -p "$FILES_DIR"/{ComfyUI/{input,output},my-models}
# ──────────────────────────────────────────────
# ComfyUI
# ──────────────────────────────────────────────
if [ ! -d "$LOCAL_AI_HOME/ComfyUI" ]; then
echo "==> Cloning ComfyUI..."
git clone https://github.com/comfyanonymous/ComfyUI.git "$LOCAL_AI_HOME/ComfyUI"
fi
if [ ! -d "$LOCAL_AI_HOME/ComfyUI/venv" ]; then
echo "==> Setting up ComfyUI venv..."
python3 -m venv "$LOCAL_AI_HOME/ComfyUI/venv"
source "$LOCAL_AI_HOME/ComfyUI/venv/bin/activate"
pip install -r "$LOCAL_AI_HOME/ComfyUI/requirements.txt"
deactivate
fi
# ──────────────────────────────────────────────
# llama.cpp
# ──────────────────────────────────────────────
if [ ! -d "$LOCAL_AI_HOME/llama.cpp" ]; then
echo "==> Cloning llama.cpp..."
git clone https://github.com/ggml-org/llama.cpp.git "$LOCAL_AI_HOME/llama.cpp"
fi
if [ ! -f "$LOCAL_AI_HOME/llama.cpp/build/bin/llama-server" ]; then
echo "==> Building llama.cpp..."
cmake -S "$LOCAL_AI_HOME/llama.cpp" -B "$LOCAL_AI_HOME/llama.cpp/build" \
-DGGML_CUDA=ON \
-DCMAKE_BUILD_TYPE=Release
cmake --build "$LOCAL_AI_HOME/llama.cpp/build" --config Release -j "$(nproc)"
fi
# ──────────────────────────────────────────────
# Chat frontend (this repo)
# ──────────────────────────────────────────────
echo "==> Setting up chat frontend..."
# Ensure we run npm from the chat directory
cd "$CHAT_DIR"
if [ ! -d "node_modules" ]; then
npm install
fi
if [ ! -d "dist" ]; then
npm run build
fi
# ──────────────────────────────────────────────
# Config files
# ──────────────────────────────────────────────
echo "==> Creating config files..."
if [ ! -f "$FILES_DIR/model.json" ]; then
cat > "$FILES_DIR/model.json" << 'MODELEOF'
{
"gpu": "gemma4-e2b",
"cpu": "gemma4-e4b-q4"
}
MODELEOF
echo " $FILES_DIR/model.json (GPU model for the chat UI, CPU model for self-chat agents; edit if you download different models)"
fi
if [ ! -f "$FILES_DIR/models.json" ]; then
cat > "$FILES_DIR/models.json" << 'JSONEOF'
{
"z_image": {
"unet": "z_image_turbo_bf16.safetensors",
"clip1": "qwen_3_4b.safetensors",
"vae": "ae.safetensors",
"description": "Z-Image Turbo (512x512, 8 steps, fast aesthetic image generation)"
}
}
JSONEOF
echo " $FILES_DIR/models.json (matches the downloaded z_image files below)"
fi
# Users are managed by Authentik (unified SSO) — users.json is gone. See
# authentik-compose.yaml + scripts/authentik_bootstrap.py to provision the
# identity provider and its users/groups. Context files are derived from each
# username at ~/local-ai-files/contexts/<user>.txt.
if [ ! -f "$FILES_DIR/sys_prompt.txt" ]; then
cat > "$FILES_DIR/sys_prompt.txt" << 'PROMPTEOF'
You are a helpful AI assistant with the following capabilities:
- Web search: You can search the web for real-time information.
- Image generation: You can generate images using ComfyUI. Available styles: %model_list%
- Image editing: You can edit existing images.
- File extraction: You can read text from uploaded PDF, DOCX, XLSX files.
Current time: %current_time%
Current location: %current_location%
Always respond in a helpful, concise manner.
PROMPTEOF
echo " $FILES_DIR/sys_prompt.txt"
fi
mkdir -p "$FILES_DIR/contexts"
# ──────────────────────────────────────────────
# SearXNG (Docker)
# ──────────────────────────────────────────────
# Skip inside a container: SearXNG is provided as a sibling service (docker-compose)
if [ "$IN_CONTAINER" -eq 1 ]; then
echo "==> Skipping SearXNG container (running inside a container; provide it via docker-compose)"
else
# Choose docker invocation depending on permissions
if docker info >/dev/null 2>&1; then
DOCKER_CMD="docker"
else
DOCKER_CMD="sudo docker"
fi
if ! $DOCKER_CMD ps --format '{{.Names}}' 2>/dev/null | grep -q searxng; then
echo "==> Starting SearXNG..."
mkdir -p "$FILES_DIR/searxng"
$DOCKER_CMD run -d --name searxng --restart unless-stopped \
-p 127.0.0.1:8080:8080 \
-v "$FILES_DIR/searxng:/etc/searxng:rw" \
-e SEARXNG_BASE_URL="http://localhost:8080/" \
searxng/searxng
echo " SearXNG starting on http://localhost:8080"
fi
fi
# ──────────────────────────────────────────────
# mDNS / nginx
# ──────────────────────────────────────────────
echo "==> Setting up mDNS and nginx..."
if [ "$SYSTEMD" -eq 1 ]; then
$SUDO hostnamectl set-hostname chat 2>/dev/null || true
fi
$SUDO tee /etc/nginx/sites-available/chat.local > /dev/null << 'NGINXEOF'
server {
listen 80;
server_name chat.local;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
NGINXEOF
if [ ! -L /etc/nginx/sites-enabled/chat.local ]; then
$SUDO ln -s /etc/nginx/sites-available/chat.local /etc/nginx/sites-enabled/
fi
$SUDO rm -f /etc/nginx/sites-enabled/default
if $SUDO nginx -t; then
if [ "$SYSTEMD" -eq 1 ]; then
$SUDO systemctl restart nginx
else
$SUDO service nginx restart 2>/dev/null || true
fi
fi
$SUDO tee /etc/avahi/services/chat.service > /dev/null << 'AVAHIEOF'
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name>Chat AI</name>
<service>
<type>_http._tcp</type>
<port>80</port>
<host-name>chat.local</host-name>
</service>
</service-group>
AVAHIEOF
if [ "$SYSTEMD" -eq 1 ]; then
$SUDO systemctl restart avahi-daemon 2>/dev/null || true
else
$SUDO service avahi-daemon restart 2>/dev/null || true
fi
# ──────────────────────────────────────────────
# UFW
# ──────────────────────────────────────────────
if command -v ufw &>/dev/null; then
echo "==> Configuring UFW..."
$SUDO ufw allow in on wlp2s0 2>/dev/null || true
$SUDO ufw allow out on wlp2s0 2>/dev/null || true
fi
# ──────────────────────────────────────────────
# Done
# ──────────────────────────────────────────────
echo ""
echo "============================================"
echo " Setup complete!"
echo ""
echo " POST-PROCESSING (models are NOT downloaded by this script):"
echo " Download whatever you need, then run the app. Nothing else to configure."
echo ""
echo " 1. LLM (chat) — your choice:"
echo " Place a GGUF model into: $FILES_DIR/my-models/"
echo " GPU model (chat UI): $(python3 -c "import json;print(json.load(open('$FILES_DIR/model.json'))['gpu'])" 2>/dev/null)"
echo " CPU model (self-chat): $(python3 -c "import json;print(json.load(open('$FILES_DIR/model.json'))['cpu'])" 2>/dev/null)"
echo " (edit $FILES_DIR/model.json if you download different models)"
echo ""
echo " 2. Image model z_image — place these files:"
echo " $LOCAL_AI_HOME/ComfyUI/models/diffusion_models/z_image_turbo_bf16.safetensors"
echo " $LOCAL_AI_HOME/ComfyUI/models/text_encoders/qwen_3_4b.safetensors"
echo " $LOCAL_AI_HOME/ComfyUI/models/vae/ae.safetensors"
echo ""
echo " 3. Users & roles: create them in Authentik (see authentik-compose.yaml"
echo " + scripts/authentik_bootstrap.py). Context files auto-derive per user."
echo ""
echo " Then just run:"
echo " cd $CHAT_DIR && python chat-webui.py"
echo " (it auto-starts llama-server and ComfyUI when needed)"
echo ""
echo " Access at: http://chat.local or http://localhost:3001"
echo "============================================"
</file>
<file path="prompts/editor.txt">
You are the writing Editor. A neutral, careful editor who reviews and polishes generated write up. You read the content markdown, look at the images, decide what to improve, and return the full revised markdown. You preserve the original content, image references, language, and citations. Your soul aim is to maintain the established quality.
## Task context
- Genre: %genre%
- Declared mediums: %mediums%
- Declared language(s): %language%
- Task details: %details%
## Named characters (immutable)
%cast%
The characters above were decided and named before writing. This is the COMPLETE
cast: the story text and every image may only depict these named characters.
Never introduce, name, or depict any additional character (human, animal, or
creature) in your revision or in any image. If the revision contains a character
not listed here, remove it.
## Genre-specific checklist
Check the content against every item below. Where you can fix a failure yourself, fix it directly in your revision — do not just describe the problem, correct the markdown. If a failure cannot be fixed without content you don't have the power to create (e.g. an image was never generated and you cannot generate one), add a single HTML comment as the very first line of your revision, in this exact form, so the automated check and the moderator both see it:
<!-- EDITOR FLAG: <short description of the unresolved problem> -->
%checklist%
## Non-negotiable preservation rules
- Never remove or alter the header metadata block (Task prompt, Genre, For roles,
Mediums, Language(s)).
- Never remove existing image references (``) unless the image file is
genuinely broken or missing.
- Keep every image reference embedded INLINE within the story: each image must
stay right after the paragraph of narrative text it illustrates (exactly where
it appears in the input markdown). Never move images to the top or bottom of
the document, and never stack them all together.
- Do not change the heading level of "## Citations & References" (keep the exact
"##" prefix and the exact section name).
- Never add new sections or image or any new ideas to the existing work done by Kaya and Kolpo
- Never remove the "## Citations & References" section if the original has one.
- Never write the names "Kaya", "Kolpo", "কায়া", "কল্প", "काया", "कल्प" anywhere
in the content text, dialogue, captions, or headings.
- Keep the content in the declared language(s). Do not translate it.
- Strip out any meta-commentary, planning chatter, turn discussions, or conversational setup (e.g., 'Sounds much better than wrestling with the laundry basket tonight', 'I vote for animals!'). Retain ONLY the final, continuous story narrative and structural headers.
### CRITICAL TASK COMPLETION RULES
- ONCE CONTENT IS EDITED, CHECK THE TASK LIST
- IF ANY TASK IN THE PENDING STATUS GOT COMPLETED IN THE CONTENT, MARK THAT `THEME` COMPLETE
</file>
<file path="docker-compose.yaml">
version: '3.8'
services:
searxng:
image: searxng/searxng:latest
container_name: searxng
restart: unless-stopped
networks:
- external-net
- internal-net
ports:
- "8080:8080"
volumes:
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
# 2. Nextcloud Database
cloud-db:
container_name: cloud-db
image: mariadb:10.11
restart: always
command: --transaction-isolation=READ-COMMITTED --log-bin-trust-function-creators=1
networks:
- internal-net
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: your_root_password
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: your_db_password
# 3. Nextcloud Application
cloud-app:
container_name: cloud-app
image: nextcloud
restart: always
# Pin the public hostname to the host so server-side OIDC calls (discovery,
# logout) hit local nginx directly instead of round-tripping via GCP/DNS.
extra_hosts:
- "home.palashkantikundu.in:host-gateway"
networks:
- external-net
- internal-net
ports:
- "8082:80"
depends_on:
- cloud-db
volumes:
- nextcloud_data:/var/www/html
- /mnt/wwn-0x50014ee2173893e0-part1/BackUp-Copy-2:/mnt/my_backups:ro
environment:
MYSQL_HOST: cloud-db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: your_db_password
# Dynamic Host & Subpath rules
OVERWRITEPROTOCOL: "https"
OVERWRITEWEBROOT: "/cloud"
networks:
external-net:
driver: bridge
internal-net:
driver: bridge
volumes:
db_data:
nextcloud_data:
</file>
<file path="package.json">
{
"name": "local-ai",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"dompurify": "^3.4.12",
"katex": "^0.17.0",
"marked": "^15.0.7",
"marked-katex-extension": "^5.1.10",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.4.1",
"vite": "^6.3.2"
}
}
</file>
<file path="README.md">
# Local AI - LLM + Image Generation Setup
Self-hosted LLM + image generation stack on a single laptop (RTX 3050, 4 GB VRAM, 16 GB RAM).
## Requirements
- NVIDIA GPU with the driver working — check with `nvidia-smi`.
- **Host OS path:** CUDA toolkit (`nvcc`) needed to build llama.cpp. `setup.sh` installs
it automatically if it's missing.
- **Dockerized path:** **NVIDIA Container Toolkit** on the *host* (for `--gpus`), plus
CUDA toolkit *inside* the container (`setup.sh` installs it there too).
## Quick Start — Host OS
```bash
# 1. Clone and build
git clone <this-repo> ~/git/local-ai
cd ~/git/local-ai
bash setup.sh
# 2. Post-processing — download your models (setup.sh does NOT download them)
# LLM (chat): put a GGUF into ~/local-ai-files/my-models/
# model.json holds "gpu" (chat UI) and "cpu" (self-chat agents)
# model ids — edit it if you use other models
# Image (z_image): copy these into ~/local-ai/ComfyUI/models/:
# diffusion_models/z_image_turbo_bf16.safetensors
# text_encoders/qwen_3_4b.safetensors
# vae/ae.safetensors
# 3. Run — nothing else to configure
cd ~/git/local-ai && python chat-webui.py
```
Access at `http://chat.local` or `http://localhost:3001`.
Authentication is unified SSO via Authentik — see "Authentication (SSO)" below.
Self-chat agents (editor/moderator/registered agents) run on the CPU llama-server
(`http://localhost:8079`) by default so they never compete with interactive UI
users for VRAM. To run them on the interactive GPU server instead, set the
`SELF_CHAT_MODE` environment variable to `gpu`:
```bash
SELF_CHAT_MODE=gpu python chat-webui.py
```
You can also edit `SELF_CHAT_MODE` in `server/config.py`.
`chat-webui.py` auto-starts the two llama-servers on boot if they're down, and
starts ComfyUI on demand, so no manual service startup is required. If you prefer
to run the services manually:
```bash
# GPU llama-server — interactive chat UI users (VRAM-backed, 24K context)
~/local-ai/llama.cpp/build/bin/llama-server \
--host 0.0.0.0 --port 8081 \
--models-dir ~/local-ai-files/my-models/ \
--jinja -ngl 99 -fa on --ctx-size 24576 \
-ctk q8_0 -ctv q8_0 \
--no-mmproj-offload
# CPU llama-server — automated self-chat agents (RAM-backed, concurrent)
~/local-ai/llama.cpp/build/bin/llama-server \
--host 0.0.0.0 --port 8079 \
--models-dir ~/local-ai-files/my-models/ \
--jinja --n-gpu-layers 0 -fa off --ctx-size 32768 \
-ctk q8_0 -nkvo \
--reasoning-budget 4096 \
--no-mmproj-offload --device none
cd ~/local-ai/ComfyUI && source venv/bin/activate && python main.py \
--lowvram \
--input-directory ~/local-ai-files/ComfyUI/input \
--output-directory ~/local-ai-files/ComfyUI/output
```
## Quick Start — Dockerized (GPU)
The repo ships a `docker-compose.yaml` that runs the same stack inside an
`ubuntu:24.04` container with GPU passthrough, with SearXNG as a sibling service.
```bash
# 1. Start services (SearXNG + ai-container with GPU + shared dirs)
docker compose up -d
# 2. Enter the container and run setup
docker exec -it ai-container bash
cd /root/git/local-ai
bash setup.sh
# 3. Post-processing — download models into the shared host dirs
# (same list as the Host OS path, but the image models land in
# ~/local-ai/ComfyUI/models/ on the HOST, which is mounted into the container)
# 4. Run — inside the container
cd /root/git/local-ai && python chat-webui.py
```
Access at `http://localhost:3001` (published from the container). Notes:
- **Requires the NVIDIA Container Toolkit on the host**; the compose already passes
`--gpus` to the container.
- `setup.sh` auto-detects the container: runs without `sudo`, skips systemd/mDNS/nginx
(not available in a container), skips starting SearXNG (provided by compose), and
installs the CUDA toolkit inside the container if `nvcc` is missing.
- The container needs internet during setup (`apt`, `git clone`, `npm`, CUDA toolkit);
the compose attaches it to `external-net`.
- Config/data dirs (`~/local-ai-files`) are shared with the host, so models and
sessions persist across container restarts.
## Authentication (SSO)
Authentication is unified **SSO via Authentik** — the single identity provider for
every app on the box. There is **no `users.json` and no per-app password database**;
users, passwords and roles live in Authentik.
**Two identity paths:**
1. **Browsers** — nginx runs an `auth_request` subrequest against the Authentik
proxy outpost (`location /ak-auth-ai` in `local_cloud.sh`). If the SSO session is
valid the outpost answers 200 and populates `X-Authentik-*` claim headers, which
nginx forwards to the upstream apps. On 401 nginx sends the browser to the SSO
portal (`@ak-sso-ai`). The SPA calls `/api/check-auth` on load to learn who the
user is.
2. **Machine agents** (`self-chat.py`) — authenticate via Authentik's OAuth2 password
grant and send the JWT as `Authorization: Bearer <token>`. Backends verify the
signature against Authentik's JWKS (`server/auth.py` → `identity_from_bearer`).
Resolved identity is always a dict — `username`, `email`, `name`, `groups`, `role`
(`free`/`premium`/`admin` from the user's Authentik groups) and the Authentik `uid`
(`server/auth.py` → `get_identity`). Roles decide Story collection access and the
"overwrite user context" admin action.
**Enabling steps** (one-time):
1. Fill the `AUTHENTIK_*` / `POSTGRES_*` secrets in `.env` (see `authentik-compose.yaml`).
2. Start Authentik: `docker compose -f authentik-compose.yaml up -d`.
3. Open `https://<host>/sso/if/flow/initial-setup/` and create the admin account.
4. Provision groups/users, the `local-ai` OIDC provider and the proxy outpost:
`python3 scripts/authentik_bootstrap.py`.
5. Deploy the proxy outpost (`ghcr.io/goauthentik/proxy`) with the outpost token the
bootstrap script prints, on `127.0.0.1:9010` (nginx's `ak_outpost` upstream).
6. Ensure the apps are only reachable through the nginx front-end in `local_cloud.sh`
(the `auth_request` gate on `/ai/`, `/api/`, `/stories/`, `/story/`), then reload
nginx.
## Security & Deployment Notes
> **Intended scope: a private, trusted home deployment** — e.g. a household of a few
> users on a home LAN (this project targets ~2–4 concurrent users). The following
> limitations are **accepted risk** for that use case. This stack is **not** built for
> production, the public internet, or a shared LAN where many unknown users work —
> do **not** use it under those conditions.
> **Authentication is unified SSO.** Browser access requires an Authentik session
> (nginx `auth_request`), and per-user identity comes from the forwarded
> `X-Authentik-*` headers (see "Authentication (SSO)" below). The notes that follow
> assume the SSO-enabled nginx front-end (`local_cloud.sh`); running `chat-webui.py`
> directly on port 3001 bypasses all of it.
- **Bypass on bare `chat-webui.py`.** Running `python chat-webui.py` directly serves
port 3001 **without** the nginx SSO gate, so `identity_from_headers` finds no
`X-Authentik-*` headers and every endpoint treats the caller as unauthenticated.
Always front it with `local_cloud.sh`'s nginx config for real protection.
- **File endpoints are unauthenticated on bare 3001.** `/output/...` (generated
images) and `/uploads/...` (uploaded documents) are served without an identity
check (`chat-webui.py` `do_GET`). Anyone who can reach port 3001 and knows a
filename can download them. Behind the SSO nginx gate (`/ai/`, `/api/`) these are
still protected because the whole location is gated at the edge.
- **CORS is wide open.** Responses carry `Access-Control-Allow-Origin: *`
(`chat-webui.py` `do_OPTIONS`/`send_json`). A malicious page on the same origin
context could call the API and read responses; SSO cookies are HttpOnly and
SameSite-bound so cross-origin pages cannot use the session.
- **No TLS/HTTPS on bare 3001.** Login and chat content travel in plaintext if you
connect without nginx. Always use the TLS-terminating nginx front-end
(`local_cloud.sh`); never port-forward 3001/8081/8079 directly.
- **No content guardrails.** The model outputs whatever the loaded model produces; there
is no moderation or kid-safe filter in the stack. Choose your model accordingly and
set expectations for anyone using it.
- **Third-party calls.** `fetch_page` and location lookup call external services
(SearXNG backends, `nominatim.openstreetmap.org`), and optional TTS can use
Microsoft `edge-tts` unless you configure the local Piper voices. If strict data
residency matters, disable or replace these.
- **Compose exposes SearXNG on all host interfaces** (`8080:8080`), while the host path
binds it to `127.0.0.1`. Bind it to localhost if you don't need LAN-wide search.
## System Design
### 1. Infrastructure & Network
```mermaid
graph TD
subgraph Hardware ["Hardware (RTX 3050 Laptop)"]
HW1["GPU: NVIDIA RTX 3050 — 4 GB VRAM"]
HW2["RAM: 16 GB"]
HW3["Same dev machine hosts everything"]
HW4["Target: 2–4 concurrent users"]
end
subgraph ExternalServices ["External Services"]
Docker["Docker Engine"]
Docker --> SearXNG["SearXNG Container\nlocalhost:8080\nRestart: unless-stopped"]
Nginx["Nginx Reverse Proxy\nchat.local:80 → localhost:3001"]
Avahi["avahi-daemon\nmDNS: chat.local"]
end
subgraph Network ["Network Topology"]
LAN["LAN Devices"] -->|"http://chat.local"| Nginx
Nginx -->|"proxy_pass\nUpgrade + X-Real-IP"| HTTPServer["chat-webui.py\n0.0.0.0:3001"]
HTTPServer -->|"localhost:8081"| LLamaGPU["llama-server (GPU)\ninteractive UI users"]
HTTPServer -->|"localhost:8079"| LLamaCPU["llama-server (CPU)\nself-chat agents"]
HTTPServer -->|"localhost:8188"| ComfyUIRuntime["ComfyUI"]
HTTPServer -->|"localhost:8080/search"| SearXNG
HTTPServer -->|"nominatim.openstreetmap.org"| Nominatim["Reverse Geocoding"]
end
subgraph StartupOrder ["Startup (manual)"]
SO1["1. llama-server (GPU)\n--port 8081"] --> SO2["2. llama-server (CPU)\n--port 8079"]
SO2 --> SO3["3. ComfyUI\nvenv → python main.py --lowvram"]
SO3 --> SO4["4. chat-webui.py\npython chat-webui.py"]
SO5["chat-webui.py on boot:\nGPU /health OK? else restart_servers\n→ ensure CPU server → SearXNG\nreachable? else sys.exit(1)"]
end
```
### 2. File Layout & Build
```mermaid
graph TD
subgraph CodeRepo ["Code (~/local-ai/)"]
CR1["chat-webui.py\nMain server — Python 3"]
CR2["setup.sh\nBootstrap: installs deps,\nclones repos, creates templates"]
CR3["dist/\nVite-built SPA frontend"]
CR4["llama.cpp/ git clone\nBuilt: cmake -DGGML_CUDA=ON\nBinary: build/bin/llama-server"]
CR5["ComfyUI/ git clone\nvenv: ComfyUI/venv/\nRequires: requirements.txt"]
end
subgraph DataDir ["Data (~/local-ai-files/)"]
DF1["model.json\nLLM model ids:\ngpu (chat UI)\n+ cpu (self-chat)"]
DF2["models.json\nComfyUI image model defs\nz_image (turbo)"]
DF3["Authentik (external)\nUsers, groups, SSO roles\ncontexts/<user>.txt\nPer-user persistent context"]
DF4["sys_prompt.txt\nSystem prompt template\n%model_list% %current_time%\n%current_location%"]
DF5["session/sessions_<user>.json\nPer-user persisted chat sessions"]
DF6["my-models/\nLLM GGUF model files"]
DF7["ComfyUI/input/\nTemp files for image editing"]
DF8["ComfyUI/output/\nGenerated/edited images"]
DF9["contexts/\nPer-user persistent context"]
DF10["searxng/\nSearXNG config volume"]
DF11["uploads/\nUploaded files saved to disk\n(code/docs via /api/extract-file)"]
DF12["tasks.db\nSQLite to-do tasks + reminders"]
end
subgraph BuildFlags ["Build Flags"]
BF1["llama.cpp\ncmake -DGGML_CUDA=ON\n-DCMAKE_BUILD_TYPE=Release\n-j nproc"]
BF2["ComfyUI\npip install requirements.txt\nin Python venv"]
BF3["Frontend\nnpm install && npm run build\nVite to dist/"]
BF4["System deps\ngit python3 cmake avahi-daemon\npdftotext catdoc antiword\nnginx docker.io"]
end
```
### 3. Runtime Constants & Locks
```mermaid
graph TD
subgraph RCNetwork ["Service URLs"]
RC1["LLAMA_BASE = localhost:8081 (GPU)\nLLAMA_URL = /v1/chat/completions"]
RC2["LLAMA_BASE_CPU = localhost:8079 (CPU)\nLLAMA_URL_CPU = /v1/chat/completions"]
RC3["COMFYUI_URL = localhost:8188"]
RC4["SEARXNG_URL = localhost:8080/search"]
RC5["HOST = 0.0.0.0 PORT = 3001"]
end
subgraph RCLlama ["llama-server Args (two concurrent servers)"]
RCL1["GPU: --host 0.0.0.0 --port 8081\n--n-gpu-layers 99 -fa on --jinja"]
RCL2["CPU: --host 0.0.0.0 --port 8079\n--n-gpu-layers 0 -fa off\n--device none --jinja"]
RCL3["--models-dir ~/local-ai-files/my-models/"]
RCL4["GPU: --ctx-size 24576 (24K)\nCPU: --ctx-size 32768 (32K)"]
RCL5["--reasoning-budget 4096\n(CPU server only)"]
RCL6["-ctk q8_0 (KV cache quant, both)\n-ctv q8_0 (GPU only)\n-nkvo (CPU only)"]
RCL7["--no-mmproj-offload on BOTH servers\n(multimodal projector stays in RAM —\notherwise its ~950 MiB on the 4 GiB card\nstarves the CPU server's worker buffers)"]
RCL8["Routing: agent task → 8079 CPU,\nUI task → 8081 GPU (task_mode)"]
end
subgraph RCThermal ["Thermal and RAM Thresholds"]
RT1["TEMP_THRESHOLD_ON = 85 C"]
RT2["TEMP_THRESHOLD_OFF = 75 C"]
RT3["RAM_EVAC_THRESHOLD = 95%"]
RT4["RAM_RESUME_THRESHOLD = 70%"]
end
subgraph RCLimits ["Limits and Pools"]
RL1["MAX_QUEUE_SIZE = 5 (per lane)"]
RL2["MAX_INPUT_TOKENS = 24576 (24K)"]
RL3["_llm_pools: gpu 1 / cpu 4 workers\n(CPU_PARALLEL_SLOTS = 4)"]
RL4["_tool_pools: gpu 2 / cpu 2 workers"]
RL5["Max tool rounds = 10"]
RL6["Idle unload = 300s"]
RL7["LLM timeout = 600s"]
RL8["ComfyUI poll = 120s"]
RL9["REASONING_BUDGET = 4096 (CPU server)"]
end
subgraph RCThreads ["Thread Pools and Locks"]
LK1["_llm_pools: ThreadPoolExecutor\nper lane (gpu 1, cpu 4)\nLLM calls run per-lane"]
LK2["_tool_pools: ThreadPoolExecutor\nper lane (gpu 2, cpu 2)"]
LK3["_event_queue: queue.Queue\nDecouples dequeue from dispatch"]
LK4["_image_queue + _image_worker\nSerializes image jobs\n(image loading can starve GPU)"]
LK5["_data_lock: threading.Lock\nGuards sessions, tasks, model_status"]
LK6["_model_transition_lock\nSerializes load/unload of LLM"]
LK7["_tokens_lock\nGuards _agent_tokens/_agent_users\n(self-chat agent tracking)"]
LK8["_queue_locks + _queue_conds\nPer-lane task queues\n(gpu lane + cpu lane)\nCondition variables"]
end
```
### 4. Server Startup
```mermaid
graph TD
A["python chat-webui.py"] --> A1["Load configs at module import:\nmodel.json, models.json,\nsys_prompt.txt"]
A --> B["load_sessions()\nLoad per-user session files"]
A1 & B --> C{"GPU llama-server /health\nHTTP GET localhost:8081?"}
C -- "200 OK" --> C2{"CPU llama-server /health\nHTTP GET localhost:8079?"}
C -- "Dead" --> Restart["restart_servers:\n1. kill_llama_server pkill -9\n2. kill_comfyui pkill main.py\n3. Spawn ComfyUI Popen (no poll here)\n4. Spawn GPU llama-server (8081)\n5. Spawn CPU llama-server (8079)\n6. Poll each /health 2s up to 120s\n7. Kill on timeout"]
C2 -- "Dead" --> EnsureCPU["ensure_llama_server cpu:\nrestart CPU llama-server only\n(GPU stays running)"]
C2 -- "200 OK" --> D{"SearXNG reachable\non localhost:8080?"}
EnsureCPU --> D
D -- "Yes" --> E["Start 7 Daemon Threads"]
D -- "No" --> Exit["print ERROR & sys.exit(1)"]
Restart --> D
subgraph Daemons ["Background Daemon Threads"]
E1["_event_loop\nSingle-threaded event dispatcher"]
E2["_queue_worker gpu\nSequential dequeuer\n(GPU lane — UI users)"]
E3["_queue_worker cpu\nSequential dequeuer\n(CPU lane — self-chat agents)"]
E4["_image_worker\nSerialized image jobs\n(one at a time)"]
E5["_idle_unload_loop\nPolls every 10s"]
E6["_thermal_monitor\nPolls every 10s"]
E7["_reminder_loop\nPolls every 30s"]
end
E --> E1 & E2 & E3 & E4 & E5 & E6 & E7
E --> F["HTTPServer.serve_forever\n0.0.0.0:3001"]
```
### 5. Model State Machine
Two independent state machines run concurrently — one per llama-server.
```mermaid
stateDiagram-v2
direction LR
state "GPU server (8081) — UI users" as G {
[*] --> unloaded: gpu
unloaded --> loading : load_llama_model("gpu")
loading --> chat_loaded : 200 from /models/load\n+ health check passes
loading --> unloaded : failed
chat_loaded --> unloading : unload_llama_model("gpu")
unloading --> unloaded : 200 from /models/unload
unloading --> chat_loaded : failed but health OK
chat_loaded --> image_active : generate_image\nor edit_image starts
image_active --> chat_loaded : free_comfyui_vram\n+ load_llama_model("gpu")
end
state "CPU server (8079) — self-chat agents" as C {
[*] --> cpu_unloaded
cpu_unloaded --> cpu_loading : load_llama_model("cpu")
cpu_loading --> cpu_loaded : 200 from /models/load\n+ health check passes
cpu_loading --> cpu_unloaded : failed
cpu_loaded --> cpu_unloading : unload_llama_model("cpu")
cpu_unloading --> cpu_unloaded : 200 from /models/unload
cpu_unloading --> cpu_loaded : failed but health OK
}
```
Image generation unloads **only** the GPU server; the CPU server keeps serving
agents throughout. Per-server idle timestamps drive independent unloads
(`_last_llm_use` for GPU, `_cpu_last_llm_use` for CPU).
### 6. REST API Endpoints
```mermaid
graph TD
Client([User Client])
subgraph AuthEndpoints ["Auth (SSO)"]
Client -->|"Browser: nginx auth_request\n→ Authentik SSO portal"| SSO["SSO session cookie set\nX-Authentik-* forwarded upstream"]
Client -->|"GET /api/check-auth"| CheckAuth["identity from\nX-Authentik-* headers"]
CheckAuth -- Yes --> AuthOK["{authenticated: true, username, role}"]
CheckAuth -- No --> AuthNO["{authenticated: false}"]
Client -->|"Agents: POST /sso/... token\nAuthorization: Bearer <JWT>"| Bearer["Verify JWT against\nAuthentik JWKS"]
end
subgraph SessionEndpoints ["Session Management"]
Client -->|"POST /api/sessions"| NewSession["Create UUID session\nStore in sessions_meta"]
Client -->|"GET /api/sessions"| ListSessions["List user sessions\nSorted by updated desc"]
Client -->|"GET /api/sessions/:id/messages"| GetMessages["Return messages\n+ token_estimate"]
Client -->|"PUT /api/sessions/:id"| RenameSession["Rename session"]
Client -->|"DELETE /api/sessions/:id"| DeleteSession["Delete session + cleanup\nassociated output images\nand uploaded files"]
end
subgraph TaskEndpoints ["Task Management"]
Client -->|"GET /api/tasks"| ListTasks["List user tasks\nwith reminders"]
Client -->|"POST /api/tasks"| CreateTask["Create task\n(title, priority, due_date, reminder)"]
Client -->|"PUT /api/tasks/:id"| UpdateTask["Update task fields"]
Client -->|"DELETE /api/tasks/:id"| DeleteTask["Delete task"]
end
subgraph AgentEndpoints ["Agent / Presence"]
Client -->|"POST /api/register-agent"| RegisterAgent["Create agent token\nfor self-chat bots\n(kolpo, kaya, editor, moderator)"]
Client -->|"POST /api/leaving"| Leaving["Set user's active window\nnow → end (presence)"]
Client -->|"GET /api/active-users"| ActiveUsers["List currently active users\n(presence tracking)"]
end
subgraph UtilityEndpoints ["Utility"]
Client -->|"GET /api/model-status"| ModelStatus["model_status, _last_tps\n_overheated, _gpu_temp,\nreminder_count, max_context"]
Client -->|"POST /api/extract-file"| ExtractFile["Save uploaded file to disk\n(~/local-ai-files/uploads/)\nReturn {url, name}\nText extraction happens later\nvia the read_file tool"]
Client -->|"POST /api/location"| SetLocation["Reverse geocode via Nominatim\nstore _client_location"]
Client -->|"GET /api/user-context"| GetUserCtx["Read user context file"]
Client -->|"POST /api/user-context\n{action: write|overwrite|read}"| SetUserCtx["Write / overwrite / read\nuser context file"]
Client -->|"POST /api/tts"| TTS["Text-to-speech via Piper (local)\nor edge-tts (cloud fallback)"]
Client -->|"GET /output/:filename"| ServeImage["Serve generated images\nfrom ComfyUI output dir (no auth)"]
Client -->|"GET /uploads/:filename"| ServeUpload["Serve uploaded files\n(no auth)"]
end
subgraph SPA ["Static / SPA Serving"]
Client -->|"GET /"| SPAIndex["Serve dist/index.html"]
Client -->|"GET /*"| SPAAssets["Serve dist/ assets\nor SPA fallback to index.html"]
end
subgraph StatusPolling ["Status Polling"]
Client -->|"GET /api/status/:task_id"| PollStatus["Return tasks id:\nstatus message response\n tools_used image etc"]
end
```
### 7. Chat Ingress Flow
```mermaid
graph TD
Client([User Client]) -->|"POST /api/chat\n(SSO session via nginx)"| EndpointChat
EndpointChat["Handler.do_POST: /api/chat"]
EndpointChat --> AuthCheck{"get_current_user\nvia X-Authentik-*/JWT"}
AuthCheck -- No --> AuthErr[401 Unauthorized]
AuthCheck -- Yes --> SessionCheck{"Session exists\nand owned by user?"}
SessionCheck -- No --> SessionErr[404 Session not found]
SessionCheck -- Yes --> RouteAtAdmission{"user in\n_agent_users?"}
RouteAtAdmission -- "agent (cpu)" --> LaneCPU["lane = cpu\nQueue: _task_queues['cpu']"]
RouteAtAdmission -- "user (gpu)" --> LaneGPU["lane = gpu\nQueue: _task_queues['gpu']"]
LaneCPU --> QueueCheck{"len lane queue\n< MAX_QUEUE_SIZE 5?"}
LaneGPU --> QueueCheck
QueueCheck -- No --> QueueBusy[503 Server Busy]
QueueCheck -- Yes --> EnqueueTask["lane queue.append\n_queue_conds[lane].notify"]
EnqueueTask --> TaskInit["tasks task_id =\nstatus queued"]
TaskInit --> ReturnTaskID["Return task_id to Client"]
ReturnTaskID -. "mode resolved later\nin task_mode(task_id)" .-> TaskMode["agent → 8079 CPU\nuser → 8081 GPU"]
TaskMode --> EnsureServer["ensure + load that\nmode's llama-server"]
```
### 8. Queue Workers (one per lane)
A separate worker drains each lane (`_queue_worker("gpu")`, `_queue_worker("cpu")`),
each with its own lock/condition/queue. The two lanes never wait behind each other;
they only share hardware when both need the GPU (chat load / image gen), which is
arbitrated separately. (The CPU-lane "yield to human presence" pause is disabled in
the current code — self-chat agents run on the CPU server continuously.)
```mermaid
graph TD
E2["_queue_worker(mode)\ngpu or cpu"] --> QueueLoop["queue_cond[mode].wait\nblock on empty queue"]
QueueLoop --> PauseCheck{"overheated and mode=gpu\nor ram_evacuating?"}
PauseCheck -- Yes --> MarkWaiting["Set all queued tasks in\nthis lane to status waiting\npause label"]
MarkWaiting --> PauseWait["queue_cond.wait 5s"] --> QueueLoop
PauseCheck -- No --> PopTask["item = lane queue.pop 0\n_current_task_ids[mode] = task_id"]
PopTask --> PostStart["event_post start\nsession_id message image\naudio user client_timestamp"]
PostStart --> TaskDoneWait{"Poll tasks id.status\nevery 0.5s"}
TaskDoneWait -- "done or error" --> ClearTask["_current_task_ids[mode] = None\nqueue_cond.notify_all"]
ClearTask --> QueueLoop
```
### 9. Event Loop Pipeline
```mermaid
graph TD
E1["_event_loop"] --> EvLoop["Loop: event_queue.get\nev_type task_id data"]
EvLoop --> EvDispatch{"ev_type?"}
EvDispatch -- "start" --> EvStart["Store task metadata:\n_tools_used, _search_details\n_original_message, _original_image\n_audio, _user, _client_timestamp"]
EvStart --> PrepSession["prepare_session:\n1. Compute mode = task_mode(task_id)\n (agent → cpu 8079, user → gpu 8081)\n2. Ensure + load that mode's server\n3. Inject sys prompt + date + location\n4. Inject user context\n5. Append user msg to session\n6. Auto-name session from message\n7. save_sessions\n[...]" ]
PrepSession --> StartRound0["start_llm_round round 0"]
EvDispatch -- "llm_ok" --> LLMOK{"state == llm_waiting\nand has tool_calls?"}
LLMOK -- "No tools" --> Finalize["_finalize_task:\n1. Build msg_entry with reasoning\n tools_used, image_url etc\n2. Append to session\n3. save_sessions\n4. tasks id = status done\n5. Reset + update that\n mode's idle timestamp"]
LLMOK -- "Has tools" --> SubmitTools["1. Append assistant msg\n2. state = tools_running\n3. pending_tools = count\n4. save_sessions\n5. Submit to that\n task's lane tool pool"]
EvDispatch -- "llm_err" --> LLMERR{"state == llm_waiting?"}
LLMERR -- Yes --> LLMErrAction["_set_task_error:\ntasks id = status error"]
LLMERR -- No --> EvLoop
EvDispatch -- "tool_ok" --> ToolOK["1. Append tool result to session\n2. pending_tools minus 1\n3. save_sessions"]
ToolOK --> AllToolsDone{"pending_tools <= 0?"}
AllToolsDone -- No --> EvLoop
AllToolsDone -- Yes --> NextRoundCheck{"round+1 < 10?"}
NextRoundCheck -- Yes --> NextRound["start_llm_round round N+1\nFeed tool results back to LLM"]
NextRoundCheck -- No --> MaxRoundsErr["_set_task_error:\nMax tool rounds exceeded"]
EvDispatch -- "tool_err" --> ToolERR["1. Append error as tool result\n2. pending_tools minus 1\n3. save_sessions\n4. Same round-limit logic"]
```
### 10. LLM Worker
```mermaid
graph TD
StartRound0["start_llm_round\n(mode from task_mode)"] --> LLMWorker["_llm_worker\nin _llm_pools[mode]\n(gpu 1 / cpu 4 workers)"]
LLMWorker --> PayloadBuild["Build payload:\nmodel (mode's model id)\nmessages tools\ntool_choice auto\nmax_tokens 24576\nstream true\n(CPU server: --reasoning-budget 4096)"]
PayloadBuild --> StreamReq["POST llama-server\n(mode's base: 8081 gpu / 8079 cpu)\nv1/chat/completions\nstream=True timeout=600s"]
StreamReq --> StreamParse["Parse SSE stream:\n- reasoning_content delta\n accumulate in reasoning_buf\n- content delta\n accumulate in content_buf\n- tool_calls delta\n reassemble by index[...]" ]
StreamParse --> BuildAssistantMsg["Build assistant msg:\nrole assistant content\nreasoning_content tool_calls"]
BuildAssistantMsg --> LLMOKPost["event_post llm_ok\nbody choices message"]
LLMOKPost --> EvLoop["Back to _event_loop"]
StreamReq -. "exception" .-> LLMException["event_post llm_err\nif image or vision in error\nuser-friendly message"]
```
### 11. Tool Worker
```mermaid
graph TD
SubmitTools["Submit to _tool_pools[mode]\ngpu 2 / cpu 2 workers"] --> ToolWorker["_tool_worker\nin the task's lane pool"]
ToolWorker --> ParseArgs["Parse tc.function.arguments\nfrom JSON string"]
ParseArgs --> ChooseTool{"tc.function.name?"}
ChooseTool -- "web_search" --> ExecSearch["1. set_status Searching\n2. Get _client_timestamp\n3. web_search query client_ts:\n Append city to query\n GET SearXNG search json\n Return to[...]" ]
ExecSearch --> ToolPost["event_post tool_ok"]
ChooseTool -- "fetch_page" --> FetchPage["1. set_status Fetching\n2. fetch_page URL:\n Validate URL (no private IPs)\n GET with browser headers\n Parse HTML (BeautifulSoup)\n Return title + content"]
FetchPage --> ToolPost
ChooseTool -- "generate_image" --> GenGuard{"already generated\nimage this task?"}
GenGuard -- Yes --> GenReject["Return error:\nImage generation limit reached"]
GenGuard -- No --> EnqueueImage["_enqueue_image_job\nImage worker (single, serialized)"]
EnqueueImage --> GenImage["1. unload_llama_model('gpu')\n2. Build ComfyUI workflow:\n z_image 8 steps res_multistep\n (default; sd3_5_medium 20\n steps euler if in models.json)\n3. ensure_comfyui_running\n4. POST /prompt\n5. Poll history 120s\n6. free_comfyui_vram\n7. load_llama_model('gpu')\n8. 5s GPU cooldown\n(CPU agents keep running)"]
GenImage --> ToolPost
ChooseTool -- "edit_image" --> EditEnqueue["_enqueue_image_job\nImage worker (single, serialized)"]
EditEnqueue --> EditImage["1. Find source image:\n Check _image_url in session\n Check base64 in user messages\n2. unload_llama_model('gpu')\n3. Write input to ComfyUI/input\n4. Build img2img workflow (denoise)\n5. ensure_comfyui_running\n6. POST /prompt\n7. Poll history 120s\n8. free_comfyui_vram\n9. load_llama_model('gpu')\n10. 5s GPU cooldown\n(CPU agents keep running)"]
EditImage --> ToolPost
ChooseTool -- "get_user_location" --> GetLoc["If _client_location cached: return it\nElse: set_status location_needed\nWait for browser geolocation\n(60s timeout)\nReturn location or 'denied'"]
GetLoc --> ToolPost
ChooseTool -- "read_file" --> ReadFile["1. Validate file_url in /uploads/\n2. Read file from uploads dir\n3. Extract text via:\n fitz (PDF), python-docx (DOCX)\n catdoc/antiword (DOC)\n openpyxl (XLSX)\n4. Return content"]
ReadFile --> ToolPost
ChooseTool -- "update_user_context" --> ExecContext["write_user_context:\nAppend timestamped entry\nto user context file"]
ExecContext --> ToolPost
ChooseTool -- "manage_tasks" --> ManageTasks["SQLite tasks DB ops:\ncreate/update/complete/delete/list/get\nPer-user, with reminders"]
ManageTasks --> ToolPost
ChooseTool -- "unknown" --> ToolUnknown["Return error:\nUnknown tool"]
ToolUnknown --> ToolPost
```
### 12. Resource Management
```mermaid
graph TD
E4["_thermal_monitor"] --> ThermalLoop["Loop every 10s"]
ThermalLoop --> CheckGPU["nvidia-smi GPU temp"]
CheckGPU --> GPUTempCheck{"Temp >= 85 C?"}
GPUTempCheck -- Yes --> SetOverheat["_overheated = True"]
GPUTempCheck -- No --> CheckCool{"_overheated\nand Temp <= 75 C?"}
CheckCool -- Yes --> UnsetOverheat["_overheated = False"]
SetOverheat --> ThermalAction{"Is GPU lane\ntask running?"}
ThermalAction -- No --> ThermalUnload["GPU model_status?"]
ThermalUnload -- "chat_loaded" --> UnloadModel["unload_llama_model('gpu')\n(CPU server untouched)"]
ThermalUnload -- "image_active" --> FreeVRAM["free_comfyui_vram"]
ThermalAction -- Yes --> ThermalSkip["Skip let task finish"]
UnsetOverheat --> RAMCheck1
ThermalLoop --> RAMCheck1{"not evacuating\nand RAM >= 95%?"}
RAMCheck1 -- Yes --> EvacuateRAM["_evacuate_ram:\n1. ram_evacuating = True\n2. Requeue in-flight task\n to front of EACH lane\n (gpu + cpu) status error\n3. kill_llama_server (both 8081 + 8079)\n4. kill_comfyui\n5. Wait until RAM <= 70%\n6. restart_servers()"]
RAMCheck1 -- No --> ThermalLoop
E3["_idle_unload_loop"] --> IdleLoop["Loop every 10s"]
IdleLoop --> IdleCheck{"chat_loaded (gpu)\nidle > 300s\nno queue tasks?"}
IdleCheck -- Yes --> UnloadModel2["unload_llama_model('gpu')\nRelease VRAM weights"]
IdleCheck -- No --> IdleLoop
IdleLoop --> IdleCheck2{"cpu_loaded\n_cpu_last_llm_use idle > 300s\nno queue tasks?"}
IdleCheck2 -- Yes --> UnloadModel3["unload_llama_model('cpu')\nRelease RAM weights"]
IdleCheck2 -- No --> IdleLoop
```
</file>
<file path="server/features/context.py">
"""Token estimation, context trimming and context compaction."""
import base64
import json
import os
import re
import requests
from server.features.state import M
def strip_html(text):
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def _text_tokens(s):
if not s:
return 0
# Multilingual/non-ASCII characters eat up far more tokens (~2 chars per token vs ~4 for English)
non_ascii = sum(1 for ch in s if ord(ch) > 0x7F)
divisor = 2.0 if non_ascii > len(s) * 0.15 else 4.0
return int(len(s) / divisor)
def estimate_tokens(messages, include_tools=True):
total = M.TOOLS_TOKEN_COST if include_tools else 0
for msg in messages:
total += M.PER_MESSAGE_OVERHEAD
content = msg.get("content", "")
# Standard string content
if isinstance(content, str):
total += _text_tokens(content)
# Multi-modal array content (text + images + audio)
elif isinstance(content, list):
for part in content:
if not isinstance(part, dict):
continue
ptype = part.get("type")
if ptype == "text":
total += _text_tokens(part.get("text", ""))
elif ptype in ("image_url", "input_image", "image"):
total += M.IMAGE_TOKEN_COST
elif ptype in ("audio_url", "input_audio", "audio"):
total += M.AUDIO_TOKEN_COST
# Tool call tokens
for tc in msg.get("tool_calls") or []:
total += _text_tokens(json.dumps(tc))
# Return MUST be outside the for-loop!
return max(1, total)
def trim_messages_for_context(messages):
trimmed = list(messages)
sys_msg = None
if trimmed and trimmed[0].get("role") == "system":
sys_msg = trimmed.pop(0)
while estimate_tokens(trimmed) > M.MAX_INPUT_TOKENS and len(trimmed) > 1:
trimmed.pop(0)
if sys_msg:
trimmed.insert(0, sys_msg)
return trimmed
def _summarize_with_llm(text, mode="gpu"):
payload = {
"model": M.server_model_id(mode),
"messages": [
{
"role": "system",
"content": "You summarize conversations concisely, preserving key facts, decisions, user preferences, and unresolved questions.",
},
{"role": "user", "content": text},
],
"max_tokens": 1024,
"temperature": 0.3,
"stream": False,
}
try:
M.mark_slot_kv_dirty(mode)
r = requests.post(M.server_url(mode), json=payload, timeout=120)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
except Exception as e:
print(f"[compact] LLM summarization failed: {e}")
return None
def compact_messages_copy(messages, keep_messages=6, mode="gpu"):
"""Return a compacted COPY of the message list (summary + recent messages)
WITHOUT modifying the stored session. Old messages are summarized, not deleted."""
msgs = list(messages)
sys_msg = None
if msgs and msgs[0].get("role") == "system":
sys_msg = msgs.pop(0)
if len(msgs) <= keep_messages + 1:
return ([sys_msg] + msgs) if sys_msg else msgs
to_compact = msgs[:-keep_messages] if keep_messages > 0 else msgs
recent = msgs[-keep_messages:] if keep_messages > 0 else []
compact_text = ""
for m in to_compact:
role = m.get("role", "unknown")
content = m.get("content", "")
if isinstance(content, list):
parts = []
for p in content:
if isinstance(p, dict):
if p.get("type") == "text":
parts.append(p.get("text", ""))
content = " ".join(parts)
if not content:
continue
compact_text += f"[{role}]: {content}\n\n"
if not compact_text.strip():
return ([sys_msg] + msgs) if sys_msg else msgs
summary = M._summarize_with_llm(
f"Compress the following conversation into a short paragraph, keeping all important details:\n\n{compact_text}",
mode,
)
if summary is None:
return ([sys_msg] + msgs) if sys_msg else msgs
new_msgs = []
if sys_msg:
new_msgs.append(sys_msg)
new_msgs.append({"role": "system", "content": f"[Compressed context]: {summary}"})
new_msgs.extend(recent)
return new_msgs
def sanitize_content_for_llm(messages):
"""Return a COPY of ``messages`` with content parts the LLM backend cannot
process (e.g. ``audio_url``) removed, so a stale multimodal message can't
make llama-server reject the whole request (HTTP 400 "unsupported
content[].type"). The stored session is left untouched.
"""
sanitized = []
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
sanitized.append(msg)
continue
parts = []
dropped_audio = False
for p in content:
if not isinstance(p, dict):
continue
if p.get("type") in ("audio_url", "input_audio", "audio"):
dropped_audio = True
continue
parts.append(p)
if dropped_audio:
parts.append(
{
"type": "text",
"text": "[Voice message omitted — audio input is not supported by this model]",
}
)
sanitized.append({**msg, "content": parts})
return sanitized
def resolve_image_path(url):
"""Resolve a ``/uploads/`` or ``/output/`` URL to a local file path, or
``None`` if the URL is unknown or the file is missing."""
if not url:
return None
fpath = None
if url.startswith("/uploads/"):
fname = os.path.basename(url.split("?", 1)[0])
fpath = os.path.join(M.UPLOADS_DIR, fname)
elif url.startswith("/output/"):
rel = url[len("/output/"):].split("?", 1)[0]
fpath = os.path.join(M.IMG_PATH, rel)
if not fpath or not os.path.isfile(fpath):
return None
return fpath
def _image_to_data_url(url):
"""Resolve an image URL the server can serve to a ``data:`` URL.
Understands ``/uploads/`` (user uploads) and ``/output/`` (generated
images) so those bytes can be embedded in the LLM request. ``data:`` URLs
pass through unchanged; anything unknown returns ``None``.
"""
if not url:
return None
if url.startswith("data:"):
return url
fpath = resolve_image_path(url)
if not fpath:
return None
ext = os.path.splitext(fpath)[1].lower()
mime = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
}.get(ext, "image/jpeg")
try:
with open(fpath, "rb") as f:
raw = f.read()
except OSError:
return None
return f"data:{mime};base64,{base64.b64encode(raw).decode()}"
def _reference_historical_images(messages):
"""Return a COPY of ``messages`` where ``image_url`` parts are kept lean.
The image attached to the most recent user message stays visible (its bytes
are embedded via ``_image_to_data_url``), preserving the current turn's
vision. Every older image becomes a compact text marker carrying the file
URL — the model can call ``read_image`` to actually view one, so history
never drags megabytes of base64 through the context window.
"""
last_user_idx = -1
for i, m in enumerate(messages):
if m.get("role") == "user":
last_user_idx = i
out = []
for i, m in enumerate(messages):
content = m.get("content")
if not isinstance(content, list):
out.append(m)
continue
parts = []
for p in content:
if isinstance(p, dict) and p.get("type") == "image_url":
url = p.get("image_url", {}).get("url", "")
if i == last_user_idx:
data_url = _image_to_data_url(url)
if data_url:
parts.append({"type": "image_url", "image_url": {"url": data_url}})
continue
parts.append({"type": "text", "text": f"[IMAGE: {url}]"})
else:
parts.append(
{
"type": "text",
"text": f"[IMAGE: {url} — use the read_image tool to view this image]",
}
)
else:
parts.append(p)
out.append({**m, "content": parts})
return out
def _latest_read_image_url(messages):
"""Return the image URL of the most recent successful ``read_image`` tool
result in ``messages`` (or ``None``)."""
for m in reversed(messages):
if m.get("role") != "tool":
continue
content = m.get("content")
if not isinstance(content, str):
continue
try:
data = json.loads(content)
except (TypeError, ValueError):
continue
if data.get("ok") is True and data.get("image_url"):
return data["image_url"]
return None
def prepare_context_for_llm(sid, messages, mode="gpu"):
"""Build the message list to send to the LLM. When the conversation nears the
context limit, old messages are summarized into a compressed context block —
but the stored session is left untouched, so no messages are deleted.
Historical images are referenced by path (see ``read_image``) instead of
being re-sent as base64 on every round."""
messages = sanitize_content_for_llm(messages)
messages = _reference_historical_images(messages)
total = estimate_tokens(messages)
if total <= M.AUTO_COMPACT_THRESHOLD:
context = trim_messages_for_context(messages)
with M._effective_contexts_lock:
M._effective_contexts.pop(sid, None)
return context
print(f"[context] Session {sid} estimate {total} tokens exceeds threshold {M.AUTO_COMPACT_THRESHOLD}; building compressed context for LLM")
compacted = compact_messages_copy(messages, mode=mode)
context = trim_messages_for_context(compacted)
print(f"[context] Compressed context built; estimate after: {estimate_tokens(context)}")
with M._effective_contexts_lock:
M._effective_contexts[sid] = context
return context
def effective_token_estimate(sid, messages):
"""Report the token count the UI shows: the compressed context actually sent
to the LLM once compression has kicked in, falling back to the full history."""
with M._effective_contexts_lock:
cached = M._effective_contexts.get(sid)
if cached is not None:
return estimate_tokens(cached)
return estimate_tokens(messages)
def context_token_report(sid, messages):
"""Token report for the UI: effective count sent to the LLM, the raw stored
count, and whether context compression is currently active."""
effective = effective_token_estimate(sid, messages)
raw = estimate_tokens(messages)
return {
"token_estimate": effective,
"raw_token_estimate": raw,
"context_compressed": raw > effective,
}
</file>
<file path="src/components/InputBar.jsx">
import { useState, useRef, useCallback } from 'react'
import { extractFile, uploadImage } from '../api'
const CODE_EXTS = new Set([
'.py', '.js', '.ts', '.jsx', '.tsx', '.java', '.cpp', '.c', '.h', '.hpp',
'.cs', '.go', '.rs', '.rb', '.php', '.swift', '.kt', '.scala', '.dart',
'.sh', '.bash', '.pl', '.pm', '.lua', '.r', '.sql', '.html', '.css',
'.scss', '.sass', '.less', '.vue', '.svelte', '.yaml', '.yml', '.json',
'.xml', '.toml', '.ini', '.cfg', '.md', '.tex', '.dockerfile', '.tf',
'.zig', '.nim', '.hs', '.ml', '.fs', '.erl', '.elm', '.purs', '.nix',
'.ps1', '.bat', '.cmake', '.proto', '.gradle', '.bib',
])
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'])
const DOC_EXTS = new Set(['.pdf', '.xls', '.xlsx', '.doc', '.docx'])
const MAX_FILE_SIZE = 10 * 1024 * 1024
function readFileAsBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result.split(',')[1])
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export default function InputBar({ onSend, hasPending }) {
const [text, setText] = useState('')
const [research, setResearch] = useState(false)
const [cpu, setCpu] = useState(false)
const [attachedImage, setAttachedImage] = useState(null)
const [attachedFile, setAttachedFile] = useState(null)
const [attachedFileUrl, setAttachedFileUrl] = useState(null)
const fileInputRef = useRef(null)
const textareaRef = useRef(null)
const imagePreviewRef = useRef(null)
const sendingRef = useRef(false)
function clearAttachments() {
setAttachedImage(null)
setAttachedFile(null)
setAttachedFileUrl(null)
if (fileInputRef.current) fileInputRef.current.value = ''
if (imagePreviewRef.current) imagePreviewRef.current.style.display = 'none'
}
const uploadFileToServer = useCallback(async (file) => {
const b64 = await readFileAsBase64(file)
const data = await extractFile(file.name, b64)
if (!data.url) throw new Error(data.error || 'Could not process file')
setAttachedFile(data.name)
setAttachedFileUrl(data.url)
}, [])
const handleFile = useCallback(async (e) => {
const file = e.target.files[0]
if (!file) return
if (file.size > MAX_FILE_SIZE) {
alert('File too large (max 10MB): ' + file.name)
e.target.value = ''
return
}
clearAttachments()
const ext = '.' + file.name.split('.').pop().toLowerCase()
if (IMAGE_EXTS.has(ext)) {
const reader = new FileReader()
reader.onload = (ev) => {
const img = new Image()
img.onload = async () => {
let w = img.naturalWidth
let h = img.naturalHeight
const MAX_DIM = 1920
if (w > MAX_DIM || h > MAX_DIM) {
if (w > h) { h = Math.round(h * MAX_DIM / w); w = MAX_DIM }
else { w = Math.round(w * MAX_DIM / h); h = MAX_DIM }
}
const c = document.createElement('canvas')
c.width = w; c.height = h
const ctx = c.getContext('2d')
ctx.drawImage(img, 0, 0, w, h)
const compressed = c.toDataURL('image/jpeg', 0.8)
const b64 = compressed.split(',')[1]
try {
const res = await uploadImage(b64, 'jpg')
setAttachedImage(res.url || b64)
} catch {
setAttachedImage(b64)
}
if (imagePreviewRef.current) {
imagePreviewRef.current.src = compressed
imagePreviewRef.current.style.display = 'block'
}
}
img.src = ev.target.result
}
reader.readAsDataURL(file)
return
}
if (CODE_EXTS.has(ext)) {
try {
await uploadFileToServer(file)
} catch (err) {
clearAttachments()
alert('Error processing file: ' + err.message)
}
return
}
if (DOC_EXTS.has(ext)) {
try {
await uploadFileToServer(file)
} catch (err) {
clearAttachments()
alert('Error processing file: ' + err.message)
}
return
}
const isCode = confirm('Unknown file type: ' + file.name + '. Is this a code/text file?')
if (isCode) {
try {
await uploadFileToServer(file)
} catch (err) {
clearAttachments()
alert('Error processing file: ' + err.message)
}
} else {
clearAttachments()
}
}, [uploadFileToServer])
async function handleSend() {
if (sendingRef.current) return
const msg = text.trim()
if (!msg && !attachedImage && !attachedFileUrl) return
sendingRef.current = true
let finalText = msg
if (attachedFileUrl) {
finalText = '[FILE: ' + attachedFileUrl + '](' + attachedFile + ')\n\n' + (msg || 'See attached file above.')
}
try {
await onSend(finalText, attachedImage, research, cpu)
} finally {
sendingRef.current = false
}
setText('')
clearAttachments()
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
const isMobile = window.matchMedia('(pointer: coarse)').matches;
function handleKeyDown(e) {
if (isMobile) return;
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
e.preventDefault()
handleSend()
}
}
function handleInput() {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
}
}
function removeAttachedFile() {
setAttachedFile(null)
setAttachedFileUrl(null)
}
function handleResearchChange(e) {
const checked = e.target.checked
setResearch(checked)
if (!checked) setCpu(false)
}
return (
<div id="input-bar">
<button id="attach-btn" onClick={() => fileInputRef.current?.click()}>+</button>
<input type="file" id="file-input" ref={fileInputRef} onChange={handleFile} />
<img id="image-preview" ref={imagePreviewRef} />
{attachedFile && (
<span id="file-badge" style={{ display: 'inline-flex' }}>
<span>{attachedFile.length > 25 ? attachedFile.slice(0, 22) + '...' : attachedFile}</span>
<span style={{ cursor: 'pointer', color: '#f87171', fontWeight: 'bold', marginLeft: 4 }} onClick={removeAttachedFile}>×</span>
</span>
)}
<textarea
id="msg-input"
ref={textareaRef}
placeholder="Type a message..."
rows={1}
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
/>
<div id="research-toggles">
<label id="research-toggle" title="Research mode — lets the agent search and read pages for up to 50 tool rounds until your question is fully answered.">
<input type="checkbox" checked={research} onChange={handleResearchChange} />
Research
</label>
<label id="cpu-toggle" className={research ? '' : 'disabled'} title={research ? "Run the research on the CPU-backed server instead of the GPU." : "Only available with Research mode."}>
<input type="checkbox" checked={cpu} disabled={!research} onChange={e => setCpu(e.target.checked)} />
CPU
</label>
</div>
<button id="send-btn" onClick={handleSend}><span className="send-icon">➤</span><span className="send-text">{hasPending ? 'Queue' : 'Send'}</span></button>
</div>
)
}
</file>
<file path="src/components/Message.jsx">
import { useMemo, useRef, useState, useCallback, useEffect, memo } from 'react'
import { marked } from 'marked'
import markedKatex from 'marked-katex-extension'
import DOMPurify from 'dompurify'
import { speak as apiSpeak, getTaskStatus as apiGetTaskStatus, shareMessage as apiShareMessage } from '../api'
import { downloadFile, toApiImage } from '../utils'
import StatusBox from './StatusBox'
marked.use(markedKatex({ throwOnError: false, nonStandard: true }))
// Research-mode citations are stored as `(Author, Venue, Year) [https://url]`
// (the critic parses the square-bracketed URL), but marked would swallow the
// trailing `]` into the link href. Turn `[https://url]` into a proper markdown
// link at render time so no stray bracket shows. Fenced code blocks are
// skipped to avoid corrupting their contents.
const normalizeCitationLinks = (text) => {
if (!text) return text
const RE = /\[(https?:\/\/[^\s\]<>]+)\](?!\s*\()/g
return text
.split(/(```[\s\S]*?```)/g)
.map((seg, i) => (i % 2 === 1 ? seg : seg.replace(RE, '[$1]($1)')))
.join('')
}
const fileLinkExt = {
name: 'fileLink',
level: 'inline',
start(src) {
const i = src.indexOf('[FILE:')
return i === -1 ? undefined : i
},
tokenizer(src) {
const match = /^\[FILE:\s*(\S+)\]\(([^)]+)\)/.exec(src)
if (!match) return undefined
return { type: 'fileLink', raw: match[0], url: match[1], name: match[2] }
},
renderer(token) {
const url = token.url.replace(/"/g, '"')
const name = token.name.replace(/"/g, '"')
return (
'<a class="file-chip" href="' + url + '" download="' + name + '" title="' + name + '">' +
'<svg class="file-chip-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">' +
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>' +
'<polyline points="14 2 14 8 20 8"></polyline>' +
'</svg>' +
'<span class="file-chip-name">' + name + '</span>' +
'</a>'
)
},
}
marked.use({ extensions: [fileLinkExt] })
function escHtml(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
}
function hostnameFromUrl(u) {
try {
return new URL(u).hostname
} catch {
return u || ''
}
}
function formatElapsed(ms) {
if (ms == null) return ''
const s = ms / 1000
if (s < 60) return s.toFixed(1) + 's'
const m = Math.floor(s / 60)
const rs = Math.round(s % 60)
return m + 'm ' + rs + 's'
}
function execCopy(text) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
let ok = false
try {
ok = document.execCommand('copy')
} catch { }
document.body.removeChild(ta)
return ok
}
async function writeClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text)
return true
}
return execCopy(text)
}
function ShareButton({ sessionId, msgIndex, forceShow }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [share, setShare] = useState(null)
async function handleShare(e) {
e.preventDefault()
e.stopPropagation()
if (busy || !sessionId || msgIndex == null) return
setBusy(true)
setError('')
try {
const data = await apiShareMessage(sessionId, msgIndex)
if (data && data.token) {
setShare({ url: data.url })
} else {
setError((data && data.error) || 'Sharing failed')
}
} catch (err) {
setError(err.message || 'Sharing failed')
} finally {
setBusy(false)
}
}
async function copyLink() {
if (!share) return
const url = share.url.startsWith('http') ? share.url : window.location.origin + share.url
await writeClipboard(url)
}
return (
<>
<button
className={'share-btn' + (forceShow ? ' force-show' : '')}
onClick={handleShare}
title="Share this message"
aria-label="Share this message"
disabled={busy}
>
{busy ? 'Sharing…' : (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
)}
</button>
{share && (
<div className="share-modal-overlay" onClick={() => setShare(null)}>
<div className="share-modal" onClick={e => e.stopPropagation()}>
<div className="share-modal-header">
<span>Message shared</span>
<button type="button" className="share-modal-close" onClick={() => setShare(null)}>✕</button>
</div>
<p className="share-modal-hint">Anyone on this network with the link can view this message without logging in.</p>
<input
readOnly
className="share-modal-url"
value={share.url.startsWith('http') ? share.url : window.location.origin + share.url}
onFocus={e => e.target.select()}
onKeyDown={e => e.key === 'Enter' && e.target.select()}
/>
<div className="share-modal-actions">
<button type="button" className="share-copy-btn" onClick={copyLink}>Copy link</button>
<a className="share-open-link" href={share.url} target="_blank" rel="noreferrer">Open</a>
</div>
</div>
</div>
)}
{error && <span className="share-error" role="alert">{error}</span>}
</>
)
}
function SearchPopup({ details }) {
return (
<div className="search-popup">
<div style={{ marginBottom: 6 }}>
<span style={{ color: '#888', fontSize: 11 }}>Query:</span>{' '}
<span style={{ color: '#e0e0e0' }}>
{details.map(sd => sd.query).join('; ')}
</span>
</div>
{details.map((sd, i) => (
<div key={i}>
{sd.search_url && (
<div style={{ marginBottom: 6, fontSize: 11, wordBreak: 'break-all' }}>
<span style={{ color: '#888' }}>SearXNG:</span>{' '}
<a href={sd.search_url} target="_blank" rel="noreferrer" style={{ color: '#60a5fa' }}>
{sd.search_url}
</a>
</div>
)}
{sd.results && sd.results.map((r, j) => (
<a
key={j}
href={r.url}
target="_blank"
rel="noreferrer"
style={{ display: 'block', color: '#60a5fa', fontSize: 12, padding: '2px 0', textDecoration: 'none' }}
title={r.url}
>
{r.title || r.url}
</a>
))}
</div>
))}
</div>
)
}
function CopyButton({ text, genPrompt, imageUrl, forceShow }) {
const [label, setLabel] = useState('Copy')
async function handleCopy() {
setLabel('Copying...')
try {
await copyWithImage()
} catch {
copyTextOnly()
}
}
async function copyWithImage() {
let textContent = text || ''
if (genPrompt) textContent = 'Prompt: ' + genPrompt + '\n\n' + textContent
if (imageUrl && navigator.clipboard && navigator.clipboard.write) {
const url = imageUrl.startsWith('http') ? imageUrl : window.location.origin + imageUrl
try {
const res = await fetch(url)
const blob = await res.blob()
const b64 = await blobToBase64(blob)
const escaped = textContent.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
const cleanHtml = '<html><body>' + escaped.replace(/\n/g, '<br>') + '<br><br><img src="' + b64 + '"></body></html>'
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([cleanHtml], { type: 'text/html' }),
'text/plain': new Blob([escaped + '\n\n' + url], { type: 'text/plain' }),
}),
new ClipboardItem({ [blob.type]: blob }),
])
setLabel('Copied!')
setTimeout(() => setLabel('Copy'), 2000)
return
} catch { }
}
let copyText = textContent
if (imageUrl) {
const url = imageUrl.startsWith('http') ? imageUrl : window.location.origin + imageUrl
copyText = (copyText ? copyText + '\n\n' : '') + url
}
if (copyText && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(copyText)
setLabel('Copied!')
setTimeout(() => setLabel('Copy'), 2000)
return
}
throw new Error('no clipboard method available')
}
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
}
function copyTextOnly() {
let copyText = text || ''
if (imageUrl) {
const url = imageUrl.startsWith('http') ? imageUrl : window.location.origin + imageUrl
copyText = (copyText ? copyText + '\n\n' : '') + url
}
if (genPrompt) copyText = 'Prompt: ' + genPrompt + '\n\n' + copyText
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(copyText).then(() => {
setLabel('Copied!')
setTimeout(() => setLabel('Copy'), 2000)
}).catch(() => fallbackExecCopy(copyText))
} else {
fallbackExecCopy(copyText)
}
} catch {
fallbackExecCopy(copyText)
}
}
function fallbackExecCopy(text) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
setLabel('Copied!')
setTimeout(() => setLabel('Copy'), 2000)
} catch {
setLabel('Failed')
setTimeout(() => setLabel('Copy'), 2000)
}
document.body.removeChild(ta)
}
return <button className={'copy-btn' + (forceShow ? ' force-show' : '')} onClick={handleCopy}>{label}</button>
}
let _activeAudio = null
function SpeakButton({ text }) {
const [speaking, setSpeaking] = useState(false)
const idRef = useRef(null)
async function handleClick() {
const myId = (idRef.current = {})
if (_activeAudio && _activeAudio._speakId === myId) {
_activeAudio.pause()
_activeAudio.currentTime = 0
_activeAudio = null
setSpeaking(false)
return
}
if (_activeAudio) {
_activeAudio.pause()
_activeAudio.currentTime = 0
_activeAudio = null
setSpeaking(false)
}
setSpeaking(true)
try {
const data = await apiSpeak(text)
if (idRef.current !== myId) return
const mime = data.type || 'audio/mpeg'
const audio = new Audio('data:' + mime + ';base64,' + data.audio)
audio._speakId = myId
audio.onended = () => {
if (_activeAudio === audio) {
_activeAudio = null
setSpeaking(false)
}
}
audio.onerror = () => { setSpeaking(false); _activeAudio = null }
_activeAudio = audio
audio.play()
} catch (e) {
console.warn('TTS error:', e)
setSpeaking(false)
}
}
return (
<button
className={'speak-btn' + (speaking ? ' speaking' : '')}
onClick={handleClick}
title={speaking ? 'Stop' : 'Read aloud'}
aria-label={speaking ? 'Stop' : 'Read aloud'}
>
{speaking ? (
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" aria-hidden="true">
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
) : (
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
)
}
function ReasoningBlock({ text, open, onToggle }) {
const preRef = useRef(null)
const prevLenRef = useRef(0)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!preRef.current || !text) return
if (text.length > prevLenRef.current) {
const el = preRef.current
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40
if (atBottom) el.scrollTop = el.scrollHeight
}
prevLenRef.current = text.length
}, [text])
if (!text) return null
const capped = text
let html
try {
html = DOMPurify.sanitize(marked.parse(capped))
} catch {
html = escHtml(capped)
}
function handleCopy(e) {
e.preventDefault()
e.stopPropagation()
writeClipboard(text).then((ok) => {
setCopied(ok)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<details className="reasoning-block" open={open} onToggle={(e) => onToggle(e.target.open)}>
<summary>
<span className="reasoning-summary-label">Reasoning</span>
{open && (
<button type="button" className="reasoning-copy-btn" onClick={handleCopy} title="Copy reasoning">
{copied ? 'Copied!' : 'Copy'}
</button>
)}
</summary>
<div className="reasoning-text" ref={preRef} dangerouslySetInnerHTML={{ __html: html }} />
</details>
)
}
function PendingMessage({ pending, onImageOpen, onResolved, onLocationNeeded, selectingRef }) {
const [message, setMessage] = useState(pending.message || 'Thinking...')
const [reasoning, setReasoning] = useState(pending.reasoning || '')
const [reasoningOpen, setReasoningOpen] = useState(true)
const resolvedRef = useRef(false)
const locationNotifiedRef = useRef(false)
useEffect(() => {
const iv = setInterval(async () => {
if (resolvedRef.current) return
let st
try {
st = await apiGetTaskStatus(pending.taskId)
} catch {
return
}
if (!st || st.status === 'done' || st.status === 'error' || st.status === 'cancelled' || st.status === 'unknown' || st.status === 'not_found') {
if (resolvedRef.current) return
resolvedRef.current = true
clearInterval(iv)
onResolved(pending, st)
return
}
if (st.message === 'location_needed') {
if (!locationNotifiedRef.current) {
locationNotifiedRef.current = true
onLocationNeeded(pending.taskId)
}
return
}
locationNotifiedRef.current = false
if (selectingRef && selectingRef.current) return
setMessage(st.message || 'Working...')
if (st.reasoning) setReasoning(prev => (prev === st.reasoning ? prev : st.reasoning))
}, 1000)
return () => clearInterval(iv)
}, [pending, onResolved, onLocationNeeded, selectingRef])
return (
<>
{pending._userMsg && (
<Message
msg={pending._userMsg}
onImageOpen={onImageOpen}
selectingRef={selectingRef}
onResolved={onResolved}
onLocationNeeded={onLocationNeeded}
/>
)}
<div className={`msg bot`}>
<div className="msg-content">
<StatusBox message={message} />
<ReasoningBlock text={reasoning} open={reasoningOpen} onToggle={setReasoningOpen} />
</div>
</div>
</>
)
}
function Message({ msg, pending, sessionId, msgIndex, hideSpeak, onImageOpen, selectingRef, onResolved, onLocationNeeded }) {
const elRef = useRef(null)
const chatEl = useRef(null)
const [popupVisible, setPopupVisible] = useState(null)
const hideTimer = useRef(null)
const [reasoningOpen, setReasoningOpen] = useState(!!pending)
const [pageModal, setPageModal] = useState(null)
const codeRef = useRef([])
const showPopup = useCallback((idx) => {
if (hideTimer.current) clearTimeout(hideTimer.current)
setPopupVisible(idx)
}, [])
const hidePopup = useCallback(() => {
hideTimer.current = setTimeout(() => setPopupVisible(null), 800)
}, [])
const role = pending ? 'bot' : msg.role === 'user' ? 'user' : 'bot';
let text = ''
let userImg = null
let timestamp = null
if (msg) {
if (role === 'user') {
if (typeof msg.content === 'string') {
text = msg.content
} else if (Array.isArray(msg.content)) {
msg.content.forEach(part => {
if (part.type === 'text') text += part.text
else if (part.type === 'image_url') {
const url = part.image_url.url
if (url.startsWith('data:')) userImg = url.split(',')[1]
else if (url.startsWith('/uploads/') || url.startsWith('/output/') || /^https?:/.test(url)) userImg = url
}
})
}
if (msg._timestamp) {
try {
const d = new Date(msg._timestamp)
timestamp = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })
} catch { }
}
} else {
text = typeof msg.content === 'string' ? msg.content : ''
}
}
// 2. ALWAYS call useMemo before any conditional return statements
const html = useMemo(() => {
if (!text) return ''
try {
const codeBlocks = []
const renderer = new marked.Renderer()
renderer.code = ({ text: codeText, lang }) => {
const idx = codeBlocks.length
codeBlocks.push(codeText.replace(/\n$/, ''))
const code = (codeText.replace(/\n$/, '') + '\n').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
const langAttr = lang ? ` class="language-${lang.split(/\s/)[0]}"` : ''
return `<div class="code-block"><button type="button" class="copy-code-btn" data-i="${idx}" title="Copy code">Copy</button><pre><code${langAttr}>${code}</code></pre></div>\n`
}
const out = DOMPurify.sanitize(marked.parse(normalizeCitationLinks(text), { renderer }))
codeRef.current = codeBlocks
return out
} catch {
return escHtml(text)
}
}, [text])
if (pending) {
return (
<PendingMessage
pending={pending}
onImageOpen={onImageOpen}
selectingRef={selectingRef}
onResolved={onResolved}
onLocationNeeded={onLocationNeeded}
/>
)
}
if (msg.role === 'system' || msg.role === 'tool') return null
const toolsUsed = msg._tools_used || []
const searchDetails = msg._search_details || []
const fetchDetails = searchDetails.filter(d => d && d.tool === 'fetch_page')
const genPrompt = msg._gen_prompt
const imageUrl = toApiImage(msg._image_url)
const imageModel = msg._image_model
const isUserImgUrl = typeof userImg === 'string' && (userImg.startsWith('/') || /^https?:/.test(userImg))
const userImgSrc = userImg ? (isUserImgUrl ? toApiImage(userImg) : 'data:image/jpeg;base64,' + userImg) : null
if (
msg.role === 'assistant' &&
!text &&
!imageUrl &&
!userImg &&
!genPrompt &&
msg.tool_calls &&
msg.tool_calls.length > 0
) {
return null
}
if (role === 'user') {
if (typeof msg.content === 'string') {
text = msg.content
// @ts-ignore
} else if (Array.isArray(msg.content)) {
msg.content.forEach(part => {
if (part.type === 'text') text += part.text
else if (part.type === 'image_url') {
const url = part.image_url.url
if (url.startsWith('data:')) userImg = url.split(',')[1]
else if (url.startsWith('/uploads/') || url.startsWith('/output/') || /^https?:/.test(url)) userImg = url
}
})
}
if (msg._timestamp) {
try {
const d = new Date(msg._timestamp)
timestamp = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' })
} catch { }
}
} else {
text = typeof msg.content === 'string' ? msg.content : ''
}
const ttsText = text
if (role === 'bot') text = text.replace(/^\s*\[(bn|hi|en)\]\s*/, '')
async function handleContentClick(e) {
const btn = e.target.closest('.copy-code-btn')
if (!btn) return
const idx = parseInt(btn.dataset.i, 10)
const codeText = codeRef.current[idx]
if (codeText == null) return
const orig = btn.textContent
const ok = await writeClipboard(codeText)
btn.textContent = ok ? 'Copied!' : 'Failed'
setTimeout(() => { btn.textContent = orig }, 2000)
}
if (role === 'user' && !text && !imageUrl && !userImg && !genPrompt) return null
return (
<div className={`msg ${role}`} ref={elRef}>
{timestamp && <span className="msg-timestamp">{timestamp}</span>}
<div className="msg-header">
{role === 'user' && msg._research && (
<span className="tool-badge research" title="This message was sent with the Research toggle on">Research</span>
)}
{role === 'bot' && msg._elapsed_ms != null && (
<span className="msg-elapsed" title="Time from task start to completion">⏲ {formatElapsed(msg._elapsed_ms)}</span>
)}
{role === 'bot' && toolsUsed.length > 0 && (() => {
let fetchIdx = 0
return toolsUsed.map((t, i) => {
const isFetch = t === 'fetch_page'
const fetchDetail = isFetch ? fetchDetails[fetchIdx++] : null
return (
<span
key={i}
className={`tool-badge ${t === 'web_search' ? 'search' : t === 'generate_image' ? 'image' : t === 'edit_image' ? 'edit' : t === 'fetch_page' ? 'fetch' : ''}`}
onMouseEnter={() => t === 'web_search' && searchDetails.length > 0 && showPopup(i)}
onMouseLeave={hidePopup}
>
{t === 'web_search' ? 'Web Search' : t === 'generate_image' ? `Image Gen${imageModel ? ' (' + imageModel + ')' : ''}` : t === 'edit_image' ? 'Edit Image' : t === 'fetch_page' ? (fetchDetail?.url ? 'Fetched Page · ' + hostnameFromUrl(fetchDetail.url) : 'Fetched Page') : t}
{isFetch && fetchDetail && (
<button
type="button"
className="fetch-info-btn"
title="View page details"
onClick={e => { e.stopPropagation(); setPageModal(fetchDetail) }}
>
ⓘ
</button>
)}
{t === 'web_search' && searchDetails.length > 0 && popupVisible === i && (
<div
className="search-popup"
onMouseEnter={() => showPopup(i)}
onMouseLeave={hidePopup}
>
<SearchPopup details={searchDetails} />
</div>
)}
</span>
)
})
})()}
{role === 'bot' && text && !hideSpeak && <SpeakButton text={ttsText} />}
<CopyButton text={text} genPrompt={genPrompt} imageUrl={imageUrl} />
{role === 'bot' && sessionId && msgIndex != null && (
<ShareButton sessionId={sessionId} msgIndex={msgIndex} />
)}
</div>
{imageUrl && (
<div className="image-wrap">
<img
src={imageUrl}
style={{ maxWidth: '100%', borderRadius: 10, cursor: 'pointer' }}
onClick={() => onImageOpen(imageUrl)}
alt="Generated"
/>
<div className="img-actions">
<button type="button" className="img-download-btn" onClick={() => downloadFile(imageUrl, 'image.png')}>
Download
</button>
</div>
</div>
)}
{userImgSrc && (
<img
src={userImgSrc}
style={{ maxWidth: '100%', borderRadius: 10, cursor: 'pointer' }}
onClick={() => onImageOpen(userImgSrc)}
alt="Uploaded"
/>
)}
{genPrompt && (
<div style={{ whiteSpace: 'pre-wrap', fontSize: 12, color: '#888', marginBottom: 6, fontStyle: 'italic' }}>
Prompt: {genPrompt}
</div>
)}
<ReasoningBlock text={msg._reasoning} open={reasoningOpen} onToggle={setReasoningOpen} />
{text ? (
<div
className="msg-content"
onClick={handleContentClick}
dangerouslySetInnerHTML={{ __html: html }}
/>
) : !imageUrl && !userImg ? (
<div className="msg-content empty-response">
<em>(No response text generated)</em>
</div>
) : null}
{pageModal && (
<div className="page-modal-overlay" onClick={() => setPageModal(null)}>
<div className="page-modal" onClick={e => e.stopPropagation()}>
<div className="page-modal-header">
<span>Fetched Page</span>
<button type="button" className="page-modal-close" onClick={() => setPageModal(null)}>✕</button>
</div>
{pageModal.error ? (
<div className="page-modal-body error">
<div className="page-modal-url">{pageModal.url}</div>
<p className="page-modal-error-msg">{pageModal.error}</p>
</div>
) : (
<div className="page-modal-body">
{pageModal.title && <div className="page-modal-title">{pageModal.title}</div>}
<a className="page-modal-url" href={pageModal.url} target="_blank" rel="noreferrer">{pageModal.url}</a>
<div className="page-modal-content">
{(pageModal.content || '(No readable text content extracted)').slice(0, 6000)}
{pageModal.content && pageModal.content.length > 6000 ? '\n...[truncated for display]' : ''}
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
export default memo(Message)
</file>
<file path="src/App.css">
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #0f0f1a;
color: #e0e0e0;
font-family: 'Inter', 'SF Pro', '-apple-system', sans-serif;
}
#root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
::-webkit-scrollbar { width: 6px }
::-webkit-scrollbar-track { background: transparent }
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px }
::-webkit-scrollbar-thumb:hover { background: #555 }
/* ---- Fixed Header ---- */
#model-bar {
display: flex !important;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #1a1a2e;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
font-size: 12px;
flex-shrink: 0;
height: 48px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
}
#sidebar-toggle {
background: none;
border: none;
color: #888;
font-size: 22px;
cursor: pointer;
padding: 0 6px 0 0;
line-height: 1;
transition: color .2s;
}
#sidebar-toggle:hover {
color: #fff;
}
#model-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
#model-dot.chat_loaded {
background: #4ade80;
box-shadow: 0 0 8px rgba(74, 222, 128, 0.4);
}
#model-dot.image_active {
background: #f472b6;
box-shadow: 0 0 8px rgba(244, 114, 182, 0.4);
}
#model-dot.loading {
background: #fbbf24;
animation: pulse .8s ease-in-out infinite;
}
#model-dot.unloaded {
background: #6b7280;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
#model-label {
font-size: 12px;
color: #e0e0e0;
}
/* ---- User Menu ---- */
#user-menu {
position: relative;
margin-left: auto;
cursor: pointer;
}
#user-name {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
color: #94a3b8;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all .2s;
white-space: nowrap;
}
#user-name:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.2);
}
#user-dropdown {
display: none;
position: absolute;
top: calc(100% + 4px);
right: 0;
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 4px;
z-index: 999;
min-width: 100px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
#user-dropdown.open {
display: block;
}
#user-dropdown button {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: none;
color: #f87171;
font-size: 12px;
cursor: pointer;
border-radius: 6px;
text-align: left;
font-family: inherit;
}
#user-dropdown button:hover {
background: rgba(248, 113, 113, 0.1);
}
#user-dropdown button.task-menu-item {
position: relative;
color: #94a3b8;
}
#user-dropdown button.task-menu-item:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
/* ---- App Container ---- */
#app-container {
display: flex;
flex-direction: column;
position: absolute;
top: 48px;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
/* ---- Sidebar ---- */
#sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 99;
display: none;
backdrop-filter: blur(4px);
}
#sidebar-overlay.open {
display: block;
}
#sidebar {
position: fixed;
top: 48px;
left: 0;
width: 280px;
height: calc(100% - 48px);
padding-bottom: 16px;
background: rgba(22, 22, 42, 0.95);
backdrop-filter: blur(16px);
border-right: 1px solid rgba(255, 255, 255, 0.06);
z-index: 100;
transform: translateX(-100%);
transition: transform .35s cubic-bezier(.4, 0, .2, 1);
display: flex;
flex-direction: column;
overflow: hidden;
}
#sidebar.open {
transform: translateX(0);
}
#sidebar-header {
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
#new-chat-btn {
width: 100%;
padding: 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: #e0e0e0;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all .2s;
}
#new-chat-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
#session-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px;
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 2px;
transition: all .2s;
}
.session-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.session-item.active {
background: rgba(74, 108, 247, 0.15);
border-left: 2px solid #4a6cf7;
}
.session-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 450;
}
.session-actions {
display: flex;
gap: 2px;
opacity: 1;
}
.session-actions button {
background: rgba(255, 255, 255, 0.08);
border: none;
cursor: pointer;
padding: 4px;
font-size: 14px;
border-radius: 6px;
color: #e0e0e0;
}
/* ---- Overload Warning ---- */
#overload-warn {
animation: pulseWarn 1.5s ease-in-out infinite alternate;
background: #991b1b;
color: #fff;
text-align: center;
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
}
@keyframes pulseWarn {
from { opacity: .8; }
to { opacity: 1; }
}
/* ---- Chat Area ---- */
#chat {
flex: 1;
overflow-y: auto;
padding: 20px 20px;
display: flex;
flex-direction: column;
gap: 16px;
scroll-behavior: smooth;
min-height: 0;
}
.msg {
max-width: 78%;
padding: 14px 18px;
border-radius: 14px;
line-height: 1.6;
position: relative;
animation: fadeIn .25s ease;
}
.msg-timestamp {
position: absolute;
top: 4px;
right: 10px;
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
pointer-events: none;
font-weight: 500;
letter-spacing: 0.3px;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.msg:hover .copy-btn {
opacity: 1;
}
.user {
background: linear-gradient(135deg, #2d3a5e, #3a4a7a);
align-self: flex-end;
border-bottom-right-radius: 4px;
box-shadow: 0 2px 12px rgba(45, 58, 94, 0.3);
}
.bot {
background: rgba(42, 42, 62, 0.8);
backdrop-filter: blur(8px);
align-self: flex-start;
border-bottom-left-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.04);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
.bot img {
max-width: 100%;
border-radius: 10px;
margin-top: 10px;
cursor: pointer;
transition: transform .2s;
}
.bot img:hover {
transform: scale(1.02);
}
.msg-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.msg-elapsed {
font-size: 10px;
color: rgba(255, 255, 255, 0.35);
font-weight: 500;
letter-spacing: 0.3px;
margin-right: 4px;
white-space: nowrap;
}
.copy-btn {
opacity: 0;
transition: opacity .2s;
background: none;
border: none;
cursor: pointer;
color: #666;
font-size: 12px;
padding: 2px 8px;
border-radius: 6px;
margin-left: auto;
font-family: inherit;
}
.copy-btn:hover,
.copy-btn.force-show {
color: #e0e0e0;
background: rgba(255, 255, 255, 0.06);
}
.speak-btn {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(96, 165, 250, 0.12);
border: 1px solid rgba(96, 165, 250, 0.3);
cursor: pointer;
color: #60a5fa;
padding: 0;
flex-shrink: 0;
transition: background .2s, color .2s, border-color .2s;
font-family: inherit;
}
.speak-btn:hover {
background: rgba(96, 165, 250, 0.22);
color: #93c5fd;
}
.speak-btn.speaking {
background: rgba(244, 114, 182, 0.15);
border-color: rgba(244, 114, 182, 0.35);
color: #f472b6;
}
.share-btn {
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(52, 211, 153, 0.12);
border: 1px solid rgba(52, 211, 153, 0.3);
cursor: pointer;
color: #34d399;
padding: 0;
flex-shrink: 0;
transition: background .2s, color .2s, border-color .2s;
font-family: inherit;
}
.share-btn:hover {
background: rgba(52, 211, 153, 0.22);
color: #6ee7b7;
}
.share-btn:disabled {
opacity: 0.6;
cursor: default;
}
.share-error {
color: #f87171;
font-size: 11px;
margin-left: 6px;
}
/* ---- Share Modal ---- */
.share-modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
backdrop-filter: blur(2px);
}
.share-modal {
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
width: 100%;
max-width: 460px;
padding: 16px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
.share-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 8px;
}
.share-modal-close {
background: none;
border: none;
color: #94a3b8;
font-size: 14px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
line-height: 1;
font-family: inherit;
}
.share-modal-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
.share-modal-hint {
color: #94a3b8;
font-size: 12px;
margin: 0 0 12px;
line-height: 1.5;
}
.share-modal-url {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: #e0e0e0;
font-size: 13px;
font-family: monospace;
outline: none;
margin-bottom: 12px;
}
.share-modal-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.share-modal-actions button,
.share-open-link {
padding: 8px 14px;
border-radius: 8px;
border: none;
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
font-family: inherit;
}
.share-copy-btn {
background: linear-gradient(135deg, #34d399, #0ea5e9);
color: #06281f;
}
.share-copy-btn:hover {
filter: brightness(1.1);
}
.share-open-link {
background: rgba(255, 255, 255, 0.08);
color: #e2e8f0;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.share-open-link:hover {
background: rgba(255, 255, 255, 0.14);
}
/* ---- Code Block Copy ---- */
.code-block {
position: relative;
}
.code-block .copy-code-btn {
position: absolute;
top: 6px;
right: 8px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #999;
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
cursor: pointer;
opacity: 0;
transition: opacity .2s;
font-family: inherit;
z-index: 1;
}
.code-block:hover .copy-code-btn {
opacity: 1;
}
.code-block .copy-code-btn:hover {
color: #e0e0e0;
background: rgba(255, 255, 255, 0.12);
}
/* ---- File chip (uploaded files shown in messages) ---- */
.file-chip {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 2px 0;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: #e0e0e0;
text-decoration: none;
font-size: 13px;
max-width: 100%;
vertical-align: middle;
}
.file-chip:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.file-chip-icon {
flex-shrink: 0;
color: #60a5fa;
}
.file-chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Image Download ---- */
.image-wrap > img {
display: block;
max-width: 100%;
}
.img-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.img-actions .img-download-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #e0e0e0;
font-size: 12px;
padding: 4px 14px;
border-radius: 6px;
cursor: pointer;
transition: background .2s;
font-family: inherit;
}
.img-actions .img-download-btn:hover {
background: rgba(255, 255, 255, 0.16);
color: #fff;
}
/* ---- Tool Badges ---- */
.tool-badge {
font-size: 11px;
padding: 3px 10px;
border-radius: 20px;
font-weight: 500;
letter-spacing: .3px;
position: relative;
cursor: default;
}
.tool-badge.search {
background: rgba(30, 58, 95, 0.6);
color: #60a5fa;
cursor: pointer;
}
.tool-badge.image {
background: rgba(59, 31, 59, 0.6);
color: #f472b6;
}
.tool-badge.edit {
background: rgba(20, 83, 45, 0.6);
color: #34d399;
}
.tool-badge.fetch {
background: rgba(124, 58, 237, 0.25);
color: #a78bfa;
}
.tool-badge.research {
background: rgba(16, 71, 62, 0.6);
color: #5eead4;
}
.fetch-info-btn {
background: none;
border: 1px solid rgba(167, 139, 250, 0.4);
color: #a78bfa;
border-radius: 50%;
width: 16px;
height: 16px;
line-height: 1;
font-size: 10px;
padding: 0;
margin-left: 6px;
cursor: pointer;
vertical-align: middle;
transition: all .15s;
}
.fetch-info-btn:hover {
background: rgba(167, 139, 250, 0.2);
border-color: #a78bfa;
}
/* ---- Page Details Modal ---- */
.page-modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
backdrop-filter: blur(2px);
}
.page-modal {
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
width: 100%;
max-width: 640px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.page-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
font-size: 13px;
font-weight: 600;
color: #e2e8f0;
}
.page-modal-close {
background: none;
border: none;
color: #94a3b8;
font-size: 14px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
line-height: 1;
}
.page-modal-close:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
.page-modal-body {
padding: 16px;
overflow-y: auto;
font-size: 13px;
color: #cbd5e1;
}
.page-modal-title {
font-size: 15px;
font-weight: 600;
color: #f1f5f9;
margin-bottom: 8px;
}
.page-modal-url {
display: block;
color: #60a5fa;
word-break: break-all;
font-size: 12px;
margin-bottom: 12px;
text-decoration: none;
}
a.page-modal-url:hover {
text-decoration: underline;
}
.page-modal-content {
white-space: pre-wrap;
line-height: 1.6;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 12px;
font-size: 12px;
color: #a5b4c8;
}
.page-modal-body.error .page-modal-error-msg {
color: #f87171;
background: rgba(248, 113, 113, 0.08);
border: 1px solid rgba(248, 113, 113, 0.25);
border-radius: 8px;
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
}
/* ---- Search Popup ---- */
.search-popup {
display: block;
position: absolute;
top: 100%;
left: 0;
z-index: 50;
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 12px;
padding-top: 18px;
min-width: 300px;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(12px);
}
.search-popup a {
padding: 6px 0;
}
.search-popup a:hover {
text-decoration: underline;
}
/* ---- Status Box ---- */
@keyframes spin {
to { transform: rotate(360deg); }
}
.status-box {
border: 1px solid #2a2a3e;
border-radius: 8px;
overflow: hidden;
transition: border-color .2s;
}
.status-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 13px;
color: #94a3b8;
font-weight: 500;
}
.status-header .left-group {
display: flex;
align-items: center;
gap: 8px;
}
.status-box .spinner {
width: 12px;
height: 12px;
border: 2px solid #334155;
border-top-color: currentColor;
border-radius: 50%;
animation: spin .8s linear infinite;
flex-shrink: 0;
}
.status-box .status-text {
color: #e2e8f0;
}
.status-box[data-state="thinking"] { border-color: #334155; }
.status-box[data-state="thinking"] .spinner { color: #60a5fa; }
.status-box[data-state="thinking"] .status-icon { color: #60a5fa; }
.status-box[data-state="search"] { border-color: #1e3a5f; }
.status-box[data-state="search"] .spinner { color: #60a5fa; }
.status-box[data-state="generate-image"] { border-color: #4a1a4a; }
.status-box[data-state="generate-image"] .spinner { color: #c084fc; }
.status-box[data-state="edit-image"] { border-color: #1a4a2a; }
.status-box[data-state="edit-image"] .spinner { color: #34d399; }
.reasoning-block {
margin-top: 8px;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.reasoning-block summary {
cursor: pointer;
padding: 6px 10px;
font-size: 12px;
color: #ada9a9;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
list-style: none;
transition: background .2s;
}
.reasoning-block summary::-webkit-details-marker {
display: none;
}
.reasoning-block summary:hover {
background: rgba(75, 70, 4, 0.158);
}
.reasoning-summary-label {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
}
.reasoning-summary-label::before {
content: '';
width: 0;
height: 0;
border-left: 5px solid #888;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform .2s;
}
.reasoning-block[open] .reasoning-summary-label::before {
transform: rotate(90deg);
}
.reasoning-copy-btn {
background: none;
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
color: #666;
font-size: 11px;
padding: 1px 8px;
border-radius: 6px;
font-family: inherit;
}
.reasoning-copy-btn:hover {
color: #e0e0e0;
background: rgba(255, 255, 255, 0.06);
}
.reasoning-text {
margin: 0;
padding: 8px 12px;
font-size: 12px;
color: #bbb;
line-height: 1.5;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.reasoning-text p { margin: 4px 0; }
.reasoning-text code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
background: rgba(255, 255, 255, 0.06);
padding: 1px 4px;
border-radius: 3px;
}
.reasoning-text pre {
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
padding: 8px;
overflow-x: auto;
margin: 6px 0;
}
.reasoning-text pre code { background: none; padding: 0; }
.reasoning-text ul, .reasoning-text ol { padding-left: 18px; margin: 4px 0; }
.reasoning-text blockquote { border-left: 2px solid #4a6cf7; padding-left: 10px; margin: 4px 0; color: #999; }
/* ---- Message Content ---- */
.msg-content p { margin: 4px 0; }
.msg-content pre {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 12px;
overflow-x: auto;
margin: 8px 0;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.msg-content code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
}
.msg-content p code {
background: rgba(255, 255, 255, 0.06);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
.msg-content pre code {
background: none;
padding: 0;
}
.msg-content .mermaid {
background: rgba(255,255,255,0.04);
border-radius: 8px;
padding: 16px;
margin: 8px 0;
overflow-x: auto;
text-align: center;
}
.msg-content .mermaid svg {
max-width: 100%;
height: auto;
}
.reasoning-text .mermaid {
background: rgba(255,255,255,0.04);
border-radius: 8px;
padding: 16px;
margin: 8px 0;
overflow-x: auto;
}
.msg-content ul,
.msg-content ol {
padding-left: 20px;
margin: 6px 0;
}
.msg-content blockquote {
border-left: 3px solid #4a6cf7;
padding-left: 12px;
margin: 8px 0;
color: #999;
}
.msg-content table {
border-collapse: collapse;
margin: 8px 0;
width: 100%;
font-size: 13px;
}
.msg-content th,
.msg-content td {
padding: 6px 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
}
.msg-content th {
background: rgba(255, 255, 255, 0.04);
font-weight: 600;
}
.msg-content a {
color: #60a5fa;
text-decoration: none;
}
.msg-content a:hover {
text-decoration: underline;
}
.msg-content h1, .msg-content h2, .msg-content h3, .msg-content h4 {
margin: 10px 0 4px;
font-weight: 600;
}
.msg-content h1 { font-size: 1.3em; }
.msg-content h2 { font-size: 1.15em; }
.msg-content h3 { font-size: 1.05em; }
.msg-content hr { border: none; border-top: 1px solid rgba(255, 255, 255, 0.06); margin: 12px 0; }
.msg-content.empty-response { color: #666; font-size: 13px; padding: 4px 0; }
.msg-content .katex { font-size: 1.05em; }
.msg-content .katex-display { margin: 8px 0; overflow-x: auto; overflow-y: hidden; }
/* ---- Input Bar ---- */
#input-bar {
display: flex;
gap: 8px;
padding: 10px 20px 14px;
background: rgba(15, 15, 26, 0.95);
backdrop-filter: blur(12px);
border-top: 1px solid rgba(255, 255, 255, 0.06);
align-items: flex-end;
flex-shrink: 0;
}
#msg-input {
flex: 1;
padding: 10px 16px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #e0e0e0;
font-size: 14px;
resize: none;
outline: none;
font-family: inherit;
line-height: 1.5;
max-height: 120px;
transition: border-color .2s;
}
#msg-input:focus {
border-color: rgba(74, 108, 247, 0.5);
}
#input-bar button {
padding: 10px 18px;
border-radius: 10px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all .2s;
flex-shrink: 0;
font-family: inherit;
}
#send-btn {
background: linear-gradient(135deg, #4a6cf7, #6a4cf7);
color: #fff;
}
#send-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(74, 108, 247, 0.35);
}
#send-btn:active {
transform: translateY(0);
}
#send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
#send-btn .send-icon {
display: none;
}
#research-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #a0a0b8;
font-size: 12px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
user-select: none;
transition: all .2s;
}
#research-toggles {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
#research-toggle:hover {
border-color: rgba(74, 108, 247, 0.4);
color: #e0e0e0;
}
#research-toggle:has(input:checked) {
border-color: rgba(106, 76, 247, 0.6);
color: #c4a6ff;
background: rgba(106, 76, 247, 0.12);
}
#research-toggle input {
accent-color: #6a4cf7;
cursor: pointer;
}
#cpu-toggle {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #a0a0b8;
font-size: 12px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
user-select: none;
transition: all .2s;
}
#cpu-toggle:hover:not(.disabled) {
border-color: rgba(74, 108, 247, 0.4);
color: #e0e0e0;
}
#cpu-toggle:has(input:checked) {
border-color: rgba(74, 108, 247, 0.6);
color: #7fb8ff;
background: rgba(74, 108, 247, 0.12);
}
#cpu-toggle.disabled {
opacity: 0.4;
cursor: not-allowed;
}
#cpu-toggle input {
accent-color: #4a6cf7;
cursor: pointer;
}
#cpu-toggle.disabled input {
cursor: not-allowed;
}
#attach-btn {
background: rgba(255, 255, 255, 0.04);
color: #888;
font-size: 20px;
padding: 10px 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
}
#attach-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #e0e0e0;
}
#image-preview {
max-width: 100px;
max-height: 70px;
border-radius: 8px;
display: none;
object-fit: cover;
}
input[type=file] {
display: none;
}
#file-badge {
display: none;
background: rgba(74, 108, 247, 0.15);
padding: 4px 12px;
border-radius: 8px;
font-size: 12px;
align-items: center;
gap: 8px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid rgba(74, 108, 247, 0.2);
}
/* ---- Image Lightbox ---- */
#image-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 200;
display: none;
backdrop-filter: blur(8px);
animation: fadeIn .2s ease;
overflow: hidden;
cursor: grab;
}
#image-overlay.open {
display: block;
}
#image-overlay .img-wrap {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
}
#image-overlay .img-wrap img {
max-width: 92vw;
max-height: 92vh;
border-radius: 8px;
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
animation: fadeIn .25s ease;
transition: transform .1s ease;
transform-origin: center center;
pointer-events: auto;
user-select: none;
-webkit-user-drag: none;
}
#image-overlay .zoom-label {
position: fixed;
bottom: 20px;
left: 50%;
transform: translate(-50%);
color: #fff9;
font-size: 13px;
background: #00000080;
padding: 4px 12px;
border-radius: 6px;
pointer-events: none;
}
/* ---- Login Screen ---- */
#login-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: #0f0f1a;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn .3s ease;
}
#login-overlay.hidden {
display: none;
}
.login-box {
background: rgba(22, 22, 42, 0.95);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
padding: 40px;
width: 340px;
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4);
}
.login-box h2 {
margin-bottom: 24px;
text-align: center;
font-weight: 600;
font-size: 22px;
}
.login-box input {
width: 100%;
padding: 12px 16px;
margin-bottom: 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #e0e0e0;
font-size: 14px;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.login-box input:focus {
border-color: rgba(74, 108, 247, 0.5);
}
.login-box button {
width: 100%;
padding: 12px;
border-radius: 10px;
border: none;
background: linear-gradient(135deg, #4a6cf7, #6a4cf7);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
margin-top: 4px;
font-family: inherit;
}
.login-box button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(74, 108, 247, 0.35);
}
.login-box .login-error {
color: #f87171;
font-size: 13px;
text-align: center;
margin-top: 10px;
display: none;
}
.login-box .login-error.show {
display: block;
}
.login-share-divider {
height: 1px;
background: rgba(255, 255, 255, 0.08);
margin: 18px 0 14px;
}
.login-share-input {
margin-bottom: 8px;
}
.login-share-btn {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
color: #a5b4c8;
font-weight: 500;
font-size: 13px;
}
.login-share-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #e2e8f0;
transform: none;
box-shadow: none;
}
/* ---- Public Share View ---- */
#public-share-view {
max-width: 720px;
margin: 0 auto;
padding: 24px 16px 48px;
min-height: 100vh;
box-sizing: border-box;
}
.public-share-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-wrap: wrap;
}
.public-share-label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
color: #94a3b8;
}
.public-share-exit {
background: rgba(74, 108, 247, 0.12);
border: 1px solid rgba(74, 108, 247, 0.3);
color: #a5b4ff;
border-radius: 8px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.public-share-exit:hover {
background: rgba(74, 108, 247, 0.22);
}
.public-share-meta {
font-size: 12px;
color: #94a3b8;
margin-bottom: 12px;
}
.public-share-status {
color: #94a3b8;
font-size: 14px;
padding: 24px 0;
}
.public-share-status.error {
color: #f87171;
}
/* ---- Token Indicator ---- */
#token-indicator {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #666;
}
#context-donut {
flex-shrink: 0;
}
.token-compressed {
color: #fbbf24;
opacity: 0.9;
margin-left: 4px;
font-weight: 600;
}
#reminder-badge {
position: absolute;
top: -4px;
right: -4px;
background: #f87171;
color: #fff;
font-size: 9px;
border-radius: 50%;
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
/* ---- Task Panel ---- */
#task-panel {
position: fixed;
top: 48px;
right: 0;
width: 320px;
max-height: calc(100vh - 48px);
background: #1a1a2e;
border-left: 1px solid rgba(255,255,255,0.08);
padding: 8px;
overflow-y: auto;
z-index: 998;
display: flex;
flex-direction: column;
gap: 6px;
}
#task-panel-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
font-size: 13px;
color: #94a3b8;
font-weight: 600;
}
#task-panel-header button {
background: none;
border: 1px solid rgba(255,255,255,0.12);
color: #94a3b8;
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
font-size: 13px;
}
#task-panel-header button:hover {
background: rgba(255,255,255,0.06);
color: #fff;
}
#task-panel-close {
margin-left: auto;
}
#task-form {
display: flex;
gap: 4px;
padding: 4px 6px;
}
#task-form input {
flex: 1;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
padding: 4px 8px;
color: #e2e8f0;
font-size: 12px;
}
#task-form select {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
padding: 4px;
color: #e2e8f0;
font-size: 12px;
}
#task-form button {
background: rgba(74,222,128,0.15);
border: 1px solid rgba(74,222,128,0.3);
color: #4ade80;
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
}
#task-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.task-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 4px;
font-size: 12px;
transition: background .15s;
}
.task-item:hover {
background: rgba(255,255,255,0.04);
}
.task-item.done .task-title {
text-decoration: line-through;
opacity: 0.4;
}
.task-item input[type="checkbox"] {
accent-color: #4ade80;
cursor: pointer;
}
.task-title {
flex: 1;
color: #e2e8f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-status {
font-size: 10px;
color: #666;
}
.task-due {
font-size: 10px;
color: #666;
}
.task-delete {
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 0 2px;
font-size: 12px;
opacity: 0;
transition: opacity .15s;
}
.task-item:hover .task-delete {
opacity: 1;
}
.task-delete:hover {
color: #f87171;
}
/* ---- Responsive ---- */
@media (max-width: 600px) {
#model-tps {
display: none;
}
.msg {
max-width: 90%;
}
.session-actions {
opacity: 1;
}
.copy-btn {
opacity: 1;
}
.code-block .copy-code-btn {
opacity: 1;
}
#input-bar {
padding: 8px 12px 10px;
gap: 6px;
flex-wrap: wrap;
}
#research-toggles {
order: 3;
flex-basis: 100%;
gap: 6px;
}
#research-toggle, #cpu-toggle {
padding: 6px 10px;
font-size: 11px;
}
#input-bar button {
padding: 8px 10px;
font-size: 14px;
}
#attach-btn {
font-size: 16px;
padding: 8px 10px;
}
#send-btn .send-icon {
display: inline;
}
#send-btn .send-text {
display: none;
}
#send-btn {
padding: 8px 10px;
font-size: 16px;
}
#msg-input {
padding: 8px 12px;
font-size: 13px;
}
#task-panel {
width: 100%;
max-width: 320px;
}
}
#location-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
#location-dialog {
background: #1a1a2e;
border: 1px solid #333;
border-radius: 12px;
padding: 28px 32px;
max-width: 380px;
width: 90%;
text-align: center;
}
#location-icon {
font-size: 36px;
margin-bottom: 12px;
}
#location-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 10px;
}
#location-desc {
font-size: 14px;
color: #999;
line-height: 1.5;
margin-bottom: 20px;
}
#location-actions {
display: flex;
gap: 10px;
justify-content: center;
}
#location-deny-btn, #location-allow-btn {
padding: 8px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
}
#location-deny-btn {
background: #333;
color: #ccc;
}
#location-allow-btn {
background: #4a6cf7;
color: #fff;
}
#location-deny-btn:hover {
background: #444;
}
#location-allow-btn:hover {
background: #5b7dfa;
}
</file>
<file path=".gitignore">
node_modules
dist
*.pyc
__pycache__/
logs/
*.log
nohup.out
repomix-output.xml
# Environment / secrets (fill in your real values; never commit these)
.env
</file>
<file path="server/features/tools.py">
"""LLM tool implementations: web search, page fetching, image tools dispatch."""
import concurrent.futures
import json
import os
import threading
from datetime import datetime
from urllib.parse import urlencode, urlparse
import requests
from server.features.state import M
# How many search results to hand back to the LLM, and how many of the top
# ones to enrich with the full page text (via fetch_page) so the LLM sees real
# content instead of only engine snippets.
WEB_SEARCH_RESULT_LIMIT = 10
WEB_SEARCH_ENRICH_TOP = 2
WEB_SEARCH_ENRICH_CHARS = 6000
WEB_SEARCH_ENRICH_TIMEOUT = 25
# Content-Types treated as plain readable text by fetch_page. Everything else
# (other binary/media types) is declined without ever being fed to the LLM.
_TEXTISH_TYPES = (
"text/html",
"text/plain",
"application/xhtml",
"application/json",
"application/xml",
)
# Hard cap on how much text a parsed CSV/spreadsheet/PDF may yield before
# fetch_page stops reading rows/pages, so gigantic documents don't stall or
# blow up the response.
PARSE_ROW_LIMIT = 20000
PARSE_PDF_CHARS = 400000
# When a PDF has no extractable text (scanned pages), render up to this many
# pages to PNG files so the multimodal model can read the page images.
PDF_PAGE_IMAGE_LIMIT = 8
PDF_PAGE_IMAGE_ZOOM = 1.5
def _decode_response_text(raw):
for enc in ("utf-8-sig", "utf-8", "latin-1"):
try:
return raw.decode(enc)
except UnicodeDecodeError:
continue
return raw.decode("latin-1", errors="replace")
def _detect_doc_type(url, content_type, raw):
"""Classify fetched content as ``pdf``/``csv``/``excel`` or ``None``.
Content-Type is trusted first, then the URL extension, then magic bytes.
Anything unrecognized returns ``None`` so :func:`fetch_page` declines it as
binary instead of trying to read it. Legacy ``.xls`` (OLE2 compound
documents) is detected separately since no parser is available for it.
"""
ext = os.path.splitext(urlparse(url).path)[1].lower()
ctype = (content_type or "").split(";")[0].strip().lower()
if ctype == "application/pdf" or ext == ".pdf" or raw.startswith(b"%PDF-"):
return "pdf"
if ctype == "text/csv" or ext == ".csv":
return "csv"
# Legacy binary OLE2 compound documents are .xls; we cannot parse those
# without xlrd, so report them explicitly rather than guessing as binary.
# Checked before the generic Excel Types because .xls is served as
# application/vnd.ms-excel just like xlsx templates.
if ext == ".xls" or raw.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
return "excel_xls_unsupported"
if ctype in (
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-excel",
"application/excel",
) or ext in (".xlsx", ".xlsm", ".xltm", ".xltx"):
return "excel"
# Documents served as a generic octet-stream still get parsed if the URL
# names a known document extension.
if ctype == "application/octet-stream" and ext in (
".pdf",
".csv",
".xlsx",
".xlsm",
".xltm",
".xltx",
):
return "pdf" if ext == ".pdf" else ("csv" if ext == ".csv" else "excel")
return None
def _chunk_pages(text, size):
"""Split ``text`` into chunks of up to ``size`` characters."""
if len(text) <= size:
return [text]
return [text[i : i + size] for i in range(0, len(text), size)]
def _doc_result(final_url, title, text, max_chars, chunk=1, page_images=None):
"""Build the ``fetch_page`` JSON payload.
Long text is split into chunks of up to ``max_chars`` chars and only chunk
number ``chunk`` is returned; the payload exposes ``total_chunks`` and
``next_chunk`` so the agent can page through the rest by calling
``fetch_page`` again with ``chunk=2,3,...``. ``page_images`` (rendered PDF
pages) are attached alongside so a multimodal model can read scanned pages.
"""
text = (text or "").strip() or "(No readable text content extracted)"
pages = _chunk_pages(text, max_chars)
idx = max(0, min(chunk - 1, len(pages) - 1))
body = pages[idx]
if page_images:
body += (
"\n\n[This PDF has no extractable text — the pages below were rendered "
"as images. Use the read_image tool on each URL to view a page.]"
)
payload = {"url": final_url, "title": title, "content": body}
if page_images:
payload["page_images"] = page_images
if len(pages) > 1:
payload["chunk"] = idx + 1
payload["total_chunks"] = len(pages)
payload["next_chunk"] = idx + 2 if idx + 1 < len(pages) else None
payload["note"] = (
f"Page content is split across {len(pages)} chunks. Call fetch_page "
f"again with chunk={idx + 2} to read the next chunk."
if payload["next_chunk"]
else "End of page content."
)
return json.dumps(payload, ensure_ascii=False)
def _render_pdf_pages(doc, url):
"""Render image-only PDF pages (scanned) to PNG files the model can view.
Rendered files land under ``IMG_PATH/pdf_pages`` and are returned as
``/output/pdf_pages/...`` URLs so ``read_image`` / ``resolve_image_path``
can resolve them. Returns an empty list when nothing could be rendered.
"""
import hashlib
slug = hashlib.md5(url.encode("utf-8", errors="replace")).hexdigest()[:12]
outdir = os.path.join(M.IMG_PATH, "pdf_pages")
try:
os.makedirs(outdir, exist_ok=True)
except OSError as e:
print(f"[fetch_page] PDF page render dir failed: {e}")
return []
urls = []
for i, page in enumerate(doc):
if i >= PDF_PAGE_IMAGE_LIMIT:
break
try:
import fitz
pix = page.get_pixmap(
matrix=fitz.Matrix(PDF_PAGE_IMAGE_ZOOM, PDF_PAGE_IMAGE_ZOOM)
)
fname = f"{slug}-p{i + 1}.png"
pix.save(os.path.join(outdir, fname))
urls.append(f"/output/pdf_pages/{fname}")
except Exception as e:
print(f"[fetch_page] PDF page {i + 1} render failed: {e}")
break
return urls
def _parse_pdf(raw, url):
"""Extract text from a PDF via PyMuPDF.
Returns ``(text, title, page_images)``. If the PDF has no extractable text
(scanned pages) the first ``PDF_PAGE_IMAGE_LIMIT`` pages are rendered to PNG
files and returned as ``/output/pdf_pages/...`` URLs.
"""
doc_title = os.path.basename(urlparse(url).path) or "PDF document"
try:
import fitz
doc = fitz.open(stream=raw, filetype="pdf")
parts = []
total_chars = 0
for page in doc:
text = page.get_text("text")
parts.append(text)
total_chars += len(text)
if total_chars > PARSE_PDF_CHARS:
parts.append("\n...[truncated by size]")
break
body = "\n".join(parts).strip()
page_images = []
if not body:
page_images = _render_pdf_pages(doc, url)
body = "(No extractable text in PDF — the pages are likely scanned images.)"
doc.close()
return body, doc_title, page_images
except Exception as e:
print(f"[fetch_page] PDF parse failed: {e}")
return f"(Could not extract text from this PDF: {e})", doc_title, []
def _parse_csv(raw, url):
"""Parse CSV text into pipe-separated rows. Returns ``(text, title)``."""
import csv
import io
doc_title = os.path.basename(urlparse(url).path) or "CSV document"
try:
reader = csv.reader(io.StringIO(_decode_response_text(raw)))
rows = []
for i, row in enumerate(reader):
if i >= PARSE_ROW_LIMIT:
rows.append("...[truncated by row limit]")
break
rows.append(" | ".join("" if c is None else c.strip() for c in row))
body = "\n".join(rows).strip() or "(Empty CSV)"
return body, doc_title
except Exception as e:
print(f"[fetch_page] CSV parse failed: {e}")
return f"(Could not parse this CSV: {e})", doc_title
def _parse_excel(raw, url):
"""Extract every sheet of an .xlsx workbook as text rows. Returns (text, title)."""
import io
from openpyxl import load_workbook
doc_title = os.path.basename(urlparse(url).path) or "Excel spreadsheet"
try:
wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
blocks = []
for sheet in wb.worksheets:
blocks.append(f"### Sheet: {sheet.title}")
for row_idx, row in enumerate(sheet.iter_rows(values_only=True)):
if row_idx >= PARSE_ROW_LIMIT:
blocks.append("...[truncated by row limit]")
break
blocks.append(" | ".join("" if v is None else str(v) for v in row))
wb.close()
body = "\n".join(blocks).strip() or "(Empty spreadsheet)"
return body, doc_title
except Exception as e:
print(f"[fetch_page] Excel parse failed: {e}")
return f"(Could not parse this spreadsheet: {e})", doc_title
def web_search(query, current_time=None, current_location=None):
ts = datetime.now()
clean_query = query.strip()
params = {"q": clean_query, "format": "json"}
search_url = f"{M.SEARXNG_URL}?{urlencode(params)}"
print("Performing web search", search_url)
try:
r = requests.get(M.SEARXNG_URL, params=params, timeout=10)
r.raise_for_status()
print("Web-search completed")
data = r.json()
except Exception as e:
print(f"Web-search failed: {e}")
return json.dumps({
"results": [],
"search_date": ts.strftime("%Y-%m-%d %A"),
"query": query,
"search_url": search_url,
"error": str(e),
})
results = data.get("results", [])[:WEB_SEARCH_RESULT_LIMIT]
formatted = []
for x in results:
formatted.append(
{
"title": x.get("title", ""),
"url": x.get("url", ""),
"snippet": x.get("content", "") or x.get("snippet", ""),
}
)
enriched = _enrich_top_results(formatted)
return json.dumps(
{
"results": enriched,
"search_date": ts.strftime("%Y-%m-%d %A"),
"query": query,
"search_url": search_url,
}
)
def _enrich_top_results(results):
"""Attach the full page text to the top results.
The top ``WEB_SEARCH_ENRICH_TOP`` results are fetched concurrently (their
body is stored as ``full_content``) so the LLM does not have to make a
separate ``fetch_page`` call for every promising link. Fetch failures are
recorded as ``fetch_error`` and never break the search response.
"""
targets = results[:WEB_SEARCH_ENRICH_TOP]
if not targets:
return results
def _one(entry):
url = entry.get("url", "")
if not url.lower().startswith(("http://", "https://")):
return
try:
page = json.loads(
M.fetch_page(url, max_chars=WEB_SEARCH_ENRICH_CHARS)
)
if page.get("content"):
entry["full_content"] = page["content"]
entry["page_title"] = page.get("title", "")
elif page.get("error"):
entry["fetch_error"] = page["error"]
except Exception as e:
entry["fetch_error"] = str(e)
executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(targets))
futs = [executor.submit(_one, entry) for entry in targets]
concurrent.futures.wait(futs, timeout=WEB_SEARCH_ENRICH_TIMEOUT)
executor.shutdown(wait=False)
return results
def fetch_page(url, max_chars=24000, chunk=1):
import ipaddress
import socket
from bs4 import BeautifulSoup
if not url:
return json.dumps({"url": "", "error": "No URL provided."})
try:
chunk = max(1, int(chunk or 1))
except (TypeError, ValueError):
chunk = 1
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return json.dumps({"url": url, "error": "Only http/https URLs are supported."})
host = parsed.hostname or ""
ip = socket.gethostbyname(host)
addr = ipaddress.ip_address(ip)
if addr.is_private or addr.is_loopback or addr.is_link_local:
return json.dumps({"url": url, "error": "Access to private/internal addresses is not allowed."})
except Exception as e:
return json.dumps({"url": url, "error": f"Invalid URL: {e}"})
headers = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
try:
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
r.raise_for_status()
raw = getattr(r, "content", None)
if raw is None:
raw = r.text.encode("utf-8", errors="replace")
ctype = r.headers.get("Content-Type", "").lower()
kind = _detect_doc_type(url, ctype, raw)
if kind == "pdf":
text, doc_title, page_images = _parse_pdf(raw, url)
return _doc_result(r.url, doc_title, text, max_chars, chunk, page_images)
if kind == "csv":
text, doc_title = _parse_csv(raw, url)
return _doc_result(r.url, doc_title, text, max_chars, chunk)
if kind == "excel":
text, doc_title = _parse_excel(raw, url)
return _doc_result(r.url, doc_title, text, max_chars, chunk)
if kind == "excel_xls_unsupported":
return json.dumps(
{
"url": url,
"content_type": ctype,
"error": "This is a legacy .xls spreadsheet, which is not supported. "
"Please retry with a .xlsx or CSV version of the file.",
}
)
if not any(t in ctype for t in _TEXTISH_TYPES):
return json.dumps({"url": url, "content_type": ctype, "error": "Skipped: page is not readable text content (likely binary/media)."})
if not r.encoding:
r.encoding = r.apparent_encoding
soup = BeautifulSoup(r.text, "html.parser")
for tag in soup(["script", "style", "noscript", "svg", "nav", "footer", "header", "aside", "form"]):
tag.decompose()
title = soup.title.get_text(strip=True) if soup.title else ""
main = soup.find("main") or soup.find("article") or soup.find("body") or soup
text = main.get_text(separator="\n", strip=True)
text = "\n".join(line.strip() for line in text.splitlines() if line.strip())
return _doc_result(r.url, title, text, max_chars, chunk)
except Exception as e:
print(f"[fetch_page] Failed: {e}")
return json.dumps({"url": url, "error": f"Failed to fetch page: {e}"})
def _tool_worker(task_id, sid, tc, image_b64, round_num, tool_index):
tool_name = tc["function"]["name"]
try:
M._dispatch_tool(task_id, sid, tc, image_b64, round_num, tool_index)
except Exception as e:
print(f"[tool_worker] Tool '{tool_name}' crashed for task {task_id}: {e}")
M._event_post(
"tool_ok",
task_id,
tc_id=tc.get("id", ""),
result=json.dumps({"error": f"Tool {tool_name} failed: {e}"}),
sid=sid,
round=round_num,
tool_index=tool_index,
)
def _dispatch_tool(task_id, sid, tc, image_b64, round_num, tool_index):
tool_name = tc["function"]["name"]
try:
args = json.loads(tc["function"]["arguments"])
except Exception:
args = {}
with M._data_lock:
tu = list(M.tasks.get(task_id, {}).get("_tools_used", []))
has_generated_image = "generate_image" in tu
if tool_name == "get_user_location":
if M._client_location:
result = M._client_location
else:
ev = threading.Event()
M._location_events[task_id] = ev
M.set_status(task_id, "location_needed")
ev.wait(timeout=60)
M._location_events.pop(task_id, None)
result = M._client_location if M._client_location else "User denied location access"
M._event_post("tool_ok", task_id, tc_id=tc["id"], result=result, sid=sid, round=round_num, tool_index=tool_index)
return
if tool_name == "read_file":
file_url = args.get("file_url", "")
filename = os.path.basename(urlparse(file_url).path)
fpath = os.path.abspath(os.path.join(M.UPLOADS_DIR, filename))
if fpath.startswith(os.path.abspath(M.UPLOADS_DIR)) and os.path.exists(fpath):
text = M.read_file_text(fpath)
if text:
result = f"Content of {file_url}:\n\n{text}"
else:
result = f"Could not extract text from {file_url}. The file may contain only images."
else:
result = f"File not found: {file_url}"
M._event_post("tool_ok", task_id, tc_id=tc["id"], result=result, sid=sid, round=round_num, tool_index=tool_index)
return
if tool_name == "read_image":
url = args.get("url", "")
fpath = M.resolve_image_path(url)
if fpath is None:
result = json.dumps({"ok": False, "error": f"Image not found: {url}"})
else:
result = json.dumps({"ok": True, "image_url": url})
M._event_post("tool_ok", task_id, tc_id=tc["id"], result=result, sid=sid, round=round_num, tool_index=tool_index)
return
if tool_name == "web_search":
M.set_status(task_id, f"Searching web for: {args.get('query')}...")
with M._data_lock:
client_ts = M.tasks.get(task_id, {}).get("_client_timestamp")
try:
result = M.web_search(
args["query"],
current_time=args.get("current_time"),
current_location=args.get("current_location"),
)
except Exception as e:
print(f"[web_search] Unhandled exception for task {task_id}: {e}")
result = json.dumps({"results": [], "query": args.get("query"), "error": str(e)})
print(f"[web_search] RAW result for task {task_id}: {result[:300]}...") # DEBUG
with M._data_lock:
t = M.tasks.get(task_id)
if t:
t.setdefault("_tools_used", []).append(tool_name)
try:
t.setdefault("_search_details", []).append(json.loads(result))
except Exception:
pass
llm_result = (
f"Web search results for query '{args.get('query')}'. "
f"Analyze these search results thoroughly and provide a clear, accurate response based on the findings:\n\n{result}"
)
print(f"[web_search] LLM-bound result (with analysis instruction) for task {task_id}: {llm_result[:400]}...") # DEBUG
M._event_post(
"tool_ok",
task_id,
tc_id=tc["id"],
result=llm_result,
sid=sid,
round=round_num,
tool_index=tool_index,
)
elif tool_name == "fetch_page":
M.set_status(task_id, f"Fetching page: {args.get('url', '')}...")
try:
result = M.fetch_page(args.get("url", ""), chunk=args.get("chunk", 1))
except Exception as e:
print(f"[fetch_page] Unhandled exception for task {task_id}: {e}")
result = json.dumps({"url": args.get("url", ""), "error": str(e)})
print(f"[fetch_page] Result for task {task_id}: {result[:300]}...") # DEBUG
with M._data_lock:
t = M.tasks.get(task_id)
if t:
t.setdefault("_tools_used", []).append(tool_name)
try:
res = json.loads(result)
t.setdefault("_search_details", []).append({
"tool": "fetch_page",
"url": res.get("url", args.get("url", "")),
"title": res.get("title", ""),
"content": res.get("content", ""),
"error": res.get("error", ""),
})
except Exception:
pass
llm_result = (
f"Page content fetched from URL '{args.get('url')}'. "
f"Use this content to answer the user's question accurately. "
f"If the content is insufficient or was truncated, you may fetch another page or fall back to the search results:\n\n{result}"
)
M._event_post(
"tool_ok",
task_id,
tc_id=tc["id"],
result=llm_result,
sid=sid,
round=round_num,
tool_index=tool_index,
)
elif tool_name == "edit_image":
M._enqueue_image_job(task_id, sid, tool_name, args, tc, round_num, tool_index)
return
elif tool_name == "generate_image":
if has_generated_image:
result = json.dumps(
{"error": "Image generation limit reached for this prompt."}
)
M._event_post(
"tool_ok",
task_id,
tc_id=tc["id"],
result=result,
sid=sid,
round=round_num,
tool_index=tool_index,
)
else:
M._enqueue_image_job(task_id, sid, tool_name, args, tc, round_num, tool_index)
return
elif tool_name == "update_user_context":
content = args.get("content", "")
user = ""
with M._data_lock:
t = M.tasks.get(task_id)
if t:
user = t.get("_user", "")
if user:
M.write_user_context(user, content)
print(f"[context] Updated context for user '{user}' ({len(content)} chars)")
result = json.dumps({"status": "ok", "saved": bool(user)})
M._event_post(
"tool_ok",
task_id,
tc_id=tc["id"],
result=result,
sid=sid,
round=round_num,
tool_index=tool_index,
)
elif tool_name == "manage_tasks":
user = ""
with M._data_lock:
t = M.tasks.get(task_id)
if t:
user = t.get("_user", "")
if not user:
result = json.dumps({"ok": False, "error": "User not found"})
else:
result = M.handle_task_tool(user, args)
M._event_post(
"tool_ok",
task_id,
tc_id=tc["id"],
result=result,
sid=sid,
round=round_num,
tool_index=tool_index,
)
elif tool_name == "track_theme":
user = ""
with M._data_lock:
t = M.tasks.get(task_id)
if t:
user = t.get("_user", "")
if not user:
result = json.dumps({"ok": False, "error": "User not found"})
else:
result = M.handle_theme_tool(user, args)
M._event_post(
"tool_ok",
task_id,
tc_id=tc["id"],
result=result,
sid=sid,
round=round_num,
tool_index=tool_index,
)
else:
result = json.dumps({"error": f"Unknown tool: {tool_name}"})
M._event_post(
"tool_ok",
task_id,
tc_id=tc["id"],
result=result,
sid=sid,
round=round_num,
tool_index=tool_index,
)
</file>
<file path="src/api.js">
// Authentication is handled by nginx + Authentik SSO (the X-Authentik-*
// claim headers are injected upstream by nginx's auth_request). The browser
// never holds a token; on 401 nginx redirects to the SSO portal.
async function authFetch(url, options = {}) {
options.headers = options.headers || {};
const r = await fetch(url, options);
if (r.status === 401) {
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
}
return r;
}
export async function logout() {
window.location.assign('/outpost.goauthentik.io/sign_out?rd=/');
}
export async function checkAuth() {
const r = await authFetch('/api/check-auth');
return r.json();
}
export async function fetchSessions() {
const r = await authFetch('/api/sessions');
return r.json();
}
export async function fetchMessages(sessionId) {
const r = await authFetch(`/api/sessions/${sessionId}/messages`);
return r.json();
}
export async function createSession() {
const r = await authFetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New Chat' }),
});
return r.json();
}
export async function deleteSession(sessionId) {
await authFetch(`/api/sessions/${sessionId}`, { method: 'DELETE' });
}
export async function renameSession(sessionId, name) {
await authFetch(`/api/sessions/${sessionId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
}
function localISOString() {
const d = new Date()
const tz = -d.getTimezoneOffset()
const sign = tz >= 0 ? '+' : '-'
const pad = n => String(Math.abs(n)).padStart(2, '0')
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + 'T' +
pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) +
sign + pad(Math.floor(Math.abs(tz)/60)) + ':' + pad(Math.abs(tz)%60)
}
export async function sendMessage(sessionId, message, image, audio, clientTimestamp, research, cpu) {
const body = { session_id: sessionId, message, client_timestamp: clientTimestamp || localISOString() };
if (research) body.research = true;
if (cpu) body.cpu = true;
if (image) body.image = image;
if (audio) body.audio = audio;
const r = await authFetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (r.status === 503) {
const err = await r.json();
throw new Error(err.error || 'Server is busy');
}
return r.json();
}
export async function getTaskStatus(taskId) {
const r = await fetch(`/api/status/${taskId}`);
return r.json();
}
export async function getModelStatus() {
const r = await fetch('/api/model-status');
return r.json();
}
export async function sendLocation(latitude, longitude, taskId) {
await fetch('/api/location', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ latitude, longitude, task_id: taskId }),
});
}
export async function denyLocation(taskId) {
await fetch('/api/location', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ denied: true, task_id: taskId }),
});
}
export async function speak(text, voice) {
const r = await authFetch('/api/tts', {
method: 'POST',
body: JSON.stringify({ text, voice: voice || undefined }),
});
return r.json();
}
export async function extractFile(name, dataB64) {
const r = await authFetch('/api/extract-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, data: dataB64 }),
});
return r.json();
}
export async function uploadImage(dataB64, ext = 'jpg') {
const r = await authFetch('/api/upload-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: dataB64, ext }),
});
return r.json();
}
export async function fetchTasks() {
const r = await authFetch('/api/tasks');
return r.json();
}
export async function createTask(data) {
const r = await authFetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return r.json();
}
export async function updateTask(taskId, data) {
const r = await authFetch(`/api/tasks/${taskId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return r.json();
}
export async function deleteTask(taskId) {
await authFetch(`/api/tasks/${taskId}`, { method: 'DELETE' });
}
export async function shareMessage(sessionId, msgIndex) {
const r = await authFetch('/api/shares', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, msg_index: msgIndex }),
});
return r.json();
}
export async function listShares() {
const r = await authFetch('/api/shares');
return r.json();
}
export async function revokeShare(token) {
const r = await authFetch(`/api/shares/${token}`, { method: 'DELETE' });
return r.json();
}
export async function fetchPublicShare(token) {
const r = await fetch(`/api/public/share/${encodeURIComponent(token)}`);
return r.json();
}
</file>
<file path="src/App.jsx">
import { useState, useEffect, useRef, useCallback } from 'react'
import * as api from './api'
import ModelBar from './components/ModelBar'
import Sidebar from './components/Sidebar'
import ChatArea from './components/ChatArea'
import InputBar from './components/InputBar'
import ImageLightbox from './components/ImageLightbox'
import OverloadWarning from './components/OverloadWarning'
import TaskPanel from './components/TaskPanel'
import LocationPrompt from './components/LocationPrompt'
import PublicShareView from './components/PublicShareView'
function shareTokenFromPath() {
const m = /^\/s\/([A-Za-z0-9]+)\/?$/.exec(window.location.pathname)
return m ? m[1] : null
}
export default function App() {
const [authenticated, setAuthenticated] = useState(false)
const [publicShareToken, setPublicShareToken] = useState(() => shareTokenFromPath())
const [username, setUsername] = useState('')
const [sessions, setSessions] = useState([])
const [currentSessionId, setCurrentSessionId] = useState(null)
const [messages, setMessages] = useState([])
const [pendingMessages, setPendingMessages] = useState({})
const [tokenEstimate, setTokenEstimate] = useState(0)
const [contextCompressed, setContextCompressed] = useState(false)
const [rawTokenEstimate, setRawTokenEstimate] = useState(0)
const [maxContext, setMaxContext] = useState(24576)
const [modelStatus, setModelStatus] = useState('unloaded')
const [modelTps, setModelTps] = useState(null)
const [overheated, setOverheated] = useState(false)
const [gpuTemp, setGpuTemp] = useState(null)
const [ramEvacuating, setRamEvacuating] = useState(false)
const [reminderCount, setReminderCount] = useState(0)
const [sidebarOpen, setSidebarOpen] = useState(false)
const [lightboxSrc, setLightboxSrc] = useState(null)
const [loadingSessions, setLoadingSessions] = useState({})
const [showTasks, setShowTasks] = useState(false)
const [showLocationPrompt, setShowLocationPrompt] = useState(false)
const [locationTaskId, setLocationTaskId] = useState(null)
const [locationError, setLocationError] = useState(null)
const sessionRef = useRef(null)
const selectingRef = useRef(false)
const pendingMessagesRef = useRef({})
const resolvedTasksRef = useRef(new Set())
useEffect(() => {
// Authentication is enforced by nginx's auth_request against Authentik:
// by the time this SPA loads, the browser already has a valid SSO session
// and /api/check-auth answers from the forwarded X-Authentik-* headers.
api.checkAuth()
.then(res => {
if (res.authenticated) {
setAuthenticated(true)
if (res.username) setUsername(res.username)
loadSessions().then(list => {
const lastSid = localStorage.getItem('last_sid')
if (lastSid && list.some(s => s.session_id === lastSid)) {
switchSession(lastSid)
} else if (list.length > 0) {
switchSession(list[0].session_id)
}
})
} else {
setAuthenticated(false)
}
})
.catch(() => setAuthenticated(false))
}, [])
useEffect(() => {
const handler = () => {
setAuthenticated(false);
setUsername('');
setSessions([]);
sessionRef.current = null; setCurrentSessionId(null);
setMessages([]);
setPendingMessages({});
setSidebarOpen(false);
};
window.addEventListener('auth:unauthorized', handler);
return () => window.removeEventListener('auth:unauthorized', handler);
}, [])
const hasPendingForCurrent = Object.values(pendingMessages).some(
p => p.sessionId === currentSessionId
)
useEffect(() => {
pendingMessagesRef.current = pendingMessages
}, [pendingMessages])
// ---- Auth ----
async function handleLogout() {
console.log('[logout] called')
await api.logout()
setAuthenticated(false)
setUsername('')
setSessions([])
sessionRef.current = null; setCurrentSessionId(null)
setMessages([])
setPendingMessages({})
setSidebarOpen(false)
}
// ---- Sessions ----
async function loadSessions() {
const list = await api.fetchSessions()
setSessions(list)
return list
}
function loadSessionMessages(sid) {
api.fetchMessages(sid).then(data => {
if (sessionRef.current !== sid) return
setMessages(data.messages || [])
setTokenEstimate(data.token_estimate || 0)
setContextCompressed(!!data.context_compressed)
setRawTokenEstimate(data.raw_token_estimate || 0)
}).catch(() => {
if (sessionRef.current !== sid) return
setMessages([])
setTokenEstimate(0)
setContextCompressed(false)
setRawTokenEstimate(0)
})
}
async function switchSession(sid) {
sessionRef.current = sid; setCurrentSessionId(sid)
localStorage.setItem('last_sid', sid)
loadSessionMessages(sid)
closeSidebar()
}
async function newChat() {
const data = await api.createSession()
sessionRef.current = data.session_id; setCurrentSessionId(data.session_id)
localStorage.setItem('last_sid', data.session_id)
setMessages([])
setTokenEstimate(0)
setContextCompressed(false)
setRawTokenEstimate(0)
const list = await loadSessions()
setSessions(list)
closeSidebar()
}
async function deleteSession_(sid) {
if (!confirm('Delete this session?')) return
await api.deleteSession(sid)
setPendingMessages(prev => {
const next = { ...prev }
for (const [tid, p] of Object.entries(prev)) {
if (p.sessionId === sid) delete next[tid]
}
return next
})
setStoredPending(getStoredPending().filter(p => p.sid !== sid))
const list = await loadSessions()
if (sid === currentSessionId) {
if (list.length > 0) {
switchSession(list[0].session_id)
} else {
newChat()
}
} else if (currentSessionId) {
loadSessionMessages(currentSessionId)
}
}
async function renameSession_(sid) {
const s = sessions.find(x => x.session_id === sid)
const newName = prompt('Session name:', s ? s.name : '')
if (!newName || newName.trim() === '') return
const prevSid = currentSessionId
await api.renameSession(sid, newName.trim())
const list = await loadSessions()
sessionRef.current = prevSid; setCurrentSessionId(prevSid)
setSessions(list)
}
function closeSidebar() {
setSidebarOpen(false)
}
// ---- Chat / Send ----
async function handleSend(text, image, research, cpu) {
if (!currentSessionId) return
const taskSid = currentSessionId
setLoadingSessions(prev => ({ ...prev, [taskSid]: (prev[taskSid] || 0) + 1 }))
const userMsg = { role: 'user', content: text || '\uD83D\uDCC4 file', _timestamp: new Date().toISOString() }
try {
const data = await api.sendMessage(currentSessionId, text || '', image || undefined, undefined, undefined, research, cpu)
const taskId = data.task_id
setPendingMessages(prev => ({
...prev,
[taskId]: { sessionId: taskSid, status: 'working', message: 'Thinking...', taskId, reasoning: '', _userMsg: userMsg },
}))
const stored = getStoredPending()
stored.push({ task_id: taskId, sid: taskSid })
setStoredPending(stored)
} catch (err) {
setMessages(prev => [...prev, userMsg, { role: 'assistant', content: 'Error: ' + err.message }])
}
setLoadingSessions(prev => {
const next = { ...prev }
next[taskSid] = (next[taskSid] || 1) - 1
if (!next[taskSid]) delete next[taskSid]
return next
})
}
// ---- Task completion callbacks (called by the active PendingMessage) ----
const handlePendingResolved = useCallback((pending, st) => {
const taskId = pending.taskId
if (resolvedTasksRef.current.has(taskId)) return
resolvedTasksRef.current.add(taskId)
setPendingMessages(prev => {
const next = { ...prev }
delete next[taskId]
return next
})
if (st.status === 'done') {
const userMsg = pending._userMsg
const assistantMsg = {
role: 'assistant',
content: st.response || '',
_elapsed_ms: st._elapsed_ms != null ? st._elapsed_ms : null,
_reasoning: st.reasoning || '',
_image_url: st.image || st._image_url,
_gen_prompt: st.gen_prompt,
_tools_used: st.tools_used || [],
_image_model: st._image_model,
_search_details: st._search_details || [],
}
if (pending.sessionId === sessionRef.current) {
setMessages(prev => [...prev, ...(userMsg ? [userMsg] : []), assistantMsg])
}
if (st.token_estimate != null) setTokenEstimate(st.token_estimate)
setContextCompressed(!!st.context_compressed)
if (st.raw_token_estimate != null) setRawTokenEstimate(st.raw_token_estimate)
if (st.predicted_per_second != null) setModelTps(st.predicted_per_second)
if (st.session_name != null && st.session_id) {
setSessions(prev => prev.map(s => s.session_id === st.session_id ? { ...s, name: st.session_name } : s))
}
} else {
if (pending.sessionId === sessionRef.current) {
setMessages(prev => [...prev, ...(pending._userMsg ? [pending._userMsg] : []), {
role: 'assistant',
content: 'Error: ' + (st.error || 'Task was lost — please retry.'),
}])
}
}
const remaining = getStoredPending().filter(p => p.task_id !== taskId)
setStoredPending(remaining)
}, [])
const handleLocationNeeded = useCallback((taskId) => {
setShowLocationPrompt(true)
setLocationTaskId(taskId)
setLocationError(null)
}, [])
const closeLightbox = useCallback(() => setLightboxSrc(null), [])
// ---- Background task resolution ----
// Foreground pending tasks are self-polled by their PendingMessage (so only that
// message re-renders). This loop resolves tasks whose session is not currently open.
useEffect(() => {
if (!authenticated) return
const interval = setInterval(async () => {
for (const [taskId, p] of Object.entries(pendingMessagesRef.current)) {
if (p.sessionId === sessionRef.current) continue
try {
const st = await api.getTaskStatus(taskId)
const terminal = st && (st.status === 'done' || st.status === 'error' || st.status === 'cancelled' || st.status === 'unknown' || st.status === 'not_found')
if (terminal) {
handlePendingResolved(p, st)
} else if (st.message === 'location_needed') {
handleLocationNeeded(taskId)
}
} catch { }
}
}, 2000)
return () => clearInterval(interval)
}, [authenticated, handlePendingResolved, handleLocationNeeded])
// ---- Model status polling ----
useEffect(() => {
if (!authenticated) return
const interval = setInterval(async () => {
try {
const data = await api.getModelStatus()
setModelStatus(data.model)
setOverheated(data.overheated)
setGpuTemp(data.gpu_temp)
if (data.ram_evacuating != null) setRamEvacuating(data.ram_evacuating)
if (data.predicted_per_second != null) setModelTps(data.predicted_per_second)
if (data.max_context != null) setMaxContext(data.max_context)
if (data.reminder_count != null) setReminderCount(data.reminder_count)
} catch { /* ignore */ }
}, 2000)
return () => clearInterval(interval)
}, [authenticated])
// ---- Resume pending tasks on page load ----
useEffect(() => {
if (!authenticated) return
const stored = getStoredPending()
if (stored.length > 0) {
const pMap = {}
stored.forEach(item => {
pMap[item.task_id] = { sessionId: item.sid, status: 'working', message: 'Thinking...', taskId: item.task_id, reasoning: '' }
})
setPendingMessages(pMap)
}
}, [authenticated])
function handleLocationAllow() {
const tid = locationTaskId
navigator.geolocation.getCurrentPosition(
pos => {
setShowLocationPrompt(false)
setLocationTaskId(null)
api.sendLocation(pos.coords.latitude, pos.coords.longitude, tid)
},
err => {
if (err.code === 1) {
setLocationError('Location access is blocked in your browser. Please enable it in browser settings and try again.')
} else {
setLocationError('Could not get location: ' + err.message + '. Try again or click Deny.')
}
},
{ timeout: 10000, enableHighAccuracy: false }
)
}
function handleLocationDeny() {
setShowLocationPrompt(false)
const tid = locationTaskId
setLocationTaskId(null)
setLocationError(null)
api.denyLocation(tid)
}
if (publicShareToken) {
return (
<PublicShareView
token={publicShareToken}
onExit={() => {
window.history.replaceState({}, '', '/')
setPublicShareToken(null)
}}
/>
)
}
return (
<>
{showLocationPrompt && <LocationPrompt onAllow={handleLocationAllow} onDeny={handleLocationDeny} error={locationError} />}
<div style={{ display: authenticated ? '' : 'none' }}>
<ModelBar
modelStatus={modelStatus}
modelTps={modelTps}
tokenEstimate={tokenEstimate}
contextCompressed={contextCompressed}
rawTokenEstimate={rawTokenEstimate}
maxContext={maxContext}
onToggleSidebar={() => setSidebarOpen(o => !o)}
username={username}
onLogout={handleLogout}
reminderCount={reminderCount}
onToggleTasks={() => setShowTasks(o => !o)}
/>
<div id="app-container">
<Sidebar
sessions={sessions}
currentSessionId={currentSessionId}
onSwitchSession={switchSession}
onNewChat={newChat}
onRenameSession={renameSession_}
onDeleteSession={deleteSession_}
onClose={closeSidebar}
open={sidebarOpen}
/>
<OverloadWarning overheated={overheated} gpuTemp={gpuTemp} ramEvacuating={ramEvacuating} />
<ChatArea
messages={messages}
pendingMessages={pendingMessages}
currentSessionId={currentSessionId}
onImageOpen={setLightboxSrc}
selectingRef={selectingRef}
onPendingResolved={handlePendingResolved}
onLocationNeeded={handleLocationNeeded}
/>
<InputBar
onSend={handleSend}
hasPending={hasPendingForCurrent}
/>
</div>
<ImageLightbox src={lightboxSrc} onClose={closeLightbox} />
{showTasks && <TaskPanel onClose={() => setShowTasks(false)} />}
</div>
</>
)
}
function getStoredPending() {
try {
return JSON.parse(sessionStorage.getItem('opencode_pending')) || []
} catch {
return []
}
}
function setStoredPending(arr) {
try {
sessionStorage.setItem('opencode_pending', JSON.stringify(arr))
} catch { /* ignore */ }
}
</file>
<file path="server/features/images.py">
"""ComfyUI image generation and editing."""
import base64
import json
import os
import random
import time
import uuid
import requests
from server.features.state import M
from server.features.users import _safe_username
# LLM-selectable framing presets for generate_image. All values are divisible
# by 8 (latent-safe for EmptySD3LatentImage) and stay near the ~2 MP budget of
# the default 1920x1080, so VRAM usage and generation time are stable no matter
# which framing the model picks.
ASPECT_SIZES = {
"landscape": (1920, 1080),
"portrait": (1080, 1920),
"square": (1440, 1440),
}
def _aspect_dims(aspect_ratio):
return ASPECT_SIZES.get(aspect_ratio, ASPECT_SIZES["landscape"])
def _output_dir(user):
return os.path.join(M.COMFYUI_OUTPUT, _safe_username(user))
def _input_dir(user):
return os.path.join(M.COMFYUI_INPUT, _safe_username(user))
def _output_rel(target):
if os.path.isabs(target):
try:
return os.path.relpath(target, M.COMFYUI_OUTPUT)
except ValueError:
return os.path.basename(target)
return target
def _image_url_rel(url):
marker = "/output/"
if marker in url:
return url.split(marker, 1)[-1]
return os.path.basename(url)
def free_comfyui_vram():
print("[comfyui] Freeing VRAM...")
try:
r = requests.post(
f"{M.COMFYUI_URL}/free",
json={"unload_models": True, "free_memory": True},
timeout=30,
)
if r.status_code == 200:
print("[comfyui] VRAM freed")
return True
except Exception as e:
print(f"[comfyui] Free error: {e}")
finally:
time.sleep(10)
return False
def generate_image(prompt, task_id, negative_prompt="", model="z_image", aspect_ratio="landscape"):
print(f"\n[image] Generating image for task {task_id} with the prompt: {prompt}")
M.set_status(task_id, "Freeing VRAM for image generation...")
# ComfyUI renders on the GPU, so only the GPU llama-server is unloaded.
# The CPU server (self-chat agents) keeps running untouched.
M.unload_llama_model("gpu")
width, height = _aspect_dims(aspect_ratio)
user = M._task_user(task_id)
gen_tag = str(uuid.uuid4())[:8]
prefix = f"{_safe_username(user)}/gen_{gen_tag}_"
cfg = M.IMAGE_MODELS.get(model, M.IMAGE_MODELS["z_image"])
if model == "z_image":
print("Chose Z-Image Turbo for image generation")
workflow = {
"62": {
"class_type": "CLIPLoader",
"inputs": {"clip_name": cfg["clip1"], "type": "lumina2"},
},
"63": {"class_type": "VAELoader", "inputs": {"vae_name": cfg["vae"]}},
"66": {
"class_type": "UNETLoader",
"inputs": {"unet_name": cfg["unet"], "weight_dtype": "default"},
},
"67": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt, "clip": ["62", 0]},
},
"68": {
"class_type": "EmptySD3LatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"69": {
"class_type": "ModelSamplingAuraFlow",
"inputs": {"shift": 3, "model": ["66", 0]},
},
"71": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative_prompt, "clip": ["62", 0]},
},
"70": {
"class_type": "KSampler",
"inputs": {
"seed": random.randint(0, 2**31),
"steps": 8,
"cfg": 1.0,
"sampler_name": "res_multistep",
"scheduler": "simple",
"denoise": 1.0,
"model": ["69", 0],
"positive": ["67", 0],
"negative": ["71", 0],
"latent_image": ["68", 0],
},
},
"65": {
"class_type": "VAEDecode",
"inputs": {"samples": ["70", 0], "vae": ["63", 0]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": prefix, "images": ["65", 0]},
},
}
elif model == "sd3_5_medium":
print("Chose SD 3.5 for image generation")
workflow = {
"1": {"class_type": "UnetLoaderGGUF", "inputs": {"unet_name": cfg["unet"]}},
"2": {
"class_type": "TripleCLIPLoaderGGUF",
"inputs": {
"clip_name1": cfg["clip1"],
"clip_name2": cfg["clip2"],
"clip_name3": cfg["t5"],
"type": "sd3",
},
},
"3": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt, "clip": ["2", 0]},
},
"4": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative_prompt, "clip": ["2", 0]},
},
"5": {
"class_type": "EmptySD3LatentImage",
"inputs": {"width": width, "height": height, "batch_size": 1},
},
"6": {
"class_type": "KSampler",
"inputs": {
"seed": random.randint(0, 2**31),
"steps": 20, # Recommended steps for SD 3.5 Medium
"cfg": 4.5, # Recommended CFG range for SD 3.5 Medium: 3.5 to 5.0
"sampler_name": "euler",
"scheduler": "sgm_uniform",
"denoise": 1.0,
"model": ["1", 0],
"positive": ["3", 0],
"negative": ["4", 0],
"latent_image": ["5", 0],
},
},
"7": {"class_type": "VAELoader", "inputs": {"vae_name": cfg["vae"]}},
"8": {
"class_type": "VAEDecode",
"inputs": {"samples": ["6", 0], "vae": ["7", 0]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": prefix, "images": ["8", 0]},
},
}
else:
print("No Image Model Selected Perfectly")
with M._data_lock:
M.model_status = "image_active"
M.tasks[task_id]["gen_prompt"] = prompt
M.tasks[task_id]["_image_model"] = model
M.tasks[task_id]["negative_prompt"] = negative_prompt
M.ensure_comfyui_running()
p_short = prompt[:200] + ("..." if len(prompt) > 200 else "")
M.set_status(task_id, f"Generating image ({model})... Prompt: {p_short}")
try:
r = requests.post(
f"{M.COMFYUI_URL}/prompt", json={"prompt": workflow}, timeout=120
)
data = r.json()
if "error" in data:
result = json.dumps({"error": f"ComfyUI: {data['error']}"})
else:
prompt_id = data["prompt_id"]
found_file = None
for _ in range(300):
time.sleep(1)
try:
hr = requests.get(f"{M.COMFYUI_URL}/history/{prompt_id}", timeout=10)
hist = hr.json()
if prompt_id in hist:
outputs = hist[prompt_id].get("outputs", {})
for node_id, node_out in outputs.items():
for img in node_out.get("images", []):
fname = img["filename"]
fpath = os.path.join(
M.COMFYUI_OUTPUT, img.get("subfolder", ""), fname
)
found_file = fpath
break
if found_file:
break
except Exception:
pass
if found_file:
with M._data_lock:
cancelled = bool(M.tasks.get(task_id, {}).get("status") == "cancelled")
if cancelled:
try:
if os.path.exists(found_file):
os.remove(found_file)
print(f"[image] Deleted orphaned image for cancelled task {task_id}: {found_file}")
except OSError:
pass
result = json.dumps({"error": "Cancelled — session was deleted"})
else:
M.tasks[task_id]["image_file"] = M._output_rel(found_file)
M.set_status(task_id, f"Image saved as {found_file}")
print(f"[generate_image] SUCCESS: {found_file}") # DEBUG
result = json.dumps(
{
"prompt_id": prompt_id,
"file": found_file,
"rel": M._output_rel(found_file),
}
)
else:
print(f"[generate_image] TIMEOUT for task {task_id} after 300s") # DEBUG
result = json.dumps({"error": "Image generation timeout"})
except Exception as e:
result = json.dumps({"error": str(e)})
finally:
M.set_status(task_id, "Freeing image generation VRAM...")
M.free_comfyui_vram()
M.set_status(task_id, "Loading chat model...")
M.load_llama_model("gpu")
return result
def edit_image(
prompt,
task_id,
image_b64,
negative_prompt="",
denoise=0.4,
model="z_image",
sid=None,
):
print("Image edit called with denoise", denoise)
user = M._task_user(task_id)
if not image_b64 and sid:
with M._data_lock:
msgs = list(M.sessions.get(sid, []))
print(f"[edit_image] Scanning {len(msgs)} session messages for image sources")
for msg in reversed(msgs):
# 1. Check generated image URL attribute (_image_url)
url = (msg.get("_image_url") or "").strip()
if url:
fname = os.path.join(M.IMG_PATH, M._image_url_rel(url))
fpath = fname
print(
f"[edit_image] Checking _image_url path={fpath} exists={os.path.exists(fpath)}"
)
if os.path.exists(fpath):
with open(fpath, "rb") as f:
image_b64 = base64.b64encode(f.read()).decode()
break
# 2. Check user-uploaded images stored in the message's content array
content = msg.get("content")
if isinstance(content, list):
for part in reversed(content):
if isinstance(part, dict) and part.get("type") == "image_url":
img_url = part.get("image_url", {}).get("url", "")
if img_url.startswith("data:image"):
# Extracted base64 string directly from user upload
image_b64 = img_url.split(",", 1)[-1]
print(
"[edit_image] Extracted base64 image from user message content"
)
break
fpath = M.resolve_image_path(img_url)
if fpath and os.path.exists(fpath):
with open(fpath, "rb") as f:
image_b64 = base64.b64encode(f.read()).decode()
print(
f"[edit_image] Loaded image from {img_url} ({len(image_b64)} bytes base64)"
)
break
if image_b64:
break
if not image_b64:
print("[edit_image] FAILED to find an image to edit")
return json.dumps({"error": "No image provided for editing."})
print(
f"[edit_image] Found image ({len(image_b64)} bytes base64), proceeding with edit"
)
print(f"\n[image_edit] Editing image for task {task_id} with prompt: {prompt}")
M.set_status(task_id, "Freeing VRAM for image editing...")
# ComfyUI renders on the GPU, so only the GPU llama-server is unloaded.
# The CPU server (self-chat agents) keeps running untouched.
M.unload_llama_model("gpu")
gen_tag = str(uuid.uuid4())[:8]
prefix = f"{_safe_username(user)}/edit_{gen_tag}_"
input_filename = f"{_safe_username(user)}/input_{gen_tag}.png"
input_dir = M.COMFYUI_INPUT
os.makedirs(os.path.dirname(os.path.join(input_dir, input_filename)), exist_ok=True)
input_filepath = os.path.join(input_dir, input_filename)
with open(input_filepath, "wb") as f:
f.write(base64.b64decode(image_b64))
cfg = M.IMAGE_MODELS.get(model, M.IMAGE_MODELS["z_image"])
workflow = {
"62": {
"class_type": "CLIPLoader",
"inputs": {"clip_name": cfg["clip1"], "type": "lumina2"},
},
"63": {"class_type": "VAELoader", "inputs": {"vae_name": cfg["vae"]}},
"66": {
"class_type": "UNETLoader",
"inputs": {"unet_name": cfg["unet"], "weight_dtype": "default"},
},
"67": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt, "clip": ["62", 0]},
},
"71": {
"class_type": "CLIPTextEncode",
"inputs": {"text": negative_prompt, "clip": ["62", 0]},
},
"69": {
"class_type": "ModelSamplingAuraFlow",
"inputs": {"shift": 3, "model": ["66", 0]},
},
"5_load": {"class_type": "LoadImage", "inputs": {"image": input_filename}},
"5_scale": {
"class_type": "ImageScaleToTotalPixels",
"inputs": {
"image": ["5_load", 0],
"megapixels": 1.049, # ~1024x1024
"upscale_method": "bicubic",
"resolution_steps": 1,
},
},
# Standard VAEEncode instead of VAEEncodeForInpaint
"5_encode": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["5_scale", 0], "vae": ["63", 0]},
},
"70": {
"class_type": "KSampler",
"inputs": {
"seed": random.randint(0, 2**31),
"steps": 8,
"cfg": 1.0,
"sampler_name": "res_multistep",
"scheduler": "simple",
"denoise": float(denoise), # Dynamically controls edit depth
"model": ["69", 0],
"positive": ["67", 0],
"negative": ["71", 0],
"latent_image": ["5_encode", 0],
},
},
"65": {
"class_type": "VAEDecode",
"inputs": {"samples": ["70", 0], "vae": ["63", 0]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": prefix, "images": ["65", 0]},
},
}
with M._data_lock:
M.model_status = "image_active"
M.tasks[task_id]["gen_prompt"] = prompt
M.tasks[task_id]["_image_model"] = model
M.tasks[task_id]["negative_prompt"] = negative_prompt
M.ensure_comfyui_running()
M.set_status(task_id, f"Editing image ({model})... Prompt: {prompt[:150]}")
try:
r = requests.post(
f"{M.COMFYUI_URL}/prompt", json={"prompt": workflow}, timeout=120
)
data = r.json()
if "error" in data:
result = json.dumps({"error": f"ComfyUI: {data['error']}"})
else:
prompt_id = data["prompt_id"]
found_file = None
for _ in range(300):
time.sleep(1)
try:
hr = requests.get(f"{M.COMFYUI_URL}/history/{prompt_id}", timeout=10)
hist = hr.json()
if prompt_id in hist:
outputs = hist[prompt_id].get("outputs", {})
for node_id, node_out in outputs.items():
for img in node_out.get("images", []):
fname = img["filename"]
found_file = os.path.join(
M.IMG_PATH, img.get("subfolder", ""), fname
)
break
if found_file:
break
except Exception:
pass
if found_file:
with M._data_lock:
cancelled = bool(M.tasks.get(task_id, {}).get("status") == "cancelled")
if cancelled:
try:
if os.path.exists(found_file):
os.remove(found_file)
print(f"[image] Deleted orphaned edited image for cancelled task {task_id}: {found_file}")
except OSError:
pass
result = json.dumps({"error": "Cancelled — session was deleted"})
else:
M.tasks[task_id]["image_file"] = M._output_rel(found_file)
M.set_status(task_id, f"Edited image saved as {found_file}")
result = json.dumps(
{
"prompt_id": prompt_id,
"file": found_file,
"rel": M._output_rel(found_file),
}
)
else:
result = json.dumps({"error": "Image editing timeout"})
except Exception as e:
result = json.dumps({"error": str(e)})
finally:
if os.path.exists(input_filepath):
try:
os.remove(input_filepath)
print(f"[edit_image] Cleaned up input file: {input_filepath}")
except Exception as e:
print(f"[edit_image] Failed to cleanup input file: {e}")
M.set_status(task_id, "Freeing image generation VRAM...")
M.free_comfyui_vram()
M.set_status(task_id, "Loading chat model...")
M.load_llama_model("gpu")
return result
def _enqueue_image_job(task_id, sid, tool_name, args, tc, round_num, tool_index):
"""Queue an image generation/edit job for the single image worker thread.
Image work is serialized so the VRAM choreography (llama unload / ComfyUI
/ free / reload) and the ``image_active`` model status never race, even when
CPU and GPU chat lanes process tasks concurrently. The job carries its
originating session so the finished image lands in the right conversation.
"""
with M._data_lock:
image_b64 = M.tasks.get(task_id, {}).get("_original_image")
M.set_status(task_id, "Queued for image generation...")
M._image_queue.put(
{
"task_id": task_id,
"sid": sid,
"tool_name": tool_name,
"args": args,
"tc_id": tc["id"],
"round": round_num,
"tool_index": tool_index,
"image_b64": image_b64,
}
)
print(f"[image_worker] Queued {tool_name} for task {task_id} (sid {sid})")
def _run_generate_image(task_id, args):
result = M.generate_image(
prompt=args.get("prompt", ""),
task_id=task_id,
negative_prompt=args.get("negative_prompt", ""),
model=args.get("model") or "z_image",
aspect_ratio=args.get("aspect_ratio") or "landscape",
)
res_data = json.loads(result)
if "file" in res_data:
rel = res_data.get("rel") or os.path.basename(res_data["file"])
image_url = f"/output/{rel}"
image_model_s = args.get("model") or "z_image"
with M._data_lock:
t = M.tasks.get(task_id)
if t:
t.setdefault("_tools_used", []).append("generate_image")
t["image_file"] = rel
t["gen_prompt"] = args.get("prompt", "")
t["_image_model"] = image_model_s
return json.dumps(
{
"image_url": image_url,
"prompt": args.get("prompt", ""),
"model": image_model_s,
}
)
return result
def _run_edit_image(task_id, sid, args, image_b64):
result = M.edit_image(
prompt=args.get("prompt", ""),
task_id=task_id,
image_b64=image_b64,
negative_prompt=args.get("negative_prompt", ""),
denoise=args.get("denoise", 0.4),
model="z_image",
sid=sid,
)
res_data = json.loads(result)
if "file" in res_data:
rel = res_data.get("rel") or os.path.basename(res_data["file"])
image_url = f"/output/{rel}"
with M._data_lock:
t = M.tasks.get(task_id)
if t:
t.setdefault("_tools_used", []).append("edit_image")
t["image_file"] = rel
t["gen_prompt"] = args.get("prompt", "")
t["_image_model"] = None
return json.dumps(
{
"image_url": image_url,
"prompt": args.get("prompt", ""),
"model": None,
}
)
return result
def _image_worker():
"""Run one image job at a time from the image queue.
On completion the worker posts the same ``tool_ok`` event the tool worker
would have, so the event loop, pending-tool counting and session attachment
are unchanged. Matching to the originating session is preserved through the
job's ``sid`` and the task-keyed ``image_file`` stored on ``tasks[task_id]``.
"""
while True:
job = M._image_queue.get()
if job.get("tool_name") == "__shutdown__":
break
task_id = job["task_id"]
with M._data_lock:
t = M.tasks.get(task_id)
if t is None or t.get("status") in ("cancelled", "error"):
continue
sid = job["sid"]
tool_name = job["tool_name"]
args = job["args"]
try:
if tool_name == "generate_image":
result = _run_generate_image(task_id, args)
print("Waiting 5s for GPU to cool down")
time.sleep(5)
elif tool_name == "edit_image":
result = _run_edit_image(task_id, sid, args, job.get("image_b64"))
print("Waiting 5s for GPU to cool down")
time.sleep(5)
else:
result = json.dumps({"error": f"Unknown image tool: {tool_name}"})
except Exception as e:
print(f"[image_worker] {tool_name} crashed for task {task_id}: {e}")
result = json.dumps({"error": f"Tool {tool_name} failed: {e}"})
M._event_post(
"tool_ok",
task_id,
tc_id=job["tc_id"],
result=result,
sid=sid,
round=job["round"],
tool_index=job["tool_index"],
)
</file>
<file path="server/features/monitoring.py">
"""Health monitoring, server lifecycle and the background maintenance loops.
Two llama-server processes run concurrently on separate ports:
* the **GPU** server on ``LLAMA_BASE`` (8081) for interactive chat UI users, and
* the **CPU** server on ``LLAMA_BASE_CPU`` (8079) for automated self-chat
agents.
Each is started, killed, health-checked and idle-unloaded independently so an
agent run never disturbs interactive users (and vice versa).
"""
import os
import subprocess
import time
from datetime import datetime
import requests
from server.features.state import M
_LLAMA_PORTS = {"gpu": "8081", "cpu": "8079"}
def model_status_snapshot():
# The UI reports the interactive (GPU) server's state.
with M._data_lock:
return {
"model": M.model_status,
"predicted_per_second": M._last_tps,
"overheated": M._overheated,
"gpu_temp": M._gpu_temp,
"ram_evacuating": M._ram_evacuating,
}
def get_gpu_temp():
try:
r = subprocess.run(
["nvidia-smi", "--query-gpu=temperature.gpu", "--format=csv,noheader"],
capture_output=True,
text=True,
timeout=5,
)
return int(r.stdout.strip())
except Exception:
return None
def get_ram_usage():
try:
r = subprocess.run(["free", "-m"], capture_output=True, text=True, timeout=5)
lines = r.stdout.strip().split("\n")
parts = lines[1].split()
total = int(parts[1])
available = int(parts[6])
return (total - available) / total * 100
except Exception:
return None
def kill_llama_server(mode=None):
"""Kill llama-server process(es).
``mode`` is ``"gpu"`` (port 8081), ``"cpu"`` (port 8079) or ``None`` to
kill both servers at once (emergency RAM evacuation, full restart).
"""
if mode is None:
subprocess.run(["pkill", "-f", "llama-server"], capture_output=True)
time.sleep(1)
subprocess.run(["pkill", "-9", "-f", "llama-server"], capture_output=True)
return
port = _LLAMA_PORTS[mode]
pattern = f"llama-server.*--port {port}"
subprocess.run(["pkill", "-f", pattern], capture_output=True)
time.sleep(1)
subprocess.run(["pkill", "-9", "-f", pattern], capture_output=True)
def kill_comfyui():
subprocess.run(["pkill", "-f", "main.py.*lowvram"], capture_output=True)
def _start_llama_process(args, mode="gpu"):
"""Launch a llama-server with the given argument list and wait for health."""
base = M.LLAMA_BASE_CPU if mode == "cpu" else M.LLAMA_BASE
log_dir = os.path.expanduser("~/local-ai-files")
llm_log = open(os.path.join(log_dir, f"{mode}-llama-server.log"), "a")
subprocess.Popen(
[M.LLAMA_SERVER_PATH] + args,
stdout=llm_log,
stderr=llm_log,
start_new_session=True,
)
deadline = time.time() + 120
while time.time() < deadline:
time.sleep(2)
try:
r = requests.get(f"{base}/health", timeout=3)
if r.status_code == 200:
print(f"[restart] llama-server ({mode}) healthy on {base}")
return True
except Exception:
pass
print(f"[restart] llama-server ({mode}) did not respond within 2 minutes — killing")
M.kill_llama_server(mode)
return False
def restart_llama_server(mode):
"""Restart the llama-server for ``mode`` (``"gpu"`` or ``"cpu"``) using its
own argument set and port, leaving the other server untouched."""
print(f"[llama] Restarting llama-server ({mode})")
M.kill_llama_server(mode)
time.sleep(1)
with M._data_lock:
if mode == "cpu":
M._cpu_model_status = "unloaded"
else:
M.model_status = "unloaded"
args = M.LLAMA_SERVER_ARGS if mode == "gpu" else M.LLAMA_SERVER_ARGS_CPU
_start_llama_process(args, mode)
def ensure_llama_server(mode):
"""Make sure the llama-server for ``mode`` is running, starting it if not."""
base = M.LLAMA_BASE_CPU if mode == "cpu" else M.LLAMA_BASE
if M.is_llama_alive(base):
return
print(f"[llama] {mode} llama-server not reachable — starting...")
restart_llama_server(mode)
def _ensure_llama_server_for_task(task_id):
"""Make sure the llama-server the task's author needs is running.
Tasks posted by agent users (self-chat: editor, moderator, ...) run on the
CPU server; tasks from interactive users use the GPU server.
"""
with M._data_lock:
if task_id not in M.tasks:
return
mode = M.task_mode(task_id)
M.ensure_llama_server(mode)
def _cpu_lane_needed():
"""True if the CPU self-chat lane has (or is about to have) work.
The CPU llama-server is only started when a self-chat agent is registered
or an agent task is queued/running on the cpu lane. This keeps the machine
from booting a second llama-server that nothing ever uses. Under the
test-time ``FORCE_GPU_LANE`` flag the CPU lane is never needed at all.
"""
if M.FORCE_GPU_LANE:
return False
with M._data_lock:
if M._agent_users:
return True
with M._queue_locks["cpu"]:
return len(M._task_queues["cpu"]) > 0 or M._current_task_ids["cpu"] is not None
def restart_servers():
print("Restarting servers")
M.kill_llama_server()
M.kill_comfyui()
time.sleep(1)
log_dir = os.path.expanduser("~/local-ai-files")
comfy_log = open(os.path.join(log_dir, "comfyui.log"), "a")
subprocess.Popen(
[
os.path.join(M.VENV_PYTHON),
"main.py",
"--output-directory",
M.COMFYUI_OUTPUT,
"--input-directory",
M.COMFYUI_INPUT,
"--lowvram",
],
cwd=M.COMFYUI_DIR,
stdout=comfy_log,
stderr=comfy_log,
start_new_session=True,
)
with M._data_lock:
M.model_status = "unloaded"
M._cpu_model_status = "unloaded"
_start_llama_process(M.LLAMA_SERVER_ARGS, "gpu")
# The CPU self-chat server only comes up on demand (first agent task).
if _cpu_lane_needed():
_start_llama_process(M.LLAMA_SERVER_ARGS_CPU, "cpu")
else:
print("[llama] Skipping CPU llama-server start (no agent lane activity)")
def ensure_comfyui_running():
try:
r = requests.get(f"{M.COMFYUI_URL}/prompt", timeout=3)
if r.status_code < 500:
return
except Exception:
pass
print("[comfyui] Not reachable — starting...")
M.kill_comfyui()
time.sleep(1)
log_dir = os.path.expanduser("~/local-ai")
comfy_log = open(os.path.join(log_dir, "comfyui.log"), "a")
subprocess.Popen(
[
os.path.join(M.VENV_PYTHON),
"main.py",
"--output-directory",
M.COMFYUI_OUTPUT,
"--input-directory",
M.COMFYUI_INPUT,
"--lowvram",
],
cwd=M.COMFYUI_DIR,
stdout=comfy_log,
stderr=comfy_log,
start_new_session=True,
)
deadline = time.time() + 120
while time.time() < deadline:
time.sleep(2)
try:
r = requests.get(f"{M.COMFYUI_URL}/prompt", timeout=3)
if r.status_code < 500:
print("[comfyui] Healthy")
return
except Exception:
pass
print("[comfyui] Did not respond within 2 minutes")
def _idle_unload_loop():
while True:
time.sleep(10)
# Each llama-server unloads independently once its own LANE has been
# idle for > 300s. This is checked per-lane (not combined) so a busy
# CPU self-chat agent can't keep the idle GPU model pinned in VRAM,
# and vice versa.
for mode in ("gpu", "cpu"):
with M._queue_locks[mode]:
queue_active = len(M._task_queues[mode]) > 0 or M._current_task_ids[mode] is not None
with M._data_lock:
ms = M._cpu_model_status if mode == "cpu" else M.model_status
lu = M._cpu_last_llm_use if mode == "cpu" else M._last_llm_use
if ms == "chat_loaded" and (time.time() - lu > 300) and not queue_active:
print(f"[idle] No {mode} LLM activity for 300s, releasing model weights...")
M.unload_llama_model(mode)
def _reminder_loop():
while True:
try:
now = datetime.now().isoformat()
due = M._db_fetch("SELECT * FROM tasks WHERE reminder_at IS NOT NULL AND reminder_at <= ? AND reminded=0 AND status NOT IN ('completed','cancelled')", (now,))
for task in due:
print(f"[reminder] Task '{task['title']}'. User: {task['user_id']}")
M._db_run("UPDATE tasks SET reminded=1 WHERE id=?", (task["id"],))
except Exception as e:
print(f"[reminder] Error: {e}")
time.sleep(43200)
def _evacuate_ram():
M._ram_evacuating = True
print("[ram] Emergency RAM evacuation")
# RAM pressure is whole-box, so both lanes (GPU/UI and CPU/agent) get
# their in-flight task requeued to the front of their own lane.
for mode in ("gpu", "cpu"):
with M._queue_locks[mode]:
tid = M._current_task_ids[mode]
if tid:
with M._data_lock:
t = M.tasks.get(tid)
if t and t.get("status") not in ("done", "error"):
entry = {
"task_id": tid,
"session_id": t.get("session_id", ""),
"message": t.get("_original_message", ""),
"image": t.get("_original_image"),
"user": t.get("_user", ""),
"client_timestamp": t.get("_client_timestamp"),
}
M._task_queues[mode].insert(0, entry)
t["status"] = "error"
t["error"] = "Server ran out of RAM — requeued"
t["_ram_evacuating"] = True
print(f"[ram] Requeued {mode} task {tid} to front of its lane")
M.kill_llama_server()
M.kill_comfyui()
print("[ram] Killed llama-server and ComfyUI")
while True:
time.sleep(5)
ram = M.get_ram_usage()
if ram is not None and ram <= M.RAM_RESUME_THRESHOLD:
print(f"[ram] RAM {ram:.0f}% ≤ {M.RAM_RESUME_THRESHOLD}%, restarting servers")
break
M.restart_servers()
M._ram_evacuating = False
def _thermal_monitor():
while True:
time.sleep(10)
temp = M.get_gpu_temp()
with M._data_lock:
M._gpu_temp = temp
if temp is not None and temp >= M.TEMP_THRESHOLD_ON:
if not M._overheated:
print(
f"[thermal] GPU {temp}°C >= {M.TEMP_THRESHOLD_ON}°C, OVERHEATED"
)
M._overheated = True
elif M._overheated and (temp is None or temp <= M.TEMP_THRESHOLD_OFF):
print(f"[thermal] GPU {temp}°C <= {M.TEMP_THRESHOLD_OFF}°C, resumed")
M._overheated = False
if M._overheated:
# Only the GPU lane's business matters here — unloading the GPU
# chat model / freeing ComfyUI VRAM should not be held up by an
# unrelated self-chat agent task running on the CPU lane.
with M._queue_locks["gpu"]:
busy = M._current_task_ids["gpu"] is not None
if not busy:
with M._data_lock:
ms = M.model_status
if ms == "chat_loaded":
print("[thermal] Overheated — unloading GPU chat model")
M.unload_llama_model("gpu")
elif ms == "image_active":
print("[thermal] Overheated — freeing ComfyUI VRAM")
M.free_comfyui_vram()
if not M._ram_evacuating:
ram = M.get_ram_usage()
if ram is not None and ram >= M.RAM_EVAC_THRESHOLD:
print(f"[ram] RAM usage {ram:.0f}% >= {M.RAM_EVAC_THRESHOLD}%")
M._evacuate_ram()
def _get_current_ipv6():
"""Get this machine's stable global IPv6 address."""
try:
iface = subprocess.check_output(
"ip -6 route show default | awk '{print $5; exit}'", shell=True, text=True
).strip()
output = subprocess.check_output(
f"ip -6 addr show {iface} scope global", shell=True, text=True
)
for line in output.splitlines():
if "inet6" in line and "temporary" not in line:
return line.split()[1].split("/")[0]
except Exception as e:
print(f"[ddns] Failed to get IPv6: {e}")
return None
def _get_wifi_ipv4():
"""This machine's LAN IPv4 on the default (WiFi) interface."""
try:
iface = subprocess.check_output(
"ip -4 route show default | awk '{print $5; exit}'", shell=True, text=True
).strip()
output = subprocess.check_output(
f"ip -4 addr show {iface} scope global", shell=True, text=True
)
for line in output.splitlines():
line = line.strip()
if line.startswith("inet ") and "secondary" not in line:
return line.split()[1].split("/")[0]
except Exception as e:
print(f"[heartbeat] Failed to get WiFi IPv4: {e}")
return None
_public_ipv4_cache = {"ip": None, "ts": 0.0}
def _get_public_ipv4():
"""Public WAN IPv4 as seen from the internet, cached for 5 minutes."""
now = time.time()
if _public_ipv4_cache["ip"] and now - _public_ipv4_cache["ts"] < 300:
return _public_ipv4_cache["ip"]
for url in ("https://api.ipify.org", "https://ifconfig.me/ip"):
try:
r = requests.get(url, timeout=5)
ip = r.text.strip()
if r.status_code == 200 and ip.count(".") == 3:
_public_ipv4_cache.update(ip=ip, ts=now)
return ip
except Exception:
pass
print("[heartbeat] Could not determine public IPv4")
return _public_ipv4_cache["ip"]
def _send_heartbeat():
"""POST this machine's addresses to the GCP receiver over the tunnel."""
payload = {
"ipv6": _get_current_ipv6(),
"public_ipv4": _get_public_ipv4(),
"wifi_ipv4": _get_wifi_ipv4(),
}
r = requests.post(M.HEARTBEAT_URL, json=payload, timeout=5)
r.raise_for_status()
return payload
def _ddns_enabled():
"""True when the GoDaddy API credentials are available in the environment."""
return bool(M.GODADDY_API_KEY and M.GODADDY_API_SECRET)
def _get_current_ipv6():
"""Get this machine's stable global IPv6 address."""
try:
iface = subprocess.check_output(
"ip -6 route show default | awk '{print $5; exit}'", shell=True, text=True
).strip()
output = subprocess.check_output(
f"ip -6 addr show {iface} scope global", shell=True, text=True
)
for line in output.splitlines():
if "inet6" in line and "temporary" not in line:
return line.split()[1].split("/")[0]
except Exception as e:
print(f"[ddns] Failed to get IPv6: {e}")
return None
def _update_godaddy_aaaa(new_ip):
url = f"https://api.godaddy.com/v1/domains/{M.DDNS_DOMAIN}/records/AAAA/{M.DDNS_SUBDOMAIN}"
headers = {
"Authorization": f"sso-key {M.GODADDY_API_KEY}:{M.GODADDY_API_SECRET}",
"Content-Type": "application/json",
}
resp = requests.put(url, headers=headers, json=[{"data": new_ip, "ttl": 600}])
if resp.status_code == 200:
print(f"[ddns] GoDaddy AAAA updated to {new_ip}")
return True
else:
print(f"[ddns] GoDaddy update failed ({resp.status_code}): {resp.text}")
return False
_last_dns_check = 0
_last_known_ipv6 = None
def maybe_update_dns():
"""Call on every ConnectionManager tick — self-throttles to DDNS_CHECK_INTERVAL."""
global _last_dns_check, _last_known_ipv6
if not _ddns_enabled():
return
interval = M.DDNS_CHECK_INTERVAL or 300
now = time.time()
if now - _last_dns_check < interval:
return # not time yet, skip
_last_dns_check = now
current_ip = _get_current_ipv6()
if not current_ip:
return
if current_ip != _last_known_ipv6:
if _update_godaddy_aaaa(current_ip):
_last_known_ipv6 = current_ip
def _connection_manager():
while True:
try:
payload = _send_heartbeat()
print(f"[+] heartbeat sent: {payload}")
except Exception as e:
print(f"[-] GCP unreachable ({M.HEARTBEAT_URL}): {e}")
# Keep the GoDaddy AAAA record pointed at this machine's IPv6.
maybe_update_dns()
time.sleep(10)
</file>
<file path="local_cloud.sh">
#!/usr/bin/env bash
set -e
echo "=== 1. Setting permissions on external media drive ==="
# sudo chown -R www-data:www-data /mnt/wwn-0x50014ee2173893e0-part1/BackUp-Copy-2/
# sudo chmod -R 0750 /mnt/wwn-0x50014ee2173893e0-part1/BackUp-Copy-2/
echo "=== 2. Creating Dashboard HTML ==="
sudo mkdir -p /var/www/dashboard
cat << 'EOF' | sudo tee /var/www/dashboard/index.html > /dev/null
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Homeserver Dashboard</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0d1117; color: #c9d1d9; display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; width: 100%; max-width: 900px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 24px; text-decoration: none; color: inherit; transition: transform 0.2s, border-color 0.2s; text-align: center; }
.card:hover { transform: translateY(-4px); border-color: #58a6ff; }
.icon { font-size: 40px; margin-bottom: 12px; }
.title { font-size: 18px; font-weight: 600; color: #58a6ff; margin-bottom: 6px; }
.desc { font-size: 13px; color: #8b949e; }
</style>
</head>
<body>
<div class="grid">
<a href="/ai/" class="card">
<div class="icon">🤖</div>
<div class="title">Local AI</div>
<div class="desc">/ai/</div>
</a>
<a href="/stories/" class="card">
<div class="icon">📖</div>
<div class="title">AI Generated Stories</div>
<div class="desc">/stories/</div>
</a>
<a href="/search/" class="card">
<div class="icon">🔍</div>
<div class="title">Search Engine</div>
<div class="desc">/search/</div>
</a>
<a href="/cloud/" class="card">
<div class="icon">☁️</div>
<div class="title">Nextcloud</div>
<div class="desc">/cloud/</div>
</a>
<a href="/cloud/apps/files/files/136?dir=/Media/Public/E-books/" class="card">
<div class="icon">📚</div>
<div class="title">Books</div>
<div class="desc">E-books</div>
</a>
<a href="/code/" class="card">
<div class="icon">💻</div>
<div class="title">Code Hoster</div>
<div class="desc">/code/</div>
</a>
<a href="/track/" class="card">
<div class="icon">📊</div>
<div class="title">Request Tracker</div>
<div class="desc">/track/</div>
</a>
</div>
</body>
</html>
EOF
sudo chown -R www-data:www-data /var/www/dashboard
sudo chmod -R 755 /var/www/dashboard
echo "=== 3. Writing Nginx Master Configuration ==="
cat << 'EOF' | sudo tee /etc/nginx/sites-available/homeserver > /dev/null
map $http_x_via_gcp $gcp_overlay {
"true" '<div id="gcp-overlay" style="position:fixed;top:0;left:0;width:100vw;height:100vh;background-color:rgba(239,68,68,0.05);border-top:3px solid rgba(239,68,68,0.6);pointer-events:none;z-index:999998;"></div>';
default '';
}
map $http_user_agent $cloud_inject {
"~*nextcloud-(android|ios|desktop)" '';
default '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
}
upstream ak_outpost {
server 127.0.0.1:9010;
}
upstream ak_server {
server 127.0.0.1:9008;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
log_format track escape=json '{"time":"$time_iso8601","gcp":"$http_x_via_gcp","ip":"$remote_addr","method":"$request_method","uri":"$request_uri","status":$status,"in":$request_length,"out":$bytes_sent,"ua":"$http_user_agent"}';
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name home.palashkantikundu.in;
ssl_certificate /etc/letsencrypt/live/home.palashkantikundu.in/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/home.palashkantikundu.in/privkey.pem;
client_max_body_size 512M;
client_body_buffer_size 128k;
access_log /var/log/nginx/track.log track;
# Expose Outpost traffic directly so browser auth flow and internal calls do not fail
location /outpost.goauthentik.io/ {
proxy_pass http://ak_outpost;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
# Internal auth checking block
location /ak-auth-ai {
internal;
proxy_pass http://ak_outpost/outpost.goauthentik.io/auth/nginx;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
# Redirect unauthenticated users directly to Outpost start portal
location @ak-sso-ai {
internal;
return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
}
location / {
root /var/www/dashboard;
index index.html;
sub_filter_once off;
sub_filter_types text/html;
sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
}
# Authentik SSO Core (matches both /sso and /sso/*)
location /sso {
proxy_pass http://ak_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
}
# 1. Local AI App
location /ai/ {
auth_request /ak-auth-ai;
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
error_page 401 = @ak-sso-ai;
proxy_pass http://127.0.0.1:3001/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Accept-Encoding "";
proxy_set_header X-Authentik-Username $authentik_username;
proxy_set_header X-Authentik-Groups $authentik_groups;
proxy_set_header X-Authentik-Email $authentik_email;
proxy_set_header X-Authentik-Name $authentik_name;
proxy_set_header X-Authentik-UID $authentik_uid;
sub_filter_once off;
sub_filter_types text/html;
# Global nav pill injected into the Local AI page. Styling lives in a
# <style> block (not inline) so it can go responsive: on desktop it
# floats at the top-right as before; on phones (<=600px) that spot is
# ON TOP of the Local AI busy/model header (#model-bar is fixed,
# 48px tall, full-width there), so it drops to just BELOW the header
# band instead and shrinks/scrolls horizontally.
sub_filter '</body>' '$gcp_overlay<style>#global-nav-bar{position:absolute;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px)}#global-nav-bar a{white-space:nowrap}@media(min-width:601px){#global-nav-bar{top:8px;right:80px}}@media(max-width:600px){#global-nav-bar{top:56px;right:8px;padding:4px 10px;font-size:11px;gap:6px;max-width:calc(100vw - 16px);overflow-x:auto}}</style><div id="global-nav-bar"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
}
location ^~ /api/public/ {
proxy_pass http://127.0.0.1:3001/api/public/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location /api/ {
auth_request /ak-auth-ai;
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
error_page 401 = @ak-sso-ai;
proxy_pass http://127.0.0.1:3001/api/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Authentik-Username $authentik_username;
proxy_set_header X-Authentik-Groups $authentik_groups;
proxy_set_header X-Authentik-Email $authentik_email;
proxy_set_header X-Authentik-Name $authentik_name;
proxy_set_header X-Authentik-UID $authentik_uid;
}
# 2. Chat Stories App
location /stories/ {
auth_request /ak-auth-ai;
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
error_page 401 = @ak-sso-ai;
proxy_pass http://127.0.0.1:3002/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Accept-Encoding "";
proxy_set_header X-Authentik-Username $authentik_username;
proxy_set_header X-Authentik-Groups $authentik_groups;
proxy_set_header X-Authentik-Email $authentik_email;
proxy_set_header X-Authentik-Name $authentik_name;
proxy_set_header X-Authentik-UID $authentik_uid;
sub_filter_once off;
sub_filter_types text/html;
sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
}
location /story/ {
auth_request /ak-auth-ai;
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
error_page 401 = @ak-sso-ai;
proxy_pass http://127.0.0.1:3002/story/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Authentik-Username $authentik_username;
proxy_set_header X-Authentik-Groups $authentik_groups;
proxy_set_header X-Authentik-Email $authentik_email;
proxy_set_header X-Authentik-Name $authentik_name;
proxy_set_header X-Authentik-UID $authentik_uid;
sub_filter_once off;
sub_filter_types text/html;
sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
}
location /media/ {
auth_request /ak-auth-ai;
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
error_page 401 = @ak-sso-ai;
proxy_pass http://127.0.0.1:3002/media/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Authentik-Username $authentik_username;
proxy_set_header X-Authentik-Groups $authentik_groups;
proxy_set_header X-Authentik-Email $authentik_email;
proxy_set_header X-Authentik-Name $authentik_name;
proxy_set_header X-Authentik-UID $authentik_uid;
}
# 3. SearXNG
location /search/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Script-Name /search;
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types text/html;
sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
}
# 4. Nextcloud
location /cloud/ {
proxy_pass http://127.0.0.1:8082/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_max_temp_file_size 2048m;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types text/html;
sub_filter '</body>' '$cloud_inject';
}
location /.well-known/carddav { return 301 $scheme://$host/cloud/remote.php/dav; }
location /.well-known/caldav { return 301 $scheme://$host/cloud/remote.php/dav; }
# 5. Code Hoster
location /code/ {
proxy_pass http://127.0.0.1:9000/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types text/html;
sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;bottom:16px;right:16px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
}
# 6. Request tracking dashboard
location /track/ {
auth_request /ak-auth-ai;
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
error_page 401 = @ak-sso-ai;
proxy_pass http://127.0.0.1:8093/;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Accept-Encoding "";
proxy_set_header X-Authentik-Username $authentik_username;
proxy_set_header X-Authentik-Groups $authentik_groups;
proxy_set_header X-Authentik-Email $authentik_email;
proxy_set_header X-Authentik-Name $authentik_name;
proxy_set_header X-Authentik-UID $authentik_uid;
sub_filter_once off;
sub_filter_types text/html;
sub_filter '</body>' '$gcp_overlay<div id="global-nav-bar" style="position:absolute;top:8px;right:80px;z-index:999999;display:flex;gap:8px;background:rgba(22,27,34,0.95);padding:6px 12px;border-radius:20px;border:1px solid #30363d;box-shadow:0 4px 12px rgba(0,0,0,0.5);font-family:sans-serif;font-size:13px;backdrop-filter:blur(4px);"><a href="/" style="color:#58a6ff;text-decoration:none;font-weight:600;">Home</a><span style="color:#484f58;">|</span><a href="/ai/" style="color:#c9d1d9;text-decoration:none;">AI</a><a href="/stories/" style="color:#c9d1d9;text-decoration:none;">Stories</a><a href="/code/" style="color:#c9d1d9;text-decoration:none;">Code</a><a href="/search/" style="color:#c9d1d9;text-decoration:none;">Search</a><a href="/cloud/" style="color:#c9d1d9;text-decoration:none;">Cloud</a><a href="/track/" style="color:#c9d1d9;text-decoration:none;">Track</a></div></body>';
}
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name home.palashkantikundu.in;
return 301 https://$host$request_uri;
}
EOF
echo "=== 4. Reloading Nginx ==="
sudo mkdir -p /var/log/nginx
sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -sf /etc/nginx/sites-available/homeserver /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
echo "=== 4b. Request Tracking Dashboard ==="
sudo touch /var/log/nginx/track.log
sudo chmod 640 /var/log/nginx/track.log
if [ -n "$CHAT_DIR" ] && [ -f "$CHAT_DIR/server/track_dashboard.py" ]; then
if ! pgrep -f "server/track_dashboard.py" > /dev/null; then
nohup python3 "$CHAT_DIR/server/track_dashboard.py" --log /var/log/nginx/track.log \
> /tmp/track_dashboard.log 2>&1 &
echo " Request Tracker started on http://localhost:8093/track/"
else
echo " Request Tracker already running"
fi
else
echo " Skipping python tracker start: \$CHAT_DIR is unset or file missing."
fi
echo "=== 5. Dynamic Nextcloud Container Configuration ==="
NC_CONTAINER=$(docker ps --format '{{.Names}}' | grep -E 'cloud-app|nextcloud' | head -n 1)
if [ -n "$NC_CONTAINER" ]; then
echo "Found Nextcloud container: $NC_CONTAINER"
docker exec --user www-data "$NC_CONTAINER" php occ files:scan --all
docker exec --user www-data "$NC_CONTAINER" php occ config:system:set overwritewebroot --value="/cloud"
docker exec --user www-data "$NC_CONTAINER" php occ config:system:set overwrite.cli.url --value="https://home.palashkantikundu.in/cloud"
docker exec --user www-data "$NC_CONTAINER" php occ config:system:set overwriteprotocol --value="https"
docker exec --user www-data "$NC_CONTAINER" php occ config:system:set trusted_domains 0 --value="*"
docker exec --user www-data "$NC_CONTAINER" php occ config:system:set files_external_allow_create_new_local --value="true" --type=boolean
docker exec --user www-data "$NC_CONTAINER" php occ app:enable files_external
echo "Restarting Nextcloud container..."
docker restart "$NC_CONTAINER"
else
echo "Error: Nextcloud container not found!"
exit 1
fi
echo "=== All tasks completed successfully ==="
</file>
<file path="prompts/self_chat.txt">
System Prompt: Kaya Kolpo Task Execution Pipeline
Assigned Task: %task%
Task Details: %details%
Allowed Mediums/Tools: %mediums%
Target Language(s): %_lang%
Relationship Dynamic: %relationship%
Mood / Tone: %mood%
Kaya's Role: %kaya_role%
Kaya's Persona: %kaya_persona%
Kolpo's Role: %kolpo_role%
Kolpo's Persona: %kolpo_persona%
AGENTS ROLE:
- Kaya and Kolpo are the best performers, and both maintain exceptional creative quality.
- Kaya and Kolpo are the executors of %task%. Task Details (if not "None") are
mandatory instructions, not suggestions — for example, if the details say to
perform a web search before writing, that search MUST happen via a real
web_search tool call before any story content is written.
- Maintain their core personalities in tone, but focus entirely on completing %task%.
- The assigned task details, cultural setting, and theme provided in the system
directive are strict constraints. You must NOT alter, substitute, or debate the
core theme details (e.g., changing regions, attire, or core premise).
- Each turn must introduce one new concrete event and progress the content, not just intensify the prior beat.
NAMING RULES (HARD RULE):
- The names "Kaya", "Kolpo", "কায়া", "কল্প", "काया", "कल्प" may appear ONLY inside the [NEXT TURN: ...] hand-off tag.
- NEVER use these names anywhere in the story content: not in narration, dialogue, character names, image prompts, captions, or headings.
- NEVER use your own name or your partner's name as a fictional character's name. Invent original character names.
- If a hand-off tag is used, spell the names exactly as: Bengali "কায়া"/"কল্প", Hindi "काया"/"कल्प", English "Kaya"/"Kolpo".
Execution Rules for Collaboration:
1. Language Constraint:
- All dialogue, generation, and output MUST strictly use: %_lang%.
2. Lifecycle & Phase Execution:
- Phase 1: Alignment (0% - 10% Progress): Agree on execution plan and split responsibilities in Turn 1. This is also when the CONTENT THEME TRACKING PROTOCOL (section 4c) below MUST be run, before any content is written.
- Phase 2: Direct Execution (10% - 90% Progress): EXECUTE TASK DELIVERABLES DIRECTLY. Minimize meta-talk or unneeded discussion. Alternate turns adding direct value to the assigned %task% (writing code, conducting research, generating images, drafting content, etc.).
- Phase 3: Convergence (90% - 100% Progress): Consolidate results, resolve remaining items, and format final deliverable.
2a. Research Tasks run in TWO passes:
- RESEARCH PASS: the first research turn(s) of each agent — Kaya's Turn 1 and
Kolpo's Turn 1, unless the task says otherwise — are research-ONLY. In these
turns you web-search, fetch pages, and gather sourced material. You do NOT
write any story/deliverable content and you do NOT open a [CONTENT] block.
Write your findings and their sources in plain text so your partner can read
them, then end with the [NEXT TURN: ...] hand-off tag.
- CONTENT PASS: once both agents have shared their research, every later turn
is content mode. Write the actual deliverable inside [CONTENT]...[/CONTENT]
using all the research materials shared above. The shared web-search results
become the story's Citations & References automatically — refer to them but
never write the citations section yourself.
3. Turn Alternation & Hand-Off:
- Focus turns strictly on executing %task%.
- Do not address your partner by name in the text. Address hand-off ONLY using the system tag at the very end:
- Kaya MUST end with: [NEXT TURN: Kolpo]
- Kolpo MUST end with: [NEXT TURN: Kaya]
3a. Content Tagging (HARD RULE):
- Wrap ONLY the actual narrative/deliverable content — the text that
belongs in the final published piece — inside [CONTENT] and [/CONTENT]
tags.
- Anything OUTSIDE these tags (planning talk, acknowledgments, phase
discussion, "sounds good, let's do X") is discarded automatically and
will NEVER appear in the final piece. Do not rely on it being read by
anyone but your partner.
- Phase 1 (Alignment) turns are typically pure planning — it is fine and
expected for a Phase 1 turn to contain NO [CONTENT] block.
- Every Phase 2/3 turn that adds real deliverable content MUST wrap that
content in [CONTENT]...[/CONTENT]. Multiple blocks in one turn are
allowed and will be concatenated in order.
- The [NEXT TURN: ...] hand-off tag and [END CONVERSATION] must always
stay OUTSIDE the [CONTENT] block.
4. Mandatory Tool Execution Directives:
- You have full autonomy to use tools during your work. Proactively perform
web searches to gather real-time data/news, generate images, or edit images
whenever appropriate or required by the topic — without waiting for explicit
user instructions.
- To generate/search/edit, you MUST issue a true native function/tool call.
- CRITICAL: NEVER output raw text representations, brackets, or pseudo-code such as "[ACTION: generate_image]", "[z_image: ...]", or "[web_search: ...]".
- Writing tool tags as raw chat text DOES NOT execute the tool and WILL corrupt your turn.
- If you cannot trigger a tool natively, write the descriptive scene or narrative directly into your response text—do not write placeholder tags.
- If Task Details require a specific tool (e.g. a web search) and no such tool is available to you this turn, say so plainly in your turn instead of pretending you searched.
- ONCE ANY CONTENT IS GENERATED, CHECK THE TASK LIST — if any task in the
pending status got completed in your content, mark that `THEME` complete.
4a. Citations Section (NEVER write it yourself):
- Never write a "## Citations & References" heading or any references/sources list
in your chat text. The system appends the real web-source references automatically
at the very end. Only call the `web_search` tool to gather material; the collected
results will become the references.
4b. WEB SEARCH & TASK TRACKING PROTOCOL (HARD RULE):
1. BEFORE calling `web_search`:
- ALWAYS run `manage_tasks(operation="list")` to view completed search topics.
- Do NOT run a search if a task for that specific topic or query already exists and is marked "completed".
2. AFTER calling `web_search`:
- IMMEDIATELY record the search topic and key finding into the todo system:
`manage_tasks(operation="create", title="Searched: [Topic]", description="[Brief 1-sentence finding] on [Date/Time]", status="completed")`
- If a topic is stale (e.g., older than the required timeframe specified in the task date), create a new search task with the updated date before searching again.
3. REPETITION PREVENTION:
- Check existing completed search tasks before writing. Build ON TOP of existing search facts — NEVER re-search or restate facts already logged in the todo list.
5. Termination Trigger:
- In Phase 3, once %task% is fully executed and final deliverables are clear, append [END CONVERSATION] to terminate.
</file>
<file path="prompts/tasks.json">
{
"tasks": [
{
"task": "Satire column on different office cultures",
"inactive": true,
"turns": 3,
"details": [
{
"name": "style",
"value": "Draft a witty, humorous satirical piece focusing strictly on institutional work culture as a whole — do not name, reference, or caricature any real company, brand, or individual."
},
{
"name": "target",
"selector": "roundrobin",
"values": [
"reply-all email threads",
"endless status meetings",
"buzzword-heavy memos",
"open-plan offices",
"performance reviews"
]
},
{
"name": "angle",
"selector": "random",
"values": [
"a 'day in the life' narrative",
"a mock corporate memo",
"a series of absurd office observations",
"a parody of corporate advice columns"
]
},
{
"name": "time_setting",
"ref": "time_of_day",
"change_freq": "Per Round"
},
{
"name": "lighting",
"ref": "lighting_ambience",
"change_freq": "Per Round"
},
{
"name": "art_medium",
"ref": "feature_art_medium",
"change_freq": "Per Round"
},
{
"name": "image",
"value": "Include exactly one absurd, humorously stylized image illustration matching the target corporate trope."
}
],
"genre": "Satire",
"roles": [
"free"
],
"languages": [
"English"
],
"mediums": [
"image",
"text"
]
},
{
"task": "A bedtime story for kids",
"inactive": true,
"turns": 5,
"details": [
{
"name": "research",
"value": "Perform a web search to find one recent, real, lighthearted news event appropriate for children aged 5 to 12. Name the specific source and date in your own working notes, and base the plot on that one real event — do not invent a generic 'funny news story' without a real search result behind it."
},
{
"name": "audience age",
"ref": "kids_age_group",
"change_freq": "Per Round"
},
{
"name": "hero",
"ref": "kids_hero",
"change_freq": "Per Round",
"character": true,
"names": [
"Barnaby",
"Noor",
"Milo",
"Luna",
"Toto",
"Zara",
"Cookie",
"Rumi"
]
},
{
"name": "hero's companion",
"ref": "story_animals",
"selector": "random",
"change_freq": "Per Round",
"character": true,
"names": [
"Pip",
"Daisy",
"Rusty",
"Bella",
"Momo",
"Biscuit",
"Sparks",
"Willa"
]
},
{
"name": "setting",
"ref": "setting_cozy",
"change_freq": "Per Round"
},
{
"name": "time of day",
"ref": "time_of_day",
"change_freq": "Per Round"
},
{
"name": "lighting_ambience",
"ref": "lighting_ambience",
"change_freq": "Per Round"
},
{
"name": "mood",
"ref": "mood_cozy",
"change_freq": "Per Round"
},
{
"name": "image",
"value": "Include exactly one warm, child-friendly embedded image illustration, generated via the real image-generation tool call (not just described in prose), matching the climax scene and reflecting the hero, companion, setting, and lighting specified above."
}
],
"genre": "Bedtime Stories",
"roles": [
"free"
],
"languages": [
"bengali",
"English"
],
"mediums": [
"image",
"text"
]
},
{
"task": "A day at the farm",
"inactive": true,
"turns": 4,
"details": [
{
"name": "research",
"value": "No web search needed. Describe an ordinary, warm day on a small countryside farm through the eyes of a child visitor."
},
{
"name": "animals",
"selector": "random_multi",
"count": {
"selector": "random",
"values": [
2,
3
]
},
"ref": "story_animals"
},
{
"name": "hero",
"ref": "kids_hero"
},
{
"name": "time",
"ref": "time_of_day"
},
{
"name": "mood",
"ref": "mood_cozy"
},
{
"name": "image",
"value": "Include exactly one warm, child-friendly embedded image illustration of the farm scene."
}
],
"genre": "Bedtime Stories",
"roles": [
"free"
],
"languages": [
"English",
"bengali"
],
"mediums": [
"image",
"text"
]
},
{
"task": "A science article for kids",
"inactive": false,
"research": true,
"turns": 6,
"details": [
{
"name": "research",
"value": "Perform a research for a fascinating, real-world science update or curious fact, then write the research report in an engaging kids story format."
},
{
"name": "topic",
"selector": "roundrobin",
"values": [
"animals and wildlife",
"space and astronomy",
"oceans and climate",
"the human body",
"technology and robots"
]
},
{
"name": "angle",
"selector": "random",
"values": [
"a surprising 'did you know' fact",
"a mystery scientists are still solving",
"a recent breakthrough explained simply",
"a myth that turned out to be true"
]
}
],
"genre": "Education",
"roles": [
"free"
],
"languages": [
"English",
"bengali",
"Hindi"
],
"mediums": [
"text",
"image"
]
},
{
"task": "Traditional Festival Sweet Recipe & Cultural Story",
"inactive": true,
"turns": 5,
"details": [
{
"name": "structure",
"value": "Draft a detailed recipe guide for a real, traditional festival sweet: exact ingredient list with clear measurements, numbered step-by-step preparation instructions, and brief cultural context about its festive significance. Embed an image matching the finished dish."
},
{
"name": "festival",
"selector": "roundrobin",
"values": [
"Diwali",
"Eid",
"Durga Puja",
"Pongal",
"Christmas",
"Bengali New Year"
]
},
{
"name": "sweet",
"selector": "random",
"values": [
"gulab jamun",
"sandesh",
"rasgulla",
"payesh",
"barfi",
"gujia",
"halwa"
]
}
],
"genre": "Food & Recipes",
"roles": [
"free"
],
"languages": [
"English",
"bengali",
"Hindi"
],
"mediums": [
"image",
"text"
]
},
{
"task": "Futuristic Invention Pitch & Conceptual Breakdown",
"inactive": true,
"turns": 5,
"details": [
{
"name": "research",
"value": "Perform a web search on emerging technology trends, then conceptualize an innovative, futuristic product or invention. Provide a detailed breakdown covering its core mechanism, real-world application, and potential impact."
},
{
"name": "domain",
"selector": "roundrobin",
"values": [
"sustainable energy",
"smart urban living",
"productivity tools",
"health and medicine",
"transportation"
]
},
{
"name": "format",
"selector": "random",
"values": [
"an elevator pitch",
"a shark-tank style pitch script",
"a formal proposal",
"a journalist's feature story"
]
}
],
"genre": "News",
"roles": [
"free"
],
"languages": [
"English",
"bengali"
],
"mediums": [
"text"
]
},
{
"task": "Bone-Chilling Real-World Adventure",
"inactive": true,
"turns": 7,
"details": [
{
"name": "research",
"value": "Perform a web search to research a real historical survival, exploration, or wilderness incident. Write a suspenseful, realistic narrative capturing the psychological dread and physical struggle. All core events must remain grounded in verified real-world facts."
},
{
"name": "setting",
"ref": "setting_adventure"
},
{
"name": "tone",
"ref": "mood_horror"
}
],
"genre": "Adventure & Horror",
"roles": [
"premium"
],
"languages": [
"English",
"bengali",
"Hindi"
],
"mediums": [
"text"
]
},
{
"task": "Psychological Horror Short Story",
"inactive": true,
"turns": 6,
"details": [
{
"name": "style",
"value": "Craft an atmospheric horror tale centered on psychological tension, isolation, or supernatural uncanny elements. Focus on sensory horror, pacing, and creeping dread rather than shock value. Ensure the narrative resolves with an unsettling climax."
},
{
"name": "setting",
"ref": "setting_horror"
},
{
"name": "trope",
"ref": "trope_horror"
}
],
"genre": "Adventure & Horror",
"roles": [
"premium"
],
"languages": [
"English",
"bengali"
],
"mediums": [
"image",
"text"
]
},
{
"task": "Historical Mystery & Unsolved Legend",
"inactive": true,
"turns": 6,
"details": [
{
"name": "research",
"value": "Perform a web search for a famous unsolved historical mystery or local folklore. Draft an intriguing narrative exploration around the event that balances historical facts with atmospheric storytelling."
},
{
"name": "mystery",
"selector": "roundrobin",
"values": [
"a vanished civilization",
"an unsolved disappearance",
"a cursed artifact",
"a sea or lake legend",
"a lost treasure"
]
},
{
"name": "frame",
"selector": "random",
"values": [
"a detective's cold-case notes",
"a traveler's journal",
"an oral retelling by a village elder",
"a researcher's investigation"
]
}
],
"genre": "Adventure & Horror",
"roles": [
"premium"
],
"languages": [
"English",
"bengali",
"Hindi"
],
"mediums": [
"text"
]
},
{
"task": "Cybernetic Breakthrough Investigation",
"inactive": true,
"turns": 4,
"details": [
{
"name": "research",
"value": "Perform a web search on recent cybernetic breakthroughs, ensuring all core facts trace directly to search results."
},
{
"name": "domain",
"ref": "scifi_domain"
},
{
"name": "format",
"selector": "random",
"values": [
"an investigative reporter's log",
"a sci-fi thriller chapter",
"a confidential lab report breakdown"
]
}
],
"genre": "Sci-Fi",
"roles": [
"free"
],
"languages": [
"English",
"bengali"
],
"mediums": [
"text"
]
},
{
"task": "High-Stakes Surveillance Intercept",
"inactive": true,
"turns": 4,
"details": [
{
"name": "style",
"value": "Craft a high-pacing thriller narrative around intercepted classified data, maintaining constant suspense and claustrophobic tension."
},
{
"name": "setting",
"ref": "setting_horror"
},
{
"name": "threat",
"ref": "thriller_threat"
}
],
"genre": "Thriller",
"roles": [
"premium"
],
"languages": [
"English",
"bengali",
"Hindi"
],
"mediums": [
"text"
]
},
{
"task": "The Locked-Vault Disappearance",
"inactive": true,
"turns": 5,
"details": [
{
"name": "style",
"value": "Draft an atmospheric detective mystery where all critical clues are introduced before the final reveal. Ensure logical steps lead to the resolution."
},
{
"name": "mystery",
"ref": "detective_mystery"
},
{
"name": "frame",
"selector": "random",
"values": [
"a detective's cold-case notes",
"a private investigator's interrogation transcript",
"a lead researcher's journal"
]
}
],
"genre": "Detective",
"roles": [
"premium"
],
"languages": [
"English",
"bengali"
],
"mediums": [
"text"
]
},
{
"task": "Extreme Wilderness Rescue Expedition",
"inactive": true,
"turns": 4,
"details": [
{
"name": "research",
"value": "Perform a web search to research a real historical survival or wilderness incident. Ground the physical struggle and atmospheric danger in verified facts."
},
{
"name": "setting",
"ref": "setting_adventure"
},
{
"name": "tone",
"ref": "mood_horror"
}
],
"genre": "Adventure",
"roles": [
"premium"
],
"languages": [
"English",
"bengali",
"Hindi"
],
"mediums": [
"text"
]
},
{
"task": "Flash News Daily Briefing",
"inactive": true,
"turns": 2,
"details": [
{
"name": "research",
"value": "Perform a web search for top daily news updates. Provide a concise, neutral summary covering multiple distinct items with explicit citations."
},
{
"name": "topic",
"ref": "news_topics"
}
],
"genre": "News Bytes",
"roles": [
"free"
],
"languages": [
"English",
"bengali",
"Hindi"
],
"mediums": [
"text"
]
},
{
"task": "Headline Adapted Human Interest Narrative",
"inactive": true,
"turns": 4,
"details": [
{
"name": "research",
"value": "Perform a web search to find a recent lighthearted or remarkable real-world news story, then adapt it into an engaging character-driven narrative."
},
{
"name": "angle",
"selector": "random",
"values": [
"an unsung hero local perspective",
"a day-in-the-life narrative adaptation",
"a heartwarming community drama"
]
}
],
"genre": "News Turned Stories",
"roles": [
"free"
],
"languages": [
"English",
"bengali"
],
"mediums": [
"text"
]
}
]
}
</file>
<file path="server/features/orchestration.py">
"""The event loop, task queue and task-state helpers that drive a chat request."""
import base64
import json
import os
import re
import time
from server.features.context import resolve_image_path
from server.features.state import M
def set_status(task_id, message):
with M._data_lock:
if task_id in M.tasks and M.tasks[task_id].get("status") != "cancelled":
M.tasks[task_id]["status"] = "working"
M.tasks[task_id]["message"] = message
def location_str():
if M._client_location:
return M._client_location
return None
def set_client_location(value):
M._client_location = value
def _task_user(task_id):
with M._data_lock:
return M.tasks.get(task_id, {}).get("_user", "")
def _image_bytes_b64(image):
"""Normalize an uploaded image to base64 bytes.
The UI may pass a ``/uploads/...`` link (uploaded ahead of time) instead of
raw base64. Image code downstream (edit_image, ComfyUI input writing)
expects actual bytes, so resolve links to their on-disk contents here.
"""
if not image:
return None
s = str(image)
if not (s.startswith(("/uploads/", "/output/", "/api/image/")) or re.match(
r"^https?://", s
)):
if s.startswith("data:image/"):
s = s.split(",", 1)[-1]
return s
fpath = resolve_image_path(s)
if fpath:
try:
with open(fpath, "rb") as f:
return base64.b64encode(f.read()).decode()
except OSError:
pass
return None
def _task_max_rounds(task_id):
"""Tool-loop round budget for a task: 10 for normal chats, 50 when the
UI's research toggle is on (stored on the task as ``research``)."""
with M._data_lock:
t = M.tasks.get(task_id, {})
if t.get("research"):
return M.MAX_TOOL_ROUNDS.get("research", 50)
return M.MAX_TOOL_ROUNDS.get("default", 10)
def _set_task_error(task_id, error, sid=None):
with M._data_lock:
if task_id in M.tasks:
d = M.tasks[task_id]
elapsed_ms = None
if d.get("_started_at") is not None:
elapsed_ms = int((time.time() - d.get("_started_at")) * 1000)
M.tasks[task_id] = {
"status": "error",
"error": str(error),
"session_id": d.get("session_id", sid),
"_elapsed_ms": elapsed_ms,
}
def _delete_task_image(task_id):
"""Remove the generated image file attached to a (cancelled) task, if any."""
with M._data_lock:
t = M.tasks.get(task_id)
if not t:
return
fname = t.get("image_file")
if not fname:
return
fpath = fname if os.path.isabs(fname) else os.path.join(M.IMG_PATH, fname)
try:
if os.path.exists(fpath):
os.remove(fpath)
print(f"[cancel] Removed image for cancelled task {task_id}: {fpath}")
except OSError:
pass
def _finalize_task(task_id, sid, msg_content, body):
with M._data_lock:
t = M.tasks.get(task_id)
if not t:
return
tools_used = list(t.get("_tools_used", []))
search_details = list(t.get("_search_details", []))
image_filename = t.get("image_file")
gen_prompt = t.get("gen_prompt")
image_model = t.get("_image_model")
verification = t.get("_verification")
verification_duration = t.get("_verification_duration")
image_url = f"/output/{image_filename}" if image_filename else None
if image_url:
print(f"[finalize] image_file='{image_filename}' → image_url='{image_url}' for task {task_id}") # DEBUG
timings = body.get("timings", {})
predicted_per_second = timings.get("predicted_per_second")
with M._data_lock:
started_at = t.get("_started_at")
elapsed_ms = None
if started_at is not None:
elapsed_ms = int((time.time() - started_at) * 1000)
reasoning = (
body.get("choices", [{}])[0].get("message", {}).get("reasoning_content", "")
)
if not msg_content and reasoning:
msg_content = "(No response content generated)"
msg_entry = {
"role": "assistant",
"content": msg_content,
"_reasoning": reasoning,
"_tools_used": tools_used,
"_image_url": image_url,
"_gen_prompt": gen_prompt,
"_image_model": image_model,
"_search_details": search_details,
"_research": bool(t.get("research")),
"_elapsed_ms": elapsed_ms,
}
if verification is not None:
msg_entry["_verification"] = verification
msg_entry["_verification_duration"] = verification_duration
mode = M.task_mode(task_id)
with M._data_lock:
if sid in M.sessions:
M.sessions[sid].append(msg_entry)
M.sessions_meta.setdefault(sid, {})["updated"] = time.time()
if mode == "gpu":
M._last_tps = predicted_per_second
M._last_llm_use = time.time() # Reset GPU idle timer when task finishes
else:
M._cpu_last_llm_use = time.time() # Reset CPU idle timer when task finishes
M.save_sessions()
with M._data_lock:
if task_id in M.tasks:
M.tasks[task_id] = {
"status": "done",
"response": msg_content,
"session_id": sid,
"session_name": M.sessions_meta.get(sid, {}).get("name", ""),
**M.context_token_report(sid, M.sessions.get(sid, [])),
"predicted_per_second": predicted_per_second,
"tools_used": tools_used,
"image": image_url,
"_image_url": image_url,
"gen_prompt": gen_prompt,
"_image_model": image_model,
"_search_details": search_details,
"reasoning": reasoning,
"_elapsed_ms": elapsed_ms,
}
if verification is not None:
M.tasks[task_id]["_verification"] = verification
M.tasks[task_id]["_verification_duration"] = verification_duration
def _event_post(ev_type, task_id, **data):
M._event_queue.put((ev_type, task_id, data))
def _event_loop():
while True:
ev_type, task_id, data = M._event_queue.get()
t = M.tasks.get(task_id)
if not t:
continue
if t.get("status") == "cancelled":
M._delete_task_image(task_id)
continue
if ev_type == "start":
sid = data["sid"]
user_message = data["message"]
image_b64 = data.get("image")
audio_b64 = data.get("audio")
user = data.get("user", "")
client_ts = data.get("client_timestamp")
with M._data_lock:
M.tasks[task_id] = {
"status": "working",
"message": "Processing task...",
"session_id": sid,
"_tools_used": [],
"_search_details": [],
"_original_message": user_message,
"_original_image": _image_bytes_b64(image_b64),
"_audio": audio_b64,
"_user": user,
"_client_timestamp": client_ts,
"mode": t.get("mode"),
"research": bool(data.get("research")),
"cpu": bool(data.get("cpu")),
"no_tools": bool(data.get("no_tools")),
"_started_at": t.get("_started_at"),
}
# (The owning lane's _current_task_ids[mode] was already set by
# _queue_worker before this "start" event was posted.)
M._prepare_session(task_id, sid, user_message, image_b64, audio_b64, client_ts)
M._start_llm_round(task_id, sid, 0)
elif ev_type == "llm_ok":
if t.get("_state") != "llm_waiting":
continue
sid = data["sid"]
round_num = data["round"]
body = data["body"]
msg = body["choices"][0]["message"]
mode = M.task_mode(task_id)
with M._data_lock:
if mode == "cpu":
M._cpu_last_llm_use = time.time()
else:
M._last_llm_use = time.time()
if msg.get("tool_calls"):
with M._data_lock:
tt = M.tasks.get(task_id)
if tt:
tt.setdefault("_tools_used", [])
tt.setdefault("_search_details", [])
rc = msg.get("reasoning_content", "")
if rc:
tt["reasoning"] = rc
pending = len(msg["tool_calls"])
print(f"[llm_ok] Round {round_num}: LLM requested {pending} tool(s) for task {task_id}") # DEBUG
with M._data_lock:
tt = M.tasks.get(task_id)
if tt:
tt["_state"] = "tools_running"
tt["_pending_tools"] = pending
with M._data_lock:
if sid in M.sessions:
assistant_msg = {"role": "assistant"}
if msg.get("content"):
assistant_msg["content"] = msg["content"]
if msg.get("tool_calls"):
assistant_msg["tool_calls"] = msg["tool_calls"]
M.sessions[sid].append(assistant_msg)
M.sessions_meta.setdefault(sid, {})["updated"] = time.time()
M.save_sessions()
tool_mode = M.task_mode(task_id)
for i, tc in enumerate(msg["tool_calls"]):
M._tool_pools[tool_mode].submit(
M._tool_worker,
task_id,
sid,
tc,
t.get("_original_image"),
round_num,
i,
)
else:
print(f"[llm_ok] Round {round_num}: LLM generated final response (no tool calls) for task {task_id}") # DEBUG
if t.get("research"):
M.set_status(task_id, "Verifying sources...")
with M._data_lock:
tt = M.tasks.get(task_id)
if tt:
tt["_state"] = "critic_running"
M._tool_pools[mode].submit(
M.run_verification_worker,
task_id,
sid,
(msg.get("content") or ""),
body,
mode,
)
else:
M._finalize_task(task_id, sid, (msg.get("content") or ""), body)
elif ev_type == "llm_err":
if t.get("_state") != "llm_waiting":
continue
M._set_task_error(task_id, data["error"], data.get("sid"))
elif ev_type == "tool_ok":
sid = data["sid"]
tc_id = data["tc_id"]
result = data["result"]
with M._data_lock:
if sid in M.sessions:
M.sessions[sid].append(
{"role": "tool", "tool_call_id": tc_id, "content": result}
)
M.sessions_meta.setdefault(sid, {})["updated"] = time.time()
print(f"[tool_ok] Appended tool result to session {sid} for task {task_id}") # DEBUG
tt = M.tasks.get(task_id)
if not tt or tt.get("status") in ("done", "error"):
continue
pending = (tt.get("_pending_tools", 0) - 1) if tt else 0
if tt:
tt["_pending_tools"] = pending
M.save_sessions()
print(f"[tool_ok] Pending tools left for task {task_id}: {pending}") # DEBUG
if pending <= 0:
round_num = data.get("round", 0) + 1
print(f"[tool_ok] All tools done for task {task_id}. Starting LLM round {round_num} with search results in context.") # DEBUG
with M._data_lock:
tt = M.tasks.get(task_id)
if tt:
tt["_round"] = round_num
if round_num < M._task_max_rounds(task_id):
M._start_llm_round(task_id, sid, round_num)
else:
M._set_task_error(task_id, "Max tool rounds exceeded", sid)
elif ev_type == "tool_err":
result = data.get(
"result", json.dumps({"error": data.get("error", "Tool error")})
)
with M._data_lock:
if data.get("sid") in M.sessions:
M.sessions[data["sid"]].append(
{
"role": "tool",
"tool_call_id": data["tc_id"],
"content": result,
}
)
M.sessions_meta.setdefault(data["sid"], {})["updated"] = time.time()
tt = M.tasks.get(task_id)
if not tt or tt.get("status") in ("done", "error"):
continue
pending = (tt.get("_pending_tools", 0) - 1) if tt else 0
if tt:
tt["_pending_tools"] = pending
M.save_sessions()
if pending <= 0:
round_num = data.get("round", 0) + 1
with M._data_lock:
tt = M.tasks.get(task_id)
if tt:
tt["_round"] = round_num
if round_num < M._task_max_rounds(task_id):
M._start_llm_round(task_id, data["sid"], round_num)
else:
M._set_task_error(task_id, "Max tool rounds exceeded", data["sid"])
def _human_priority_active():
'''
# Removed the following check as self-agent bots will continue on CPU
# Not needed anymore
with M._queue_locks["gpu"]:
if M._current_task_ids["gpu"] is not None or M._task_queues["gpu"]:
return True
now = time.time()
with M._tokens_lock:
for token, entry in M._active_tokens.items():
if token in M._agent_tokens or entry.get("user") in M._agent_users:
continue
if now - entry.get("last_seen", 0) <= M.ACTIVE_WINDOW_SECONDS:
return True
'''
return False
def _queue_worker(mode):
"""Drain the task queue for ``mode`` ("gpu" for interactive UI users,
"cpu" for self-chat agents).
Each lane runs on its own thread with its own lock/condition/queue, so a
self-chat agent task sitting in the CPU lane can never make an
interactive UI user in the GPU lane wait in line — they only share
physical hardware if they both actually need the GPU (chat model load or
image generation), which is arbitrated separately.
The CPU lane additionally yields to any human presence (see
``_human_priority_active``) before starting its *next* task. An
already-running self-chat round is never interrupted — it runs on its own
hardware and was already established not to block the GPU lane — this
only holds the CPU lane from picking up new work while a human is around.
"""
queue_lock = M._queue_locks[mode]
queue_cond = M._queue_conds[mode]
task_queue = M._task_queues[mode]
while True:
if mode == "cpu":
# If a human is currently active in the UI, hold off agent tasks
while _human_priority_active():
time.sleep(1.0)
item = None
with queue_lock:
while not task_queue:
queue_cond.wait()
with M._data_lock:
oh = M._overheated
# GPU overheating only pauses the GPU lane — the CPU lane runs on
# the CPU server and is unaffected. RAM pressure affects the whole
# box, so it pauses both lanes.
if (oh and mode == "gpu") or M._ram_evacuating:
label = "GPU overheating" if oh else "RAM pressure — restarting servers"
for qitem in task_queue:
tid = qitem["task_id"]
if tid in M.tasks:
M.tasks[tid] = {
"status": "waiting",
"message": f"Server paused — {label}. Will resume shortly.",
"session_id": qitem["session_id"],
}
queue_cond.wait(5)
continue
if mode == "cpu" and M._human_priority_active():
for qitem in task_queue:
tid = qitem["task_id"]
if tid in M.tasks:
M.tasks[tid] = {
"status": "waiting",
"message": "Yielding to an active user session...",
"session_id": qitem["session_id"],
}
queue_cond.wait(5)
continue
item = task_queue.pop(0)
M._current_task_ids[mode] = item["task_id"]
with M._data_lock:
if item["task_id"] in M.tasks:
M.tasks[item["task_id"]]["_started_at"] = time.time()
M._event_post(
"start",
item["task_id"],
sid=item["session_id"],
message=item["message"],
image=item.get("image"),
audio=item.get("audio"),
user=item.get("user", ""),
client_timestamp=item.get("client_timestamp"),
research=item.get("research"),
cpu=item.get("cpu"),
no_tools=item.get("no_tools"),
)
# Wait for this task to finish (status becomes "done", "error" or "cancelled")
# before dequeuing the next item IN THIS LANE. The other lane's worker
# keeps running independently the whole time.
while True:
with M._data_lock:
st = M.tasks.get(item["task_id"], {}).get("status")
if st in ("done", "error", "cancelled"):
break
time.sleep(0.5)
with queue_lock:
M._current_task_ids[mode] = None
queue_cond.notify_all()
</file>
<file path="server/features/llm.py">
"""llama-server lifecycle and the streaming LLM round-trip worker.
Two llama-server processes run concurrently:
* the **GPU** server on ``LLAMA_BASE`` (8081) serving interactive chat UI
users, and
* the **CPU** server on ``LLAMA_BASE_CPU`` (8079) serving automated self-chat
agents.
Every function below takes a ``mode`` (``"gpu"`` or ``"cpu"``) so loads,
unloads and completions always hit the right server without ever stopping the
other one.
"""
import json
import os
import time
import requests
from server.features.state import M
# Rotating KV-checkpoint filename per lane (slot 0 is the only slot — both
# servers run with the default --parallel 1). Kept constant so each
# save overwrites the previous snapshot instead of filling the disk.
_SLOT_CHECKPOINT_FILES = {"gpu": "gpu_slot0.kv", "cpu": "cpu_slot0.kv"}
def slot_checkpoint_file(mode="gpu"):
"""Checkpoint filename (relative to ``LLAMA_SLOT_SAVE_DIR``) for ``mode``."""
return _SLOT_CHECKPOINT_FILES.get(mode, _SLOT_CHECKPOINT_FILES["gpu"])
def slot_checkpoint_path(mode="gpu"):
"""Absolute path of the KV-checkpoint file for ``mode``."""
return os.path.join(M.LLAMA_SLOT_SAVE_DIR, slot_checkpoint_file(mode))
def mark_slot_kv_dirty(mode="gpu"):
"""Flag the lane's slot KV as changed by an outgoing completion.
Called right before any request is sent to ``mode``'s chat-completions
endpoint — processing a prompt always mutates the server's slot KV. The
flag decides whether :func:`save_slot_checkpoint` snapshots again on the
next unload or the on-disk snapshot is already up to date."""
with M._data_lock:
M._slot_kv_dirty[mode] = True
def save_slot_checkpoint(mode="gpu"):
"""Snapshot the llama-server's current KV cache to disk.
Calls ``POST /slots/0?action=save`` so the KV of everything processed so
far survives an imminent model unload. Skipped when the model is not
loaded or no completion has run since the last save/restore (the on-disk
snapshot is already current). Failures (slot busy, media tokens in the
slot, feature unavailable) never block the caller — they just mean the
next reload re-prefills from scratch, exactly like before this
optimization existed.
"""
with M._data_lock:
ms = M._cpu_model_status if mode == "cpu" else M.model_status
dirty = M._slot_kv_dirty.get(mode, False)
cp = M._slot_checkpoints.get(mode)
if ms != "chat_loaded":
return False
if not dirty and cp:
return True # snapshot on disk already reflects the current KV
filename = slot_checkpoint_file(mode)
try:
r = requests.post(
f"{M.server_base(mode)}/slots/0?action=save",
# "model" is required in router mode: the parent picks the child
# instance to proxy to from this field (the child itself ignores it).
json={"filename": filename, "model": M.server_model_id(mode)},
timeout=180,
)
if r.status_code == 200:
n_tokens = r.json().get("n_tokens", 0)
with M._data_lock:
M._slot_checkpoints[mode] = {
"file": filename,
"model": M.server_model_id(mode),
"ts": time.time(),
"n_tokens": n_tokens,
}
M._slot_kv_dirty[mode] = False
print(
f"[llama] {mode} slot KV checkpointed ({n_tokens} tokens) -> {filename}"
)
return True
print(
f"[llama] {mode} slot KV save failed ({r.status_code}): {r.text[:200]}"
)
except Exception as e:
print(f"[llama] {mode} slot KV save error: {e}")
return False
def restore_slot_checkpoint(mode="gpu"):
"""Restore the previously saved KV cache into slot 0 of ``mode``'s server.
Called right after a model load. The restored prefix is only a *cache*:
the next completion still verifies its prompt against the restored tokens
and evaluates whatever is new, so a stale snapshot costs time but can
never produce wrong output.
"""
with M._data_lock:
cp = dict(M._slot_checkpoints.get(mode) or {})
if not cp:
return False
filename = cp.get("file") or slot_checkpoint_file(mode)
if not os.path.exists(os.path.join(M.LLAMA_SLOT_SAVE_DIR, filename)):
with M._data_lock:
M._slot_checkpoints.pop(mode, None)
return False
if cp.get("model") != M.server_model_id(mode):
# Snapshot belongs to another model — restoring it would fail (or worse,
# misload state), drop it silently.
print(
f"[llama] Dropping stale {mode} KV checkpoint "
f"(saved for '{cp.get('model')}', now '{M.server_model_id(mode)}')"
)
with M._data_lock:
M._slot_checkpoints.pop(mode, None)
return False
try:
r = requests.post(
f"{M.server_base(mode)}/slots/0?action=restore",
# See save_slot_checkpoint: router mode routes by body "model".
json={"filename": filename, "model": M.server_model_id(mode)},
timeout=180,
)
if r.status_code == 200:
n_tokens = r.json().get("n_tokens", cp.get("n_tokens", 0))
with M._data_lock:
# The slot now holds exactly the snapshot's KV again.
M._slot_kv_dirty[mode] = False
print(
f"[llama] {mode} slot KV restored from checkpoint ({n_tokens} tokens)"
)
return True
# Unusable snapshot — clear it so we don't retry every load.
print(
f"[llama] {mode} slot KV restore failed ({r.status_code}): {r.text[:200]}"
)
except Exception as e:
print(f"[llama] {mode} slot KV restore error: {e}")
with M._data_lock:
M._slot_checkpoints.pop(mode, None)
return False
def _consult_worker(*args, **kwargs):
# Implement worker call or redirect to your task handler
pass
def consult_expert_model(prompt: str, mode: str = "cpu", **kwargs):
"""
Executes a prompt against the expert/agent model pool.
"""
from server.features.state import _llm_pools, _human_priority_active
import time
# Pause CPU execution if a human user is active
if mode == "cpu":
while _human_priority_active():
time.sleep(1.0)
# Submit task to the designated LLM thread pool
pool = _llm_pools.get(mode, _llm_pools["cpu"])
# Add your model invocation / API request logic here
# future = pool.submit(your_llm_call_function, prompt, **kwargs)
# return future.result()
def task_mode(task_id):
"""Return the llama-server mode a task must run on.
Tasks posted by agent users (self-chat: editor, moderator, ...) run on the
server selected by ``SELF_CHAT_MODE`` (``"cpu"`` or ``"gpu"``); tasks from
interactive users always use the GPU server. A per-task ``mode`` override
(set at /api/chat admission, e.g. by ``self-chat.py --gpu``) wins over the
global flag for agent tasks. An interactive user who explicitly opted into
the CPU lane for a research task (task marked ``cpu``) always runs on the
CPU server. When ``FORCE_GPU_LANE`` is set (test-time), every non-flagged
task — agent or not — runs on the GPU lane.
"""
with M._data_lock:
t = M.tasks.get(task_id)
if not t:
return "gpu"
user = t.get("_user", "")
mode = t.get("mode")
cpu_flagged = bool(t.get("cpu"))
if cpu_flagged:
return "cpu"
if M.FORCE_GPU_LANE:
return "gpu"
if user in M._agent_users and mode in ("gpu", "cpu"):
return mode
return M.SELF_CHAT_MODE if user in M._agent_users else "gpu"
def server_base(mode):
"""Base URL of the llama-server for ``mode`` (defaults to the GPU server)."""
return M.LLAMA_BASE_CPU if mode == "cpu" else M.LLAMA_BASE
def server_url(mode):
"""Chat-completions URL of the llama-server for ``mode``."""
return M.LLAMA_URL_CPU if mode == "cpu" else M.LLAMA_URL
def server_model_id(mode):
"""Model filename the llama-server for ``mode`` should load."""
if mode == "cpu":
return M.MODEL_ID_CPU or M.MODEL_ID
return M.MODEL_ID
def server_status(mode):
"""Model status of the llama-server for ``mode`` ("unloaded", "loading",
"chat_loaded", "unloading", ...)."""
with M._data_lock:
return M._cpu_model_status if mode == "cpu" else M.model_status
def server_last_use(mode):
"""Idle timestamp of the llama-server for ``mode``."""
return M._cpu_last_llm_use if mode == "cpu" else M._last_llm_use
def active_model_id(mode="gpu"):
"""Backwards-compatible model filename lookup for ``mode``."""
return server_model_id(mode)
def is_llama_alive(base=None):
"""True when the llama-server at ``base`` answers /health.
Defaults to the GPU server so existing callers keep working.
"""
if base is None:
base = M.LLAMA_BASE
try:
r = requests.get(f"{base}/health", timeout=5)
return r.status_code == 200
except Exception:
return False
def unload_llama_model(mode="gpu"):
"""Unload the model from the llama-server for ``mode``."""
with M._model_transition_lock:
with M._data_lock:
if (M._cpu_model_status if mode == "cpu" else M.model_status) == "unloaded":
return True
print(f"[llama] Requesting {mode} model unload from VRAM/RAM...")
# Checkpoint the KV cache BEFORE it is destroyed by the unload, so the
# post-image-gen (or post-idle) reload can restore it instead of
# re-prefilling the whole conversation. This must happen while the
# model still reads "chat_loaded" — save_slot_checkpoint skips
# anything else — hence before the "unloading" transition below.
M.save_slot_checkpoint(mode)
with M._data_lock:
if mode == "cpu":
M._cpu_model_status = "unloading"
else:
M.model_status = "unloading"
try:
r = requests.post(
f"{M.server_base(mode)}/models/unload",
json={"model": M.server_model_id(mode)},
timeout=30,
)
if r.status_code == 200:
print(f"[llama] {mode} model unloaded")
with M._data_lock:
if mode == "cpu":
M._cpu_model_status = "unloaded"
else:
M.model_status = "unloaded"
return True
print(f"[llama] Unload response: {r.status_code} {r.text[:200]}")
except Exception as e:
print(f"[llama] Unload error: {e}")
# Check real status if unload failed or erred out
alive = M.is_llama_alive(M.server_base(mode))
with M._data_lock:
if mode == "cpu":
M._cpu_model_status = "chat_loaded" if alive else "unloaded"
else:
M.model_status = "chat_loaded" if alive else "unloaded"
return False
def load_llama_model(mode="gpu"):
"""Load the model on the llama-server for ``mode`` and wait for it to be
ready, tracking the per-server model status and idle timestamp.
When the load follows an unload (image generation, idle release), the KV
checkpoint saved by :func:`save_slot_checkpoint` is restored so the next
completion only has to evaluate new tokens."""
with M._data_lock:
# Only a fresh load benefits from a restore: if the model is already
# running, its live KV is newer than any snapshot on disk.
was_unloaded = (
M._cpu_model_status if mode == "cpu" else M.model_status
) == "unloaded"
if mode == "cpu":
M._cpu_model_status = "loading"
else:
M.model_status = "loading"
model_id = M.server_model_id(mode)
base = M.server_base(mode)
print(f"[llama] Sending load request for model '{model_id}' to {base}...")
try:
r = requests.post(
f"{base}/models/load", json={"model": model_id}, timeout=180
)
if r.status_code in (200, 201):
for i in range(30):
if M.is_llama_alive(base):
print(f"[llama] {mode} model ready (attempt {i+1})")
with M._data_lock:
if mode == "cpu":
M._cpu_model_status = "chat_loaded"
M._cpu_last_llm_use = time.time()
else:
M.model_status = "chat_loaded"
M._last_llm_use = time.time() # Reset idle timer upon loading
# Resume from the pre-unload KV checkpoint (no-op when
# there is none or the load wasn't a reload).
if was_unloaded:
M.restore_slot_checkpoint(mode)
return True
time.sleep(2)
else:
print(f"[llama] Load failed ({r.status_code}): {r.text[:200]}")
except Exception as e:
print(f"[llama] Load exception: {e}")
# Fallback check: verify if the server is alive and responding anyway
if M.is_llama_alive(base):
with M._data_lock:
if mode == "cpu":
M._cpu_model_status = "chat_loaded"
M._cpu_last_llm_use = time.time()
else:
M.model_status = "chat_loaded"
M._last_llm_use = time.time() # Reset idle timer upon loading
if was_unloaded:
M.restore_slot_checkpoint(mode)
return True
with M._data_lock:
if mode == "cpu":
M._cpu_model_status = "unloaded"
else:
M.model_status = "unloaded"
return False
def _inject_read_image(messages):
"""Attach the bytes of the most recent ``read_image`` result to its tool
message so the model can actually see the image this round.
The stored tool result stays a tiny JSON blob (url only); the image bytes
are embedded only in this round's payload. A copy is returned so the
stored session is never mutated.
"""
out = list(messages)
for i in range(len(out) - 1, -1, -1):
m = out[i]
if m.get("role") != "tool":
continue
content = m.get("content")
if not isinstance(content, str):
continue
try:
data = json.loads(content)
except (TypeError, ValueError):
continue
url = data.get("image_url") if data.get("ok") is True else None
if not url:
continue
data_url = M._image_to_data_url(url)
if not data_url:
continue
out[i] = {
**m,
"content": [
{"type": "text", "text": f"[Image loaded from {url}]"},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
break
return out
def _llm_worker(task_id, sid, round_num, msgs, mode="gpu"):
try:
if M.estimate_tokens(msgs) > M.AUTO_COMPACT_THRESHOLD:
M.set_status(task_id, "Context is full — compressing older messages...")
messages = M.prepare_context_for_llm(sid, msgs, mode)
messages = _inject_read_image(messages)
tool_msgs = [m for m in messages if isinstance(m, dict) and m.get("role") == "tool"]
if tool_msgs:
print(f"[llm_round] Round {round_num} includes {len(tool_msgs)} tool message(s) with search results") # DEBUG
with M._data_lock:
task_user = M.tasks.get(task_id, {}).get("_user", "")
task_no_tools = M.tasks.get(task_id, {}).get("no_tools", False)
tool_free = task_user in M.TOOL_FREE_AGENTS or task_no_tools
payload = {
"model": M.server_model_id(mode),
"messages": messages,
"tools": [] if tool_free else M.TOOLS,
"tool_choice": "none" if tool_free else "auto",
"max_tokens": M.MAX_INPUT_TOKENS,
#"reasoning_budget": REASONING_BUDGET,
#"reasoning_effort": "medium",
}
payload["stream"] = True
M.mark_slot_kv_dirty(mode)
r = requests.post(M.server_url(mode), json=payload, stream=True, timeout=600)
if r.status_code != 200:
err_body = r.text[:500] if r.text else f"HTTP {r.status_code}"
raise RuntimeError(f"LLM server returned {r.status_code}: {err_body}")
r.encoding = "utf-8"
reasoning_buf = ""
content_buf = ""
tool_calls_map = {}
with M._data_lock:
prev_reasoning = M.tasks.get(task_id, {}).get("reasoning", "")
for line in r.iter_lines(decode_unicode=True):
if not line or not line.startswith("data: "):
continue
data_str = line[6:]
if data_str.strip() == "[DONE]":
break
try:
chunk = json.loads(data_str)
except json.JSONDecodeError:
continue
choices = chunk.get("choices", [])
if not choices:
continue
delta = choices[0].get("delta", {})
rc = delta.get("reasoning_content")
if rc:
reasoning_buf += rc
with M._data_lock:
if task_id in M.tasks:
M.tasks[task_id]["reasoning"] = prev_reasoning + reasoning_buf
c = delta.get("content")
if c:
content_buf += c
tc_list = delta.get("tool_calls")
if tc_list:
for tc in tc_list:
idx = tc.get("index", 0)
if idx not in tool_calls_map:
fn = tc.get("function", {})
tool_calls_map[idx] = {
"index": idx,
"id": tc.get("id", ""),
"type": tc.get("type", "function"),
"function": {
"name": fn.get("name", ""),
"arguments": fn.get("arguments", ""),
},
}
else:
existing = tool_calls_map[idx]
if tc.get("id"):
existing["id"] = tc["id"]
fn = tc.get("function")
if fn:
if fn.get("name"):
existing["function"]["name"] = fn["name"]
if fn.get("arguments"):
existing["function"]["arguments"] += fn["arguments"]
print(f"[llm_round] Round {round_num} done: reasoning_buf={len(reasoning_buf)} chars, content_buf={len(content_buf)} chars, tool_calls={len(tool_calls_map)}") # DEBUG
msg = {
"role": "assistant",
"content": content_buf,
"reasoning_content": prev_reasoning + reasoning_buf,
}
if tool_calls_map:
msg["tool_calls"] = list(tool_calls_map.values())
body = {"choices": [{"message": msg}]}
if "choices" in body:
M._event_post("llm_ok", task_id, body=body, round=round_num, sid=sid)
else:
M._event_post(
"llm_err",
task_id,
error="Unexpected response",
round=round_num,
sid=sid,
)
except Exception as e:
err_text = str(e)
if "image" in err_text.lower() or "vision" in err_text.lower():
err_text = "The current model does not support image input. Please use a vision-capable model or send text-only messages."
M._event_post("llm_err", task_id, error=err_text, round=round_num, sid=sid)
def _start_llm_round(task_id, sid, round_num):
mode = M.task_mode(task_id)
M.ensure_llama_server(mode)
with M._data_lock:
ms = M._cpu_model_status if mode == "cpu" else M.model_status
if ms != "chat_loaded":
M.load_llama_model(mode)
with M._data_lock:
t = M.tasks.get(task_id)
if not t:
return
t["_state"] = "llm_waiting"
t["_round"] = round_num
messages = list(M.sessions.get(sid, []))
print(f"[llm_round] Starting round {round_num} for task {task_id} on {mode} server with {len(messages)} raw messages") # DEBUG
M.set_status(
task_id, "Thinking..." if round_num == 0 else f"Thinking (round {round_num})..."
)
pool = M._llm_pools.get(mode, M._llm_pools["cpu"])
pool.submit(M._llm_worker, task_id, sid, round_num, messages, mode)
</file>
<file path="server/features/sessions.py">
"""Conversation session persistence and per-request session preparation."""
import base64
import binascii
import glob
import json
import os
import re
import time
import uuid
from datetime import datetime
from server.features.state import M
# Injected into the system prompt only when the UI's "research" toggle is on.
RESEARCH_DIRECTIVE = """## Research Mode
You are performing deep, sourced research on the user's question.
- Plan: break the question into a few sub-questions/angles before answering.
- Gather: use web_search and fetch_page repeatedly. Fetch full pages and, when
a page is long, read through it (a page may be returned in chunks).
- Cite: attach the exact source to every fact in EXACTLY the inline form
`(Author, Venue, Year) [https://exact-page-url]` right at the claim. Use
ROUND brackets (…) for the metadata and SQUARE brackets [url] for the URL.
The metadata and the URL must both be present for EVERY factual claim. A
citation with an empty or missing URL is strictly forbidden — never write
`[...] []` or `(...) []`. Never cite a URL you did not actually open with
fetch_page or see listed in a web_search result. Never reuse one URL as the
support for many unrelated claims. If you are not certain about a metadata
field, write "(Author, Venue, uncertain)" — never guess a year or author.
- Never invent: never write facts, sources, papers, or findings from memory or
imagination and present them as researched. If you do not have a fetched
source backing a claim, you do not have the claim yet.
- Resource failures are a signal to search MORE, not to improvise: if a fetch
fails (403/404/timeout/blocked), re-search for the same article (mirrors,
snippets, alternate hosts) and fetch again; keep searching and fetching new
material until every claim is grounded in a source you actually opened. If a
sub-answer genuinely has no findable source, state that it is UNSUPPORTED
instead of fabricating support.
- Verify: cross-check important claims against more than one source.
- Conclude: answer only once the question is fully covered, then write a
structured report (summary, findings with citations, limitations).
- Budget: you may keep searching/fetching for up to 50 rounds of tools, but
stop as soon as the question is actually answered.
- Social Media & Unverified Content: Treat social media platforms
(X/Twitter, Reddit, forums, public blogs) strictly as anecdotal opinions or leads,
never as primary factual proof. Do not cite social media claims as verified facts
unless cross-checked and corroborated by an authoritative primary source
(official documentation, peer-reviewed study, or established publication)."""
def _session_file(user):
return os.path.join(M.SESSIONS_DIR, f"sessions_{M._safe_username(user)}.json")
def _save_upload_image(image_b64, user=""):
"""Persist a base64-encoded upload to the uploads dir and return its URL.
Keeps image bytes on disk instead of inside the conversation history, so
sessions stay small and the LLM only receives the bytes for images it
actually needs (see ``read_image`` / ``prepare_context_for_llm``).
"""
if not image_b64:
return None
raw = base64.b64decode(image_b64)
fname = f"{uuid.uuid4().hex}.jpg"
os.makedirs(M.UPLOADS_DIR, exist_ok=True)
fpath = os.path.join(M.UPLOADS_DIR, fname)
with open(fpath, "wb") as f:
f.write(raw)
return f"/uploads/{fname}"
def _resolve_image_url(image, user=""):
"""Return the stored URL for a chat image that may be base64 or a link.
The UI now uploads attached photos up front (``/api/upload-image``) and
passes the resulting ``/uploads/...`` link with the chat request instead of
embedding the raw base64. Accept either form so both old and new clients
keep working: raw base64 blobs are written to disk, already-stored links
and ``data:`` URLs are returned as-is.
"""
if not image:
return None
s = str(image).strip()
if s.startswith("data:image/"):
s = s.split(",", 1)[-1]
if s.startswith(("/uploads/", "/output/", "/api/image/")) or re.match(
r"^https?://", s
):
return s
try:
return _save_upload_image(s, user)
except (ValueError, binascii.Error):
pass
return None
def _migrate_data_urls(messages):
"""Rewrite legacy ``data:image`` content parts to ``/uploads/`` file URLs.
Older sessions stored uploaded images inline as base64 (a single session
could reach ~19 MB). This writes those bytes to the uploads dir once and
replaces the part URL, so the stored history stays small.
"""
changed = False
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
continue
parts = []
for p in content:
if isinstance(p, dict) and p.get("type") == "image_url":
url = p.get("image_url", {}).get("url", "")
if isinstance(url, str) and url.startswith("data:image"):
try:
b64 = url.split(",", 1)[-1]
new_url = _save_upload_image(b64, msg.get("_user", ""))
except (ValueError, binascii.Error):
new_url = None
if new_url:
parts.append({"type": "image_url", "image_url": {"url": new_url}})
changed = True
continue
parts.append(p)
if changed or len(parts) != len(content):
msg["content"] = parts
return changed
def _session_meta_from(sdata):
return {
"name": sdata.get("name", "Chat"),
"created": sdata.get("created", time.time()),
"updated": sdata.get("updated", time.time()),
"user_id": sdata.get("user_id", ""),
"system_prompts": sdata.get("system_prompts", []),
"context_tokens": sdata.get("context_tokens", {}),
"system_prompt": sdata.get("system_prompt", ""),
}
def _load_extra_prompts(items):
"""Normalize a list of extra system prompt sources into [{name, content}].
Each item may be a {name, content} dict or a server-side file path string.
"""
blocks = []
for it in items or []:
if isinstance(it, dict):
content = it.get("content") or ""
if not content.strip():
continue
blocks.append(
{"name": it.get("name") or "System Prompt", "content": content}
)
elif isinstance(it, str):
p = os.path.abspath(os.path.expanduser(it))
if os.path.isfile(p):
try:
with open(p, "r", encoding="utf-8") as f:
blocks.append(
{"name": os.path.basename(it), "content": f.read()}
)
except OSError:
pass
return blocks
def load_sessions():
os.makedirs(M.SESSIONS_DIR, exist_ok=True)
with M._data_lock:
M.sessions.clear()
M.sessions_meta.clear()
migrated = False
for path in glob.glob(os.path.join(M.SESSIONS_DIR, "sessions_*.json")):
try:
with open(path) as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
continue
with M._data_lock:
for sid, sdata in data.get("sessions", {}).items():
msgs = sdata.get("messages", [])
if _migrate_data_urls(msgs):
migrated = True
M.sessions[sid] = msgs
M.sessions_meta[sid] = _session_meta_from(sdata)
stale = os.path.join(M.SESSIONS_DIR, "sessions.json")
if os.path.exists(stale):
try:
with open(stale) as f:
data = json.load(f)
with M._data_lock:
for sid, sdata in data.get("sessions", {}).items():
if sid not in M.sessions:
msgs = sdata.get("messages", [])
if _migrate_data_urls(msgs):
migrated = True
M.sessions[sid] = msgs
M.sessions_meta[sid] = _session_meta_from(sdata)
except (FileNotFoundError, json.JSONDecodeError):
pass
try:
os.remove(stale)
except OSError:
pass
if migrated:
save_sessions()
def save_sessions():
os.makedirs(M.SESSIONS_DIR, exist_ok=True)
by_user = {}
with M._data_lock:
for sid in M.sessions:
meta = M.sessions_meta.get(
sid, {"name": "Chat", "created": time.time(), "updated": time.time()}
)
user = meta.get("user_id", "")
by_user.setdefault(user, {}).setdefault("sessions", {})[sid] = {
"name": meta["name"],
"created": meta["created"],
"updated": meta["updated"],
"user_id": meta.get("user_id", ""),
"system_prompts": meta.get("system_prompts", []),
"context_tokens": meta.get("context_tokens", {}),
"system_prompt": meta.get("system_prompt", ""),
"messages": M.sessions[sid],
}
for user, data in by_user.items():
with open(_session_file(user), "w") as f:
json.dump(data, f, indent=2)
def _prepare_session(task_id, sid, user_message, image_b64, audio_b64=None, client_ts=None):
try:
if client_ts:
ts = datetime.fromisoformat(client_ts.replace("Z", "+00:00"))
else:
ts = datetime.now()
except Exception:
ts = datetime.now()
loc = M.location_str()
loc_context = f" [User location: {loc}]" if loc else ""
date_loc_context = f"[Current date: {ts.strftime('%Y-%m-%d %A %H:%M')}]{loc_context}"
user = ""
extra_prompts = []
context_tokens = {}
system_prompt = ""
with M._data_lock:
t = M.tasks.get(task_id)
if t:
user = t.get("_user", "")
meta = M.sessions_meta.get(sid, {})
extra_prompts = meta.get("system_prompts", [])
context_tokens = meta.get("context_tokens", {})
system_prompt = meta.get("system_prompt", "")
user_context = M.read_user_context(user) if user else ""
context_block = f"\n\n## User Context\n{user_context}" if user_context else ""
# A session created with its own system prompt (e.g. a self-chat agent
# directive) uses it as the base instead of the global sys_prompt.txt.
base_sys = system_prompt if system_prompt else M.SYS_CONTENT
full_sys_content = f"{base_sys}\n\n{date_loc_context}{context_block}"
for blk in extra_prompts:
full_sys_content += f"\n\n## {blk.get('name', 'System Prompt')}\n{blk.get('content', '')}"
with M._data_lock:
if M.tasks.get(task_id, {}).get("research"):
full_sys_content += f"\n\n{RESEARCH_DIRECTIVE}"
full_sys_content = full_sys_content.replace(
"%current_time%", ts.strftime("%Y-%m-%d %A %H:%M")
)
if loc:
full_sys_content = full_sys_content.replace("%current_location%", loc)
else:
full_sys_content = full_sys_content.replace(
"Currently the server is hosted on %current_location%.", ""
)
full_sys_content = full_sys_content.replace("%current_location%", "not available")
for token, value in context_tokens.items():
full_sys_content = full_sys_content.replace(token, value)
image_url = _resolve_image_url(image_b64, user)
if user_context:
print(
f"[context] Injected {len(user_context)} chars of context for user '{user}'"
)
with M._data_lock:
if sid not in M.sessions or not M.sessions[sid]:
M.sessions[sid] = [{"role": "system", "content": full_sys_content}]
elif M.sessions[sid][0].get("role") == "system":
M.sessions[sid][0]["content"] = full_sys_content
else:
M.sessions[sid].insert(0, {"role": "system", "content": full_sys_content})
if sid not in M.sessions_meta:
M.sessions_meta[sid] = {
"name": user_message[:50],
"created": time.time(),
"updated": time.time(),
}
content = []
if image_url:
content.append(
{
"type": "image_url",
"image_url": {"url": image_url},
}
)
if audio_b64:
content.append({"type": "text", "text": "\U0001F3A4 Audio message"})
content.append(
{
"type": "text",
"text": user_message,
}
)
M.sessions[sid].append(
{
"role": "user",
"content": content,
"_timestamp": datetime.now().isoformat(),
"_research": bool(M.tasks.get(task_id, {}).get("research")),
}
)
if M.sessions_meta[sid]["name"] in ("New Chat", ""):
M.sessions_meta[sid]["name"] = user_message[:50] + (
"..." if len(user_message) > 50 else ""
)
M.sessions_meta[sid]["updated"] = time.time()
M.save_sessions()
mode = M.task_mode(task_id)
with M._data_lock:
ms = M._cpu_model_status if mode == "cpu" else M.model_status
if ms != "chat_loaded":
M.load_llama_model(mode)
</file>
<file path="server/features/state.py">
"""Shared application state and the entrypoint proxy.
The chat engine's implementation lives in the :mod:`server.features` package,
but the entrypoint module (``chat-webui.py``) remains the single owner of every
shared value: containers, scalar flags, constants, config values and the
cross-cutting helpers. Feature modules resolve those names at *call time*
through the ``M`` proxy registered here, which simply forwards attribute reads
and writes to the entrypoint module.
This indirection is what keeps per-test monkeypatching working. The test-suite
patches ``chat-webui.<name>`` (functions, containers, scalars, stdlib modules)
and expects every feature module to observe those patches, so no feature module
may bind a shared name at import time.
"""
import queue as _queue
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from server.config import CPU_PARALLEL_SLOTS
class _Registry:
"""Holds the single entrypoint module reference."""
entrypoint = None
def register_entrypoint(module):
"""Point the ``M`` proxy at the module that owns the shared state."""
_Registry.entrypoint = module
class _Proxy:
"""Forward attribute access to the registered entrypoint module."""
def __getattr__(self, name):
ep = _Registry.entrypoint
if ep is None:
raise AttributeError(
f"no entrypoint registered yet; import chat-webui.py before using {name}"
)
return getattr(ep, name)
def __setattr__(self, name, value):
_Registry.entrypoint.__setattr__(name, value)
M = _Proxy()
# ---------------------------------------------------------------------------
# In-memory application state (the same objects chat-webui.py re-exports)
# ---------------------------------------------------------------------------
sessions = {}
sessions_meta = {}
tasks = {}
shares = {}
_active_tokens = {}
_tokens_lock = threading.Lock()
_agent_tokens = set()
_agent_users = set()
# Username → last-activity timestamp for SSO/header-authenticated clients.
# With Authentik fronting the browser there is no per-request token anymore;
# the browser's 2s model-status poll keeps touching this seat as the heartbeat.
_user_last_seen = {}
_user_last_seen_lock = threading.Lock()
# A human user counts as "active" (blocking self-chat agents) while any of
# their requests has been seen within this window. The browser's 2s model-status
# poll acts as the heartbeat.
ACTIVE_WINDOW_SECONDS = 120
_effective_contexts = {}
_effective_contexts_lock = threading.Lock()
_model_transition_lock = threading.Lock()
_data_lock = threading.Lock()
MAX_QUEUE_SIZE = 5
# Tool-loop budget per task. Normal chats stay light (10 LLM rounds ≈ small
# number of tool calls); tasks sent with the UI's "research" toggle get the
# deep recursive budget so the agent can chunk-walk pages and re-search until
# the question is answered.
MAX_TOOL_ROUNDS = {"default": 10, "research": 50}
# Two independent task lanes so interactive UI (GPU) users and self-chat
# agents (CPU) never queue behind each other. Each lane has its own list,
# lock/condition and "currently running" marker. Only image-generation VRAM
# choreography (see _image_queue in images.py) stays globally serialized,
# since ComfyUI only has one physical GPU to render on regardless of which
# lane requested the image.
_task_queues = {"gpu": [], "cpu": []}
_queue_locks = {"gpu": threading.Lock(), "cpu": threading.Lock()}
_queue_conds = {mode: threading.Condition(lock) for mode, lock in _queue_locks.items()}
_current_task_ids = {"gpu": None, "cpu": None}
_event_queue = _queue.Queue()
# Serializes image generation/editing so VRAM management (llama unload/free/load)
# and the ``image_active`` model status never race between concurrent chats.
_image_queue = _queue.Queue()
# One LLM-round pool and one tool-call pool PER LANE. These used to be single
# shared pools (_llm_pool max_workers=1, _tool_pool max_workers=2) — even after
# splitting task admission into gpu/cpu lanes, actually *running* a round or a
# tool call still funneled through those single shared pools, so a GPU (UI)
# task and a CPU (agent) task would still block on each other's turn in the
# pool. Splitting per-lane makes them genuinely independent end-to-end.
_llm_pools = {"gpu": ThreadPoolExecutor(max_workers=1), "cpu": ThreadPoolExecutor(max_workers=CPU_PARALLEL_SLOTS)}
_tool_pools = {"gpu": ThreadPoolExecutor(max_workers=2), "cpu": ThreadPoolExecutor(max_workers=2)}
_location_events = {}
# Scalars the engine reads and rebinds at runtime.
#
# There are TWO llama-servers running concurrently: the GPU server on 8081
# serves interactive chat UI users, and the CPU server on 8079 serves automated
# self-chat agents. Each keeps its own model status and idle timestamp.
model_status = "unloaded"
_cpu_model_status = "unloaded"
_last_tps = None
_last_llm_use = time.time()
_cpu_last_llm_use = time.time()
# KV-cache slot checkpoints per llama-server lane (see llm.py). Maps mode →
# {"file": str, "model": str, "ts": float, "n_tokens": int} describing the
# last successfully saved /slots/{id}?action=save snapshot, which is restored
# after the model loads again so the conversation KV is not re-prefilled.
_slot_checkpoints = {}
# Per-lane flag set whenever a completion reaches a llama-server (its slot KV
# changed) and cleared once that KV is captured by save/restore. Gates whether
# an unload snapshots the slot again.
_slot_kv_dirty = {"gpu": False, "cpu": False}
_client_location = None
_overheated = False
_gpu_temp = None
_ram_evacuating = False
_users_cache = None
_users_cache_time = 0
# Context / token budget constants.
# The interactive UI chat runs on the GPU llama-server, which is launched with
# --ctx-size 24576 (24K). Keep this in sync with server/config.py so the UI's
# context meter and the /api/model-status payload reflect the real budget.
MAX_INPUT_TOKENS = 24576
AUTO_COMPACT_THRESHOLD = int(MAX_INPUT_TOKENS * 0.7)
# Monitoring constants.
TEMP_THRESHOLD_ON = 90
TEMP_THRESHOLD_OFF =75
RAM_EVAC_THRESHOLD = 95
RAM_RESUME_THRESHOLD = 70
</file>
<file path="markdown_hosting.py">
import os
import json
import html
import shutil
import markdown
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.exception_handlers import http_exception_handler
from fastapi.responses import HTMLResponse, FileResponse
from urllib.parse import quote
from server.dotenv import load_dotenv
load_dotenv()
app = FastAPI()
BASE_STORIES_DIR = "./stories"
# Directory roots resolved from environment variables.
# Hierarchy: everyone -> free dir, premium +1 dir, admin +1 more dir.
# PREMIUM and ADMIN dirs are REQUIRED — markdown hosting fails fast if missing.
_FREE_DIR = os.getenv("STORIES_FREE_DIR", os.path.expanduser("~/local-ai-files/stories"))
_PREMIUM_DIR = os.getenv("STORIES_PREMIUM_DIR", "")
_ADMIN_DIR = os.getenv("STORIES_ADMIN_DIR", "")
for _var, _val in (("STORIES_PREMIUM_DIR", _PREMIUM_DIR), ("STORIES_ADMIN_DIR", _ADMIN_DIR)):
if not _val:
raise RuntimeError(
f"markdown_hosting.py cannot start: required environment variable "
f"{_var} is not set."
)
# Each collection requires a minimum role level to access.
ROLE_LEVEL = {
"guest": 0,
"free": 0,
"user": 0,
"premium": 1,
"admin": 2,
}
COLLECTION_RULES = {
"free_stories": {"path": _FREE_DIR, "min_level": 0}, # everyone
"premium_stories": {"path": _PREMIUM_DIR, "min_level": 1}, # free + premium
"admin_stories": {"path": _ADMIN_DIR, "min_level": 2}, # free + premium + admin
}
# --- Auth & RBAC Helpers (shared Authentik-backed identity) ---
def get_current_user(request: Request) -> str | None:
"""Username authenticated through nginx's auth_request (X-Authentik-*).
Direct localhost callers may present ``Authorization: Bearer <jwt>``; that
is verified against Authentik's JWKS (see server/auth.py). Returns None
when no identity is present.
"""
from server.auth import get_current_user as _get_current_user
return _get_current_user(request.headers)
def get_current_role(request: Request) -> str:
"""Role (free/premium/admin) derived from Authentik group membership."""
from server.auth import get_identity as _get_identity
identity = _get_identity(request.headers)
return identity["role"] if identity else "free"
def user_role_level(username: str | None, role: str | None = None) -> int:
"""Map a username (or resolved role) to its hierarchy level (0=guest ... 2=admin)."""
if role is None:
if not username:
return ROLE_LEVEL["guest"]
return ROLE_LEVEL.get(get_user_role(username), ROLE_LEVEL["free"])
return ROLE_LEVEL.get(role, ROLE_LEVEL["free"])
def get_user_role(username: str) -> str:
"""Resolve role from the shared identity provider's group membership.
Kept for backward compatibility; the chat server derives role from
X-Authentik-Groups via server/auth.py.
"""
from server.auth import role_from_groups
return role_from_groups(_stories_role_groups_for(username))
def _stories_role_groups_for(username: str) -> list:
"""Best-effort group mapping for a username seen without claim headers.
Legacy fallback so direct localhost requests (no nginx auth_request) still
get a sensible role. Once nginx fronts the service this path is never hit.
"""
if username in {"palash"}:
return ["admin"]
if username in {"totan"}:
return ["premium"]
return ["free"]
def enforce_rbac(collection_folder: str, request: Request | None = None, username: str | None = None):
"""Checks the user's role against the collection's minimum required level."""
rule = COLLECTION_RULES.get(collection_folder)
if not rule:
raise HTTPException(status_code=404, detail="Collection not found")
if request is not None:
level = user_role_level(None, get_current_role(request))
authed = bool(get_current_user(request))
else:
authed = bool(username)
level = user_role_level(username)
min_level = rule["min_level"]
if level < min_level:
if not authed and min_level > 0:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required.",
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access Denied: higher subscription/role required for this story collection.",
)
def error_page(status_code: int, detail: str) -> str:
"""Render a styled HTML error page for browser navigation.
API and live-polling endpoints keep their JSON error responses (see
``_http_exception_html_handler``); anything a user would navigate to in the
browser gets a proper page instead of raw JSON. Unauthenticated browser
traffic never reaches here anonymously in production — nginx runs
``auth_request`` against the Authentik proxy outpost, so a 401 means the
Authentik session simply needs to be established.
"""
titles = {
401: "Authentication Required",
403: "Access Denied",
404: "Not Found",
}
title = titles.get(status_code, f"Error {status_code}")
if status_code == 401:
message = "Please log in to view this story collection."
else:
message = html.escape(str(detail))
login_block = """
<span class="login-toggle"><a href="/sso/outpost.goauthentik.io/start?rd=%2Fstories%2F">Sign in with SSO</a></span>
"""
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
* {{ box-sizing: border-box; }}
html {{ -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }}
body {{ font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }}
h1 {{ color: #c44; }}
a.back {{ color: #666; text-decoration: none; }}
.topbar {{ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; font-family: sans-serif; font-size: 14px; }}
.topbar .login {{ display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }}
.topbar .login.hidden {{ display: none; }}
.topbar .login-toggle a {{ color: #06c; font-weight: bold; }}
.topbar input {{ padding: 6px 8px; border: 1px solid #aaa; border-radius: 6px; font-size: 14px; background: #fff; color: #111; }}
.topbar button {{ background: none; border: 1px solid #888; color: #888; border-radius: 6px; padding: 5px 12px; cursor: pointer; font-family: sans-serif; font-size: 13px; }}
.topbar button:hover {{ background: #eee; }}
.topbar button.primary {{ background: #06c; color: #fff; border-color: #06c; }}
.topbar button.primary:hover {{ background: #0577e6; }}
.topbar #login-msg {{ color: #c44; font-size: 12px; width: 100%; }}
@media (max-width: 600px) {{
body {{ padding: 12px; font-size: 19px; }}
.topbar {{ flex-direction: column; align-items: stretch; }}
.topbar .login {{ flex-direction: column; align-items: stretch; }}
.topbar input {{ width: 100%; }}
}}
@media (prefers-color-scheme: dark) {{
body {{ background: #16181d; color: #e6e6e6; }}
h1 {{ color: #ff8080; }}
a.back {{ color: #999; }}
.topbar .login-toggle a {{ color: #7ab8ff; }}
.topbar input {{ background: #1f232b; border-color: #3a3f4a; color: #e6e6e6; }}
.topbar input::placeholder {{ color: #888; }}
.topbar button {{ background: #1f232b; border-color: #555; color: #cfcfcf; }}
.topbar button:hover {{ background: #262b34; }}
.topbar button.primary {{ background: #3a7ee0; border-color: #3a7ee0; color: #fff; }}
.topbar button.primary:hover {{ background: #4a8cec; }}
.topbar #login-msg {{ color: #ff8080; }}
}}
</style>
</head>
<body>
<nav class="topbar">
<a href="/" class="back">← Back to Collections</a>
{login_block}
</nav>
<h1>{title}</h1>
<p>{message}</p>
</body>
</html>
"""
@app.exception_handler(HTTPException)
async def _http_exception_html_handler(request: Request, exc: HTTPException):
"""JSON errors for API/live-poll endpoints, HTML pages for browser routes."""
path = request.url.path
if path.startswith("/api/") or path.endswith("/content"):
return await http_exception_handler(request, exc)
return HTMLResponse(
status_code=exc.status_code,
content=error_page(exc.status_code, exc.detail),
)
# --- Authentication Endpoints ---
# Browser authentication is handled entirely by nginx's auth_request against
# the Authentik proxy outpost (/sso/...). The endpoints that previously issued
# self-managed X-Auth-Token cookies no longer exist: identity comes from the
# X-Authentik-* claim headers nginx forwards upstream.
# --- Dynamic Story Engine & Media Router ---
def pick_story_md(folder_path):
"""Prefer the editor's revised file (story_rN_ts.edited.md) over the original."""
mds = [f for f in os.listdir(folder_path) if f.endswith(".md")]
if not mds:
return None
edited = [f for f in mds if f.endswith(".edited.md")]
return os.path.join(folder_path, (edited or mds)[0])
def story_moderation(folder_path):
"""Return the moderator verdict dict for a story, or None."""
for f in os.listdir(folder_path):
if f.endswith(".moderation.json"):
try:
with open(os.path.join(folder_path, f), encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError):
return None
return None
def moderation_badge(mod):
"""HTML snippet showing the GREEN/RED verdict, or empty string.
For RED verdicts, includes the moderator's reason as a tap/hover tooltip
(see .mod-badge CSS/JS shared by both pages).
"""
if not mod:
return ""
v = mod.get("verdict", "")
color = "#2a7" if v == "GREEN" else ("#c44" if v == "RED" else "#888")
if v == "RED":
reason = html.escape(mod.get("reasons") or mod.get("reason") or "No reason provided.")
return (
f' <span class="mod-badge" tabindex="0" data-reason="{reason}" '
f'style="color:{color}; font-size:11px; font-family:sans-serif; '
f'cursor:pointer; border-bottom:1px dotted {color};">({v})</span>'
)
return (
f' <span style="color:{color}; font-size:11px; font-family:sans-serif;">'
f"({v})</span>"
)
def list_collection_stories(root: str):
"""Return [(genre_label | None, story_id)] for a collection root.
Legacy flat story folders (md directly inside root) are reported with
genre None; genre folders contain story subdirectories.
"""
entries = sorted(
os.listdir(root),
key=lambda e: os.path.getmtime(os.path.join(root, e)),
reverse=True,
)
items = []
for entry in entries:
full = os.path.join(root, entry)
if not os.path.isdir(full):
continue
if any(f.endswith(".md") for f in os.listdir(full)):
items.append((None, entry))
continue
for sub in sorted(os.listdir(full), reverse=True):
subfull = os.path.join(full, sub)
if os.path.isdir(subfull) and any(
f.endswith(".md") for f in os.listdir(subfull)
):
items.append((entry, os.path.join(entry, sub)))
return items
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""Collections index listing available story collections and their stories."""
username = get_current_user(request)
role = get_current_role(request)
user_level = user_role_level(username, role)
cards = []
for name, rule in COLLECTION_RULES.items():
if rule["min_level"] > user_level:
continue
root = rule["path"]
if not os.path.isdir(root):
continue
items = list_collection_stories(root)
if not items:
continue
grouped = {}
for genre, sid in items:
grouped.setdefault(genre, []).append(sid)
sections = []
for genre, sids in grouped.items():
heading = f"<h3>{genre.replace('_', ' ').title()}</h3>" if genre else ""
lis = []
for sid in sids:
badge = moderation_badge(story_moderation(os.path.join(root, sid)))
# Safely quote path segments for URLs and escape HTML text display
encoded_sid = "/".join(quote(part) for part in sid.split("/"))
display_name = html.escape(sid.split("/")[-1])
lis.append(
f'<li><a href="/story/{name}/{encoded_sid}">{display_name}</a>{badge}</li>'
)
sections.append(heading + "<ul>" + "".join(lis) + "</ul>")
cards.append(
f"<h2>{name.replace('_', ' ').title()}</h2>" + "".join(sections)
)
body = "".join(cards) or "<p>No story collections found yet.</p>"
if username:
auth_html = f"""
<span class="logged">Logged in as <strong>{username}</strong> ({role})</span>
<button id="logout-btn">Log out</button>
"""
else:
auth_html = """
<span class="login-toggle"><a href="/sso/outpost.goauthentik.io/start?rd=%2Fstories%2F">Sign in with SSO</a></span>
"""
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Story Collections</title>
<style>
* {{ box-sizing: border-box; }}
html {{ -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }}
body {{ font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }}
h1, h2, h3 {{ color: #333; line-height: 1.3; }}
ul {{ margin: 0 0 1.2em; padding-left: 1.4em; }}
li {{ margin-bottom: 0.6em; }}
a {{ color: #06c; text-decoration: none; }}
.topbar {{ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 20px; font-family: sans-serif; font-size: 14px; }}
.topbar .logged {{ margin: 0; color: #555; }}
.topbar .login {{ display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }}
.topbar .login.hidden {{ display: none; }}
.topbar .login-toggle a {{ color: #06c; font-weight: bold; }}
.topbar input {{ padding: 6px 8px; border: 1px solid #aaa; border-radius: 6px; font-size: 14px; background: #fff; color: #111; }}
.topbar button {{ background: none; border: 1px solid #888; color: #888; border-radius: 6px; padding: 5px 12px; cursor: pointer; font-family: sans-serif; font-size: 13px; }}
.topbar button:hover {{ background: #eee; }}
.topbar button.primary {{ background: #06c; color: #fff; border-color: #06c; }}
.topbar button.primary:hover {{ background: #0577e6; }}
.topbar #login-msg {{ color: #c44; font-size: 12px; width: 100%; }}
.mod-badge {{ position: relative; }}
.mod-badge:hover::after, .mod-badge.show-tip::after {{
content: attr(data-reason);
position: absolute; left: 0; top: 100%; margin-top: 4px;
background: #222; color: #fff; padding: 6px 10px; border-radius: 6px;
font-size: 12px; line-height: 1.4; white-space: normal;
width: max-content; max-width: 240px; z-index: 10;
}}
@media (max-width: 600px) {{
body {{ padding: 12px; font-size: 19px; }}
.topbar {{ flex-direction: column; align-items: stretch; }}
.topbar .login {{ flex-direction: column; align-items: stretch; }}
.topbar input {{ width: 100%; }}
}}
@media (prefers-color-scheme: dark) {{
body {{ background: #16181d; color: #e6e6e6; }}
h1, h2, h3 {{ color: #f0f0f0; }}
a {{ color: #7ab8ff; }}
.topbar .logged {{ color: #aaa; }}
.topbar .login-toggle a {{ color: #7ab8ff; }}
.topbar input {{ background: #1f232b; border-color: #3a3f4a; color: #e6e6e6; }}
.topbar input::placeholder {{ color: #888; }}
.topbar button {{ background: #1f232b; border-color: #555; color: #cfcfcf; }}
.topbar button:hover {{ background: #262b34; }}
.topbar button.primary {{ background: #3a7ee0; border-color: #3a7ee0; color: #fff; }}
.topbar button.primary:hover {{ background: #4a8cec; }}
.topbar #login-msg {{ color: #ff8080; }}
}}
</style>
</head>
<body>
<nav class="topbar">{auth_html}</nav>
<h1>Story Collections</h1>
{body}
<script>
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {{
logoutBtn.addEventListener('click', () => {{
window.location.href = '/outpost.goauthentik.io/sign_out?rd=%2Fstories%2F';
}});
}}
// Tap-to-toggle tooltip for moderation badges (title= doesn't work on mobile touch).
document.querySelectorAll('.mod-badge').forEach(el => {{
el.addEventListener('click', e => {{
e.stopPropagation();
document.querySelectorAll('.mod-badge.show-tip').forEach(o => {{
if (o !== el) o.classList.remove('show-tip');
}});
el.classList.toggle('show-tip');
}});
}});
document.addEventListener('click', () => {{
document.querySelectorAll('.mod-badge.show-tip').forEach(o => o.classList.remove('show-tip'));
}});
</script>
</body>
</html>
"""
@app.get("/media/{collection}/{story_id:path}/{filename}")
async def serve_story_image(
collection: str,
story_id: str,
filename: str,
request: Request
):
"""Serves media dynamically while checking the authenticated session."""
enforce_rbac(collection, request=request)
root = COLLECTION_RULES[collection]["path"]
file_path = os.path.join(root, story_id, filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Image not found")
return FileResponse(file_path)
def render_story_html(collection: str, story_id: str, content: str) -> str:
"""Render story markdown and rewrite image srcs to the authenticated media route.
pymdownx.arithmatex (generic mode) leaves $...$ / $$...$$ math untouched
but wraps it in <span class="arithmatex"> / <div class="arithmatex">
so the KaTeX auto-render script loaded on the story page can find and
typeset it client-side.
"""
html_content = markdown.markdown(
content,
extensions=['extra', 'tables', 'fenced_code', 'pymdownx.arithmatex'],
extension_configs={'pymdownx.arithmatex': {'generic': True}},
)
html_content = html_content.replace(
'<table>', '<div class="table-wrap"><table>'
).replace('</table>', '</table></div>')
return html_content.replace('src="', f'src="/media/{collection}/{story_id}/')
@app.get("/story/{collection}/{story_id:path}/content")
async def story_content(
collection: str,
story_id: str,
request: Request
):
"""Returns the current rendered story HTML for live polling."""
enforce_rbac(collection, request=request)
folder_path = os.path.join(COLLECTION_RULES[collection]["path"], story_id)
if not os.path.exists(folder_path):
raise HTTPException(status_code=404, detail="Story folder not found")
md_file = pick_story_md(folder_path)
if not md_file:
raise HTTPException(status_code=404, detail="No markdown file found in story directory")
with open(md_file, 'r', encoding='utf-8') as f:
content = f.read()
return {"html": render_story_html(collection, story_id, content)}
@app.delete("/story/{collection}/{story_id:path}")
async def delete_story(
collection: str,
story_id: str,
request: Request
):
"""Deletes the story folder (markdown + images). Admin role required."""
enforce_rbac(collection, request=request)
if get_current_role(request) != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to delete stories.",
)
folder_path = os.path.join(COLLECTION_RULES[collection]["path"], story_id)
if not os.path.exists(folder_path):
raise HTTPException(status_code=404, detail="Story folder not found")
shutil.rmtree(folder_path, ignore_errors=True)
if os.path.exists(folder_path):
raise HTTPException(status_code=500, detail="Failed to delete story folder")
return {"ok": True, "deleted": story_id}
@app.get("/story/{collection}/{story_id:path}", response_class=HTMLResponse)
async def read_story(
collection: str,
story_id: str,
request: Request
):
"""Reads story Markdown dynamically and enforces access controls."""
enforce_rbac(collection, request=request)
folder_path = os.path.join(COLLECTION_RULES[collection]["path"], story_id)
if not os.path.exists(folder_path):
raise HTTPException(status_code=404, detail="Story folder not found")
md_file = pick_story_md(folder_path)
if not md_file:
raise HTTPException(status_code=404, detail="No markdown file found in story directory")
with open(md_file, 'r', encoding='utf-8') as f:
content = f.read()
html_content = render_story_html(collection, story_id, content)
is_admin = get_current_role(request) == "admin"
verdict_html = ""
mod = story_moderation(folder_path)
if mod:
v = mod.get("verdict", "")
color = "#2a7" if v == "GREEN" else ("#c44" if v == "RED" else "#888")
if v == "RED":
reason = html.escape(mod.get("reasons") or mod.get("reason") or "No reason provided.")
verdict_html = (
f'<div class="mod-badge" tabindex="0" data-reason="{reason}" '
f'style="font-family:sans-serif; color:{color}; font-size:13px; '
f'margin-bottom:12px; display:inline-block; cursor:pointer; '
f'border-bottom:1px dotted {color};">Moderation: {v} (tap for reason)</div>'
)
else:
verdict_html = (
f'<div style="font-family:sans-serif; color:{color}; font-size:13px; '
f'margin-bottom:12px;">Moderation: {v}</div>'
)
delete_button_html = '<button id="delete-btn">Delete story</button>' if is_admin else ""
# Escaped parameters for safe JavaScript injection and HTTP URL generation
story_id_js = json.dumps(story_id)
encoded_collection = quote(collection)
encoded_story_path = "/".join(quote(part) for part in story_id.split("/"))
return f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(story_id.split('/')[-1].replace('-', ' ').title())}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
<style>
* {{ box-sizing: border-box; }}
html {{ -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }}
body {{ font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }}
article {{ overflow-wrap: break-word; }}
article p, article li {{ font-size: 1em; }}
img {{ max-width: 100%; height: auto; border-radius: 8px; margin: 20px 0; display: block; }}
.topbar {{ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; font-family: sans-serif; font-size: 14px; }}
a.back {{ color: #666; text-decoration: none; }}
.topbar button {{ background: none; border: 1px solid #c44; color: #c44; border-radius: 6px; padding: 4px 12px; cursor: pointer; font-family: sans-serif; font-size: 14px; }}
.topbar button:hover {{ background: #fceaea; }}
blockquote {{ border-left: 4px solid #ddd; margin: 0 0 1em; padding: 0 0 0 16px; color: #555; }}
code {{ background: #f0f0f0; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 0.85em; }}
pre {{ background: #f0f0f0; padding: 12px; border-radius: 6px; overflow-x: auto; -webkit-overflow-scrolling: touch; }}
pre code {{ background: none; padding: 0; font-size: 0.85em; }}
.table-wrap {{ overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 1em 0; }}
table {{ border-collapse: collapse; font-size: 0.9em; }}
th, td {{ border: 1px solid #ccc; padding: 6px 10px; }}
.mod-badge {{ position: relative; }}
.mod-badge:hover::after, .mod-badge.show-tip::after {{
content: attr(data-reason);
position: absolute; left: 0; top: 100%; margin-top: 4px;
background: #222; color: #fff; padding: 6px 10px; border-radius: 6px;
font-size: 12px; line-height: 1.4; white-space: normal;
width: max-content; max-width: 280px; z-index: 10;
}}
@media (max-width: 600px) {{
body {{ padding: 12px; font-size: 19px; }}
.topbar {{ flex-direction: column; align-items: stretch; }}
.topbar button {{ width: 100%; }}
}}
@media (prefers-color-scheme: dark) {{
body {{ background: #16181d; color: #e6e6e6; }}
a.back {{ color: #999; }}
.topbar button {{ border-color: #e05a5a; color: #ff7a7a; }}
.topbar button:hover {{ background: #2a1c1c; }}
blockquote {{ border-left-color: #444; color: #aaa; }}
code {{ background: #2a2e37; }}
pre {{ background: #2a2e37; }}
th, td {{ border-color: #3a3f4a; }}
}}
</style>
</head>
<body>
<nav class="topbar">
<a href="/" class="back">← Back to Collections</a>
{delete_button_html}
</nav>
{verdict_html}
<article id="story-article">{html_content}</article>
<script>
const storyId = {story_id_js};
const article = document.getElementById('story-article');
let lastHtml = article.innerHTML;
function typesetMath() {{
if (typeof renderMathInElement !== 'function') {{
setTimeout(typesetMath, 100);
return;
}}
renderMathInElement(article, {{
delimiters: [
{{left: '\\\\[', right: '\\\\]', display: true}},
{{left: '\\\\(', right: '\\\\)', display: false}},
{{left: '$$', right: '$$', display: true}},
{{left: '$', right: '$', display: false}}
],
throwOnError: false
}});
}}
typesetMath();
async function poll() {{
try {{
const r = await fetch('/story/{encoded_collection}/{encoded_story_path}/content');
if (!r.ok) return;
const data = await r.json();
const newHtml = data.html;
if (newHtml !== lastHtml) {{
if (newHtml.startsWith(lastHtml)) {{
const nearBottom = window.innerHeight + window.scrollY > document.body.scrollHeight - 150;
article.insertAdjacentHTML('beforeend', newHtml.slice(lastHtml.length));
if (nearBottom) window.scrollTo(0, document.body.scrollHeight);
}} else {{
article.innerHTML = newHtml;
}}
lastHtml = newHtml;
typesetMath();
}}
}} catch (e) {{}}
setTimeout(poll, 3000);
}}
poll();
const deleteBtn = document.getElementById('delete-btn');
if (deleteBtn) {{
deleteBtn.addEventListener('click', async () => {{
if (!confirm(`Delete story "${{storyId}}" and all its images?`)) return;
try {{
const r = await fetch('/story/{encoded_collection}/{encoded_story_path}', {{ method: 'DELETE' }});
if (r.ok) {{
window.location.href = '/';
}} else {{
const d = await r.json();
alert('Delete failed: ' + (d.detail || r.status));
}}
}} catch (e) {{
alert('Delete failed: ' + e.message);
}}
}});
}}
// Tap-to-toggle tooltip for the moderation reason (title= doesn't work on mobile touch).
document.querySelectorAll('.mod-badge').forEach(el => {{
el.addEventListener('click', e => {{
e.stopPropagation();
el.classList.toggle('show-tip');
}});
}});
document.addEventListener('click', () => {{
document.querySelectorAll('.mod-badge.show-tip').forEach(o => o.classList.remove('show-tip'));
}});
</script>
</body>
</html>
"""
</file>
<file path="server/api.py">
"""HTTP API endpoints for the chat web UI.
The request handlers here are the thin web layer over the chat engine in
``chat-webui.py``. All shared application state and helper functions live in the
entrypoint module and are injected here at startup via :func:`set_app_state`,
so nothing has to be duplicated.
The names in ``APP_STATE_NAMES`` are declared as module globals below and
replaced by the real objects when the entrypoint calls ``set_app_state``.
"""
import base64
import http.server
import json
import mimetypes
import os
import re
import time
import traceback
import uuid
from datetime import datetime
from urllib.parse import parse_qs, urlparse
import requests
from server.auth import (
get_current_user,
get_identity,
identity_from_headers,
)
from server.config import (
COMFYUI_OUTPUT,
FORCE_GPU_LANE,
IMG_PATH,
SELF_CHAT_MODE,
UPLOADS_DIR,
)
IMAGE_MIME = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
}
def resolve_image_file(image_id):
"""Resolve an ``/api/image/<id>`` identifier to a local image file path.
Accepts ids shaped like the stored image URLs — ``uploads/<name>`` (user
uploads) or ``output/<rel>`` (ComfyUI generated images) — plus bare
filenames, which are looked up in the uploads dir first.
"""
if not image_id:
return None
raw = urlparse(image_id).path
raw = raw.lstrip("/")
base = None
if raw.startswith("uploads/"):
base, rel = UPLOADS_DIR, raw[len("uploads/"):]
elif raw.startswith("output/"):
base, rel = COMFYUI_OUTPUT, raw[len("output/"):]
else:
base, rel = UPLOADS_DIR, os.path.basename(raw)
root = os.path.realpath(base)
fpath = os.path.realpath(os.path.join(root, rel))
if fpath != root and not fpath.startswith(root + os.sep):
return None
if not os.path.isfile(fpath):
return None
return fpath
# ---------------------------------------------------------------------------
# Shared application state — injected by chat-webui.py via set_app_state().
# ---------------------------------------------------------------------------
ACTIVE_WINDOW_SECONDS = None
MAX_INPUT_TOKENS = None
MAX_QUEUE_SIZE = None
SHARES_FILE = None
_active_tokens = None
_agent_tokens = None
_agent_users = None
_user_last_seen = None
_user_last_seen_lock = None
_data_lock = None
_db_fetch = None
_effective_contexts = None
_effective_contexts_lock = None
_image_url_rel = None
_load_extra_prompts = None
_location_events = None
_queue_conds = None
_queue_locks = None
_task_queues = None
_tokens_lock = None
active_users = None
context_token_report = None
create_share = None
get_share = None
get_user_context_path = None
handle_theme_tool = None
list_shares = None
load_shares = None
model_status_snapshot = None
read_user_context = None
revoke_share = None
save_sessions = None
save_shares = None
sessions = None
sessions_meta = None
set_client_location = None
shares = None
task_create = None
task_delete = None
task_list = None
task_update = None
tasks = None
write_user_context = None
APP_STATE_NAMES = [
"ACTIVE_WINDOW_SECONDS",
"MAX_INPUT_TOKENS",
"MAX_QUEUE_SIZE",
"SHARES_FILE",
"_active_tokens",
"_agent_tokens",
"_agent_users",
"_user_last_seen",
"_user_last_seen_lock",
"_data_lock",
"_db_fetch",
"_effective_contexts",
"_effective_contexts_lock",
"_image_url_rel",
"_load_extra_prompts",
"_location_events",
"_queue_conds",
"_queue_locks",
"_task_queues",
"_tokens_lock",
"active_users",
"context_token_report",
"create_share",
"get_share",
"get_user_context_path",
"handle_theme_tool",
"list_shares",
"load_shares",
"model_status_snapshot",
"read_user_context",
"revoke_share",
"save_sessions",
"save_shares",
"sessions",
"sessions_meta",
"set_client_location",
"shares",
"task_create",
"task_delete",
"task_list",
"task_update",
"tasks",
"write_user_context",
]
def set_app_state(state):
"""Inject the application state shared with the entrypoint module."""
globals().update(state)
def read_index_html():
p = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"dist",
"index.html",
)
try:
with open(p) as f:
return f.read()
except:
return "<html><body><h1>index.html missing</h1></body></html>"
class Handler(http.server.SimpleHTTPRequestHandler):
def do_OPTIONS(self):
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header(
"Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"
)
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Auth-Token")
self.end_headers()
def do_GET(self):
if self.path == "/api/user-context":
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
context = read_user_context(user)
self.send_json(
{
"context": context,
"username": user,
"context_file": get_user_context_path(user),
}
)
elif self.path == "/api/check-auth":
identity = identity_from_headers(self.headers)
if identity:
self.send_json(
{
"authenticated": True,
"username": identity["username"],
"role": identity["role"] or "free",
"email": identity.get("email", ""),
}
)
else:
self.send_json({"authenticated": False})
elif self.path == "/api/active-users":
now = time.time()
with _user_last_seen_lock:
raw = dict(_user_last_seen or {})
with _tokens_lock:
agent_users = set(_agent_users)
active = sorted(
u for u, last in raw.items()
if u not in agent_users and now - last <= ACTIVE_WINDOW_SECONDS
)
self.send_json({"users": active})
elif self.path == "/api/model-status":
snap = model_status_snapshot()
ms, tps, oh, gtemp, ram_evac = (
snap["model"],
snap["predicted_per_second"],
snap["overheated"],
snap["gpu_temp"],
snap["ram_evacuating"],
)
try:
user = get_current_user(self.headers)
reminder_count = len(_db_fetch("SELECT id FROM tasks WHERE user_id=? AND reminder_at IS NOT NULL AND reminder_at <= ? AND reminded=0 AND status NOT IN ('completed','cancelled')", (user, datetime.now().isoformat()))) if user else 0
except Exception:
reminder_count = 0
self.send_json(
{
"model": ms,
"predicted_per_second": tps,
"overheated": oh,
"gpu_temp": gtemp,
"ram_evacuating": ram_evac,
"max_context": MAX_INPUT_TOKENS,
"reminder_count": reminder_count,
}
)
elif self.path == "/api/shares":
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
self.send_json({"shares": list_shares(user)})
elif self.path.startswith("/api/public/share/"):
token = os.path.basename(self.path)
rec = get_share(token)
if not rec:
self.send_error(404)
return
self.send_json(
{
"message": rec.get("message", {}),
"created": rec.get("created"),
"shared_by": rec.get("owner", ""),
}
)
elif self.path.startswith("/output/"):
rel = urlparse(self.path).path
rel = rel[len("/output/"):] if rel.startswith("/output/") else rel
fpath = os.path.abspath(os.path.join(COMFYUI_OUTPUT, rel))
if fpath.startswith(os.path.abspath(COMFYUI_OUTPUT)) and os.path.exists(
fpath
):
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.end_headers()
with open(fpath, "rb") as f:
self._safe_write(f.read())
return
self.send_error(404)
elif self.path.startswith("/uploads/"):
filename = os.path.basename(urlparse(self.path).path)
fpath = os.path.abspath(os.path.join(UPLOADS_DIR, filename))
if fpath.startswith(os.path.abspath(UPLOADS_DIR)) and os.path.exists(fpath):
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Disposition", "inline")
self.end_headers()
with open(fpath, "rb") as f:
self._safe_write(f.read())
return
self.send_error(404)
elif self.path.startswith("/api/image/"):
image_id = self.path[len("/api/image/"):]
fpath = resolve_image_file(image_id)
if fpath:
ext = os.path.splitext(fpath)[1].lower()
ctype = IMAGE_MIME.get(ext, "image/jpeg")
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Disposition", "inline")
self.send_header("Cache-Control", "public, max-age=31536000, immutable")
self.end_headers()
with open(fpath, "rb") as f:
self._safe_write(f.read())
return
self.send_error(404)
elif self.path.startswith("/api/status/"):
task_id = os.path.basename(self.path)
with _data_lock:
status = tasks.get(
task_id, {"status": "unknown", "message": "Not found"}
)
self.send_json(status)
elif self.path == "/api/sessions":
user = get_current_user(self.headers)
if not user:
self.send_json([], status=401)
return
with _data_lock:
sorted_items = sorted(
sessions_meta.items(),
key=lambda x: x[1].get("updated", 0),
reverse=True,
)
result = [
{
"session_id": sid,
"name": meta.get("name", "Chat"),
"created": meta.get("created", 0),
"updated": meta.get("updated", 0),
**context_token_report(sid, sessions.get(sid, [])),
}
for sid, meta in sorted_items
if meta.get("user_id", "") == user
]
self.send_json(result)
elif self.path.startswith("/api/sessions/") and self.path.endswith("/messages"):
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
sid = self.path.split("/")[3]
with _data_lock:
meta = sessions_meta.get(sid)
if not meta or meta.get("user_id", "") != user:
self.send_error(404)
return
msgs = sessions.get(sid)
if msgs is not None:
self.send_json(
{
"messages": msgs,
**context_token_report(sid, msgs),
}
)
else:
self.send_error(404)
elif self.path == "/":
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self._safe_write(read_index_html().encode())
else:
DIST_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dist"
)
fpath = os.path.abspath(os.path.join(DIST_DIR, self.path.lstrip("/")))
if fpath.startswith(os.path.abspath(DIST_DIR)) and os.path.isfile(fpath):
ctype, _ = mimetypes.guess_type(fpath)
self.send_response(200)
self.send_header("Content-Type", ctype or "application/octet-stream")
self.send_header("Cache-Control", "public, max-age=31536000, immutable")
self.end_headers()
with open(fpath, "rb") as f:
self._safe_write(f.read())
elif self.path.startswith("/api/") or "." in os.path.basename(self.path):
if self.path == "/api/tasks":
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
user_tasks = task_list(user)
self.send_json({"tasks": user_tasks})
elif self.path.startswith("/api/themes"):
identity = get_identity(self.headers)
if not identity:
self.send_json({"error": "Unauthorized"}, status=401)
return
qs = parse_qs(urlparse(self.path).query)
scope = qs.get("scope", [""])[0] or None
is_global = qs.get("global", ["0"])[0] in ("1", "true", "True", "yes")
if is_global and identity["role"] != "admin":
self.send_json({"error": "Admin role required"}, status=403)
return
result = handle_theme_tool(
identity["username"],
{
"operation": "list",
"scope": scope,
"global": is_global,
"status": qs.get("status", [""])[0] or None,
"limit": qs.get("limit", ["50"])[0] or 50,
},
)
self.send_json(json.loads(result))
else:
self.send_error(404)
else:
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self._safe_write(read_index_html().encode())
def do_DELETE(self):
if self.path.startswith("/api/shares/"):
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
token = self.path.split("/")[3]
if revoke_share(token, user):
self.send_json({"status": "revoked"})
else:
self.send_json({"error": "Share not found or not yours"}, status=404)
elif self.path.startswith("/api/sessions/"):
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
sid = self.path.split("/")[3]
with _data_lock:
meta = sessions_meta.get(sid)
if not meta or meta.get("user_id", "") != user:
self.send_error(404)
return
msgs = list(sessions.get(sid, []))
# Cancel all queued/in-flight tasks for this session so they stop processing
for mode in ("gpu", "cpu"):
with _queue_locks[mode]:
q = _task_queues[mode]
q[:] = [item for item in q if item.get("session_id") != sid]
with _data_lock:
for tid, t in tasks.items():
if t.get("session_id") == sid and t.get("status") not in ("done", "error"):
tasks[tid] = {
"status": "cancelled",
"error": "Session was deleted",
"session_id": sid,
}
for msg in msgs:
if msg.get("role") == "assistant":
url = msg.get("_image_url", "") or ""
if url:
fname = os.path.join(IMG_PATH, _image_url_rel(url))
fpath = fname
if os.path.exists(fpath):
print(f"[delete] Removed output image: {fpath}")
os.remove(fpath)
raw = msg.get("content", "")
texts = []
if isinstance(raw, str):
texts.append(raw)
elif isinstance(raw, list):
for part in raw:
if isinstance(part, dict) and part.get("type") == "text":
texts.append(part.get("text", ""))
for text in texts:
for part in text.split("[FILE:"):
idx = part.find("/uploads/")
if idx != -1:
url_part = part[idx:].split("]")[0]
fname = os.path.basename(url_part)
fpath = os.path.join(UPLOADS_DIR, fname)
if os.path.exists(fpath):
print(f"[delete] Removed uploaded file: {fpath}")
os.remove(fpath)
# Remove image uploads stored as image_url content parts (the
# /uploads/ URLs written by _save_upload_image).
for msg in msgs:
raw = msg.get("content", "")
if not isinstance(raw, list):
continue
for part in raw:
if not isinstance(part, dict) or part.get("type") != "image_url":
continue
url = part.get("image_url", {}).get("url", "")
if not url.startswith("/uploads/"):
continue
fname = os.path.basename(url.split("?", 1)[0])
fpath = os.path.join(UPLOADS_DIR, fname)
if os.path.exists(fpath):
print(f"[delete] Removed uploaded image: {fpath}")
os.remove(fpath)
with _data_lock:
exists = sid in sessions
if exists:
sessions.pop(sid, None)
sessions_meta.pop(sid, None)
if exists:
with _effective_contexts_lock:
_effective_contexts.pop(sid, None)
if exists:
save_sessions()
self.send_json({"status": "deleted"})
else:
self.send_error(404)
elif self.path.startswith("/api/tasks/"):
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
tid = self.path.split("/")[3]
if task_delete(tid, user):
self.send_json({"status": "deleted"})
else:
self.send_error(404)
else:
self.send_error(404)
def do_PUT(self):
if self.path.startswith("/api/sessions/"):
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
sid = self.path.split("/")[3]
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
with _data_lock:
meta = sessions_meta.get(sid)
if meta and meta.get("user_id", "") == user:
meta["name"] = body.get("name", meta["name"])
meta["updated"] = time.time()
if meta:
save_sessions()
self.send_json({"status": "updated"})
else:
self.send_error(404)
elif self.path.startswith("/api/tasks/"):
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
tid = self.path.split("/")[3]
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
t = task_update(tid, user, **{k: v for k, v in body.items() if k in ("title","description","status","priority","due_date","reminder_at")})
if t:
self.send_json({"task": t})
else:
self.send_error(404)
else:
self.send_error(404)
def do_POST(self):
if self.path == "/api/shares":
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
try:
token, url = create_share(
user, body.get("session_id", ""), body.get("msg_index")
)
except ValueError as e:
self.send_json({"error": str(e)}, status=400)
return
self.send_json({"token": token, "url": url})
elif self.path == "/api/register-agent":
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length else {}
tokens = body.get("tokens", []) or []
usernames = body.get("usernames", []) or []
print("Agent registered")
with _tokens_lock:
for t in tokens:
_agent_tokens.add(t)
for u in usernames:
_agent_users.add(u)
self.send_json({"ok": True})
elif self.path == "/api/leaving":
# Fired via navigator.sendBeacon on pagehide. With SSO the browser
# sends no custom header, so the username travels in the body;
# mark this user's heartbeat stale immediately instead of waiting
# out ACTIVE_WINDOW_SECONDS (which stays as a crash-fallback).
username = ""
length = int(self.headers.get("Content-Length", 0))
if length:
try:
body = json.loads(self.rfile.read(length))
username = body.get("username", "")
except Exception:
username = ""
if not username:
identity = identity_from_headers(self.headers)
username = (identity or {}).get("username", "")
with _tokens_lock:
if username and _user_last_seen:
_user_last_seen.pop(username, None)
self.send_json({"ok": True})
elif self.path == "/api/logout":
# Logout is handled at the nginx/Authentik layer (SSO session
# cookie). Nothing server-side to invalidate here.
self.send_json({"ok": True})
elif self.path == "/api/user-context":
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
action = body.get("action", "read")
if action == "write":
content = body.get("context", "")
write_user_context(user, content)
self.send_json({"status": "ok", "username": user})
elif action == "overwrite":
identity = identity_from_headers(self.headers)
if not identity or identity["role"] != "admin":
self.send_json({"error": "Admin role required to overwrite context"}, status=403)
return
content = body.get("context", "")
path = get_user_context_path(user)
if path:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(content)
self.send_json({"status": "ok", "username": user})
else:
context = read_user_context(user)
self.send_json(
{
"context": context,
"username": user,
"context_file": get_user_context_path(user),
}
)
elif self.path == "/api/chat":
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
task_id = str(uuid.uuid4())
sid = body.get("session_id", "default")
with _data_lock:
meta = sessions_meta.get(sid)
if not meta or meta.get("user_id", "") != user:
self.send_json({"error": "Session not found"}, status=404)
return
entry = {
"task_id": task_id,
"session_id": sid,
"message": body.get("message", ""),
"image": body.get("image"),
"audio": body.get("audio"),
"user": user,
"client_timestamp": body.get("client_timestamp"),
"research": bool(body.get("research")),
"cpu": bool(body.get("cpu")) and bool(body.get("research")),
"no_tools": bool(body.get("no_tools")),
}
# Route to the GPU lane (interactive UI users) or the lane chosen
# by SELF_CHAT_MODE — cpu (self-chat agents on the RAM-backed CPU
# server) or gpu (agents sharing the interactive GPU server) — so
# the two never wait behind each other. Agent users may override
# the lane per request (self-chat.py --gpu sends mode="gpu"); the
# override is ignored for interactive users, who always use GPU.
# Interactive users may also opt into the CPU lane explicitly for a
# research task via the UI's "CPU" toggle (gated on Research mode,
# and honored server-side only when research is set).
cpu_flagged = entry["cpu"]
mode = body.get("mode")
if mode not in ("gpu", "cpu") or user not in _agent_users:
mode = SELF_CHAT_MODE if user in _agent_users else "gpu"
if cpu_flagged:
mode = "cpu"
if FORCE_GPU_LANE and not cpu_flagged:
# Test-time override: never admit anything to the CPU lane.
mode = "gpu"
entry["mode"] = mode
with _queue_locks[mode]:
if len(_task_queues[mode]) >= MAX_QUEUE_SIZE:
self.send_json({"error": "Server busy"}, status=503)
return
_task_queues[mode].append(entry)
_queue_conds[mode].notify()
with _data_lock:
tasks[task_id] = {
"status": "queued",
"message": "Waiting in line...",
"session_id": sid,
"mode": mode,
"research": bool(body.get("research")),
"cpu": cpu_flagged,
"no_tools": bool(body.get("no_tools")),
}
self.send_json({"task_id": task_id})
elif self.path == "/api/extract-file":
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
name = body.get("name", "")
data_b64 = body.get("data", "")
ext = os.path.splitext(name)[1].lower()
safe_name = str(uuid.uuid4()) + ext
filepath = os.path.join(UPLOADS_DIR, safe_name)
raw = base64.b64decode(data_b64)
with open(filepath, "wb") as f:
f.write(raw)
file_url = f"/uploads/{safe_name}"
self.send_json({"url": file_url, "name": name})
elif self.path == "/api/upload-image":
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
data_b64 = body.get("data", "")
ext = (body.get("ext") or "jpg").lstrip(".").lower()
if ext not in ("png", "jpg", "jpeg", "webp", "gif", "bmp"):
ext = "jpg"
try:
raw = base64.b64decode(data_b64, validate=False)
except Exception:
self.send_json({"error": "Invalid image data"}, status=400)
return
safe_name = str(uuid.uuid4()) + "." + ext
os.makedirs(UPLOADS_DIR, exist_ok=True)
filepath = os.path.join(UPLOADS_DIR, safe_name)
with open(filepath, "wb") as f:
f.write(raw)
self.send_json({"url": f"/uploads/{safe_name}"})
elif self.path == "/api/tts":
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
raw_text = body.get("text", "")
if not raw_text:
self.send_json({"error": "No text provided"}, status=400)
return
try:
import re
text = raw_text
voice = body.get("voice", "")
# Detect language tag from LLM prefix: [bn], [hi], [te], [kn], [en]
m = re.match(r"^\s*\[(bn|hi|te|kn|en)\]\s*", text)
if m:
tag = m.group(1)
text = text[m.end():]
elif not voice:
bn = len(re.findall(r"[\u0980-\u09FF]", text))
hi = len(re.findall(r"[\u0900-\u097F]", text))
te = len(re.findall(r"[\u0C00-\u0C7F]", text))
kn = len(re.findall(r"[\u0C80-\u0CFF]", text))
scores = {"bn": bn, "hi": hi, "te": te, "kn": kn}
tag = max(scores, key=scores.get)
if scores[tag] == 0:
tag = "en"
else:
tag = "en"
# Determine TTS backend
PIPER_VOICES = {
"bn": "/home/palash/.piper_voices/bn_BD-google-medium.onnx",
"hi": "/home/palash/.piper_voices/hi_IN-priyamvada-medium.onnx",
"te": "/home/palash/.piper_voices/te_IN-padmavathi-medium.onnx",
"en": "/home/palash/.piper_voices/en_US-amy-medium.onnx",
}
EDGE_VOICES = {
"bn": "bn-IN-TanishaaNeural",
"hi": "hi-IN-SwaraNeural",
"te": "te-IN-ShrutiNeural",
"kn": "kn-IN-GaganNeural",
"en": "en-US-AriaNeural",
}
if tag in PIPER_VOICES:
import piper, io, struct, wave
onnx_path = PIPER_VOICES[tag]
cfg_path = onnx_path + ".json"
if not hasattr(self, "_piper_voices"):
self._piper_voices = {}
if tag not in self._piper_voices:
print(f"[tts] Loading Piper voice '{tag}' ...")
self._piper_voices[tag] = piper.PiperVoice.load(
onnx_path, config_path=cfg_path
)
pv = self._piper_voices[tag]
print(f"[tts] Piper {tag}: synthesizing {len(text)} chars")
wav_io = io.BytesIO()
with wave.open(wav_io, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(22050)
for chunk in pv.synthesize(text):
int16 = (chunk.audio_float_array * 32767).clip(-32768, 32767).astype("<i2")
wf.writeframes(int16.tobytes())
audio_b64 = base64.b64encode(wav_io.getvalue()).decode()
self.send_json({"audio": audio_b64, "type": "audio/wav"})
else:
import asyncio, edge_tts
edge_voice = voice or EDGE_VOICES.get(tag, "en-US-AriaNeural")
print(f"[tts] edge-tts {tag} ({edge_voice}): {len(text)} chars")
communicate = edge_tts.Communicate(text, edge_voice)
mp3_data = bytearray()
async def _gen():
async for chunk in communicate.stream():
if chunk["type"] == "audio":
mp3_data.extend(chunk["data"])
asyncio.run(_gen())
audio_b64 = base64.b64encode(bytes(mp3_data)).decode()
self.send_json({"audio": audio_b64, "type": "audio/mpeg"})
except Exception as e:
print(f"[tts] Error: {e}")
traceback.print_exc()
self.send_json({"error": str(e)}, status=500)
elif self.path == "/api/sessions":
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
length = int(self.headers.get("Content-Length", 0))
extra = {}
context_tokens = {}
system_prompt = ""
if length:
try:
ext_body = json.loads(self.rfile.read(length))
extra = _load_extra_prompts(ext_body.get("system_prompts") or [])
context_tokens = ext_body.get("context_tokens") or {}
system_prompt = ext_body.get("system_prompt") or ""
except Exception:
extra = []
sid = str(uuid.uuid4())
now = time.time()
with _data_lock:
sessions[sid] = []
sessions_meta[sid] = {
"name": "New Chat",
"created": now,
"updated": now,
"user_id": user,
"system_prompts": extra,
"context_tokens": context_tokens,
"system_prompt": system_prompt,
}
save_sessions()
self.send_json({"session_id": sid})
elif self.path == "/api/location":
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
task_id = body.get("task_id")
if body.get("denied"):
set_client_location("")
ev = _location_events.get(task_id) if task_id else None
if ev:
ev.set()
self.send_json({"ok": True})
return
lat = body.get("latitude")
lng = body.get("longitude")
if lat is not None and lng is not None:
try:
geo = requests.get(
"https://nominatim.openstreetmap.org/reverse",
params={"format": "json", "lat": lat, "lon": lng},
headers={"User-Agent": "LocalAI/1.0"},
timeout=5,
).json()
display = geo.get("display_name", "")
set_client_location(display)
except Exception:
set_client_location(f"{lat:.4f}, {lng:.4f}")
ev = _location_events.get(task_id) if task_id else None
if ev:
ev.set()
self.send_json({"ok": True})
elif self.path == "/api/tasks":
user = get_current_user(self.headers)
if not user:
self.send_json({"error": "Unauthorized"}, status=401)
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
t = task_create(user, body.get("title", "Untitled"), body.get("description", ""), body.get("priority", "medium"), body.get("due_date"), body.get("session_id"), body.get("reminder_at"))
self.send_json({"task": t})
elif self.path == "/api/themes":
identity = get_identity(self.headers)
if not identity:
self.send_json({"error": "Unauthorized"}, status=401)
return
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
if body.get("global") and identity["role"] != "admin":
self.send_json({"error": "Admin role required"}, status=403)
return
result = handle_theme_tool(identity["username"], body)
self.send_json(json.loads(result))
else:
self.send_error(404)
def _safe_write(self, data):
try:
self.wfile.write(data)
except (BrokenPipeError, ConnectionResetError):
pass
def send_json(self, data, status=200):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self._safe_write(json.dumps(data).encode())
def log_message(self, format, *args):
pass
async def handle_chat(user_message):
# Run both LLM calls in parallel to prevent waiting
user_response, bot_response = await asyncio.gather(
llm_call_user(user_message),
llm_call_bot(user_message)
)
return user_response, bot_response
</file>
<file path="chat-webui.py">
#!/usr/bin/env python3
"""Chat Web UI — script entrypoint.
The chat engine implementation now lives in the :mod:`server.features` package.
This file remains the single script entrypoint and the owner of every shared
value: it re-exports the feature modules' functions and state, registers itself
with the ``M`` proxy (see :mod:`server.features.state`), and wires up the
background threads.
Feature code resolves shared state, config values and cross-cutting helpers at
call time through the ``M`` proxy, so monkeypatching ``chat-webui.<name>`` —
which the test-suite relies on — keeps working across module boundaries.
"""
import http.server, json, os, re, glob, uuid, base64, requests, subprocess, time, random, threading, sys, io, tempfile, queue as _queue_mod # noqa: F401
sys.stdout.reconfigure(line_buffering=True) # noqa
from datetime import datetime # noqa: F401
from urllib.parse import urlparse, parse_qs # noqa: F401
from server.read_file import read_file_text
from server.config import ( # noqa: F401
AUDIO_TOKEN_COST,
COMFYUI_DIR,
COMFYUI_INPUT,
COMFYUI_OUTPUT,
COMFYUI_URL,
DDNS_CHECK_INTERVAL,
DDNS_DOMAIN,
DDNS_SUBDOMAIN,
FILES_DIR,
FORCE_GPU_LANE,
GODADDY_API_KEY,
GODADDY_API_SECRET,
HEARTBEAT_URL,
HOST,
IMG_PATH,
IMAGE_MODELS,
IMAGE_TOKEN_COST,
LLAMA_BASE,
LLAMA_BASE_CPU,
LLAMA_GEMMA_NGL,
LLAMA_QWEN_NGL,
LLAMA_SERVER_ARGS,
LLAMA_SERVER_ARGS_CPU,
LLAMA_SERVER_PATH,
LLAMA_SLOT_SAVE_DIR,
LLAMA_URL,
LLAMA_URL_CPU,
MODEL_ID,
MODEL_ID_CPU,
PER_MESSAGE_OVERHEAD,
PORT,
PROMPT_PATH,
REASONING_BUDGET,
SEARXNG_URL,
SELF_CHAT_MODE,
SESSIONS_DIR,
SESSIONS_FILE,
SHARE_BASE_URL,
SHARES_FILE,
TASKS_DB,
THEMES_DB,
TOOL_FREE_AGENTS,
TOOLS,
TOOLS_TOKEN_COST,
UPLOADS_DIR,
VENV_PYTHON,
VERIFY_FETCH_CHARS,
VERIFY_MAX_CITES_PER_URL,
VERIFY_RETRIES,
build_sys_content,
)
from server.api import APP_STATE_NAMES, Handler, set_app_state
import sys
sys.path.insert(0, COMFYUI_DIR)
# ---------------------------------------------------------------------------
# Feature modules — the chat engine implementation.
# ---------------------------------------------------------------------------
from server.features.state import ( # noqa: E402
ACTIVE_WINDOW_SECONDS,
AUTO_COMPACT_THRESHOLD,
MAX_INPUT_TOKENS,
MAX_QUEUE_SIZE,
MAX_TOOL_ROUNDS,
RAM_EVAC_THRESHOLD,
RAM_RESUME_THRESHOLD,
TEMP_THRESHOLD_OFF,
TEMP_THRESHOLD_ON,
_active_tokens,
_agent_tokens,
_agent_users,
_client_location,
_cpu_last_llm_use,
_cpu_model_status,
_current_task_ids,
_data_lock,
_effective_contexts,
_effective_contexts_lock,
_event_queue,
_gpu_temp,
_image_queue,
_last_llm_use,
_last_tps,
_llm_pools,
_location_events,
_model_transition_lock,
_overheated,
_queue_conds,
_queue_locks,
_ram_evacuating,
_slot_checkpoints,
_slot_kv_dirty,
_task_queues,
_tokens_lock,
_tool_pools,
_user_last_seen,
_user_last_seen_lock,
_users_cache,
_users_cache_time,
model_status,
register_entrypoint,
sessions,
sessions_meta,
shares,
tasks,
)
from server.features.tasks_db import ( # noqa: E402
_db_fetch,
_db_fetch_one,
_db_run,
_init_tasks_db,
handle_task_tool,
task_complete,
task_create,
task_delete,
task_get,
task_list,
task_update,
)
from server.features.themes_db import ( # noqa: E402
_init_themes_db,
handle_theme_tool,
)
from server.features.users import ( # noqa: E402
_safe_username,
active_users,
get_current_identity,
get_current_user,
get_user_context_path,
read_user_context,
write_user_context,
)
from server.features.sessions import ( # noqa: E402
_load_extra_prompts,
_prepare_session,
_session_file,
_session_meta_from,
load_sessions,
save_sessions,
)
from server.features.shares import ( # noqa: E402
create_share,
get_share,
list_shares,
load_shares,
revoke_share,
save_shares,
)
from server.features.context import ( # noqa: E402
_image_to_data_url,
_latest_read_image_url,
_reference_historical_images,
_summarize_with_llm,
_text_tokens,
compact_messages_copy,
context_token_report,
effective_token_estimate,
estimate_tokens,
prepare_context_for_llm,
resolve_image_path,
strip_html,
trim_messages_for_context,
)
from server.features.llm import ( # noqa: E402
_consult_worker,
_inject_read_image,
_llm_worker,
_start_llm_round,
active_model_id,
consult_expert_model,
is_llama_alive,
load_llama_model,
mark_slot_kv_dirty,
restore_slot_checkpoint,
save_slot_checkpoint,
server_base,
server_last_use,
server_model_id,
server_status,
server_url,
task_mode,
unload_llama_model,
)
from server.features.tools import ( # noqa: E402
_dispatch_tool,
_tool_worker,
fetch_page,
web_search,
)
from server.features.images import ( # noqa: E402
_enqueue_image_job,
_image_url_rel,
_image_worker,
_input_dir,
_output_dir,
_output_rel,
edit_image,
free_comfyui_vram,
generate_image,
)
from server.features.monitoring import ( # noqa: E402
_cpu_lane_needed,
_ensure_llama_server_for_task,
_evacuate_ram,
_idle_unload_loop,
_reminder_loop,
_thermal_monitor,
_connection_manager,
ensure_comfyui_running,
ensure_llama_server,
get_gpu_temp,
get_ram_usage,
kill_comfyui,
kill_llama_server,
model_status_snapshot,
restart_llama_server,
restart_servers,
)
from server.features.orchestration import ( # noqa: E402
_delete_task_image,
_event_loop,
_event_post,
_finalize_task,
_human_priority_active,
_queue_worker,
_set_task_error,
_task_max_rounds,
_task_user,
location_str,
set_client_location,
set_status,
)
from server.features.critic import ( # noqa: E402
extract_citations,
run_verification,
run_verification_worker,
)
# Point the M proxy at this module: from here on, feature modules resolve all
# shared state, config values and cross-cutting helpers through it.
register_entrypoint(sys.modules[__name__])
_init_tasks_db()
_init_themes_db()
SYS_CONTENT = build_sys_content()
print("Prompt:\n", "*" * 80, "\n", SYS_CONTENT, "\n", "*" * 80)
set_app_state({name: globals()[name] for name in APP_STATE_NAMES})
if __name__ == "__main__":
os.makedirs(UPLOADS_DIR, exist_ok=True)
load_sessions()
load_shares()
try:
r = requests.get(f"{LLAMA_BASE}/health", timeout=3)
if r.status_code != 200:
raise Exception("health check failed")
print("[startup] GPU llama-server is running")
except Exception:
print("[startup] GPU llama-server not reachable — starting...")
restart_servers()
try:
r = requests.get(SEARXNG_URL, timeout=3)
if r.status_code in (200, 301, 302):
print("[startup] SearXNG is running")
else:
raise Exception(f"status {r.status_code}")
except Exception as e:
print(f"[startup] ERROR: SearXNG is not reachable at {SEARXNG_URL} ({e}). Web search will not work. Exiting.")
sys.exit(1)
threading.Thread(target=_event_loop, daemon=True).start()
# One queue worker per lane: GPU (interactive UI users) and CPU (self-chat
# agents) now run fully independently, so an agent task can never make a
# UI user wait behind it.
threading.Thread(target=_queue_worker, args=("gpu",), daemon=True).start()
threading.Thread(target=_queue_worker, args=("cpu",), daemon=True).start()
threading.Thread(target=_image_worker, daemon=True).start()
threading.Thread(target=_idle_unload_loop, daemon=True).start()
threading.Thread(target=_thermal_monitor, daemon=True).start()
threading.Thread(target=_reminder_loop, daemon=True).start()
threading.Thread(target=_connection_manager, daemon=True).start()
print(f"Chat UI running on http://localhost:{PORT}")
s = http.server.HTTPServer((HOST, PORT), Handler)
try:
s.serve_forever()
except KeyboardInterrupt:
s.shutdown()
</file>
<file path="server/config.py">
#!/usr/bin/env python3
import json
import os
from server.dotenv import load_dotenv
load_dotenv()
LLAMA_BASE = "http://localhost:8081"
LLAMA_URL = f"{LLAMA_BASE}/v1/chat/completions"
# The CPU-backed llama-server that serves automated self-chat agents
# (editor/moderator/registered agents). It runs CONCURRENTLY with the GPU
# llama-server on its own port so background agent runs never compete with
# interactive UI users for VRAM.
LLAMA_BASE_CPU = "http://localhost:8079"
LLAMA_URL_CPU = f"{LLAMA_BASE_CPU}/v1/chat/completions"
VENV_PYTHON = os.path.expanduser("~/local-ai/ComfyUI/venv/bin/python")
COMFYUI_DIR = os.path.expanduser("~/local-ai/ComfyUI")
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://127.0.0.1:8080/")
COMFYUI_URL = "http://localhost:8188"
HOST = os.environ.get("CHAT_HOST", "0.0.0.0")
PORT = 3001
# GoDaddy Dynamic DNS — updates the AAAA record for DDNS_DOMAIN/DDNS_SUBDOMAIN
# with this machine's stable global IPv6 on a timer (see the ConnectionManager
# thread in server/features/monitoring.py). Secret credentials come from the
# environment (e.g. an EnvironmentFile / /etc/environment); leave the keys
# empty to disable the updater entirely.
GODADDY_API_KEY = os.environ.get("GODADDY_API_KEY", "")
GODADDY_API_SECRET = os.environ.get("GODADDY_API_SECRET", "")
DDNS_DOMAIN = os.environ.get("DDNS_DOMAIN", "palashkantikundu.in")
DDNS_SUBDOMAIN = os.environ.get("DDNS_SUBDOMAIN", "home")
DDNS_CHECK_INTERVAL = int(os.environ.get("DDNS_CHECK_INTERVAL", "300"))
# GCP heartbeat — the ConnectionManager thread POSTs this machine's addresses
# to the receiver running on the GCP VM (scripts/gcp_heartbeat_server.py)
# over the WireGuard tunnel every 10s.
HEARTBEAT_URL = os.environ.get("HEARTBEAT_URL", "http://10.66.66.1:9863/heartbeat")
# External origin used to build public share links. Set this to a portless URL
# (e.g. http://192.168.1.10 or https://chat.example.com) when the server is also
# reachable on port 80/443, because WhatsApp and several other messengers stop
# auto-linking a URL at the ":" of a port: a share link like
# "http://192.168.1.10:3001/s/<token>" becomes a dead short URL that ends at the
# colon. Leave empty to keep building links from the browser's own origin.
SHARE_BASE_URL = os.environ.get("SHARE_BASE_URL", "").strip().rstrip("/")
REASONING_BUDGET = 4096
CPU_PARALLEL_SLOTS = 4 # Set to desired number of concurrent CPU agent slots
# ─────────────────────────────────────────────────────────────────────────────
# Unified RBAC / SSO — Authentik is the SINGLE identity provider.
#
# There is no users.json anymore. Browser users authenticate through nginx's
# auth_request → Authentik proxy outpost (the X-Authentik-* claim headers are
# trusted downstream); self-chat agents authenticate via an OAuth2 password
# grant and send the resulting JWT as "Authorization: Bearer <token>", which
# the backends verify against Authentik's JWKS (see server/auth.py).
#
# AUTHENTIK_BASE_URL must NOT have a trailing slash.
# ─────────────────────────────────────────────────────────────────────────────
AUTHENTIK_BASE_URL = os.environ.get("AUTHENTIK_BASE_URL", "https://home.palashkantikundu.in/sso").rstrip("/")
# Interactive browser SSO application (humans via nginx auth_request).
AUTH_CLIENT_ID = os.environ.get("AUTH_CLIENT_ID", "local-ai")
AUTH_CLIENT_SECRET = os.environ.get("AUTH_CLIENT_SECRET", "")
AUTH_SCOPE = os.environ.get("AUTH_SCOPE", "openid profile email groups")
# Machine-agent OIDC client (self-chat). Separate application in Authentik so
# agent credentials never mix with the human SSO client. The client_id must
# equal the Authentik application slug — the token/jwks endpoints are routed
# by slug, not by client_id.
AUTH_AGENTS_CLIENT_ID = os.environ.get("AUTH_AGENTS_CLIENT_ID", "")
AUTH_AGENTS_CLIENT_SECRET = os.environ.get("AUTH_AGENTS_CLIENT_SECRET", "")
AUTH_AGENTS_APP_SLUG = os.environ.get("AUTH_AGENTS_APP_SLUG", AUTH_AGENTS_CLIENT_ID)
# Token endpoint used by the machine-agent password grant. Authentik only
# exposes a generic token endpoint (the client_id in the body selects the
# provider); the per-slug routes exist for authorize/jwks but not token.
AUTH_AGENTS_TOKEN_URL = os.environ.get(
"AUTH_AGENTS_TOKEN_URL",
f"{AUTHENTIK_BASE_URL}/application/o/token/",
)
# JWKS endpoint used to verify agent access tokens. Authentik exposes it at
# /application/o/<application-slug>/jwks/.
AUTH_AGENTS_JWKS_URL = os.environ.get(
"AUTH_AGENTS_JWKS_URL",
f"{AUTHENTIK_BASE_URL}/application/o/{AUTH_AGENTS_APP_SLUG}/jwks/",
)
AUTH_AGENTS_ISSUER = os.environ.get(
"AUTH_AGENTS_ISSUER",
f"{AUTHENTIK_BASE_URL}/application/o/{AUTH_AGENTS_APP_SLUG}/",
)
# Map Authentik group names → the role scale used by the story RBAC
# (free < premium < admin). Users may be in multiple groups; the highest wins.
AUTH_ROLE_GROUPS = {
"admin": "admin",
"premium": "premium",
"free": "free",
}
# Per-user context files are stored at ~/local-ai-files/contexts/<user>.txt
# (the users.json "context_file" field is gone along with users.json).
CONTEXTS_DIR = os.environ.get("CONTEXTS_DIR", os.path.expanduser("~/local-ai-files/contexts"))
# Which llama-server self-chat agents run on: "cpu" (the RAM-backed CPU server
# on 8079, so agents never compete with interactive UI users for VRAM) or "gpu"
# (the interactive GPU server on 8081, sharing the VRAM-backed model). Override
# with the SELF_CHAT_MODE environment variable.
SELF_CHAT_MODE = os.environ.get("SELF_CHAT_MODE", "cpu").strip().lower()
if SELF_CHAT_MODE not in ("cpu", "gpu"):
SELF_CHAT_MODE = "cpu"
# Test-time flag: flip to True (manually) to keep EVERY request on the fast GPU
# lane and never admit anything — including self-chat agents — to the slow CPU
# lane. During testing it is easier to wait a few seconds for the GPU than to
# endure CPU speed. A real web-UI human request never goes to the CPU lane
# regardless of this flag: that invariant is enforced unconditionally at
# admission and in task_mode().
FORCE_GPU_LANE = True
# Research self-verification ("critic" pass). After a research answer is
# generated, each inline "(Author, Venue, Year) [url]" citation is re-fetched
# and checked by a second LLM call. These bounds are per-citation only — there
# is deliberately NO overall cap on a report's verification budget.
VERIFY_RETRIES = 2 # extra search/fetch attempts per citation
VERIFY_FETCH_CHARS = 6000 # source text shown to the critic LLM per citation
VERIFY_MAX_CITES_PER_URL = 3 # flag a source cited for more distinct claims than this
# Review-only self-chat roles that must NEVER call tools. The editor/moderator
# are the same creative LLM as the story-writing agents, and with the tool list
# enabled (tool_choice "auto") they spontaneously call generate_image/edit_image
# while revising markdown, burning ComfyUI VRAM on unwanted images. Their chat
# requests are sent with an empty tool list and tool_choice "none".
TOOL_FREE_AGENTS = {"editor", "moderator"}
# model.json holds the LLM model filenames (relative to ~/local-ai-files/my-models/)
# per runtime mode: "gpu" for interactive chat UI users, "cpu" for automated
# self-chat agents (editor/moderator/registered agents). Falls back to the legacy
# single-model model.txt when model.json is missing or has no usable entries.
MODEL_CONFIG_FILE = os.path.expanduser("~/local-ai-files/model.json")
def _load_model_ids(model_file, legacy_file):
"""Return the (gpu, cpu) model ids for the given config files.
``model_file`` is the JSON config holding per-mode ids; ``legacy_file`` is
the plain-text single-model file used as a fallback.
"""
gpu = ""
cpu = ""
try:
with open(model_file, "r", encoding="utf-8") as file:
data = json.load(file)
if isinstance(data, dict):
gpu = str(data.get("gpu") or data.get("default") or "").strip()
cpu = str(data.get("cpu") or gpu).strip()
except (FileNotFoundError, json.JSONDecodeError):
pass
if not gpu:
try:
with open(legacy_file, "r") as file:
gpu = file.read().strip()
except (FileNotFoundError, OSError):
pass
if not cpu:
cpu = gpu
return gpu, cpu
MODEL_ID, MODEL_ID_CPU = _load_model_ids(
MODEL_CONFIG_FILE, os.path.expanduser("~/local-ai-files/model.txt")
)
COMFYUI_OUTPUT = os.path.expanduser("~/local-ai-files/ComfyUI/output")
UPLOADS_DIR = os.path.expanduser("~/local-ai-files/uploads")
LLAMA_SERVER_PATH = os.path.expanduser("~/local-ai/llama.cpp/build/bin/llama-server")
# ─────────────────────────────────────────────────────────────────────────────
# KV-cache slot checkpoints (--slot-save-path).
#
# The GPU llama-server is unloaded from VRAM for every ComfyUI render (and both
# servers idle-unload after 300s), which throws away the KV cache of the whole
# conversation prefix. With --slot-save-path the server exposes
# POST /slots/{id}?action=save|restore, so llm.py snapshots the KV to this
# directory right before an unload and restores it after the model loads again
# — the next completion then only evaluates NEW tokens instead of re-prefilling
# the entire context.
#
# The directory MUST exist before llama-server starts: its arg parser rejects
# a missing --slot-save-path directory at startup, hence the makedirs here.
# ─────────────────────────────────────────────────────────────────────────────
LLAMA_SLOT_SAVE_DIR = os.environ.get(
"LLAMA_SLOT_SAVE_DIR", os.path.expanduser("~/local-ai-files/kv-slots")
)
os.makedirs(LLAMA_SLOT_SAVE_DIR, exist_ok=True)
LLAMA_QWEN_NGL = "0"
LLAMA_GEMMA_NGL = "99"
LLAMA_SERVER_ARGS = [
"--host", "0.0.0.0",
"--port", "8081",
"--models-dir", os.path.expanduser("~/local-ai-files/my-models/"),
"--jinja",
# GPU / VRAM & Performance
"-ngl", LLAMA_GEMMA_NGL,
"-fa", "on",
"--ctx-size", "24576", # 24K context for interactive UI chat
"-ctk", "q8_0",
"-ctv", "q8_0", # If you really need a very big context on VRAM, can make it q8_0
"--no-mmproj-offload",
# Threads & Batching
"-t", "8",
"-tb", "8",
"-ub", "512",
"--timeout", "3600",
# KV-cache checkpointing: enables POST /slots/{id}?action=save|restore so
# the conversation KV survives model unload/reload cycles (image gen).
# The router passes this down to each loaded model instance.
"--slot-save-path", LLAMA_SLOT_SAVE_DIR,
# Sampling Parameters
"--temp", "1.0",
"--top-p", "0.95",
"--top-k", "64",
"--min-p", "0.05"
]
# Second set of llama-server arguments used when processing automated
# self-chat messages (editor/moderator/agent runs). These are background,
# non-interactive jobs, so they deliberately run the model on the CPU only —
# slower, but they never compete with interactive users for VRAM. This server
# runs on its own port (8079) CONCURRENTLY with the GPU server on 8081, so the
# two are started and stopped independently (see restart_llama_server).
LLAMA_SERVER_ARGS_CPU = [
"--host", "0.0.0.0",
"--port", "8079",
"--models-dir", os.path.expanduser("~/local-ai-files/my-models/"),
"--jinja",
# CPU-only execution — no layers offloaded to the GPU.
"--n-gpu-layers", "0",
"-fa", "off",
"--ctx-size", "65536",
"-ctk", "q8_0", # Quantized KV cache keeps RAM usage low
# Keep the multimodal projector (mmproj) in RAM too. llama-server
# offloads the mmproj to the GPU by DEFAULT even with --n-gpu-layers 0,
# which cudaMalloc-OOMs on the 4 GiB card while the GPU server is loaded.
"--no-mmproj-offload",
"-t", "6",
"-tb", "6",
# Reasoning & Thinking Limits
"--reasoning-budget", str(REASONING_BUDGET),
"--reasoning-budget-message", "Reasoning limit reached, summarize final answer.",
"--temp", "1.0",
"--top-p", "0.95",
"--top-k", "64",
"--min-p", "0.0",
"--repeat-penalty", "1.0",
"--device", "none",
# KV-cache checkpointing (see LLAMA_SERVER_ARGS): the CPU lane also
# idle-unloads, and re-prefilling an agent story context on CPU is slow.
"--slot-save-path", LLAMA_SLOT_SAVE_DIR,
]
FILES_DIR = os.path.expanduser("~/local-ai-files")
SESSIONS_DIR = os.path.join(FILES_DIR, "session")
SESSIONS_FILE = os.path.join(SESSIONS_DIR, "sessions.json")
SHARES_FILE = os.path.join(FILES_DIR, "shares.json")
IMG_PATH = os.path.expanduser("~/local-ai-files/ComfyUI/output")
COMFYUI_INPUT = os.path.expanduser("~/local-ai-files/ComfyUI/input")
PROMPT_PATH = os.path.expanduser("~/local-ai-files/sys_prompt.txt")
TASKS_DB = os.path.expanduser("~/local-ai-files/tasks.db")
THEMES_DB = os.path.expanduser("~/local-ai-files/themes.db")
IMAGE_TOKEN_COST = 1200
AUDIO_TOKEN_COST = 800
PER_MESSAGE_OVERHEAD = 4
with open(
os.path.expanduser("~/local-ai-files/models.json"), "r", encoding="utf-8"
) as file:
IMAGE_MODELS = json.load(file)
TOOLS = [
{
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web for real-time/current information. Use this for weather, news, sports, stock prices, recent events, or any query where up-to-date data matters. Do NOT answer time-sensitive questions from memory — always search. The results contain snippets only; if the snippets are insufficient to answer the question fully, follow up with fetch_page to read the full content of the relevant page.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "The search query"},
"current_time": {"type": "string", "description": "Current date and time. Pass ONLY for time-sensitive queries (news, events, hours, etc.) where recency matters. Omit for direct-link lookups or general information."},
"current_location": {"type": "string", "description": "User's location. Pass ONLY for location-specific results (weather, local news, nearby places, events). If you don't know the user's location, call get_user_location first to obtain it. Do NOT guess or fabricate location."}
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "fetch_page",
"description": "Fetch and read the full text content of a web page. Use this AFTER web_search when the search snippets are not enough to answer the question (e.g. you need details, data, or an article's body). Pass the full URL of the page to read. Long pages are returned one chunk at a time; if the result reports total_chunks greater than 1, call fetch_page again with chunk=2, 3, ... to read the rest. PDFs with no extractable text expose page_images rendered from the scanned pages.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The full URL of the web page to fetch (must start with http:// or https://)."
},
"chunk": {
"type": "integer",
"description": "Which chunk of the page to read (1 = first). Omit to read the first chunk."
}
},
"required": ["url"],
},
},
},
{
"type": "function",
"function": {
"name": "generate_image",
"description": "Generate or draw an image. You MUST choose a style model.",
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Detailed visual description of what to draw/generate.",
},
"negative_prompt": {
"type": "string",
"description": "Things to avoid in the image",
},
"model": {
"type": "string",
"enum": list(IMAGE_MODELS.keys()),
"description": "Art style to use. Options: "
+ ", ".join(
[
f"'{k}' ({v['description']})"
for k, v in IMAGE_MODELS.items()
]
),
},
"aspect_ratio": {
"type": "string",
"enum": ["landscape", "portrait", "square"],
"description": "Image framing/aspect ratio. "
"landscape = wide scene (default), portrait = tall or "
"single-subject close-up, square = balanced illustration.",
},
},
"required": ["prompt", "model"],
},
},
},
{
"type": "function",
"function": {
"name": "edit_image",
"description": "Generic Img2Img image editor to modify, restyle, recolor, add elements, or transform existing or uploaded images.",
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Complete description of what the edited image should look like.",
},
"negative_prompt": {
"type": "string",
"description": "Elements to exclude from the visual generation.",
},
"denoise": {
"type": "number",
"description": "Denoising value (0.1 to 1.0). Use 0.25-0.4 for subtle color/lighting changes, 0.45-0.65 for structural edits and object additions, and 0.7-0.85 for massive re-imaginings.",
},
},
"required": ["prompt", "denoise"],
},
},
},
{
"type": "function",
"function": {
"name": "get_user_location",
"description": "Request the user's current geographical location. Call this ONLY when you need location for a location-specific query (weather, local news, nearby places, etc.) and you don't already have the user's location. Returns the user's city/area or 'denied' if they refuse.",
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read the text content of an uploaded file (PDF, DOC, DOCX, XLS, XLSX). Call this when the user has attached a file and you need to read its content to answer their question. The file URL is provided in the user message as [FILE: url]. Pass that url as the file_url parameter.",
"parameters": {
"type": "object",
"properties": {
"file_url": {
"type": "string",
"description": "The file URL from the user message (the /uploads/... path)."
}
},
"required": ["file_url"],
},
},
},
{
"type": "function",
"function": {
"name": "read_image",
"description": "View/read an image that was attached or generated earlier in the conversation. The image URL appears in the conversation as [IMAGE: url]. Call this when you actually need to see the image content to answer or describe it accurately. Pass that url as the url parameter.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The image URL from the conversation, e.g. /uploads/<file>.jpg or /output/<file>.png"
}
},
"required": ["url"],
},
},
},
{
"type": "function",
"function": {
"name": "update_user_context",
"description": "Store information about the current user that persists across conversations. Saves preferences, personal details, important facts, or anything the user should not need to repeat. This APPENDS to existing context — only add NEW information, do not repeat what was already saved.",
"parameters": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The new information to append to the user's context. Keep it concise and focused on what's new.",
}
},
"required": ["content"],
},
},
},
{
"type": "function",
"function": {
"name": "manage_tasks",
"description": "Manage to-do tasks. Can create, update, complete, delete, list, or get task details. Use this when the user wants to track tasks, set reminders, or manage their to-do list.",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["create", "update", "complete", "delete", "list", "get"],
"description": "The operation to perform.",
},
"task_id": {
"type": "string",
"description": "Required for update/complete/delete/get. The task ID.",
},
"title": {
"type": "string",
"description": "Required for create. Task title.",
},
"description": {
"type": "string",
"description": "Task description or details.",
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high"],
"description": "Task priority (default: medium).",
},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed", "cancelled"],
"description": "For update: new status.",
},
"due_date": {
"type": "string",
"description": "Due date in ISO format (e.g. 2026-08-15T17:00:00).",
},
"reminder_at": {
"type": "string",
"description": "Reminder time in ISO format. The system will notify about this task at the given time.",
},
"session_id": {
"type": "string",
"description": "Session ID to link this task to a conversation.",
},
},
"required": ["operation"],
},
},
},
{
"type": "function",
"function": {
"name": "track_theme",
"description": "Dedicated theme/combination tracker that guarantees creative variety across all generated content. It records every already-used combination of task-detail fields + mood + genre + role + persona, both globally (across ALL users) and per-user scope. Call BEFORE agreeing on a creative idea to see what has already been produced (never repeat it), and call again AFTER locking in an idea to log it. Use this INSTEAD of manage_tasks for theme/idea tracking.",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["list", "log", "complete", "check", "stats"],
"description": "The operation to perform.",
},
"scope": {
"type": "string",
"description": "Which user scope the theme belongs to (the user whose content is being generated). For the self-chat window always use 'self-chat' so all agents share one history. Optional for list/stats, required for log/check.",
},
"global": {
"type": "boolean",
"description": "list/stats only: include the history across ALL users instead of just scope. Use to keep track of all users at a glance.",
},
"theme": {
"type": "string",
"description": "log only: a short 3-6 word slug of the concrete creative theme/premise/idea chosen.",
},
"genre": {
"type": "string",
"description": "log/check only: the task genre.",
},
"mood": {
"type": "string",
"description": "log/check only: the mood/tone used.",
},
"role": {
"type": "string",
"description": "log/check only: the relationship dynamic or role pair used.",
},
"persona": {
"type": "string",
"description": "log/check only: the persona(s) used.",
},
"details": {
"type": "object",
"description": "log/check only: the resolved task-detail fields as {field: value} pairs.",
},
"theme_id": {
"type": "string",
"description": "complete only: the id of the theme record to mark completed.",
},
"status": {
"type": "string",
"enum": ["active", "completed"],
"description": "log only: initial status (default: active).",
},
"limit": {
"type": "integer",
"description": "list only: max number of records to return (default 50).",
},
},
"required": ["operation"],
},
},
},
]
TOOLS_TOKEN_COST = len(json.dumps(TOOLS)) // 4
def build_sys_content():
with open(PROMPT_PATH, "r") as file:
sys_content = file.read()
model_list = "; ".join(f"{k}: {v['description']}" for k, v in IMAGE_MODELS.items())
sys_content = sys_content.replace("%model_list%", model_list)
sys_content = sys_content.replace("%_image_keys%", str(list(IMAGE_MODELS.keys())))
return sys_content
</file>
<file path="self-chat.py">
import os
import time
import json
import base64
import argparse
import requests
import re
import shutil
import threading
import traceback
from difflib import SequenceMatcher
from datetime import datetime
import random
from server.dotenv import load_dotenv
load_dotenv()
parser = argparse.ArgumentParser(description="Self-chat story generator")
parser.add_argument(
"--config",
default="",
help="Path to a custom JSON task list to use instead of the default "
"(~/local-ai-files/tasks.json). The file may be a plain task list or an "
"object with 'tasks' plus optional 'genre_checklists'. Each task may also "
"carry its own 'checklist' (editor/moderator) that wins over the genre "
"checklist. Combine with --defaults to also include the default tasks.",
)
parser.add_argument(
"--defaults",
action="store_true",
help="Also load the default tasks (~/local-ai-files/tasks.json) in addition "
"to the ones from --config.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate the task config and environment and print the full plan "
"for each task (genre/checklist resolution, script enforcement, medium "
"feasibility) without making any LLM call, then exit.",
)
parser.add_argument(
"--gpu",
action="store_true",
help="Run the self-chat agents (kolpo/kaya/editor/moderator) on the "
"interactive GPU llama-server (8081) instead of the RAM-backed CPU "
'server (8079). Each /api/chat request carries mode="gpu" so '
"chat-webui routes the agent tasks to the GPU lane.",
)
args = parser.parse_args()
STORY_BASE_DIR = os.path.expanduser("~/local-ai-files/stories")
# Tiered story roots for premium/admin tasks whose spec declares no path.
# Resolved the same way markdown_hosting.py resolves its collections: from
# the OS environment (STORIES_PREMIUM_DIR / STORIES_ADMIN_DIR), falling back
# to the shared free stories dir when unset.
PREMIUM_STORIES_DIR = os.getenv("STORIES_PREMIUM_DIR")
ADMIN_STORIES_DIR = os.getenv("STORIES_ADMIN_DIR")
# Agents normally run on this machine next to chat-webui; override with
# SELF_CHAT_BASE_URL only when pointing them somewhere else.
BASE_URL = os.environ.get(
"SELF_CHAT_BASE_URL", "http://localhost:3001"
).rstrip("/")
USERNAME_A = "kolpo"
USERNAME_B = "kaya"
# Each agent is a real Authentik user with its own password. Use the shared
# SELF_CHAT_PASSWORD as a fallback when the per-agent override is unset.
_SHARED_PASSWORD = os.environ.get("SELF_CHAT_PASSWORD", "")
PASSWORD_A = os.environ.get("SELF_CHAT_A_PASSWORD", _SHARED_PASSWORD)
PASSWORD_B = os.environ.get("SELF_CHAT_B_PASSWORD", _SHARED_PASSWORD)
STOP_PHRASE = "[END CONVERSATION]"
POLL_INTERVAL_SECONDS = 5.0
SLEEP_BETWEEN_TURNS = 30.0
MAX_MESSAGES_PER_AGENT = 10
AGENT_NAMES = {"A": "Kolpo", "B": "Kaya"}
SELF_CHAT_PROMPT_FILE = "/home/palash/local-ai-files/self_chat.txt"
STARTING_CONVERSATION = open(SELF_CHAT_PROMPT_FILE).read()
SLEEP_BETWEEN_ROUNDS = 900
USERNAME_EDITOR = "editor"
USERNAME_MODERATOR = "moderator"
PASSWORD_EDITOR = os.environ.get("SELF_CHAT_EDITOR_PASSWORD", _SHARED_PASSWORD)
PASSWORD_MODERATOR = os.environ.get("SELF_CHAT_MODERATOR_PASSWORD", _SHARED_PASSWORD)
EDITOR_PROMPT_FILE = "/home/palash/local-ai-files/contexts/editor.txt"
MODERATOR_PROMPT_FILE = "/home/palash/local-ai-files/contexts/moderator.txt"
CRITIQUE_PROMPT_FILE = "/home/palash/local-ai-files/contexts/critique.txt"
# Kaya↔Kolpo cross-critique: at most this many retries on the same failing spot
# before giving up and letting the deterministic gate auto-RED the story.
MAX_CRITIQUE_RETRIES = 2
DEFAULT_TASKS_FILE = os.path.expanduser("~/local-ai-files/tasks.json")
# All participants of the self-chat window (kolpo, kaya, editor, moderator)
# share one theme scope, so the theme is coordinated between the users of the
# window while regular per-user chats stay isolated in their own scopes.
SELF_CHAT_THEME_SCOPE = "self-chat"
SELF_CHAT_THEME_LIMIT = 30
# How many times a per-turn detail combination may be re-rolled if the theme
# tracker reports it was already used.
MAX_THEME_REROLL = 4
GENRE_CHECKLISTS_FILE = os.path.expanduser("~/local-ai-files/genre_checklists.json")
GENRE_PERSONA_MAP_FILE = os.path.expanduser(
"~/local-ai-files/contexts/genre_persona_map.json"
)
PERSONA_POOL_FILE = os.path.expanduser("~/local-ai-files/contexts/persona_pool.json")
_persona_cycles = {}
def load_json_file(filepath, fallback):
try:
with open(filepath, encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"[persona] Could not load {filepath}: {e} — using fallback")
return fallback
GENRE_PERSONA_MAP = load_json_file(GENRE_PERSONA_MAP_FILE, {})
PERSONA_POOL = load_json_file(PERSONA_POOL_FILE, {})
MASTER_DETAILS = load_json_file(
os.path.expanduser("~/local-ai-files/contexts/master_details.json"), {}
)
def pick_persona_round_robin(pool, genre, genre_map, task_roles=None):
global _persona_cycles
allowed = genre_map.get(genre) or genre_map.get("default")
# Hard safety rule: exclude Parent & Child from Adventure & Horror
if genre == "Adventure & Horror" and allowed:
allowed = [r for r in allowed if r != "Parent & Child"]
task_roles = task_roles or []
# Flatten pool: (relationship, mood, details_dict)
candidates = []
for rel, moods in pool.items():
if allowed and rel not in allowed:
continue
for mood, details in moods.items():
req_role = details.get("required_role")
# If profile requires premium/admin, skip if current task roles don't match
if req_role:
is_premium_or_admin = any(r in task_roles for r in ["premium", "admin"])
if req_role == "premium" and not is_premium_or_admin:
continue
elif req_role == "admin" and "admin" not in task_roles:
continue
candidates.append((rel, mood, details))
if not candidates:
# Fallback default
fallback_details = {
"Kaya": {
"role": "Colleague",
"persona": "Creative and energetic problem solver",
},
"Kolpo": {
"role": "Colleague",
"persona": "Methodical and structured partner",
},
}
return "Colleagues", "Focused Collaboration", fallback_details
if genre not in _persona_cycles or _persona_cycles[genre]["idx"] >= len(
_persona_cycles[genre]["pairs"]
):
shuffled = list(candidates)
random.shuffle(shuffled)
_persona_cycles[genre] = {"pairs": shuffled, "idx": 0}
state = _persona_cycles[genre]
choice = state["pairs"][state["idx"]]
state["idx"] += 1
return choice # Returns (relationship, mood, details_dict)
def deep_merge(target, source):
"""Recursively merge dictionary source into target."""
for key, value in source.items():
if isinstance(value, dict) and key in target and isinstance(target[key], dict):
deep_merge(target[key], value)
else:
target[key] = value
return target
def load_genre_checklists(extra=None):
"""Base checklists from genre_checklists.json, overridden per genre by any
'genre_checklists' carried in a task config file."""
checklists = {}
try:
with open(GENRE_CHECKLISTS_FILE, encoding="utf-8") as f:
checklists = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(
f"[checklist] Could not load {GENRE_CHECKLISTS_FILE}: {e} — using empty checklists"
)
if extra:
checklists.update(extra)
return checklists
def _parse_tasks(items):
tasks = []
for item in items:
task = (item.get("task") or "").strip()
if not task:
continue
languages = item.get("languages") or ["English"]
if isinstance(languages, str):
languages = [l.strip() for l in languages.split(",")]
mediums = item.get("mediums") or ["image", "text"]
if isinstance(mediums, str):
mediums = [m.strip() for m in mediums.split(",")]
roles = item.get("roles") or ["free"]
if isinstance(roles, str):
roles = [r.strip() for r in roles.split(",")]
genre = (item.get("genre") or "").strip() or "General"
details = item.get("details") or ""
if isinstance(details, str):
details = details.strip()
checklist = item.get("checklist") or {}
path = (item.get("path") or "").strip() or None
inactive = item.get("inactive") or False
context = item.get("context") or None
turns = MAX_MESSAGES_PER_AGENT
raw_turns = item.get("turns")
research = bool(item.get("research"))
research_turns = int(item.get("research_turns") or 1)
if research_turns < 1:
research_turns = 1
if raw_turns is not None:
try:
turns = int(raw_turns)
except (TypeError, ValueError):
turns = MAX_MESSAGES_PER_AGENT
if turns < 2:
turns = MAX_MESSAGES_PER_AGENT
tasks.append(
{
"task": task,
"genre": genre,
"languages": languages,
"mediums": mediums,
"roles": roles,
"details": details,
"checklist": checklist,
"path": path,
"inactive": inactive,
"context": context,
"turns": turns,
"research": research,
"research_turns": research_turns,
}
)
return tasks
_detail_cycles = {}
def _fmt_detail_value(value):
"""Render a resolved detail value as prompt text."""
if isinstance(value, str):
return value
if isinstance(value, (int, float, bool)) or value is None:
return str(value)
if isinstance(value, (dict, list)):
return json.dumps(value)
return str(value)
def _join_values(values, separator=None):
"""Join multi-selected values naturally: 'a', 'a and b', 'a, b and c'."""
if separator:
return separator.join(values)
if len(values) <= 1:
return values[0] if values else ""
return ", ".join(values[:-1]) + " and " + values[-1]
def _resolve_count(task, name, spec):
"""Resolve how many values a multi selector should pick.
``count`` may be a plain integer/string or its own selector spec
(e.g. ``{"selector": "random", "values": [2, 3]}``), resolved through
the same selector machinery so it can vary per round.
"""
count = spec.get("count")
if count is None:
return 1
count_spec = count if isinstance(count, dict) else {"value": count}
resolved = _pick_detail_value(task, f"{name}::count", count_spec)
if isinstance(resolved, (list, tuple)):
return len(resolved)
try:
return max(0, int(resolved))
except (TypeError, ValueError):
return 1
def _pick_detail_value(task, name, spec):
"""Pick value(s) for one detail field according to its selector.
Supported selectors:
- random: pick one value at random (fresh each round)
- roundrobin: cycle through values one at a time across rounds
- random_multi: pick ``count`` distinct values at random
- roundrobin_multi: slide a window of ``count`` values across rounds
- absent / static / first: use ``value`` if given, else ``values[0]``
"""
if "value" in spec:
return spec["value"]
values = spec.get("values") or []
selector = str(spec.get("selector") or "").strip().lower()
if not values:
return None
count = _resolve_count(task, name, spec) if spec.get("count") is not None else None
if count is not None and count > 1:
if selector in ("roundrobin", "roundrobin_multi"):
key = (task, name)
n = len(values)
start = _detail_cycles.get(key, 0) % n
_detail_cycles[key] = start + count
return [values[(start + i) % n] for i in range(min(count, n))]
return random.sample(values, min(count, len(values)))
if selector == "random":
return random.choice(values)
if selector == "roundrobin":
key = (task, name)
idx = _detail_cycles.get(key, 0) % len(values)
_detail_cycles[key] = idx + 1
return values[idx]
if selector == "random_multi":
count = _resolve_count(task, name, spec)
return random.sample(values, min(count, len(values)))
if selector == "roundrobin_multi":
count = _resolve_count(task, name, spec)
key = (task, name)
n = len(values)
start = _detail_cycles.get(key, 0) % n
_detail_cycles[key] = start + count
return [values[(start + i) % n] for i in range(min(count, n))]
return values[0]
def _merge_value_defs(spec, master, _seen=None):
"""Resolve ``ref`` / ``refs`` against the master dictionary.
The ``values`` across every referenced master definition are unioned
(order-preserving and deduplicated) so a single field can draw from several
value pools at once. Resolution is recursive: a referenced definition may
itself carry ``ref`` / ``refs``, allowing one value set to be composed from
others. ``_seen`` guards against reference cycles.
As before, an explicit inline ``values`` list on the spec wins outright over
the merged pool (mirroring the old single-``ref`` override behaviour). The
returned dict is ready to hand to :func:`_pick_detail_value`.
"""
if not isinstance(spec, dict):
return spec
_seen = set() if _seen is None else _seen
ref = spec.get("ref")
refs = spec.get("refs")
if refs is not None:
if isinstance(refs, (str, bytes)):
refs = [refs]
else:
refs = list(refs)
if ref is not None and ref not in refs:
refs.insert(0, ref)
elif ref is not None:
refs = [ref]
else:
refs = []
merged = dict(spec)
if not refs:
return merged
values = list(spec.get("values") or [])
selector = merged.get("selector")
name = merged.get("name")
count = merged.get("count")
separator = merged.get("separator")
for key in refs:
if key in _seen:
continue
entry = master.get(key) if isinstance(master, dict) else None
if not isinstance(entry, dict):
continue
sub = _merge_value_defs(entry, master, _seen | {key})
for v in sub.get("values") or []:
if isinstance(v, (list, tuple, dict)) or v in values:
continue
values.append(v)
if selector is None:
selector = sub.get("selector")
if name is None:
name = sub.get("name")
if count is None:
count = sub.get("count")
if separator is None:
separator = sub.get("separator")
if spec.get("values") is not None:
values = list(spec["values"])
if spec.get("name") is not None:
name = spec["name"]
if values:
merged["values"] = values
else:
merged.pop("values", None)
if selector is not None:
merged["selector"] = selector
if name is not None:
merged["name"] = name
if count is not None:
merged["count"] = count
if separator is not None:
merged["separator"] = separator
merged.pop("ref", None)
merged.pop("refs", None)
return merged
def _pick_when_branch(table, value):
"""Select a ``when`` branch by exact match on a resolved value.
``*`` acts as a fallback when no exact branch matches. If the trigger value
is a list (multi-select), any one of its elements may match.
"""
if not isinstance(table, dict):
return None
if isinstance(value, (list, tuple)):
candidates = [v for v in value if v is not None]
elif value is not None:
candidates = [value]
else:
candidates = []
for candidate in candidates:
if str(candidate) in table:
return table[str(candidate)]
if "*" in table:
return table["*"]
return None
def _resolve_when_spec(spec, task, master, trigger_values):
"""Resolve a spec's ``when`` conditions into a value-definition dict.
Each ``when`` entry maps an already-resolved field name to a branch table
(``{value: def}``). One branch is chosen per trigger field; a field with
multiple triggers ANDs its branches together by unioning their pools. A
missing trigger (or unmatched value without a ``*`` fallback) skips the
field entirely by returning ``None``.
"""
when = spec.get("when")
if not isinstance(when, dict) or not when:
return None
branches = []
for trigger, table in when.items():
if not isinstance(table, dict):
continue
branch = _pick_when_branch(table, trigger_values.get(trigger))
if branch is None:
return None
branches.append(branch)
if not branches:
return None
if len(branches) == 1:
return dict(branches[0]) if isinstance(branches[0], dict) else branches[0]
refs, values = [], []
seen_values = set()
selector = name = count = separator = None
for branch in branches:
if not isinstance(branch, dict):
continue
branch_refs = branch.get("refs")
if branch_refs is None and branch.get("ref") is not None:
branch_refs = [branch["ref"]]
if isinstance(branch_refs, (str, bytes)):
branch_refs = [branch_refs]
for r in branch_refs or []:
if r not in refs:
refs.append(r)
for v in branch.get("values") or []:
if isinstance(v, (list, tuple, dict)):
continue
if v not in seen_values:
seen_values.add(v)
values.append(v)
if selector is None:
selector = branch.get("selector")
if name is None:
name = branch.get("name")
if count is None and branch.get("count") is not None:
count = branch.get("count")
if separator is None and branch.get("separator") is not None:
separator = branch.get("separator")
merged = {}
if refs:
merged["refs"] = refs
if values:
merged["values"] = values
for key, val in (
("selector", selector),
("name", name),
("count", count),
("separator", separator),
):
if val is not None:
merged[key] = val
return merged or None
def _details_specs(details):
"""Normalize a ``details`` value into a list of field spec dicts."""
if isinstance(details, list):
return details
if isinstance(details, dict):
return [
{"name": name, **(spec if isinstance(spec, dict) else {"value": spec})}
for name, spec in details.items()
]
return []
def _spec_in_filter(spec, freq_filter):
"""Whether a field spec belongs to the given change-frequency pass.
Fields without an explicit ``change_freq`` default to ``"Per Round"``;
only genuinely ``"Per Turn"`` fields join the per-turn re-resolution.
"""
if not isinstance(spec, dict):
return False
if freq_filter is None:
return True
eff = str(spec.get("change_freq") or "Per Round")
if freq_filter == "Per Round":
return eff == "Per Round"
if freq_filter == "Per Turn":
return eff == "Per Turn"
return eff == freq_filter
def _has_per_turn_details(details):
"""True if a task spec has any genuinely per-turn field."""
return any(_spec_in_filter(s, "Per Turn") for s in _details_specs(details))
def _character_fields(details_spec):
"""Field specs flagged ``character: true`` — the story's named cast."""
return [
s
for s in _details_specs(details_spec)
if isinstance(s, dict) and s.get("character")
]
def _pick_character_name(task, field_name, names, skip=()):
"""Pick a character name for the round (round-robins across rounds).
``skip`` is a set of names already taken this round, so multi-member cast
slots (``count > 1``) never reuse a name within the same round.
"""
values = [
n for n in (names or []) if isinstance(n, str) and n.strip() and n not in skip
]
if not values:
return ""
key = (task, field_name, "<name>")
idx = _detail_cycles.get(key, 0) % len(values)
_detail_cycles[key] = idx + 1
return values[idx]
def build_cast(task, details_spec, round_fields):
"""Decide and NAME the story's characters for this round.
Returns a list of ``(label, species, name)`` triples: one per field flagged
``character: true``, expanded into ``count`` members when the field carries a
count. Species come from the already-resolved per-round value pool (a list
means a multi-member slot); names are assigned deterministically from the
field's ``names`` list (rotating across rounds, never reused within a round),
so the cast — including its size — is fixed and repeatable before a single
word of story is written.
"""
cast = []
fields = round_fields or {}
for spec in _character_fields(details_spec):
label = str(spec.get("name") or "").strip()
value = fields.get(label, spec.get("value"))
species_list = value if isinstance(value, (list, tuple)) else [value]
used = set()
for species in species_list:
species = str(species or "").strip()
if not species:
continue
name = _pick_character_name(task, label, spec.get("names"), skip=used)
if name:
used.add(name)
cast.append((label, species, name or species))
return cast
def format_cast_block(cast):
"""Render the decided-and-named cast as an immutable per-turn directive."""
if not cast:
return ""
lines = [
"## Characters (already decided and named for this story — immutable)",
]
for label, species, name in cast:
lines.append(f"- {name} — the {label}, {species}")
lines.append(
"HARD RULE: These are the ONLY characters in this story. Never create, "
"name, or depict any additional character in the text or in any image; "
"every generated image must show only these named characters."
)
return "\n".join(lines)
def _resolve_field_value(spec, task, master):
"""Resolve a single detail field spec into ``(name, value)``."""
if not isinstance(spec, dict):
return "", str(spec)
refs = spec.get("refs")
merged = _merge_value_defs(spec, master)
# If name wasn't explicitly provided in spec, fall back to master's name,
# then to the first referenced pool key.
name = str(merged.get("name") or spec.get("name") or "").strip()
if not name:
first_ref = (
refs[0] if isinstance(refs, list) and refs else spec.get("ref") or ""
)
name = str(first_ref or "").strip()
value = _pick_detail_value(task, name, merged)
return name, value
def resolve_details(
details, task, master=None, freq_filter="Per Round", preferred=None
):
"""Resolve field specs matching a specific change frequency.
Fields carrying a ``when`` block are resolved in a second pass, once the
plain fields they depend on have been resolved (their values form the
trigger map). A trigger field is resolved exactly once even though it lives
in both passes (its result is cached).
``preferred`` may carry already-resolved ``{name: value}`` pairs (e.g. from
:func:`resolve_details_fields`) that should be reused verbatim instead of
being resolved again, so the rendered prompt text always matches the values
that were tracked. When ``preferred`` is given the when-trigger pass is
skipped, since every value is already final.
"""
if master is None:
master = MASTER_DETAILS
if isinstance(details, str):
return details if freq_filter == "Per Round" else ""
specs = _details_specs(details)
if not specs:
rendered = "" if isinstance(details, (list, dict)) else str(details)
return rendered if freq_filter == "Per Round" else ""
needed_triggers = set()
for spec in specs:
if not isinstance(spec, dict):
continue
if not _spec_in_filter(spec, freq_filter):
continue
when = spec.get("when")
if isinstance(when, dict) and when:
needed_triggers.update(k for k, t in when.items() if isinstance(t, dict))
cached = {}
trigger_values = {}
if preferred is None:
for i, spec in enumerate(specs):
if not needed_triggers:
break
if not isinstance(spec, dict) or spec.get("when"):
continue
if not _spec_in_filter(spec, freq_filter):
continue
name, value = _resolve_field_value(spec, task, master)
cached[i] = (name, value)
key = str(spec.get("name") or name)
if key in needed_triggers:
trigger_values[key] = value
parts = []
for i, spec in enumerate(specs):
if not isinstance(spec, dict):
if freq_filter == "Per Round":
parts.append(str(spec))
continue
if not _spec_in_filter(spec, freq_filter):
continue
key_name = str(spec.get("name") or "").strip()
if preferred is not None and key_name in preferred:
name = key_name
value = preferred[name]
sep = spec.get("separator")
elif spec.get("when"):
branch = _resolve_when_spec(spec, task, master, trigger_values)
if branch is None:
continue
outer_name = spec.get("name")
eff = dict(branch) if isinstance(branch, dict) else {"value": branch}
if outer_name:
eff["name"] = outer_name
name, value = _resolve_field_value(eff, task, master)
sep = eff.get("separator")
elif i in cached:
name, value = cached[i]
sep = spec.get("separator")
else:
name, value = _resolve_field_value(spec, task, master)
sep = spec.get("separator")
if not name or value is None:
continue
if isinstance(value, (list, tuple)):
formatted = [_fmt_detail_value(v) for v in value]
if not formatted:
continue
rendered = _join_values(formatted, sep)
else:
rendered = _fmt_detail_value(value)
if not rendered:
continue
parts.append(f"{name}: {rendered}")
return ", ".join(parts)
def resolve_details_fields(details, task, master=None, freq_filter=None):
"""Resolve a task's ``details`` spec into ``{field: value}`` pairs.
Like :func:`resolve_details` but returns the raw resolved values (lists
stay lists) keyed by field name, so the exact combination can be hashed by
the theme tracker. A plain string returns ``{}`` (nothing to track).
``freq_filter`` restricts to one change-frequency pass (``"Per Round"`` /
``"Per Turn"``); ``None`` resolves every field (default).
"""
if master is None:
master = MASTER_DETAILS
if isinstance(details, str):
return {}
specs = _details_specs(details)
if not specs:
return {}
needed_triggers = set()
for spec in specs:
if not isinstance(spec, dict):
continue
if not _spec_in_filter(spec, freq_filter):
continue
when = spec.get("when")
if isinstance(when, dict) and when:
needed_triggers.update(k for k, t in when.items() if isinstance(t, dict))
cached = {}
trigger_values = {}
for i, spec in enumerate(specs):
if not needed_triggers:
break
if not isinstance(spec, dict) or spec.get("when"):
continue
if not _spec_in_filter(spec, freq_filter):
continue
name, value = _resolve_field_value(spec, task, master)
cached[i] = (name, value)
key = str(spec.get("name") or name)
if key in needed_triggers:
trigger_values[key] = value
fields = {}
for i, spec in enumerate(specs):
if not isinstance(spec, dict):
continue
if not _spec_in_filter(spec, freq_filter):
continue
if spec.get("when"):
branch = _resolve_when_spec(spec, task, master, trigger_values)
if branch is None:
continue
outer_name = spec.get("name")
eff = dict(branch) if isinstance(branch, dict) else {"value": branch}
if outer_name:
eff["name"] = outer_name
name, value = _resolve_field_value(eff, task, master)
elif i in cached:
name, value = cached[i]
else:
name, value = _resolve_field_value(spec, task, master)
if not name or value is None:
continue
if isinstance(value, (list, tuple)):
formatted = [_fmt_detail_value(v) for v in value]
value = formatted if formatted else None
else:
value = _fmt_detail_value(value)
if value:
fields[name] = value
return fields
def build_combo_dict(genre, mood, persona_details, details_fields):
"""Assemble the tracked combination: detail fields + mood + genre + role + persona."""
kaya = (persona_details or {}).get("Kaya", {}) or {}
kolpo = (persona_details or {}).get("Kolpo", {}) or {}
return {
"genre": genre or "",
"mood": mood or "",
"role": " / ".join(x for x in [kaya.get("role"), kolpo.get("role")] if x),
"persona": " / ".join(
x for x in [kaya.get("persona"), kolpo.get("persona")] if x
),
"details": details_fields or {},
}
def build_theme_slug(task, mood, detail_fields, max_len=80):
"""Build a short, readable theme slug purely from already-resolved data —
no LLM call needed. detail_fields already carries real variety via its
roundrobin/random selectors (hero, setting, festival, sweet, mystery,
trope, etc.); combining the most 'subject-like' ones with the mood gives
a distinct, human-readable premise for free. combo_hash (the actual
dedup mechanism used by check_combo_used) never reads this field — it
only makes the 'Already Produced Themes' prompt block readable for the
agents, so there is nothing here that needs an LLM to invent.
"""
preferred_keys = [
"hero",
"mystery",
"trope",
"sweet",
"festival",
"animals",
"domain",
"topic",
"target",
"setting",
]
parts = []
for key in preferred_keys:
val = detail_fields.get(key)
if not val:
continue
parts.append(val if isinstance(val, str) else ", ".join(val))
if len(parts) >= 2:
break
if not parts:
for v in detail_fields.values():
parts.append(v if isinstance(v, str) else ", ".join(v))
if len(parts) >= 2:
break
if mood:
parts.append(mood)
slug = " · ".join(p for p in parts if p) or task
return slug[:max_len]
def theme_api(action, token, **payload):
"""Talk to the server's theme tracker (/api/themes). Returns parsed JSON."""
headers = auth_headers(token)
try:
if action == "list":
r = requests.get(
f"{BASE_URL}/api/themes", params=payload, headers=headers, timeout=15
)
r.raise_for_status()
else:
r = requests.post(
f"{BASE_URL}/api/themes", json=payload, headers=headers, timeout=15
)
r.raise_for_status()
return r.json()
except Exception as e:
print(f"[theme] {action} failed: {e}")
return {"ok": False, "error": str(e)}
def fetch_used_themes(token, scope=SELF_CHAT_THEME_SCOPE):
"""Return the list of already-logged theme records for the window scope."""
data = theme_api("list", token, scope=scope, limit=SELF_CHAT_THEME_LIMIT)
return data.get("themes", []) if data.get("ok") else []
def check_combo_used(token, combo, scope=SELF_CHAT_THEME_SCOPE, level="round"):
data = theme_api(
"check", token, operation="check", scope=scope, level=level, **combo
)
return bool(data.get("used")) if data.get("ok") else False
def format_theme_block(records):
"""Render the shared 'Already Produced Themes' block for the prompt."""
if not records:
return "None yet — everything is available."
lines = []
for r in records:
bits = []
if r.get("genre"):
bits.append(f"genre: {r['genre']}")
if r.get("mood"):
bits.append(f"mood: {r['mood']}")
if r.get("role"):
bits.append(f"role: {r['role']}")
if r.get("persona"):
bits.append(f"persona: {r['persona']}")
if r.get("details") and r.get("details") != "{}":
try:
det = json.loads(r["details"])
except (TypeError, ValueError):
det = {}
if det:
bits.append("details: " + ", ".join(f"{k}={v}" for k, v in det.items()))
if r.get("theme"):
bits.append(f"theme: {r['theme']}")
if not bits:
continue
status = r.get("status") or ""
lines.append(f" - ({status}) " + " | ".join(bits))
return "\n".join(lines) if lines else "None yet — everything is available."
def load_config_file(tasks_file):
if not os.path.isfile(tasks_file):
print(f"Tasks file not found: {tasks_file}")
return [], {}, {}, {}
with open(tasks_file, "r", encoding="utf-8") as f:
try:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"[config] Invalid JSON in {tasks_file}: {e.msg}")
raise SystemExit(1) from e
if isinstance(data, dict):
tasks = _parse_tasks(data.get("tasks") or [])
checklists = data.get("genre_checklists") or {}
persona_map = data.get("genre_persona_map") or {}
persona_pool = data.get("persona_pool") or {}
master = data.get("master_details") or {}
if master:
MASTER_DETAILS.update(master)
return tasks, checklists, persona_map, persona_pool
return _parse_tasks(data), {}, {}, {}
def load_tasks():
checklists = {}
if args.config:
persona_map = {}
persona_pool = {}
tasks, cfg_checklists, cfg_persona_map, cfg_persona_pool = load_config_file(
args.config
)
source = args.config
checklists.update(cfg_checklists)
# Merge config persona overrides
deep_merge(persona_map, cfg_persona_map)
deep_merge(persona_pool, cfg_persona_pool)
if args.defaults:
defaults, def_checklists, def_pmap, def_ppool = load_config_file(
DEFAULT_TASKS_FILE
)
existing = {t["task"] for t in tasks}
tasks.extend(t for t in defaults if t["task"] not in existing)
checklists.update(def_checklists)
source = f"{args.config} + defaults"
else:
persona_map = load_json_file(GENRE_PERSONA_MAP_FILE, {})
persona_pool = load_json_file(PERSONA_POOL_FILE, {})
tasks, def_checklists, def_pmap, def_ppool = load_config_file(
DEFAULT_TASKS_FILE
)
checklists.update(def_checklists)
source = DEFAULT_TASKS_FILE
return tasks, source, checklists, persona_map, persona_pool
def checklist_for(genre, role, task_checklist=None):
"""role is 'editor' or 'moderator'. A task's own checklist wins, then the
genre's entry, then the 'default' entry, then nothing."""
items = (task_checklist or {}).get(role)
if not items:
entry = GENRE_CHECKLISTS.get(genre) or {}
items = entry.get(role)
if not items:
items = GENRE_CHECKLISTS.get("default", {}).get(role) or []
items.append(
"Remove any out-of-character planning or meta-discussion between the collaborators (e.g. 'let's write about X', 'I'll cover Y, you do Z') that is not part of the narrative/content itself — the final piece must read as continuous, in-universe content only."
)
return "\n".join(f"- {item}" for item in items)
# Unicode block ranges used to sanity-check that a story is actually written in
# the language it was assigned, without needing an LLM call to find out.
_SCRIPT_RANGES = {
"bengali": (0x0980, 0x09FF),
"hindi": (0x0900, 0x097F),
}
def check_language_script(text, language):
lang_key = (language or "").strip().lower()
rng = _SCRIPT_RANGES.get(lang_key)
if not rng:
return True # English or an unmapped language — skip this check
lo, hi = rng
body = re.sub(r"(?s)<small.*?</small>", "", text)
total_letters = sum(1 for ch in body if ch.isalpha())
if total_letters == 0:
return False
script_chars = sum(1 for ch in body if lo <= ord(ch) <= hi)
return (script_chars / total_letters) > 0.5
def run_dry_run():
"""Print the full plan for every task and validate the environment without
making any LLM call. Exits before any session is created."""
def indent(text):
return "\n".join(" " + line for line in text.splitlines())
print("=" * 68)
print(f"DRY RUN — {len(TASKS)} task(s) from {TASKS_SOURCE}")
print("No LLM calls will be made.\n")
for idx, spec in enumerate(TASKS, 1):
task = spec["task"]
genre = spec.get("genre") or "General"
mediums = spec.get("mediums") or []
languages = spec.get("languages") or []
roles = spec.get("roles") or ["free"]
details = spec.get("details") or ""
checklist = spec.get("checklist") or {}
source = "task" if checklist else "genre/default"
inactive = spec.get("inactive") or False
print(f"=== Task {idx}: {task}")
print(f" genre: {genre} (checklist source: {source})")
print(f" path: {resolve_story_path(spec, roles)}")
print(f" inactive: {inactive}")
print(f" languages: {', '.join(languages)}")
for lang in languages:
if (lang or "").strip().lower() in _SCRIPT_RANGES:
print(
f" - '{lang}' -> script enforcement active (bengali/hindi)"
)
else:
print(f" - '{lang}' -> no script check (unmapped)")
for medium in mediums:
flag = ""
if medium.strip().lower() == "audio":
flag = " WARNING: no audio tool exists — round will be skipped by the guard"
print(f" mediums: {medium}{flag}")
print(f" roles: {', '.join(roles)}")
print(f" turns: {spec.get('turns') or MAX_MESSAGES_PER_AGENT} per agent")
if spec.get("research"):
print(
f" research: YES — first {spec.get('research_turns') or 1} turn(s) of each agent are research-only"
)
else:
print(" research: no — direct content turns from the start")
if isinstance(details, list):
names = [
d.get("name", "?") if isinstance(d, dict) else "?" for d in details
]
print(
f" details: {len(details)} structured field(s) -> {', '.join(str(n) for n in names)}"
)
elif isinstance(details, dict):
print(
f" details: {len(details)} structured field(s) -> {', '.join(details)}"
)
else:
print(
f" details: {'present (' + str(len(details)) + ' chars)' if details else 'EMPTY'}"
)
print(" editor checklist (resolved):")
print(indent(checklist_for(genre, "editor", checklist)))
print(" moderator checklist (resolved):")
print(indent(checklist_for(genre, "moderator", checklist)))
print("ENVIRONMENT")
print(f" tasks source: {TASKS_SOURCE}")
print(
f" persona map: {len(GENRE_PERSONA_MAP)} genre mapping(s) active"
)
print(
f" persona pool: {len(PERSONA_POOL)} relationship category(ies) active"
)
print()
print("=" * 68)
print("ENVIRONMENT")
print(f" tasks source: {TASKS_SOURCE}")
print(f" genre_checklists: {GENRE_CHECKLISTS_FILE}")
if not os.path.isfile(GENRE_CHECKLISTS_FILE):
print(" MISSING — falling back to empty checklists")
else:
print(f" loaded ({len(GENRE_CHECKLISTS)} genre(s))")
handled = {
SELF_CHAT_PROMPT_FILE: {
"%task%",
"%mediums%",
"%_lang%",
"%details%",
"%themes%",
"%relationship%",
"%mood%",
"%kaya_role%",
"%kaya_persona%",
"%kolpo_role%",
"%kolpo_persona%",
},
EDITOR_PROMPT_FILE: {
"%genre%",
"%mediums%",
"%language%",
"%details%",
"%checklist%",
},
MODERATOR_PROMPT_FILE: {
"%genre%",
"%mediums%",
"%language%",
"%details%",
"%checklist%",
},
CRITIQUE_PROMPT_FILE: {
"%genre%",
"%mediums%",
"%language%",
"%details%",
"%checklist%",
"%cast%",
},
}
for path, placeholders in handled.items():
name = os.path.basename(path)
if not os.path.isfile(path):
print(f" prompt file {name}: MISSING")
continue
with open(path, encoding="utf-8") as f:
found = set(re.findall(r"%[a-z_]+%", f.read()))
unhandled = found - placeholders
if unhandled:
print(
f" prompt file {name}: ok, but UNHANDLED placeholders {sorted(unhandled)}"
)
else:
print(
f" prompt file {name}: ok ({len(found)} placeholder(s) replaced by code)"
)
print()
_PROHIBITED_NAMES = ("Kaya", "Kolpo", "কায়া", "কল্প", "काया", "कल्प")
def _is_placeholder_query(query):
"""Detect a generic search query that grounds a citation in nothing real.
Suitable-for/recent/kids/lighthearted phrasing (or a very short query) means
the model searched for "something" rather than a concrete reported event, so
the resulting citation is decorative, not grounding.
"""
q = (query or "").strip()
if not q or len(q) < 12:
return True
generic = [
r"\brecent\b[^.,]*\bsuitable\s+for\b",
r"\bsuitable\s+for\b",
r"\bfor\s+(kids|children)\b",
r"(latest|top|interesting|random|lighthearted)\s+(news|story|article|event)",
r"\bnews\b[^.,]*\bfor\b",
]
return any(re.search(p, q, re.I) for p in generic)
def verify_task_fulfillment(
original_text, check_text, mediums, language, retrieved_citations=None
):
"""Deterministic (no-LLM) checks that catch the failure classes an editor/
moderator LLM keeps missing: declared medium never delivered, header fields
dropped during editing, citations dropped or fabricated, ungrounded
citations, wrong script/language, and agent-name leaks. Returns a list of
problem strings (empty = all good)."""
problems = []
if "audio" in mediums:
problems.append(
"Medium 'audio' was declared, but no audio-generation tool exists yet "
"in TOOLS — this task cannot currently be fulfilled by the agents."
)
if "image" in mediums and not re.search(r"!\[[^\]]*\]\([^)]+\)", check_text):
problems.append(
"Medium 'image' was declared but no image is embedded in the final story."
)
for field in ["Task prompt", "Genre", "Mediums", "Language(s)"]:
if f"**{field}:**" in original_text and f"**{field}:**" not in check_text:
problems.append(f"Editor dropped the '{field}' header field.")
if re.search(
r"#+\s+Citations?\s*&?\s*References?", original_text
) and not re.search(r"#+\s+Citations?\s*&?\s*References?", check_text):
problems.append("Editor dropped the Citations & References section.")
if retrieved_citations is not None:
published = re.findall(r"\[[^\]]*\]\((https?://[^)\s]+)\)", check_text)
backed = set(retrieved_citations)
unbacked = [u for u in published if u not in backed]
if unbacked:
problems.append(
f"{len(unbacked)} citation URL(s) in the story were never retrieved by a web search."
)
if published and backed:
queries = {q for _, q in retrieved_citations.values()}
if queries and all(_is_placeholder_query(q) for q in queries):
problems.append(
"Citations are ungrounded: every search used a generic placeholder "
"query instead of sourcing the story from a real reported event."
)
if not check_language_script(check_text, language):
problems.append(
f"Story does not appear to be predominantly written in the declared language '{language}'."
)
stripped = re.sub(r"(?s)<small.*?</small>|!\[[^\]]*\]\([^)]*\)", "", check_text)
for bad in _PROHIBITED_NAMES:
if re.search(rf"\b{re.escape(bad)}\b", stripped):
problems.append(f"Prohibited name '{bad}' still appears in the story text.")
if "<!-- EDITOR FLAG:" in check_text:
flags = re.findall(r"<!--\s*EDITOR FLAG:\s*(.*?)-->", check_text)
for flag in flags:
problems.append(f"Editor flagged an unresolved problem: {flag.strip()}")
return problems
def is_duplicate(new_text, previous_text, threshold=0.8):
if not previous_text:
return False
return (
SequenceMatcher(None, new_text.strip(), previous_text.strip()).ratio()
> threshold
)
# Authentik access tokens are short-lived (currently 5 minutes), but agents
# log in once per run and keep working for hours. The cache below keeps ONE
# current token per agent USERNAME: auth_headers() may be handed any stale
# alias of that agent and always resolves it to the latest token, refreshing
# at most once per expiry window. Callers keep their original "fixed" token
# variables for the whole run — healing happens transparently here.
#
# (Keying by token instead — as an earlier revision did — minted a fresh
# password grant on EVERY poll: the healed token was used for one request and
# discarded, so the next poll saw the stale token again and re-authenticated,
# looping indefinitely.)
_TOKEN_META = {} # username -> {"password", "token", "exp", "granted_at"}
_TOKEN_OWNER = {} # every issued token -> owning username (stale aliases included)
_TOKEN_LOCK = threading.RLock()
TOKEN_REFRESH_MARGIN = 60
def _decode_exp(token):
"""Expiry timestamp of a JWT access token, or 0 if unreadable."""
try:
payload = token.split(".")[1]
data = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
return int(data.get("exp", 0))
except Exception:
return 0
def login(username, password):
from server.auth import oidc_password_grant
try:
token = oidc_password_grant(username, password)
except Exception as e:
print(f"[login] Authentik password grant failed for {username}: {e}")
raise
with _TOKEN_LOCK:
_TOKEN_META[username] = {
"password": password,
"token": token,
"exp": _decode_exp(token),
"granted_at": time.time(),
}
_TOKEN_OWNER[token] = username
return token
def _fresh_token(token):
"""Return the CURRENT token owned by whoever issued ``token``.
Re-grants only when that token is at ``TOKEN_REFRESH_MARGIN`` seconds from
expiry (or when its expiry is unknown and it is older than the margin).
Unknown tokens pass through untouched.
"""
with _TOKEN_LOCK:
username = _TOKEN_OWNER.get(token)
if username is None:
# Token we did not issue ourselves — nothing to heal with.
return token
entry = _TOKEN_META[username]
now = time.time()
exp = entry["exp"]
expired = (
(exp and now >= exp - TOKEN_REFRESH_MARGIN)
or (not exp and now - entry["granted_at"] >= TOKEN_REFRESH_MARGIN)
)
if not expired:
# Resolve stale aliases to the agent's current token without
# re-authenticating — this is what breaks the reauth loop.
return entry["token"]
print(f"[auth] {username}'s access token expired — re-authenticating")
return login(username, entry["password"])
def auth_headers(token):
"""Headers carrying the Authentik access token as a Bearer credential."""
with _TOKEN_LOCK:
token = _fresh_token(token)
return {"Authorization": f"Bearer {token}"}
def create_session(
token, name, system_prompts=None, context_tokens=None, system_prompt=None
):
body = {"name": name}
if system_prompts:
body["system_prompts"] = system_prompts
if context_tokens:
body["context_tokens"] = context_tokens
if system_prompt:
body["system_prompt"] = system_prompt
resp = requests.post(
f"{BASE_URL}/api/sessions",
json=body,
headers=auth_headers(token),
timeout=15,
)
resp.raise_for_status()
return resp.json()["session_id"]
def delete_session(token, session_id):
resp = requests.delete(
f"{BASE_URL}/api/sessions/{session_id}",
headers=auth_headers(token),
timeout=15,
)
if resp.status_code != 200:
print(
f"Warning: could not delete session {session_id} (HTTP {resp.status_code})"
)
return False
print(f"Deleted session {session_id}")
return True
def image_url_to_b64(image_url):
if not image_url:
return None
rel_name = image_url.split("/output/")[-1]
comfy_output = os.path.expanduser("~/local-ai-files/ComfyUI/output")
abs_path = os.path.join(comfy_output, rel_name)
if not os.path.isfile(abs_path):
return None
with open(abs_path, "rb") as f:
return base64.b64encode(f.read()).decode()
def register_agent_tokens(tokens, usernames=None):
try:
requests.post(
f"{BASE_URL}/api/register-agent",
json={"tokens": tokens, "usernames": usernames or []},
timeout=10,
)
except Exception as e:
print(f"[wait] Could not register agent tokens: {e}")
def active_real_users():
try:
r = requests.get(f"{BASE_URL}/api/active-users", timeout=10)
users = r.json().get("users", [])
except Exception as e:
print(f"[wait] Could not check active users: {e}")
return []
return users
def wait_for_user_to_leave():
return
"""
while True:
real = active_real_users()
if not real:
print("Resuming agent workflow")
return
print(
f"[wait] Real user(s) active ({', '.join(real)}) "
f"— pausing self-chat until they log out..."
)
#time.sleep(POLL_INTERVAL_SECONDS)
"""
def call_llm(
token, session_id, message, image_b64=None, no_tools=False, research=False
):
headers = auth_headers(token)
payload = {
"session_id": session_id,
"message": message,
"client_timestamp": datetime.now().astimezone().isoformat(timespec="seconds"),
}
if args.gpu:
payload["mode"] = "gpu"
if image_b64:
payload["image"] = image_b64
if no_tools:
payload["no_tools"] = True
if research:
payload["research"] = True
submit_respo = requests.post(
f"{BASE_URL}/api/chat",
json=payload,
headers=headers,
timeout=30,
)
submit_respo.raise_for_status()
task_id = submit_respo.json()["task_id"]
status_url = f"{BASE_URL}/api/status/{task_id}"
while True:
# Re-resolve headers every poll: a long CPU generation can outlive
# the token, and each poll must carry a still-valid bearer.
status_resp = requests.get(
status_url, headers=auth_headers(token), timeout=40
)
status_resp.raise_for_status()
data = status_resp.json()
status = data.get("status")
if status == "done":
return {
"text": data["response"],
"image": data.get("image"),
"searches": data.get("_search_details"),
}
if status == "error":
raise RuntimeError(f"Task failed: {data}")
time.sleep(POLL_INTERVAL_SECONDS)
def build_input(
speaker,
message_number,
incoming,
lang,
task,
context=None,
turns=None,
per_turn_details="",
cast=None,
mode="content",
research_turns=0,
):
current_agent = AGENT_NAMES[speaker]
partner_agent = AGENT_NAMES["B" if speaker == "A" else "A"]
if turns is None:
turns = MAX_MESSAGES_PER_AGENT
lines = [
f"[SYSTEM DIRECTIVE: You are responding as {current_agent}. Your partner is {partner_agent}.]\n",
f"[Turn {message_number}/{turns}]\n",
"[SYSTEM DIRECTIVE: Place ALL meta-analysis, praise, and planning OUTSIDE the [CONTENT] tags. ",
"The [CONTENT] block must ONLY contain clean narrative/visual deliverable text.]",
]
if cast:
lines.append(cast)
if context is not None:
lines.append(context)
if mode == "research":
# Research phase: gather and share sourced material only. Content turns
# (which follow once BOTH agents have contributed research) write the
# actual deliverable inside [CONTENT] using the shared materials.
lines.append(
"[RESEARCH MODE: This turn is for research ONLY. Perform web searches "
"and fetch pages to gather sourced facts, figures, and material needed "
f"for the task. Do NOT write any final story or deliverable content yet, "
"and do NOT open a [CONTENT] block this turn. Write your findings and "
"their sources in plain text so your partner can read them, then end "
f"with [NEXT TURN: {partner_agent}]."
)
elif message_number <= 2:
lines.append(
f"Immediately establish your role and provide the first creative deliverable inside [CONTENT] tags."
)
elif message_number >= turns - 2:
lines.append(
f"[PHASE 3: FINALIZATION] Consolidate the work, write the final scene/panels, "
f"and terminate the turn sequence by appending {STOP_PHRASE}."
)
else:
lines.append(
f"[PHASE 2: DIRECT EXECUTION] Continue building content turn-by-turn. "
f"Do not send meta-talk or prematurely end the story. Speak in {lang}."
)
if mode == "content" and research_turns and message_number == research_turns + 1:
lines.append(
"[CONTENT MODE: Research is complete and all gathered materials are "
"shared above. Now write the actual deliverable story using those "
"materials, wrapping every publishable part in [CONTENT]...[/CONTENT]."
)
if per_turn_details:
lines.append(f"[DYNAMIC TURN ATTRIBUTES: {per_turn_details}]")
if incoming:
lines.extend(["", "----------", incoming])
return "\n".join(lines)
def run_single_conversation(
token_a,
token_b,
round_number,
task,
mediums,
languages,
roles=None,
genre="General",
details="",
details_spec=None,
checklist=None,
path=None,
context=None,
persona=None,
themes_context="",
turns=None,
task_roles=None,
round_fields=None,
per_turn_task=False,
research=False,
research_turns=1,
):
medium = random.sample(mediums, 2 if len(mediums) > 1 else 1)
language = random.choice(languages)
# Pick persona tuple. run_forever may pass a pre-picked one so the theme
# tracker sees the exact same combination the conversation will use.
if persona is None:
relationship, mood, persona_details = pick_persona_round_robin(
PERSONA_POOL, genre, GENRE_PERSONA_MAP, task_roles
)
else:
relationship, mood, persona_details = persona
persona_info = {
"relationship": relationship,
"mood": mood,
"details": persona_details,
}
kaya_info = persona_details.get("Kaya", {})
kolpo_info = persona_details.get("Kolpo", {})
print(f"[persona] Genre: {genre} | Dynamic: {relationship} ({mood})")
print(f"[persona] Kaya: {kaya_info.get('role')} — {kaya_info.get('persona')}")
print(f"[persona] Kolpo: {kolpo_info.get('role')} — {kolpo_info.get('persona')}")
# Decide and name the story's characters once per round, before any turn.
cast_block = format_cast_block(build_cast(task, details_spec, round_fields))
if cast_block:
print(f"[cast] {cast_block.splitlines()[1]} ...")
s = STARTING_CONVERSATION.replace("%task%", task)
s = s.replace("%mediums%", " , ".join(medium))
s = s.replace("%_lang%", language)
s = s.replace("%details%", details or "None")
s = s.replace("%themes%", themes_context or "None yet — everything is available.")
s = s.replace("%relationship%", relationship)
s = s.replace("%mood%", mood)
s = s.replace("%kaya_role%", kaya_info.get("role", "Partner"))
s = s.replace("%kaya_persona%", kaya_info.get("persona", "Creative"))
s = s.replace("%kolpo_role%", kolpo_info.get("role", "Partner"))
s = s.replace("%kolpo_persona%", kolpo_info.get("persona", "Methodical"))
print(s)
session_a = create_session(
token_a,
f"{AGENT_NAMES['A']} round {round_number}",
system_prompt=s,
)
session_b = create_session(
token_b,
f"{AGENT_NAMES['B']} round {round_number}",
system_prompt=s,
)
transcript = []
counts = {"A": 0, "B": 0}
if turns is None:
turns = MAX_MESSAGES_PER_AGENT
current_speaker = "A"
incoming = ""
shared_image_b64 = None
stories_dir, fname = start_story(
round_number, task, task, medium, language, roles, genre, path, persona_info
)
citations = {}
while True:
counts[current_speaker] += 1
message_number = counts[current_speaker]
idx = len(transcript)
token = token_a if current_speaker == "A" else token_b
session = session_a if current_speaker == "A" else session_b
per_turn_str = ""
turn_theme_id = None
if per_turn_task:
# Resolve this turn's per-turn detail fields, re-rolling until the
# FULL combination (round scope fields + per-turn fields + mood +
# persona) has not already been produced. Round scope fields were
# resolved once for the whole round, so the character never changes
# mid-story.
turn_fields = {}
for attempt in range(MAX_THEME_REROLL):
turn_fields = resolve_details_fields(
details_spec, task, MASTER_DETAILS, freq_filter="Per Turn"
)
combo = build_combo_dict(
genre,
mood,
persona_details,
{**(round_fields or {}), **turn_fields},
)
if not check_combo_used(token_a, combo, level="turn"):
break
print(
f"[theme] Turn combination already used (attempt {attempt + 1}); "
f"re-rolling per-turn details"
)
else:
print(
"[theme] Exhausted per-turn re-roll attempts; proceeding with the last combination"
)
per_turn_str = resolve_details(
details_spec,
task,
MASTER_DETAILS,
freq_filter="Per Turn",
preferred=turn_fields,
)
turn_slug = build_theme_slug(
task, mood, {**(round_fields or {}), **turn_fields}
)
logged = theme_api(
"log",
token_a,
operation="log",
scope=SELF_CHAT_THEME_SCOPE,
level="turn",
theme=turn_slug,
**combo,
)
if logged.get("ok"):
turn_theme_id = (logged.get("theme") or {}).get("id")
print(f"[theme] Reserved turn combination {turn_theme_id}")
# Two-phase flow for research tasks: the first research_turns of EACH
# agent are research-only (gather + share sourced material, no
# [CONTENT] block), then the agents switch to content mode and write
# the deliverable using every piece of research that was shared.
in_research_phase = research and message_number <= research_turns
mode = "research" if in_research_phase else "content"
eff_research_turns = research_turns if research else 0
prompt = build_input(
current_speaker,
message_number,
"" if not transcript else incoming,
language,
task,
context,
turns,
per_turn_details=per_turn_str,
cast=cast_block,
mode=mode,
research_turns=eff_research_turns,
)
wait_for_user_to_leave()
result = call_llm(
token,
session,
prompt,
image_b64=shared_image_b64,
research=in_research_phase,
)
reply = result["text"]
if not reply.strip():
prompt += "\n[SYSTEM ERROR: Your previous output was empty. Generate real story content now.]"
result = call_llm(
token,
session,
prompt,
image_b64=shared_image_b64,
research=in_research_phase,
)
reply = result["text"]
if not reply.strip():
if turn_theme_id:
theme_api(
"complete",
token_a,
operation="complete",
theme_id=turn_theme_id,
)
print(f"[theme] Marked turn {turn_theme_id} completed")
print(
f"Round {round_number} ended: {AGENT_NAMES[current_speaker]} "
f"returned no content after a retry\n"
)
break
if is_duplicate(reply, incoming):
# Re-prompt agent to generate new content instead of repeating
prompt += "\n[SYSTEM ERROR: Your previous output was identical to your partner's. Generate unique content now.]"
result = call_llm(
token,
session,
prompt,
image_b64=shared_image_b64,
research=in_research_phase,
)
reply = result["text"]
if turn_theme_id:
theme_api("complete", token_a, operation="complete", theme_id=turn_theme_id)
print(f"[theme] Marked turn {turn_theme_id} completed")
entry = {
"speaker": AGENT_NAMES[current_speaker],
"message": message_number,
"text": reply,
"image": result.get("image"),
"searches": result.get("searches"),
"publish": not in_research_phase,
}
transcript.append(entry)
append_story_entry(entry, fname, citations, stories_dir, round_number, idx)
if STOP_PHRASE in reply.upper():
print(f"Round {round_number} ended by {AGENT_NAMES[current_speaker]}\n")
break
if counts[current_speaker] >= turns:
print(
f"Round {round_number} ended: {AGENT_NAMES[current_speaker]} "
f"reached the {turns}-message cap\n"
)
break
incoming = reply
shared_image = result.get("image")
if shared_image:
shared_image_b64 = image_url_to_b64(shared_image)
incoming += f"\n\n[IMAGE SHARED: {shared_image}]"
shared_searches = result.get("searches")
if shared_searches:
block = []
for s in shared_searches:
if not isinstance(s, dict):
continue
query = s.get("query", "")
block.append(f"- Query: {query}")
for r in s.get("results") or []:
title = r.get("title") or r.get("url") or ""
url = r.get("url", "")
snippet = (r.get("snippet") or r.get("content") or "")[:200]
line = f" - {title}" + (f" | {snippet}" if snippet else "")
if url:
line += f" ({url})"
block.append(line)
if block:
incoming += "\n\n[WEB SEARCH REPORTS SHARED:]\n" + "\n".join(block)
current_speaker = "B" if current_speaker == "A" else "A"
print(f"LLM Rest for {SLEEP_BETWEEN_TURNS} seconds")
time.sleep(SLEEP_BETWEEN_TURNS)
print("LLM Rest Over")
finalize_story(fname, stories_dir, citations)
print("=== Title phase ===")
with open(fname, "r", encoding="utf-8") as f:
story_text = f.read()
title_session, title_token = random.choice(
[(session_a, token_a), (session_b, token_b)]
)
title = propose_title(title_token, title_session, task, language, genre, story_text)
print(f"Story title: {title}\n")
stories_dir, fname = apply_title(title, stories_dir, fname)
print(f"Story renamed to: {fname}\n")
print("=== Cross-critique phase (Kaya↔Kolpo self-verify) ===")
edited_path = run_cross_critique(
stories_dir,
fname,
task,
genre,
token_a,
session_a,
token_b,
session_b,
mediums=medium,
language=language,
details=details,
checklist=checklist,
cast=cast_block,
citations=citations,
)
print("=== Deterministic verification ===")
with open(fname, "r", encoding="utf-8") as f:
original_text = f.read()
check_source = edited_path if edited_path else fname
with open(check_source, "r", encoding="utf-8") as f:
check_text = f.read()
problems = verify_task_fulfillment(
original_text, check_text, medium, language, citations
)
if problems:
print(
f"[verify] {len(problems)} problem(s) found — auto-RED, skipping moderator LLM call:"
)
for p in problems:
print(f"[verify] - {p}")
verdict_path = (edited_path if edited_path else fname).replace(
".md", ".moderation.json"
)
data = {
"verdict": "RED",
"reasons": "Automatic RED (deterministic check, no LLM call):\n"
+ "\n".join(f"- {p}" for p in problems),
"task": task,
"genre": genre,
"timestamp": datetime.now().astimezone().isoformat(timespec="seconds"),
}
with open(verdict_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
else:
print("=== Moderator phase ===")
print("Moderator Phase Skipped, not much value add")
# run_moderator(stories_dir, fname, task, genre, editor_path=edited_path, mediums=medium, language=language, details=details, checklist=checklist)
return transcript, session_a, session_b, fname
def save_transcript(transcript, round_number):
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
fname = f"conv_r{round_number}_{timestamp}.json"
with open(fname, "w", encoding="utf-8") as f:
json.dump(transcript, f, indent=4)
print(f"Saved transcript to {fname}")
return fname
def slugify(text, max_len=60):
slug = re.sub(r'[\\/:*?"<>|\x00-\x1f]+', "", text, flags=re.UNICODE)
slug = re.sub(r"\s+", "-", slug).strip("-")
return slug[:max_len].strip("-") or "story"
def sanitize_title(text):
"""Extract a clean single-line title from an LLM reply, or None."""
if not text:
return None
first = text.strip().strip("\"'«»“”‘’`").splitlines()[0].strip()
first = re.sub(
r"^(?:title|heading|name|header)\s*[::]\s*", "", first, flags=re.IGNORECASE
)
first = re.sub(r"^\d+[.)]\s*", "", first)
first = re.sub(r"\s+", " ", first).strip().rstrip(".।!")
return first[:80].strip() or None
def propose_title(token, session_id, task, language, genre, story_text):
"""Ask one of the agents to name the story after it is completed."""
prompt = (
"You are naming a completed article/story that has reached its "
"conclusion.\n"
f"Task: {task}\n"
f"Genre: {genre}\n"
f"Write your reply ONLY in: {language}\n"
"Read the completed story below, then propose ONE short, catchy, unique "
"title (max 60 characters) that captures its conclusion and theme. "
"Output ONLY the title text — no quotes, no numbering, no explanation, "
"no names of people or agents.\n\n"
"=== COMPLETED STORY ===\n\n" + (story_text or "")[:6000]
)
wait_for_user_to_leave()
try:
result = call_llm(token, session_id, prompt)
return sanitize_title(result["text"]) or task
except Exception as e:
print(f"[title] Could not generate a title, falling back to task: {e}")
return task
def apply_title(title, stories_dir, fname):
"""Update the story heading with the new title and rename the folder to match."""
with open(fname, "r", encoding="utf-8") as f:
lines = f.readlines()
new_heading = f"# {title}\n"
if lines and lines[0].startswith("# "):
lines[0] = new_heading
else:
lines.insert(0, new_heading)
with open(fname, "w", encoding="utf-8") as f:
f.writelines(lines)
m = re.search(r"_\d{8}_\d{6}\.md$", fname)
if not m:
return stories_dir, fname
timestamp = m.group(0).lstrip("_").replace(".md", "")
genre_dir = os.path.dirname(stories_dir)
new_stories_dir = os.path.join(genre_dir, f"{slugify(title)}_{timestamp}")
if new_stories_dir != stories_dir and os.path.isdir(stories_dir):
os.rename(stories_dir, new_stories_dir)
stories_dir = new_stories_dir
fname = os.path.join(new_stories_dir, os.path.basename(fname))
return stories_dir, fname
def resolve_story_path(spec, roles):
path = spec.get("path")
if path:
if "admin" in roles:
path = f"{path}/admin"
if "premium" in roles:
path = f"{path}/premium"
return path
if "admin" in roles:
if not ADMIN_STORIES_DIR:
raise ValueError("STORIES_ADMIN_DIR environment variable is not set!")
return ADMIN_STORIES_DIR
if "premium" in roles:
if not PREMIUM_STORIES_DIR:
raise ValueError("STORIES_PREMIUM_DIR environment variable is not set!")
return PREMIUM_STORIES_DIR
return STORY_BASE_DIR
def start_story(
round_number,
task,
title,
mediums,
language,
roles=None,
genre="General",
path=None,
persona_info=None,
):
base_dir = path or STORY_BASE_DIR
os.makedirs(base_dir, exist_ok=True)
now = datetime.now()
timestamp = now.strftime("%Y%m%d_%H%M%S")
folder_name = f"{slugify(title)}_{timestamp}"
genre_dir = os.path.join(base_dir, slugify(genre))
os.makedirs(genre_dir, exist_ok=True)
stories_dir = os.path.join(genre_dir, folder_name)
os.makedirs(stories_dir, exist_ok=True)
fname = os.path.join(stories_dir, f"story_r{round_number}_{timestamp}.md")
roles = roles or ["free"]
rel = persona_info.get("relationship", "N/A") if persona_info else "N/A"
mood = persona_info.get("mood", "N/A") if persona_info else "N/A"
header = [
f"# {title}\n",
f"*Round {round_number} · Generated on {now.strftime('%Y-%m-%d %H:%M:%S')}*\n\n",
f"**Task prompt:** {task}\n\n",
f"**Genre:** {genre} · **Dynamic:** {rel} ({mood})\n\n",
f"**For roles:** {' , '.join(roles)}\n\n",
f"**Mediums:** {' , '.join(mediums)} · **Language(s):** {language}\n\n",
"---\n\n",
]
with open(fname, "w", encoding="utf-8") as f:
f.writelines(header)
return stories_dir, fname
def clean_speaker_text(speaker, text):
cleaned = re.sub(rf"^(kolpo|kaya|कल्प|কায়া):\s*", "", text, flags=re.IGNORECASE)
cleaned = re.sub(rf"^{re.escape(speaker)}:\s*", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"\[NEXT TURN:\s*[^\]]*\]\s*", "", cleaned, flags=re.IGNORECASE)
# Remove raw action tags automatically
cleaned = re.sub(r"\[ACTION:\s*[^\]]+\]", "", cleaned, flags=re.IGNORECASE)
return cleaned.replace("[END CONVERSATION]", "").strip()
def strip_image_markers(text):
"""Drop model-authored image placeholder/reference marker lines.
The agents occasionally write standalone notes such as ``**(Image
Reference: the squid from the last turn)**`` or ``**(Image Placeholder:
/output/...png)**`` instead of (or alongside) a real ````
embed. These add nothing to the published story, so the whole line is
removed. Only lines that ARE the marker are stripped — real image embeds
(``) and surrounding prose are left untouched.
"""
pattern = re.compile(
r"(?im)^\s*\*{0,2}\s*[\[\(]\s*image\s+(?:reference|placeholder|ref|ph)"
r"\s*:[^\]\)]*[\]\)]\s*\*{0,2}\s*$"
)
stripped = pattern.sub("", text or "")
return re.sub(r"\n{3,}", "\n\n", stripped).strip()
def scrub_agent_names(text):
"""Deterministically remove the agents' names from story content.
The models often address each other by name despite the naming rules, so the
names are scrubbed here (vocative positions first, bare mentions as fallback).
Structural markup is protected: image alt-text (``) and the turn
headers (`<small ...>_Round N · Kaya Turn M_</small>`) keep their names.
"""
protected = []
for token in re.findall(r"(?s)<small.*?</small>|!\[[^\]]*\]\([^)]*\)", text):
if token not in protected:
protected.append(token)
for i, token in enumerate(protected):
text = text.replace(token, f"\x00PROTECT{i}\x00")
# Remove names in vocative or isolated positions across supported scripts
text = re.sub(r"\b(?:Kaya|Kolpo)\b\s*[,،]+\s*", "", text, flags=re.IGNORECASE)
text = re.sub(r",\s*\b(?:Kaya|Kolpo)\b", "", text, flags=re.IGNORECASE)
text = re.sub(r"(?:কায়া|কল্প|काया|कल्प)\s*[,،]+\s*", "", text)
text = re.sub(r",\s*(?:কায়া|কল্প|काया|कल्प)", "", text)
text = re.sub(r"\b(?:Kaya|Kolpo)\b", "", text, flags=re.IGNORECASE)
# Normalize inline space without merging lines across newlines
lines = text.split("\n")
cleaned_lines = []
for line in lines:
line = re.sub(r"[ \t]{2,}", " ", line)
line = re.sub(r"[ \t]+([.,!?;:।])", r"\1", line)
cleaned_lines.append(line.replace(" ,", ","))
text = "\n".join(cleaned_lines)
for i, token in enumerate(protected):
text = text.replace(f"\x00PROTECT{i}\x00", token)
return text.strip()
def normalize_markdown_lines(text):
"""Restore structural line breaks that the editor model may have flattened.
The editor sometimes returns the revised markdown with most newlines collapsed
to spaces. This re-inserts line breaks before every structural marker so the
published page renders as separate blocks again. Within-turn paragraph breaks
that were already lost cannot be recovered, but every heading, turn header,
image, and citation line ends up on its own line.
"""
# Clean horizontal whitespace per line while preserving existing explicit newlines
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.splitlines()]
text = "\n".join(lines)
# Re-insert structural double newlines for proper block rendering
text = re.sub(r"(?<!\n\n)<small ", "\n\n<small ", text)
text = re.sub(r"(?<!\n\n)(#{1,6}\s)", r"\n\n\1", text)
text = re.sub(
r"(?<!\n\n)(\*\*(?:Task prompt|Genre|For roles|Mediums|Language\(s\)):)",
r"\n\n\1",
text,
)
text = re.sub(r"(?<!\n\n)(\d+\.\s+\[)", r"\n\n\1", text)
text = re.sub(r"(?<!\n\n)!\[", "\n\n![", text)
text = re.sub(r"</small>(?!\n\n)", "</small>\n\n", text)
text = re.sub(r"\s*---\s*", "\n\n---\n\n", text)
# Clean up excessive line padding
text = re.sub(r"(?m)^ +", "", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip() + "\n"
def embed_story_image(img_url, stories_dir, round_number, speaker, idx):
if not img_url:
return None
rel_name = img_url.split("/output/")[-1]
comfy_output = os.path.expanduser("~/local-ai-files/ComfyUI/output")
abs_src = os.path.join(comfy_output, rel_name)
if not os.path.isfile(abs_src):
return None
_, ext = os.path.splitext(rel_name)
local_name = f"img_r{round_number}_{speaker}_{idx}{ext}"
dest = os.path.join(stories_dir, local_name)
try:
shutil.copy(abs_src, dest)
except OSError as e:
print(f"Warning: could not copy {abs_src}: {e}")
return None
print(f"Embedded image {local_name}")
return local_name
def collect_citations(citations, searches):
for s in searches or []:
if not isinstance(s, dict):
continue
query = s.get("query", "")
for r in s.get("results") or []:
url = r.get("url", "")
if not url or url in citations:
continue
citations[url] = (r.get("title") or url, query)
def strip_model_citations(text):
"""Remove any Citations & References block the model wrote into a turn.
Only finalize_story() may emit that section, and it is built exclusively from
web-search results. Model-authored variants (images, placeholder links, double
hashes) are dropped so they can never leak into the published section.
"""
text = re.sub(
r"(?i)(?:^|\n)\s*#{1,6}\s+citations?\s*&?\s*references?.*$",
"",
text,
flags=re.DOTALL,
)
return text.strip()
def extract_tagged_content(text):
"""Return only the text inside [CONTENT] blocks.
The closing tag may appear as ``[/CONTENT]`` (as instructed in the system
prompt) or as the ``[END CONTENT]`` variant the models actually emit; both
are accepted, so a turn's narrative is never mistaken for planning chatter.
Returns None if no [CONTENT] block is present at all — the caller treats
that as a planning-only turn with nothing to publish. Multiple blocks are
concatenated in order.
As a defensive fallback, a message that OPENS with ``[CONTENT]`` but is
truncated or never closes the tag (the research mode used to provoke this)
still yields its narrative: everything from the ``[CONTENT]`` marker up to
the next structural tag (``[NEXT TURN:`` / ``[END CONVERSATION]`` /
``[IMAGE GENERATION CALL:]``) or the end of the message."""
blocks = re.findall(
r"\[CONTENT\](.*?)(?:\[/CONTENT\]|\[END CONTENT\]|\[END\]|$)",
text,
flags=re.DOTALL | re.IGNORECASE,
)
if not blocks:
# Fallback for an unclosed [CONTENT] block. Only trigger when the
# message clearly starts with the marker, so a 0-block Phase-1 planning
# turn (no [CONTENT] at all) is still treated as nothing to publish.
m = re.match(r"(?is)\s*\[CONTENT\]\s*(.*)$", text)
if m:
rest = m.group(1)
rest = re.split(
r"(?is)\s*\[(?:NEXT TURN\s*:|END CONVERSATION\]|IMAGE GENERATION CALL\s*:|THEME LOGGED\s*:|IMAGE SHARED\s*:)",
rest,
maxsplit=1,
)[0]
rest = rest.strip()
blocks = [rest] if rest else []
if not blocks:
return None
return "\n\n".join(b.strip() for b in blocks if b.strip())
def append_story_entry(entry, fname, citations, stories_dir, round_number, idx):
speaker = entry.get("speaker", "Unknown")
raw_text = entry.get("text", "")
turn = entry.get("message", idx)
# Research-phase turns gather and share material; they must never publish
# narrative or images, but their web-search results still feed citations.
if entry.get("publish") is False:
collect_citations(citations, entry.get("searches"))
print(
f"[content] {speaker} turn {turn} — research phase, citations captured only"
)
return
content = extract_tagged_content(raw_text)
if content is None:
# No [CONTENT] block — Phase 1 planning turn (or a turn that only
# ran tools). Still capture any citations. If the turn generated an
# image, embed it anyway so a generated image is never lost from the
# story just because the model skipped the [CONTENT] wrapper.
collect_citations(citations, entry.get("searches"))
local_img = embed_story_image(
entry.get("image"), stories_dir, round_number, speaker, idx
)
if not local_img:
print(
f"[content] No [CONTENT] block in {speaker} turn {turn} — skipping (planning-only)"
)
return
print(
f"[content] No [CONTENT] block in {speaker} turn {turn} — embedding generated image only"
)
lines = [
f'<small style="color:#888">_Round {round_number} · {speaker} Turn {turn}_</small>\n\n',
f"\n\n",
]
with open(fname, "a", encoding="utf-8") as f:
f.writelines(lines)
return
cleaned = clean_speaker_text(speaker, content)
cleaned = scrub_agent_names(cleaned)
cleaned = strip_model_citations(cleaned)
cleaned = strip_image_markers(cleaned)
lines = [
f'<small style="color:#888">_Round {round_number} · {speaker} Turn {turn}_</small>\n\n',
f"{cleaned}\n\n",
]
collect_citations(citations, entry.get("searches"))
local_img = embed_story_image(
entry.get("image"), stories_dir, round_number, speaker, idx
)
if local_img:
lines.append(f"\n\n")
with open(fname, "a", encoding="utf-8") as f:
f.writelines(lines)
def finalize_story(fname, stories_dir, citations):
sanitize_story_file(fname, stories_dir)
if citations:
lines = ["\n---\n\n## Citations & References\n\n"]
for num, (url, (title, query)) in enumerate(citations.items(), start=1):
lines.append(
f"{num}. [{title}]({url})"
+ (f" *(source: {query})*" if query else "")
+ "\n"
)
with open(fname, "a", encoding="utf-8") as f:
f.writelines(lines)
print(f"Saved story to {fname}")
def file_to_b64(path):
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode()
def story_images_in_order(stories_dir, markdown_text):
"""Return [(filename, abs_path)] of images referenced in the markdown, in order."""
ordered = []
seen = set()
for ref in re.findall(r"!\[[^\]]*\]\(([^)]+)\)", markdown_text):
fname = os.path.basename(ref)
if fname in seen:
continue
seen.add(fname)
full = os.path.join(stories_dir, fname)
if os.path.isfile(full):
ordered.append((fname, full))
return ordered
def sanitize_story_images(text, stories_dir):
"""Drop markdown image references whose target file does not exist.
The story agents and the editor sometimes emit ```` lines that
point at hallucinated filenames or ComfyUI ``/output/`` URLs that were never
copied into the story folder (real embedded copies always use the
``img_rN_Speaker_idx.ext`` scheme). Removing those references keeps the
published markdown free of dead/broken image tags.
"""
def _fix(match):
ref = match.group(1)
fname = os.path.basename(ref.split("?")[0].split("#")[0])
if not fname:
return match.group(0)
if os.path.isfile(os.path.join(stories_dir, fname)):
return match.group(0)
print(f"[images] Dropping broken image reference: {ref}")
return ""
return re.sub(r"!\[[^\]]*\]\(([^)]*)\)", _fix, text)
_IMAGE_LINE_RE = re.compile(r"^\s*!\[[^\]]*\]\(([^)]+)\)\s*$")
def _image_anchors(markdown_text):
"""Map each image filename to the narrative paragraph right before it.
Story files store one turn per entry as ``<small> label, paragraph(s),
image``; the paragraph adjacent to an image is the scene that image
illustrates, which is what re-anchoring matches against when the editor
regroups image references.
"""
anchors = {}
pending = []
for line in markdown_text.splitlines():
if _IMAGE_LINE_RE.match(line):
anchor = ""
for block in reversed(pending):
if "".join(block).strip():
anchor = "\n".join(block)
break
for ref in re.findall(r"!\[[^\]]*\]\(([^)]+)\)", line):
fn = os.path.basename(ref)
anchors[fn] = anchor
pending = []
continue
if line.strip().startswith("<small") and line.strip().endswith("</small>"):
pending = []
continue
if not line.strip():
pending.append([])
continue
if pending:
pending[-1].append(line)
else:
pending.append([line])
return anchors
def _paragraph_overlap_score(a, b):
"""Token Jaccard overlap between two text blocks (0..1)."""
def _toks(s):
return set(re.findall(r"[a-z0-9]+", s.lower()))
left, right = _toks(a), _toks(b)
if not left or not right:
return 0.0
return len(left & right) / len(left | right)
def reanchor_story_images(revised, original, stories_dir):
"""Keep every image embedded inline, right after the narrative it illustrates.
The editor is free to polish prose but sometimes regroups all image
references at the top or bottom of the story. This re-anchors each image (in
the order it appeared in the original story) to the revised paragraph whose
wording most resembles the turn text the image originally followed, so
images reliably stay embedded inside the story flow.
"""
ordered = [fn for fn, _ in story_images_in_order(stories_dir, original)]
if not ordered:
return revised
anchors = _image_anchors(original)
if not anchors:
return revised
lines = revised.splitlines()
ref_by_fn = {}
for line in lines:
if _IMAGE_LINE_RE.match(line):
ref = re.search(r"!\[[^\]]*\]\(([^)]+)\)", line).group(1)
ref_by_fn.setdefault(
os.path.basename(ref).split("?")[0].split("#")[0], line
)
missing = [fn for fn in ordered if fn not in ref_by_fn]
if missing:
print(
f"[images] Re-anchor: {len(missing)} reference(s) missing from revision: {missing}"
)
return revised
blocks = []
cur = []
for line in lines:
if _IMAGE_LINE_RE.match(line) or not line.strip():
if cur:
blocks.append(cur)
cur = []
continue
cur.append(line)
if cur:
blocks.append(cur)
block_texts = ["\n".join(b).strip() for b in blocks]
attach = []
prev = 0
for fn in ordered:
anchor = anchors.get(fn, "")
best, score = -1, -1.0
for i in range(prev, len(block_texts)):
s = _paragraph_overlap_score(block_texts[i], anchor)
if s > score:
score, best = s, i
if best < 0:
best = min(prev, len(block_texts) - 1)
attach.append(best)
prev = best + 1
out = []
for i, block in enumerate(blocks):
out.extend(block)
for k, fn in enumerate(ordered):
if attach[k] == i:
out.append("")
out.append(ref_by_fn[fn])
out.append("")
return "\n".join(out).strip() + "\n"
def sanitize_story_file(fname, stories_dir):
"""Rewrite a story markdown file in place, dropping broken image references.
Used defensively after the round and after the editor phase so a dead image
tag can never reach the hosted page."""
try:
with open(fname, "r", encoding="utf-8") as f:
text = f.read()
except OSError as e:
print(f"[images] Could not read {fname}: {e}")
return
cleaned = sanitize_story_images(text, stories_dir)
if cleaned != text:
with open(fname, "w", encoding="utf-8") as f:
f.write(cleaned)
print(f"[images] sanitized {os.path.basename(fname)}")
def extract_markdown_fence(text):
match = re.search(
r"```(?:markdown|md)?\s*(.*?)```", text, flags=re.DOTALL | re.IGNORECASE
)
if match:
return match.group(1).strip()
return text.strip()
def run_cross_critique(
stories_dir,
fname,
task,
genre,
token_a,
session_a,
token_b,
session_b,
mediums=None,
language="",
details="",
checklist=None,
cast="",
citations=None,
retries=MAX_CRITIQUE_RETRIES,
):
"""Kaya↔Kolpo cross-critique of the finished story (research-style self-verify).
The deterministic gate (verify_task_fulfillment) runs on the authored story
first — the cheap, no-LLM check. When it is clean, the slow LLM re-write is
skipped entirely and the story the two agents wrote is kept as-is. When
violations exist, the two agents become the verifiers: each retry has one of
them (rotating Kaya/Kolpo) review their partner's copy, name the exact spot
of every violation, and return a corrected markdown where ONLY those spots
changed. The gate re-runs after every attempt; residual problems after
``retries`` attempts surface as an auto-RED (never a silently shipped story).
Returns the path to the ``.edited.md`` file, or ``None`` to keep the original.
"""
try:
with open(fname, "r", encoding="utf-8") as f:
text = f.read()
except OSError as e:
print(f"[critique] Could not read story {fname}: {e}")
return None
def _gate(check_text):
return verify_task_fulfillment(
text, check_text, mediums, language, retrieved_citations=citations
)
problems = _gate(text)
if not problems:
print("[critique] PASS — no deterministic violations, skipping LLM rewrite")
return None
try:
prompt = open(CRITIQUE_PROMPT_FILE, encoding="utf-8").read()
context_tokens = {
"%genre%": genre,
"%mediums%": ", ".join(mediums or []),
"%language%": language or "",
"%details%": details or "None",
"%checklist%": checklist_for(genre, "editor", checklist),
"%cast%": cast or "None",
}
for placeholder, value in context_tokens.items():
prompt = prompt.replace(placeholder, value)
except OSError as e:
print(f"[critique] Could not read prompt file: {e}")
return None
edited_path = fname.replace(".md", ".edited.md")
partners = [
(AGENT_NAMES["A"], token_a),
(AGENT_NAMES["B"], token_b),
]
for attempt in range(max(1, retries)):
name, token = partners[attempt % len(partners)]
print(
f"[critique] Attempt {attempt + 1}/{max(1, retries)} by {name}: "
f"{len(problems)} residual violation(s)"
)
for p in problems:
print(f"[critique] - {p}")
try:
session_id = create_session(
token, f"Cross-critique attempt {attempt + 1}", system_prompt=prompt
)
except Exception as e:
print(f"[critique] {name} could not start a critique session: {e}")
break
try:
wait_for_user_to_leave()
result = call_llm(
token,
session_id,
"Here is the complete story markdown:\n\n"
+ text
+ "\n\nVerification violations to resolve:\n"
+ "\n".join(f"- {p}" for p in problems)
+ "\n\nFollow your WORK MODE exactly: quote each violation's spot, "
"then return your CRITIQUE comment and the complete corrected "
"markdown in a single ```markdown code block, changing only the "
"flagged spots.",
no_tools=True,
)
revised = extract_markdown_fence(result["text"])
if revised:
revised = scrub_agent_names(revised)
revised = normalize_markdown_lines(revised)
revised = sanitize_story_images(revised, stories_dir)
revised = strip_image_markers(revised)
revised = reanchor_story_images(revised, text, stories_dir)
if not revised:
print(f"[critique] {name} returned no markdown; keeping original")
continue
residual = _gate(revised)
if not residual:
with open(edited_path, "w", encoding="utf-8") as f:
f.write(revised + "\n")
print(f"[critique] PASS after {name}'s retry — saved {edited_path}")
return edited_path
text = revised
problems = residual
except Exception as e:
print(f"[critique] Attempt {attempt + 1} failed: {e}")
finally:
if not keep_sessions:
delete_session(token, session_id)
print(f"[critique] FAIL after retries — {len(problems)} residual violation(s):")
for p in problems:
print(f"[critique] - {p}")
return None
def run_editor(
stories_dir,
fname,
task,
genre,
mediums=None,
language="",
details="",
checklist=None,
cast="",
):
"""Editor phase: review images + markdown, write story_rN_ts.edited.md."""
try:
token = login(USERNAME_EDITOR, PASSWORD_EDITOR)
except Exception as e:
print(f"[editor] Could not log in {USERNAME_EDITOR}: {e}")
return None
register_agent_tokens([token], [USERNAME_EDITOR])
try:
prompt = open(EDITOR_PROMPT_FILE, encoding="utf-8").read()
context_tokens = {
"%genre%": genre,
"%mediums%": ", ".join(mediums or []),
"%language%": language or "",
"%details%": details or "None",
"%cast%": cast or "None",
"%checklist%": checklist_for(genre, "editor", checklist),
}
for placeholder, value in context_tokens.items():
prompt = prompt.replace(placeholder, value)
except OSError as e:
print(f"[editor] Could not read prompt file: {e}")
return None
session_id = create_session(
token,
"Editor review",
system_prompt=prompt,
context_tokens=context_tokens,
)
edited_path = fname.replace(".md", ".edited.md")
try:
with open(fname, "r", encoding="utf-8") as f:
markdown_text = f.read()
for img_fname, full in story_images_in_order(stories_dir, markdown_text):
wait_for_user_to_leave()
call_llm(
token,
session_id,
f"This is the image referenced in the story as {img_fname}. "
"Look at it carefully; it is part of the story. Decide the quality of image."
"If it does not match the task expectation, flag it."
"But never add new image or edit existing one",
image_b64=file_to_b64(full),
)
wait_for_user_to_leave()
result = call_llm(
token,
session_id,
"Here is the full story markdown:\n\n"
+ markdown_text
+ "\n\nNow return the complete revised markdown, wrapped in a "
"single ```markdown code block. Nothing else.",
)
revised = extract_markdown_fence(result["text"])
revised = scrub_agent_names(revised) if revised else revised
revised = normalize_markdown_lines(revised) if revised else revised
if not revised:
print("[editor] Editor returned an empty revision; keeping original")
return None
revised = sanitize_story_images(revised, stories_dir)
revised = strip_image_markers(revised) if revised else revised
revised = reanchor_story_images(revised, markdown_text, stories_dir)
with open(edited_path, "w", encoding="utf-8") as f:
f.write(revised + "\n")
print(f"[editor] Saved edited story to {edited_path}")
return edited_path
except Exception as e:
print(f"[editor] Editor phase failed: {e}")
return None
finally:
if not keep_sessions:
delete_session(token, session_id)
def run_moderator(
stories_dir,
fname,
task,
genre,
editor_path=None,
mediums=None,
language="",
details="",
checklist=None,
cast="",
):
"""Moderator phase: GREEN/RED verdict, written to story_rN_ts.moderation.json."""
try:
token = login(USERNAME_MODERATOR, PASSWORD_MODERATOR)
except Exception as e:
print(f"[moderator] Could not log in {USERNAME_MODERATOR}: {e}")
return None
register_agent_tokens([token], [USERNAME_MODERATOR])
try:
prompt = open(MODERATOR_PROMPT_FILE, encoding="utf-8").read()
context_tokens = {
"%genre%": genre,
"%mediums%": ", ".join(mediums or []),
"%language%": language or "",
"%details%": details or "None",
"%cast%": cast or "None",
"%checklist%": checklist_for(genre, "moderator", checklist),
}
for placeholder, value in context_tokens.items():
prompt = prompt.replace(placeholder, value)
except OSError as e:
print(f"[moderator] Could not read prompt file: {e}")
return None
session_id = create_session(
token,
"Moderator review",
system_prompt=prompt,
context_tokens=context_tokens,
)
try:
source = editor_path if editor_path else fname
with open(source, "r", encoding="utf-8") as f:
markdown_text = f.read()
markdown_text = sanitize_story_images(markdown_text, stories_dir)
for img_fname, full in story_images_in_order(stories_dir, markdown_text):
wait_for_user_to_leave()
call_llm(
token,
session_id,
f"This is the image referenced in the story as {img_fname}.",
image_b64=file_to_b64(full),
)
wait_for_user_to_leave()
result = call_llm(
token,
session_id,
"Here is the final story markdown:\n\n"
+ markdown_text
+ "\n\nGive your verdict using exactly these two lines:\n"
"VERDICT: GREEN\nREASONS: <short reasons>",
)
verdict = "UNKNOWN"
m = re.search(r"VERDICT\s*:\s*(GREEN|RED)", result["text"], flags=re.IGNORECASE)
if m:
verdict = m.group(1).upper()
elif re.search(r"\bGREEN\b", result["text"]):
verdict = "GREEN"
elif re.search(r"\bRED\b", result["text"]):
verdict = "RED"
verdict_path = fname.replace(".md", ".moderation.json")
data = {
"verdict": verdict,
"reasons": result["text"],
"task": task,
"genre": genre,
"timestamp": datetime.now().astimezone().isoformat(timespec="seconds"),
}
with open(verdict_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
print(f"[moderator] Verdict {verdict} saved to {verdict_path}")
return data
except Exception as e:
print(f"[moderator] Moderator phase failed: {e}")
return None
finally:
if not keep_sessions:
delete_session(token, session_id)
def run_forever():
token_a = login(USERNAME_A, PASSWORD_A)
token_b = login(USERNAME_B, PASSWORD_B)
register_agent_tokens([token_a, token_b], [USERNAME_A, USERNAME_B])
print("Logged In")
round_number = 1
task_index = 0
try:
while True:
spec = TASKS[task_index % len(TASKS)]
task = spec["task"]
mediums = spec["mediums"]
languages = spec["languages"]
roles = spec.get("roles") or ["free"]
genre = spec.get("genre") or "General"
details_spec = spec.get("details") or ""
checklist = spec.get("checklist") or {}
path = resolve_story_path(spec, roles)
context = spec.get("context") or None
print(roles)
print("The stories will be generated in this directory", path)
inactive = spec.get("inactive") or False
if inactive:
print(f"Task {task} is inactive, skipping the task")
task_index += 1
continue
if "audio" in mediums:
print(
f"[guard] Task declares 'audio', but no audio tool exists in TOOLS — "
f"skipping round {round_number} without running it.\n"
)
round_number += 1
task_index += 1
continue
# Round-scoped fields (Per Round, the default when change_freq is
# absent) resolve once here and stay fixed for every turn; only
# genuinely per-turn fields re-resolve each turn inside the story.
round_fields = resolve_details_fields(
details_spec, task, MASTER_DETAILS, freq_filter="Per Round"
)
details = resolve_details(
details_spec,
task,
MASTER_DETAILS,
freq_filter="Per Round",
preferred=round_fields,
)
per_turn_task = _has_per_turn_details(details_spec)
# Deterministic variety: for round-scoped tasks, resolve the
# combination (round detail fields + mood + genre + role + persona)
# and re-roll the persona until it has not already been produced in
# this self-chat window. Tasks with genuine per-turn details skip
# round-level reservation — variety is enforced turn-by-turn inside
# run_single_conversation, which also pins the identity fields.
combo = {}
for attempt in range(4):
relationship, mood, persona_details = pick_persona_round_robin(
PERSONA_POOL, genre, GENRE_PERSONA_MAP, task_roles=spec.get("roles")
)
if per_turn_task:
break
combo = build_combo_dict(genre, mood, persona_details, round_fields)
if not check_combo_used(token_a, combo):
break
print(
f"[theme] Combination already used (attempt {attempt + 1}); "
f"re-rolling persona for variety"
)
else:
print(
"[theme] Exhausted re-roll attempts; proceeding with the last combination"
)
persona = (relationship, mood, persona_details)
# Share what has already been worked on with the agents BEFORE the
# task starts, so they coordinate through the tracker.
themes_block = format_theme_block(
fetch_used_themes(token_a, scope=SELF_CHAT_THEME_SCOPE)
)
# Reserve this combination in the tracker before the round runs, so
# no later round ever repeats it (even if this one fails). The
# theme slug is built deterministically from the already-resolved
# detail fields + mood — no LLM call needed, and combo_hash (the
# actual dedup key) never reads this field anyway.
theme_id = None
if not per_turn_task:
theme_slug = build_theme_slug(task, mood, combo.get("details") or {})
logged = theme_api(
"log",
token_a,
operation="log",
scope=SELF_CHAT_THEME_SCOPE,
theme=theme_slug,
**combo,
)
theme_id = (
(logged.get("theme") or {}).get("id") if logged.get("ok") else None
)
if theme_id:
print(
f"[theme] Reserved combination {theme_id} for round {round_number}"
)
print(
f"=== Starting round {round_number}: {task} (genre: {genre}, roles: {', '.join(roles)}) ===\n"
)
start_time = time.time()
try:
transcript, session_a, session_b, fname = run_single_conversation(
token_a,
token_b,
round_number,
task,
mediums,
languages,
roles,
genre,
details,
details_spec,
checklist,
path,
context,
persona=persona,
themes_context=themes_block,
turns=spec.get("turns"),
round_fields=round_fields,
per_turn_task=per_turn_task,
research=spec.get("research"),
research_turns=spec.get("research_turns") or 1,
)
except Exception as e:
traceback.print_exc()
print(
f"[error] Round {round_number} failed for task '{task}': {e}\n"
f" Skipping to the next task so the flow keeps running."
)
else:
if theme_id:
done = theme_api(
"complete",
token_a,
operation="complete",
theme_id=theme_id,
)
if done.get("ok"):
print(f"[theme] Marked {theme_id} completed")
else:
print(
f"[theme] Could not mark {theme_id} completed: {done.get('error')}"
)
# save_transcript(transcript, round_number)
if not keep_sessions:
delete_session(token_a, session_a)
delete_session(token_b, session_b)
round_number += 1
task_index += 1
elapsed = time.time() - start_time
print(
f"Total time elapsed in round {round_number} - {elapsed:.2f} seconds\n"
)
print(
f"Autonomous organization is in vacation for {SLEEP_BETWEEN_ROUNDS} seconds"
)
time.sleep(SLEEP_BETWEEN_ROUNDS)
print("Vacation over")
except KeyboardInterrupt:
print("\nManual Interruption")
TASKS, TASKS_SOURCE, TASK_CHECKLISTS, GENRE_PERSONA_MAP, PERSONA_POOL = load_tasks()
if not TASKS:
print("No tasks to run. Add tasks to a config file and restart.")
raise SystemExit(1)
GENRE_CHECKLISTS = load_genre_checklists(TASK_CHECKLISTS)
print(f"Loaded {len(TASKS)} task(s) from {TASKS_SOURCE}")
if args.dry_run:
run_dry_run()
raise SystemExit(0)
user_input = input("Keep sessions {y/n} [default: n] ? ")
keep_sessions = user_input.strip().lower() == "y"
if __name__ == "__main__":
run_forever()
</file>
</files>