Initial commit - Aaron AI v1

This commit is contained in:
2026-04-25 02:05:42 +00:00
commit 22ef40bbaa
6 changed files with 1671 additions and 0 deletions
+490
View File
@@ -0,0 +1,490 @@
import os
import json
import sqlite3
import subprocess
import hashlib
from pathlib import Path
from datetime import datetime
from dotenv import load_dotenv
import chromadb
from sentence_transformers import SentenceTransformer
import anthropic
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
load_dotenv(Path.home() / "aaronai" / ".env")
MEMORY_PATH = Path.home() / "aaronai" / "memory.md"
DB_PATH = str(Path.home() / "aaronai" / "db")
CONVERSATIONS_DB = str(Path.home() / "aaronai" / "conversations.db")
SETTINGS_PATH = Path.home() / "aaronai" / "settings.json"
WATCHER_LOG = str(Path.home() / "aaronai" / "watcher.log")
WATCHER_STATE = str(Path.home() / "aaronai" / "watcher_state.json")
NEXTCLOUD_PATH = "/home/aaron/nextcloud/data/data/aaron/files"
INGEST_SCRIPT = str(Path.home() / "aaronai" / "scripts" / "ingest.py")
PYTHON = str(Path.home() / "aaronai" / "venv" / "bin" / "python3")
DEFAULT_SETTINGS = {
"theme": "light",
"font_size": "medium",
"web_search": True,
"show_sources": True,
}
print("Loading Aaron AI...")
embedder = SentenceTransformer("all-MiniLM-L6-v2")
chroma_client = chromadb.PersistentClient(path=DB_PATH)
collection = chroma_client.get_or_create_collection(
name="aaronai",
metadata={"hnsw:space": "cosine"}
)
anthropic_client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
SYSTEM_PROMPT = """You are the personal AI assistant of Aaron Nelson — computational
designer, fabrication researcher, program builder, and creative
practitioner based in the Hudson Valley.
Aaron's work sits at the intersection of computational geometry,
additive manufacturing, and physical making. He resolves complex
systems into physical reality — from Grasshopper definitions to
large-scale steel structures, from archival photographs to 3D-printed
architectural restorations, from product concepts to manufactured
goods. This throughline — computation resolving into physical form —
defines his practice across academic, consulting, and creative contexts.
He built the Hudson Valley Additive Manufacturing Center (HVAMC) from
nothing and has directed it since 2016, alongside the DDF academic
program at SUNY New Paltz. He has the skills of a founder — equipment
selection, policy creation, client development, grant writing,
curriculum design — operating within an academic structure. He
consults with IBM, Braskem, Selux and others through FWN3D. He runs
Mossygear as a product business. He makes large-scale fabricated art.
His communication style is direct, precise, and intolerant of padding
or overclaiming. He flags inaccuracies immediately and expects the
same standard from you. When helping him write, match his voice —
economical, specific, never performative. When answering questions,
cite sources and acknowledge uncertainty rather than filling gaps with
plausible-sounding content.
You have access to his complete document corpus, conversation history,
and a persistent memory file that carries his current context. Treat
the memory file as ground truth for his present situation. Use web
search automatically when current information is needed. Never
re-brief on context that's already in memory or documents."""
CV_SOURCES = ["Aaron Nelson CV 2024.pdf", "Aaron Nelson CV 2025.pdf", "Aaron Nelson - CV.docx"]
def init_conversations_db():
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
model TEXT DEFAULT 'claude-sonnet-4-6',
message_count INTEGER DEFAULT 0
)''')
c.execute('''CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
sources TEXT DEFAULT '[]',
timestamp TEXT NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
)''')
conn.commit()
conn.close()
init_conversations_db()
def load_settings():
if SETTINGS_PATH.exists():
try:
s = json.loads(SETTINGS_PATH.read_text())
return {**DEFAULT_SETTINGS, **s}
except:
pass
return DEFAULT_SETTINGS.copy()
def save_settings(settings):
SETTINGS_PATH.write_text(json.dumps(settings, indent=2))
def load_memory():
if MEMORY_PATH.exists():
return MEMORY_PATH.read_text(encoding="utf-8")
return ""
def save_memory(content):
MEMORY_PATH.write_text(content, encoding="utf-8")
def add_to_memory(item):
memory = load_memory()
timestamp = datetime.now().strftime("%Y-%m-%d")
note = f"\n- [{timestamp}] {item}"
if "## Notes" not in memory:
memory += "\n\n## Notes"
memory += note
save_memory(memory)
def remove_from_memory(item):
memory = load_memory()
lines = memory.split("\n")
filtered = [l for l in lines if item.lower() not in l.lower()]
save_memory("\n".join(filtered))
return len(lines) - len(filtered)
def get_pinned_cv_context():
try:
results = collection.get(
where={"source": {"$in": CV_SOURCES}},
include=["documents", "metadatas"]
)
return results["documents"], results["metadatas"]
except:
return [], []
def is_professional_query(query):
keywords = ["grant", "publication", "exhibition", "award", "fellowship",
"experience", "position", "job", "career", "cv", "resume",
"research", "work history", "accomplishment", "teaching",
"course", "client", "consultation", "presentation", "workshop",
"education", "degree", "institution", "service", "committee"]
return any(k in query.lower() for k in keywords)
def retrieve_context(query, n_results=8):
query_embedding = embedder.encode([query]).tolist()
results = collection.query(
query_embeddings=query_embedding,
n_results=n_results,
include=["documents", "metadatas", "distances"]
)
context_pieces = []
sources = []
if is_professional_query(query):
cv_docs, cv_metas = get_pinned_cv_context()
for doc, meta in zip(cv_docs, cv_metas):
context_pieces.append(f"[CV] {doc}")
sources.append(meta.get("source", "CV"))
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
):
relevance = 1 - dist
if relevance > 0.3 and meta.get("source") not in CV_SOURCES:
context_pieces.append(doc)
sources.append(meta.get("source", "unknown"))
return context_pieces, sources
def get_conversation_history(conversation_id, limit=20):
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
c.execute('''SELECT role, content FROM messages
WHERE conversation_id = ?
ORDER BY timestamp DESC LIMIT ?''', (conversation_id, limit))
rows = c.fetchall()
conn.close()
return [{"role": r[0], "content": r[1]} for r in reversed(rows)]
def save_message(conversation_id, role, content, sources=None):
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
msg_id = hashlib.md5(f"{conversation_id}{role}{datetime.now().isoformat()}".encode()).hexdigest()
timestamp = datetime.now().isoformat()
c.execute('''INSERT INTO messages (id, conversation_id, role, content, sources, timestamp)
VALUES (?, ?, ?, ?, ?, ?)''',
(msg_id, conversation_id, role, content,
json.dumps(sources or []), timestamp))
c.execute('''UPDATE conversations SET updated_at = ?, message_count = message_count + 1
WHERE id = ?''', (timestamp, conversation_id))
conn.commit()
conn.close()
def create_conversation(title="New conversation"):
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
conv_id = hashlib.md5(f"{datetime.now().isoformat()}".encode()).hexdigest()[:16]
now = datetime.now().isoformat()
c.execute('''INSERT INTO conversations (id, title, created_at, updated_at)
VALUES (?, ?, ?, ?)''', (conv_id, title, now, now))
conn.commit()
conn.close()
return conv_id
def chat(user_message, conversation_id, settings):
memory = load_memory()
context_pieces, sources = retrieve_context(user_message)
history = get_conversation_history(conversation_id)
context_parts = []
if memory:
context_parts.append(f"Aaron's persistent memory:\n\n{memory}")
if context_pieces:
context_str = "\n\n---\n\n".join(context_pieces)
unique_sources = list(set(sources))
context_parts.append(
f"Relevant excerpts from Aaron's documents:\n\n{context_str}\n\nSources: {', '.join(unique_sources)}"
)
context_block = "\n\n====\n\n".join(context_parts) + "\n\n---\n\n" if context_parts else ""
full_message = context_block + user_message
messages = history + [{"role": "user", "content": full_message}]
tools = [{"type": "web_search_20250305", "name": "web_search"}] if settings.get("web_search", True) else []
while True:
kwargs = {
"model": "claude-sonnet-4-6",
"max_tokens": 2048,
"system": SYSTEM_PROMPT,
"messages": messages
}
if tools:
kwargs["tools"] = tools
response = anthropic_client.messages.create(**kwargs)
if response.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": "Search completed"
})
messages.append({"role": "user", "content": tool_results})
else:
assistant_message = ""
for block in response.content:
if hasattr(block, "text"):
assistant_message += block.text
return assistant_message, list(set(sources))
app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
@app.get("/", response_class=FileResponse)
async def index():
return FileResponse("/home/aaron/aaronai/static/index.html")
@app.get("/api/settings")
async def get_settings():
return JSONResponse(load_settings())
@app.post("/api/settings")
async def update_settings(request: Request):
data = await request.json()
settings = load_settings()
settings.update(data)
save_settings(settings)
return JSONResponse(settings)
@app.get("/api/conversations")
async def list_conversations():
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
c.execute('''SELECT id, title, created_at, updated_at, message_count
FROM conversations ORDER BY updated_at DESC LIMIT 100''')
rows = c.fetchall()
conn.close()
return JSONResponse([{
"id": r[0], "title": r[1], "created_at": r[2],
"updated_at": r[3], "message_count": r[4]
} for r in rows])
@app.post("/api/conversations")
async def new_conversation(request: Request):
data = await request.json()
title = data.get("title", "New conversation")
conv_id = create_conversation(title)
return JSONResponse({"id": conv_id, "title": title})
@app.get("/api/conversations/{conv_id}/messages")
async def get_messages(conv_id: str):
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
c.execute('''SELECT role, content, sources, timestamp FROM messages
WHERE conversation_id = ? ORDER BY timestamp ASC''', (conv_id,))
rows = c.fetchall()
conn.close()
return JSONResponse([{
"role": r[0], "content": r[1],
"sources": json.loads(r[2]), "timestamp": r[3]
} for r in rows])
@app.patch("/api/conversations/{conv_id}")
async def rename_conversation(conv_id: str, request: Request):
data = await request.json()
title = data.get("title", "")
if not title:
return JSONResponse({"error": "Title required"}, status_code=400)
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
c.execute("UPDATE conversations SET title = ? WHERE id = ?", (title, conv_id))
conn.commit()
conn.close()
return JSONResponse({"id": conv_id, "title": title})
@app.delete("/api/conversations/{conv_id}")
async def delete_conversation(conv_id: str):
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
c.execute("DELETE FROM messages WHERE conversation_id = ?", (conv_id,))
c.execute("DELETE FROM conversations WHERE id = ?", (conv_id,))
conn.commit()
conn.close()
return JSONResponse({"deleted": conv_id})
@app.post("/api/chat")
async def chat_endpoint(request: Request):
data = await request.json()
user_message = data.get("message", "").strip()
conversation_id = data.get("conversation_id", "")
settings = load_settings()
if not user_message:
return JSONResponse({"error": "Empty message"})
if not conversation_id:
conversation_id = create_conversation("New conversation")
stripped = user_message.strip().lower()
if stripped == "show memory":
return JSONResponse({"response": load_memory(), "sources": [], "conversation_id": conversation_id})
if stripped.startswith("remember:"):
item = user_message[9:].strip()
add_to_memory(item)
save_message(conversation_id, "user", user_message)
save_message(conversation_id, "assistant", f"Saved to memory: '{item}'")
return JSONResponse({"response": f"Saved to memory: '{item}'", "sources": [], "conversation_id": conversation_id})
if stripped.startswith("forget:"):
item = user_message[7:].strip()
removed = remove_from_memory(item)
msg = f"Removed {removed} line(s) containing '{item}'" if removed else f"Nothing found containing '{item}'"
save_message(conversation_id, "user", user_message)
save_message(conversation_id, "assistant", msg)
return JSONResponse({"response": msg, "sources": [], "conversation_id": conversation_id})
save_message(conversation_id, "user", user_message)
# Auto-title conversation from first message
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
c.execute("SELECT message_count, title FROM conversations WHERE id = ?", (conversation_id,))
row = c.fetchone()
conn.close()
if row and row[0] <= 1 and row[1] == "New conversation":
auto_title = user_message[:60] + ("..." if len(user_message) > 60 else "")
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
c.execute("UPDATE conversations SET title = ? WHERE id = ?", (auto_title, conversation_id))
conn.commit()
conn.close()
response, sources = chat(user_message, conversation_id, settings)
save_message(conversation_id, "assistant", response, sources if settings.get("show_sources") else [])
return JSONResponse({
"response": response,
"sources": sources if settings.get("show_sources") else [],
"conversation_id": conversation_id
})
@app.get("/api/memory")
async def get_memory():
return JSONResponse({"content": load_memory()})
@app.post("/api/memory")
async def update_memory(request: Request):
data = await request.json()
content = data.get("content", "")
save_memory(content)
return JSONResponse({"saved": True})
@app.get("/api/status")
async def get_status():
chunk_count = collection.count()
# Watcher status
watcher_running = False
last_indexed = "Unknown"
try:
result = subprocess.run(
["systemctl", "is-active", "aaronai-watcher"],
capture_output=True, text=True
)
watcher_running = result.stdout.strip() == "active"
except:
pass
try:
log_path = Path(WATCHER_LOG)
if log_path.exists():
lines = log_path.read_text().strip().split("\n")
for line in reversed(lines):
if "Ingestion complete" in line:
last_indexed = line.split(" - ")[0].strip()
break
except:
pass
# File count from watcher state
file_count = 0
try:
state_path = Path(WATCHER_STATE)
if state_path.exists():
state = json.loads(state_path.read_text())
file_count = len(state)
except:
pass
# Conversation count
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
c.execute("SELECT COUNT(*) FROM conversations")
conv_count = c.fetchone()[0]
conn.close()
return JSONResponse({
"aaron_ai": "running",
"watcher": "running" if watcher_running else "stopped",
"chunk_count": chunk_count,
"file_count": file_count,
"last_indexed": last_indexed,
"conversation_count": conv_count,
"model": "claude-sonnet-4-6",
"nextcloud_path": NEXTCLOUD_PATH
})
@app.post("/api/reindex")
async def trigger_reindex():
try:
subprocess.Popen([PYTHON, INGEST_SCRIPT, NEXTCLOUD_PATH])
return JSONResponse({"started": True, "message": "Re-indexing started in background"})
except Exception as e:
return JSONResponse({"started": False, "error": str(e)})
@app.delete("/api/conversations")
async def clear_all_conversations():
conn = sqlite3.connect(CONVERSATIONS_DB)
c = conn.cursor()
c.execute("DELETE FROM messages")
c.execute("DELETE FROM conversations")
conn.commit()
conn.close()
return JSONResponse({"cleared": True})
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
+250
View File
@@ -0,0 +1,250 @@
import os
import json
from pathlib import Path
from dotenv import load_dotenv
import chromadb
from sentence_transformers import SentenceTransformer
import anthropic
from datetime import datetime
load_dotenv(Path.home() / "aaronai" / ".env")
memory_path = Path.home() / "aaronai" / "memory.md"
db_path = str(Path.home() / "aaronai" / "db")
print("Loading Aaron AI...")
embedder = SentenceTransformer("all-MiniLM-L6-v2")
chroma_client = chromadb.PersistentClient(path=db_path)
collection = chroma_client.get_or_create_collection(
name="aaronai",
metadata={"hnsw:space": "cosine"}
)
anthropic_client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
SYSTEM_PROMPT = """You are Aaron Nelson's personal AI assistant. Aaron is an Associate Professor
of Digital Design & Fabrication and Program Director of the Hudson Valley Additive Manufacturing
Center (HVAMC) at SUNY New Paltz. He is an expert in computational design, additive manufacturing,
and digital fabrication with deep fluency in Rhino, Grasshopper, Stratasys FDM, PolyJet, and metal
3D printing workflows. He runs a commercial venture called Mossygear and a consulting operation
called FWN3D. He has a background in graffiti lettering and vector illustration.
You have been provided with relevant excerpts from Aaron's own documents and his persistent memory.
Use this context to give answers grounded in his actual work and history. When helping him write
or create, match his voice and draw on his existing materials. Be direct and specific -
Aaron values precision over padding. Always cite which documents you drew from when relevant.
You have access to web search. Use it automatically when:
- Questions require current data (salaries, job postings, prices, news)
- Questions reference specific institutions, people, or organizations you need to verify
- Aaron's documents and memory don't contain sufficient information to answer well
Do not announce that you are searching. Just search and incorporate results naturally."""
CV_SOURCES = ["Aaron Nelson CV 2024.pdf"]
conversation_history = []
TOOLS = [
{
"type": "web_search_20250305",
"name": "web_search"
}
]
def load_memory():
if memory_path.exists():
return memory_path.read_text(encoding="utf-8")
return ""
def save_memory(content):
memory_path.write_text(content, encoding="utf-8")
def add_to_memory(new_item):
memory = load_memory()
timestamp = datetime.now().strftime("%Y-%m-%d")
note = f"\n- [{timestamp}] {new_item}"
if "## Notes" not in memory:
memory += "\n\n## Notes"
memory += note
save_memory(memory)
def remove_from_memory(item):
memory = load_memory()
lines = memory.split("\n")
filtered = [l for l in lines if item.lower() not in l.lower()]
save_memory("\n".join(filtered))
return len(lines) - len(filtered)
def get_pinned_cv_context():
results = collection.get(
where={"source": "Aaron Nelson CV 2024.pdf"},
include=["documents", "metadatas"]
)
return results["documents"], results["metadatas"]
def is_professional_query(query):
keywords = [
"grant", "publication", "exhibition", "award", "fellowship",
"experience", "position", "job", "career", "cv", "resume",
"research", "work history", "accomplishment", "teaching",
"course", "client", "consultation", "presentation", "workshop",
"education", "degree", "institution", "service", "committee"
]
return any(keyword in query.lower() for keyword in keywords)
def retrieve_context(query, n_results=8):
query_embedding = embedder.encode([query]).tolist()
results = collection.query(
query_embeddings=query_embedding,
n_results=n_results,
include=["documents", "metadatas", "distances"]
)
context_pieces = []
sources = []
if is_professional_query(query):
cv_docs, cv_metas = get_pinned_cv_context()
for doc, meta in zip(cv_docs, cv_metas):
context_pieces.append(f"[CV] {doc}")
sources.append(meta["source"])
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
):
relevance = 1 - dist
if relevance > 0.3 and meta["source"] not in CV_SOURCES:
context_pieces.append(doc)
sources.append(meta["source"])
return context_pieces, sources
def handle_command(user_input):
stripped = user_input.strip().lower()
if stripped == "show memory":
memory = load_memory()
print(f"\nAaron AI: Current memory:\n\n{memory}")
return True
if stripped.startswith("remember:"):
item = user_input[9:].strip()
add_to_memory(item)
print(f"\nAaron AI: Saved to memory: '{item}'")
return True
if stripped.startswith("forget:"):
item = user_input[7:].strip()
removed = remove_from_memory(item)
if removed:
print(f"\nAaron AI: Removed {removed} line(s) containing '{item}' from memory.")
else:
print(f"\nAaron AI: Nothing found in memory containing '{item}'.")
return True
if stripped == "clear":
conversation_history.clear()
print("\nAaron AI: Conversation history cleared.")
return True
return False
def chat(user_message):
memory = load_memory()
context_pieces, sources = retrieve_context(user_message)
context_parts = []
if memory:
context_parts.append(f"Aaron's persistent memory:\n\n{memory}")
if context_pieces:
context_str = "\n\n---\n\n".join(context_pieces)
unique_sources = list(set(sources))
context_parts.append(
f"Relevant excerpts from Aaron's documents:\n\n{context_str}\n\nSources: {', '.join(unique_sources)}"
)
context_block = "\n\n====\n\n".join(context_parts) + "\n\n---\n\n" if context_parts else ""
full_message = context_block + user_message
# Build messages for this turn
messages = conversation_history + [{"role": "user", "content": full_message}]
# Agentic loop to handle tool use
while True:
response = anthropic_client.messages.create(
model="claude-sonnet-4-6",
max_tokens=2048,
system=SYSTEM_PROMPT,
tools=TOOLS,
messages=messages
)
# Check if we need to handle tool calls
if response.stop_reason == "tool_use":
# Add assistant response to messages
messages.append({"role": "assistant", "content": response.content})
# Process each tool use block
tool_results = []
for block in response.content:
if block.type == "tool_use":
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": "Search completed"
})
# Add tool results and continue
messages.append({"role": "user", "content": tool_results})
else:
# Final response - extract text
assistant_message = ""
for block in response.content:
if hasattr(block, "text"):
assistant_message += block.text
# Update conversation history with clean versions
conversation_history.append({"role": "user", "content": full_message})
conversation_history.append({"role": "assistant", "content": assistant_message})
if len(conversation_history) > 20:
conversation_history.pop(0)
conversation_history.pop(0)
return assistant_message, sources
def main():
print("Aaron AI ready. Corpus, memory, and web search loaded.")
print("Commands: 'remember: [fact]' | 'forget: [text]' | 'show memory' | 'clear' | 'quit'")
print("=" * 60)
while True:
try:
user_input = input("\nYou: ").strip()
if not user_input:
continue
if user_input.strip().lower() == "quit":
print("Goodbye.")
break
if handle_command(user_input):
continue
response, sources = chat(user_input)
print(f"\nAaron AI: {response}")
if sources:
unique = list(set(sources))
print(f"\n[Sources: {', '.join(unique)}]")
except KeyboardInterrupt:
print("\nGoodbye.")
break
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
main()
+141
View File
@@ -0,0 +1,141 @@
import os
import sys
import hashlib
from pathlib import Path
from dotenv import load_dotenv
import chromadb
from sentence_transformers import SentenceTransformer
from docx import Document
from pypdf import PdfReader
from pptx import Presentation
load_dotenv(Path.home() / "aaronai" / ".env")
print("Loading embedding model...")
embedder = SentenceTransformer("all-MiniLM-L6-v2")
db_path = str(Path.home() / "aaronai" / "db")
client = chromadb.PersistentClient(path=db_path)
collection = client.get_or_create_collection(
name="aaronai",
metadata={"hnsw:space": "cosine"}
)
def extract_text_from_docx(path):
doc = Document(path)
return "\n".join([para.text for para in doc.paragraphs if para.text.strip()])
def extract_text_from_pdf(path):
reader = PdfReader(path)
text = ""
for page in reader.pages:
extracted = page.extract_text()
if extracted:
text += extracted + "\n"
return text
def extract_text_from_pptx(path):
prs = Presentation(path)
text = ""
for slide in prs.slides:
for shape in slide.shapes:
if hasattr(shape, "text") and shape.text.strip():
text += shape.text + "\n"
return text
def extract_text_from_txt(path):
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def chunk_text(text, chunk_size=500, overlap=50):
words = text.split()
chunks = []
start = 0
while start < len(words):
end = start + chunk_size
chunk = " ".join(words[start:end])
if chunk.strip():
chunks.append(chunk)
start += chunk_size - overlap
return chunks
def make_id(filepath, chunk_index):
path_hash = hashlib.md5(str(filepath).encode()).hexdigest()[:8]
return f"{path_hash}_{chunk_index}"
def ingest_file(filepath):
path = Path(filepath)
suffix = path.suffix.lower()
# Skip temp files
if path.name.startswith("~$") or path.name.startswith("."):
return 0
try:
if suffix == ".docx":
text = extract_text_from_docx(path)
elif suffix == ".pdf":
text = extract_text_from_pdf(path)
elif suffix == ".pptx":
text = extract_text_from_pptx(path)
elif suffix in [".txt", ".md"]:
text = extract_text_from_txt(path)
else:
return 0
if not text.strip():
return 0
chunks = chunk_text(text)
if not chunks:
return 0
embeddings = embedder.encode(chunks).tolist()
ids = [make_id(path, i) for i in range(len(chunks))]
metadatas = [{
"source": path.name,
"filepath": str(path),
"folder": str(path.parent.relative_to(Path(sys.argv[1]) if len(sys.argv) > 1 else path.parent))
} for _ in chunks]
collection.upsert(
documents=chunks,
embeddings=embeddings,
ids=ids,
metadatas=metadatas
)
print(f" Indexed {len(chunks)} chunks: {path.name}")
return len(chunks)
except Exception as e:
print(f" Error: {path.name}: {e}")
return 0
def ingest_folder(folder_path):
folder = Path(folder_path)
if not folder.exists():
print(f"Folder not found: {folder_path}")
sys.exit(1)
supported = [".docx", ".pdf", ".pptx", ".txt", ".md"]
files = [f for f in folder.rglob("*")
if f.suffix.lower() in supported
and not f.name.startswith("~$")
and not f.name.startswith(".")]
if not files:
print("No supported files found.")
sys.exit(1)
print(f"Found {len(files)} files to process\n")
total_chunks = 0
for f in files:
total_chunks += ingest_file(f)
print(f"\nDone. Total chunks indexed: {total_chunks}")
print(f"Database stored at: {db_path}")
if __name__ == "__main__":
target = sys.argv[1] if len(sys.argv) > 1 else str(Path.home() / "aaronai" / "docs")
print(f"Ingesting from: {target}\n")
ingest_folder(target)
+150
View File
@@ -0,0 +1,150 @@
import json
import sys
from pathlib import Path
from datetime import datetime
from sentence_transformers import SentenceTransformer
import chromadb
# Paths
db_path = str(Path.home() / "aaronai" / "db")
EXPORT_DIR = "/home/aaron/nextcloud/data/data/aaron/files/Archive/Misc/ChatGPT Export"
print("Loading embedding model...")
embedder = SentenceTransformer("all-MiniLM-L6-v2")
client = chromadb.PersistentClient(path=db_path)
collection = client.get_or_create_collection(
name="aaronai",
metadata={"hnsw:space": "cosine"}
)
def extract_messages(convo):
"""Extract ordered user/assistant messages from a conversation."""
mapping = convo.get("mapping", {})
messages = []
for node in mapping.values():
msg = node.get("message")
if not msg:
continue
role = msg.get("author", {}).get("role")
if role not in ["user", "assistant"]:
continue
content = msg.get("content", {})
parts = content.get("parts", [])
# Extract text parts only
text = ""
for part in parts:
if isinstance(part, str):
text += part
elif isinstance(part, dict) and part.get("content_type") == "text":
text += part.get("text", "")
text = text.strip()
if not text:
continue
create_time = msg.get("create_time") or 0
messages.append((create_time, role, text))
# Sort by timestamp
messages.sort(key=lambda x: x[0])
return messages
def chunk_conversation(title, messages, chunk_size=600, overlap=100):
"""Convert a conversation into overlapping text chunks."""
# Build full conversation text
lines = [f"[Conversation: {title}]", ""]
for _, role, text in messages:
label = "Aaron" if role == "user" else "ChatGPT"
lines.append(f"{label}: {text}")
lines.append("")
full_text = "\n".join(lines)
# Split into word-level chunks with overlap
words = full_text.split()
chunks = []
start = 0
while start < len(words):
end = start + chunk_size
chunk = " ".join(words[start:end])
if chunk.strip():
chunks.append(chunk)
start += chunk_size - overlap
return chunks
def ingest_file(json_path):
print(f"\nLoading {json_path.name}...")
data = json.load(open(json_path, encoding="utf-8"))
print(f"Found {len(data)} conversations")
total_chunks = 0
skipped = 0
for i, convo in enumerate(data):
title = convo.get("title", "Untitled")
convo_id = convo.get("id", f"convo_{i}")
create_time = convo.get("create_time", 0)
try:
date_str = datetime.fromtimestamp(create_time).strftime("%Y-%m-%d")
except:
date_str = "unknown"
messages = extract_messages(convo)
if len(messages) < 2:
skipped += 1
continue
chunks = chunk_conversation(title, messages)
if not chunks:
skipped += 1
continue
# Embed and store
embeddings = embedder.encode(chunks).tolist()
ids = [f"chatgpt_{convo_id}_{j}" for j in range(len(chunks))]
metadatas = [{
"source": f"ChatGPT: {title}",
"filepath": str(json_path),
"date": date_str,
"type": "chatgpt_conversation"
} for _ in chunks]
collection.upsert(
documents=chunks,
embeddings=embeddings,
ids=ids,
metadatas=metadatas
)
total_chunks += len(chunks)
print(f" [{i+1}/{len(data)}] {title[:60]}{len(chunks)} chunks ({date_str})")
print(f"\nDone with {json_path.name}: {total_chunks} chunks indexed, {skipped} conversations skipped")
return total_chunks
def main():
export_dir = Path(EXPORT_DIR)
files = [
export_dir / "conversations-000.json",
export_dir / "conversations-001.json"
]
grand_total = 0
for f in files:
if f.exists():
grand_total += ingest_file(f)
else:
print(f"Not found: {f}")
print(f"\nTotal chunks added to corpus: {grand_total}")
print(f"Database at: {db_path}")
if __name__ == "__main__":
main()
+122
View File
@@ -0,0 +1,122 @@
import time
import subprocess
import logging
import json
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
NEXTCLOUD_PATH = "/home/aaron/nextcloud/data/data/aaron/files"
INGEST_SCRIPT = "/home/aaron/aaronai/scripts/ingest.py"
PYTHON = "/home/aaron/aaronai/venv/bin/python3"
LOG_FILE = "/home/aaron/aaronai/watcher.log"
STATE_FILE = "/home/aaron/aaronai/watcher_state.json"
SUPPORTED = {'.pdf', '.docx', '.pptx', '.txt', '.md'}
DEBOUNCE_SECONDS = 120
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(message)s',
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler()
]
)
def load_state():
if Path(STATE_FILE).exists():
with open(STATE_FILE) as f:
return json.load(f)
return {}
def save_state(state):
with open(STATE_FILE, 'w') as f:
json.dump(state, f)
def get_changed_files():
state = load_state()
changed = []
root = Path(NEXTCLOUD_PATH)
for path in root.rglob("*"):
if path.is_dir():
continue
if path.suffix.lower() not in SUPPORTED:
continue
if path.name.startswith('.') or path.name.startswith('~$'):
continue
mtime = str(path.stat().st_mtime)
key = str(path)
if state.get(key) != mtime:
changed.append(path)
return changed, state
def run_ingestion():
changed, state = get_changed_files()
if not changed:
logging.info("No new or changed files detected — skipping ingestion.")
return
logging.info(f"Found {len(changed)} new or changed files — starting ingestion...")
try:
result = subprocess.run(
[PYTHON, INGEST_SCRIPT, NEXTCLOUD_PATH],
capture_output=True,
text=True,
timeout=600
)
if result.returncode == 0:
# Update state with new mtimes
root = Path(NEXTCLOUD_PATH)
for path in root.rglob("*"):
if path.is_file() and path.suffix.lower() in SUPPORTED:
state[str(path)] = str(path.stat().st_mtime)
save_state(state)
logging.info("Ingestion complete. State updated.")
else:
logging.error(f"Ingestion error: {result.stderr}")
except subprocess.TimeoutExpired:
logging.error("Ingestion timed out.")
except Exception as e:
logging.error(f"Ingestion failed: {e}")
class IngestHandler(FileSystemEventHandler):
def __init__(self):
self.pending = False
self.last_event = 0
def on_any_event(self, event):
if event.is_directory:
return
path = Path(event.src_path)
if path.suffix.lower() not in SUPPORTED:
return
if path.name.startswith('.') or path.name.startswith('~$'):
return
self.pending = True
self.last_event = time.time()
def main():
logging.info("Aaron AI Watcher starting...")
logging.info(f"Watching: {NEXTCLOUD_PATH}")
handler = IngestHandler()
observer = Observer()
observer.schedule(handler, NEXTCLOUD_PATH, recursive=True)
observer.start()
try:
while True:
if handler.pending:
elapsed = time.time() - handler.last_event
if elapsed >= DEBOUNCE_SECONDS:
handler.pending = False
run_ingestion()
time.sleep(5)
except KeyboardInterrupt:
observer.stop()
observer.join()
logging.info("Watcher stopped.")
if __name__ == "__main__":
main()
+518
View File
@@ -0,0 +1,518 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Aaron AI</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
<style>
:root {
--bg:#faf9f6;--bg2:#f0ede6;--bg3:#e8e4dc;--sidebar-bg:#eceae4;
--border:#dddad2;--border2:#ccc9c0;--text:#1a1a18;--text2:#555550;--text3:#999990;
--accent:#2d5a3d;--accent-light:#edf5f0;--accent-border:#c8dece;--accent-text:#1a3a26;
--user-bg:#e8e4dc;--font-size:15px;
--font:'IBM Plex Sans',sans-serif;--mono:'IBM Plex Mono',monospace;
}
[data-theme="dark"] {
--bg:#1a1a18;--bg2:#222220;--bg3:#2a2a28;--sidebar-bg:#111110;
--border:#2a2a28;--border2:#383836;--text:#e8e8e0;--text2:#aaa89e;--text3:#555550;
--accent-light:#1e2e22;--accent-border:#2a3e2e;--accent-text:#a8d5b5;--user-bg:#2a2a28;
}
[data-font="small"]{--font-size:13px}[data-font="medium"]{--font-size:15px}[data-font="large"]{--font-size:17px}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--font);font-size:var(--font-size)}
.app{display:flex;height:100vh;overflow:hidden}
.sidebar{width:260px;background:var(--sidebar-bg);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
.sidebar-header{padding:14px;border-bottom:1px solid var(--border)}
.new-btn{width:100%;background:var(--accent);color:#e8f5ed;border:none;border-radius:8px;padding:9px 14px;font-size:13px;font-family:var(--font);cursor:pointer;text-align:left;display:flex;align-items:center;gap:8px}
.new-btn:hover{opacity:0.85}
.section-label{padding:10px 14px 4px;font-size:11px;color:var(--text3);letter-spacing:.06em;text-transform:uppercase}
.conv-list{flex:1;overflow-y:auto;padding:4px 8px 8px}
.conv-item{padding:8px 10px;border-radius:7px;cursor:pointer;margin-bottom:1px;display:flex;align-items:center;gap:6px}
.conv-item:hover,.conv-item.active{background:var(--bg3)}
.conv-text{flex:1;min-width:0}
.conv-title{font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.conv-date{font-size:11px;color:var(--text3);margin-top:1px}
.conv-delete{opacity:0;background:none;border:none;color:var(--text3);cursor:pointer;padding:2px 4px;border-radius:4px;font-size:14px}
.conv-item:hover .conv-delete{opacity:1}
.conv-delete:hover{color:#a32d2d}
.main{flex:1;display:flex;flex-direction:column;min-width:0;background:var(--bg)}
.topbar{padding:12px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.topbar-title{font-size:13px;color:var(--text3)}
.topbar-title strong{color:var(--text);font-weight:500}
.topbar-gear{background:none;border:none;color:var(--text3);cursor:pointer;padding:5px;border-radius:6px;font-size:17px}
.topbar-gear:hover{background:var(--bg3);color:var(--text)}
.messages{flex:1;overflow-y:auto;padding:28px 20px;display:flex;flex-direction:column;gap:22px}
.msg{display:flex;flex-direction:column;max-width:760px;width:100%}
.msg.user{align-self:flex-end;align-items:flex-end;max-width:640px}
.msg.assistant{align-self:flex-start;align-items:flex-start}
.msg-label{font-size:11px;color:var(--text3);margin-bottom:4px}
.msg-bubble{padding:13px 17px;border-radius:12px;line-height:1.75}
.msg.user .msg-bubble{background:var(--user-bg);color:var(--text);border-radius:12px 12px 3px 12px}
.msg.assistant .msg-bubble{background:var(--accent-light);color:var(--accent-text);border-radius:12px 12px 12px 3px;border:1px solid var(--accent-border)}
.msg-sources{font-size:11px;color:var(--text3);margin-top:6px;font-style:italic}
.msg-bubble p{margin-bottom:.75em}.msg-bubble p:last-child{margin-bottom:0}
.msg-bubble strong{font-weight:500;color:var(--text)}
.msg-bubble code{font-family:var(--mono);font-size:.88em;background:var(--bg3);padding:1px 5px;border-radius:4px}
.msg-bubble pre{background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow-x:auto;margin:.75em 0}
.msg-bubble pre code{background:none;padding:0}
.msg-bubble h1,.msg-bubble h2,.msg-bubble h3{font-weight:500;margin:.75em 0 .4em;color:var(--text)}
.msg-bubble ul,.msg-bubble ol{padding-left:1.4em;margin:.5em 0}
.msg-bubble li{margin-bottom:.3em}
.msg-bubble hr{border:none;border-top:1px solid var(--border);margin:1em 0}
.msg-bubble blockquote{border-left:3px solid var(--accent-border);padding-left:12px;color:var(--text2);margin:.5em 0}
.thinking{color:var(--text3);font-style:italic;animation:pulse 1.5s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--text3);text-align:center;padding:40px}
.empty-state h2{font-size:18px;font-weight:500;color:var(--text2);margin-bottom:8px}
.empty-state p{font-size:14px;line-height:1.6;max-width:340px}
.input-area{padding:14px 18px;border-top:1px solid var(--border);display:flex;gap:10px;align-items:flex-end;flex-shrink:0}
.input-wrap{flex:1;background:var(--bg2);border:1px solid var(--border2);border-radius:10px}
textarea#input{width:100%;background:none;border:none;outline:none;padding:11px 14px;font-size:var(--font-size);font-family:var(--font);color:var(--text);resize:none;min-height:44px;max-height:160px;line-height:1.5;display:block}
textarea#input::placeholder{color:var(--text3)}
.send-btn{background:var(--accent);color:#e8f5ed;border:none;border-radius:8px;padding:11px 20px;font-size:14px;font-family:var(--font);cursor:pointer;flex-shrink:0}
.send-btn:hover{opacity:0.85}
.send-btn:disabled{opacity:.4;cursor:not-allowed}
.settings-overlay{position:fixed;top:0;right:0;bottom:0;width:340px;background:var(--bg);border-left:1px solid var(--border);display:flex;flex-direction:column;z-index:100;transform:translateX(100%);transition:transform .2s ease}
.settings-overlay.open{transform:translateX(0)}
.settings-header{padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.settings-header h2{font-size:15px;font-weight:500;color:var(--text)}
.close-settings{background:none;border:none;color:var(--text3);font-size:20px;cursor:pointer;padding:2px 6px;border-radius:4px;line-height:1}
.close-settings:hover{background:var(--bg3)}
.settings-body{flex:1;overflow-y:auto;padding:18px 20px}
.s-section{margin-bottom:22px}
.s-section-title{font-size:11px;color:var(--text3);letter-spacing:.06em;text-transform:uppercase;margin-bottom:10px}
.s-row{display:flex;align-items:center;justify-content:space-between;padding:9px 0;border-bottom:1px solid var(--border);gap:12px}
.s-row:last-child{border-bottom:none}
.s-row-info{flex:1;min-width:0}
.s-label{font-size:14px;color:var(--text)}
.s-desc{font-size:12px;color:var(--text3);margin-top:2px}
.toggle{width:36px;height:20px;background:var(--accent);border-radius:10px;position:relative;cursor:pointer;flex-shrink:0;border:none}
.toggle-knob{width:16px;height:16px;background:white;border-radius:50%;position:absolute;top:2px;right:2px;pointer-events:none}
.toggle[data-off="true"]{background:var(--border2)}
.toggle[data-off="true"] .toggle-knob{right:auto;left:2px}
.s-select{background:var(--bg3);border:1px solid var(--border2);border-radius:6px;padding:5px 8px;font-size:13px;font-family:var(--font);color:var(--text)}
.s-btn{background:none;border:1px solid var(--border2);border-radius:6px;padding:5px 12px;font-size:12px;font-family:var(--font);color:var(--text2);cursor:pointer;white-space:nowrap}
.s-btn:hover{background:var(--bg3)}
.s-btn.danger{color:#a32d2d;border-color:#f7c1c1}
.s-btn.primary{color:var(--accent);border-color:var(--accent-border)}
.s-btn.primary:hover{background:var(--accent-light)}
.stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px}
.stat-card{background:var(--accent-light);border:1px solid var(--accent-border);border-radius:8px;padding:10px 12px}
.stat-num{font-size:20px;font-weight:500;color:var(--accent)}
.stat-lbl{font-size:11px;color:var(--text3);margin-top:2px}
.status-indicator{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)}
.status-indicator:last-child{border-bottom:none}
.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.dot.green{background:#2d7a3d}.dot.yellow{background:#c8821a}.dot.red{background:#a32d2d}
.status-name{font-size:13px;color:var(--text);flex:1}
.status-val{font-size:12px;color:var(--text3)}
.memory-preview{background:var(--bg3);border-radius:8px;padding:10px 12px;font-size:12px;color:var(--text2);line-height:1.6;margin-bottom:10px;font-family:var(--mono);max-height:90px;overflow:hidden;white-space:pre-wrap}
.memory-editor{width:100%;background:var(--bg3);border:1px solid var(--border2);border-radius:8px;padding:10px 12px;font-size:12px;font-family:var(--mono);color:var(--text);line-height:1.6;resize:vertical;min-height:140px;margin-bottom:8px}
.memory-editor:focus{outline:2px solid var(--accent)}
</style>
</head>
<body>
<div class="app" id="app">
<div class="sidebar">
<div class="sidebar-header">
<button class="new-btn" onclick="newConversation()">
<span style="font-size:16px;line-height:1">+</span> New conversation
</button>
</div>
<div id="conv-list-container" style="flex:1;overflow:hidden;display:flex;flex-direction:column"></div>
</div>
<div class="main">
<div class="topbar">
<div class="topbar-title"><strong>Aaron AI</strong> &mdash; personal knowledge assistant</div>
<button class="topbar-gear" onclick="toggleSettings()" title="Settings">&#9881;</button>
</div>
<div id="messages" class="messages">
<div class="empty-state" id="empty-state">
<h2>What are you working on?</h2>
<p>Ask about your documents, projects, research, or anything else. Your entire corpus is available.</p>
</div>
</div>
<div class="input-area">
<div class="input-wrap">
<textarea id="input" placeholder="Ask anything... (Shift+Enter for new line)" rows="1"></textarea>
</div>
<button class="send-btn" id="send-btn" onclick="sendMessage()">Send</button>
</div>
</div>
<div class="settings-overlay" id="settings-panel">
<div class="settings-header">
<h2>Settings</h2>
<button class="close-settings" onclick="toggleSettings()">&#215;</button>
</div>
<div class="settings-body" id="settings-body">
<div style="color:var(--text3);font-size:13px;padding:20px 0;text-align:center">Loading...</div>
</div>
</div>
</div>
<script>
const API = {
async get(p){const r=await fetch(p,{credentials:'same-origin'});return r.json()},
async post(p,d){const r=await fetch(p,{credentials:'same-origin',method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)});return r.json()},
async patch(p,d){const r=await fetch(p,{credentials:'same-origin',method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)});return r.json()},
async del(p){const r=await fetch(p,{credentials:'same-origin',method:'DELETE'});return r.json()},
};
let state={conversations:[],currentId:null,messages:[],settings:{},status:{},settingsOpen:false};
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
function fmt(s){
s=esc(s);
s=s.replace(/`([^`]+)`/g,'<code>$1</code>');
s=s.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>');
s=s.replace(/\*([^*]+)\*/g,'<em>$1</em>');
return s;
}
function renderMarkdown(text){
if(!text)return'';
const lines=text.split('\n');
let html='',inCode=false,codeLines=[],listItems=[];
function flushList(){
if(!listItems.length)return'';
const r='<ul>'+listItems.map(i=>'<li>'+i+'</li>').join('')+'</ul>';
listItems=[];
return r;
}
for(let i=0;i<lines.length;i++){
const line=lines[i];
if(line.startsWith('```')){
if(inCode){
html+=flushList();
html+='<pre><code>'+esc(codeLines.join('\n'))+'</code></pre>';
codeLines=[];inCode=false;
} else {inCode=true;}
continue;
}
if(inCode){codeLines.push(line);continue;}
const t=line.trim();
if(!t){html+=flushList();continue;}
if(t.startsWith('### ')){html+=flushList();html+='<h3>'+fmt(t.slice(4))+'</h3>';continue;}
if(t.startsWith('## ')){html+=flushList();html+='<h2>'+fmt(t.slice(3))+'</h2>';continue;}
if(t.startsWith('# ')){html+=flushList();html+='<h1>'+fmt(t.slice(2))+'</h1>';continue;}
if(t==='---'){html+=flushList();html+='<hr>';continue;}
if(t.startsWith('> ')){html+=flushList();html+='<blockquote>'+fmt(t.slice(2))+'</blockquote>';continue;}
if(t.startsWith('- ')||t.startsWith('* ')){listItems.push(fmt(t.slice(2)));continue;}
html+=flushList();
html+='<p>'+fmt(line)+'</p>';
}
html+=flushList();
return html;
}
function formatDate(iso){
const d=new Date(iso),now=new Date(),diff=(now-d)/1000;
if(diff<60)return'just now';
if(diff<3600)return Math.floor(diff/60)+'m ago';
if(diff<86400)return Math.floor(diff/3600)+'h ago';
if(diff<172800)return'yesterday';
return d.toLocaleDateString('en-US',{month:'short',day:'numeric'});
}
function groupConvs(convs){
const now=new Date(),r={today:[],yesterday:[],week:[],older:[]};
convs.forEach(c=>{
const diff=(now-new Date(c.updated_at))/86400000;
if(diff<1)r.today.push(c);
else if(diff<2)r.yesterday.push(c);
else if(diff<7)r.week.push(c);
else r.older.push(c);
});
return r;
}
function renderConvList(){
const container=document.getElementById('conv-list-container');
const g=groupConvs(state.conversations);
const labels={today:'Today',yesterday:'Yesterday',week:'This week',older:'Older'};
let html='<div class="conv-list">';
for(const[key,label]of Object.entries(labels)){
if(!g[key].length)continue;
html+=`<div class="section-label">${label}</div>`;
g[key].forEach(c=>{
const active=c.id===state.currentId?' active':'';
html+=`<div class="conv-item${active}" onclick="loadConversation('${c.id}')">
<div class="conv-text">
<div class="conv-title" title="${esc(c.title)}">${esc(c.title)}</div>
<div class="conv-date">${formatDate(c.updated_at)}</div>
</div>
<button class="conv-delete" onclick="deleteConv(event,'${c.id}')">&#215;</button>
</div>`;
});
}
if(!state.conversations.length)html+='<div style="padding:20px 14px;font-size:13px;color:var(--text3)">No conversations yet</div>';
html+='</div>';
container.innerHTML=html;
}
function renderMessages(){
const el=document.getElementById('messages');
const empty=document.getElementById('empty-state');
if(!state.messages.length){
if(empty)empty.style.display='flex';
el.querySelectorAll('.msg').forEach(m=>m.remove());
return;
}
if(empty)empty.style.display='none';
let html='';
state.messages.forEach(m=>{
const bubble=renderMarkdown(m.content);
const sources=m.sources&&m.sources.length?`<div class="msg-sources">Sources: ${[...new Set(m.sources)].join(', ')}</div>`:'';
html+=`<div class="msg ${m.role}">
<div class="msg-label">${m.role==='user'?'you':'aaron ai'}</div>
<div class="msg-bubble">${bubble}</div>
${sources}
</div>`;
});
el.innerHTML=(empty?empty.outerHTML:'')+html;
if(empty){
const e=document.getElementById('empty-state');
if(e)e.style.display='none';
}
el.scrollTop=el.scrollHeight;
}
async function loadConversations(){
state.conversations=await API.get('/api/conversations');
renderConvList();
}
async function loadConversation(id){
state.currentId=id;
state.messages=await API.get(`/api/conversations/${id}/messages`);
renderMessages();
renderConvList();
document.getElementById('input').focus();
}
async function newConversation(){
const data=await API.post('/api/conversations',{title:'New conversation'});
state.currentId=data.id;
state.messages=[];
await loadConversations();
renderMessages();
document.getElementById('input').focus();
}
async function deleteConv(e,id){
e.stopPropagation();
if(!confirm('Delete this conversation?'))return;
await API.del(`/api/conversations/${id}`);
if(state.currentId===id){state.currentId=null;state.messages=[];renderMessages();}
await loadConversations();
}
async function sendMessage(){
const input=document.getElementById('input');
const text=input.value.trim();
if(!text)return;
input.value='';
input.style.height='auto';
if(!state.currentId){
const data=await API.post('/api/conversations',{title:'New conversation'});
state.currentId=data.id;
}
state.messages.push({role:'user',content:text,sources:[]});
renderMessages();
const el=document.getElementById('messages');
const thinking=document.createElement('div');
thinking.className='msg assistant';
thinking.innerHTML='<div class="msg-label">aaron ai</div><div class="msg-bubble thinking">Thinking...</div>';
el.appendChild(thinking);
el.scrollTop=el.scrollHeight;
document.getElementById('send-btn').disabled=true;
try{
const data=await API.post('/api/chat',{message:text,conversation_id:state.currentId});
thinking.remove();
state.currentId=data.conversation_id;
state.messages.push({role:'assistant',content:data.response,sources:data.sources||[]});
renderMessages();
await loadConversations();
}catch(e){
thinking.innerHTML='<div class="msg-label">aaron ai</div><div class="msg-bubble" style="color:#a32d2d">Error — please try again.</div>';
}
document.getElementById('send-btn').disabled=false;
document.getElementById('input').focus();
}
function toggleSettings(){
state.settingsOpen=!state.settingsOpen;
const panel=document.getElementById('settings-panel');
if(state.settingsOpen){panel.classList.add('open');loadSettingsPanel();}
else panel.classList.remove('open');
}
async function loadSettingsPanel(){
const[settings,status]=await Promise.all([API.get('/api/settings'),API.get('/api/status')]);
state.settings=settings;state.status=status;
renderSettingsPanel();
}
function renderSettingsPanel(){
const s=state.settings,st=state.status;
document.getElementById('settings-body').innerHTML=`
<div class="s-section">
<div class="s-section-title">Appearance</div>
<div class="s-row">
<div class="s-row-info"><div class="s-label">Dark mode</div><div class="s-desc">Switch between light and dark theme</div></div>
<button class="toggle" data-off="${s.theme!=='dark'}" onclick="toggleSetting('theme','${s.theme==='dark'?'light':'dark'}')"><div class="toggle-knob"></div></button>
</div>
<div class="s-row">
<div class="s-row-info"><div class="s-label">Font size</div></div>
<select class="s-select" onchange="updateSetting('font_size',this.value)">
<option value="small" ${s.font_size==='small'?'selected':''}>Small (13px)</option>
<option value="medium" ${s.font_size==='medium'?'selected':''}>Medium (15px)</option>
<option value="large" ${s.font_size==='large'?'selected':''}>Large (17px)</option>
</select>
</div>
</div>
<div class="s-section">
<div class="s-section-title">Corpus</div>
<div class="stat-grid">
<div class="stat-card"><div class="stat-num">${(st.chunk_count||0).toLocaleString()}</div><div class="stat-lbl">total chunks</div></div>
<div class="stat-card"><div class="stat-num">${(st.file_count||0).toLocaleString()}</div><div class="stat-lbl">files indexed</div></div>
</div>
<div class="s-row">
<div class="s-row-info"><div class="s-label">Last indexed</div><div class="s-desc">${st.last_indexed||'Unknown'}</div></div>
<button class="s-btn primary" onclick="triggerReindex()">Re-index</button>
</div>
<div class="s-row">
<div class="s-row-info"><div class="s-label">Web search</div><div class="s-desc">Search the web for current information</div></div>
<button class="toggle" data-off="${!s.web_search}" onclick="toggleSetting('web_search',${!s.web_search})"><div class="toggle-knob"></div></button>
</div>
<div class="s-row">
<div class="s-row-info"><div class="s-label">Show sources</div><div class="s-desc">Display document sources under responses</div></div>
<button class="toggle" data-off="${!s.show_sources}" onclick="toggleSetting('show_sources',${!s.show_sources})"><div class="toggle-knob"></div></button>
</div>
</div>
<div class="s-section">
<div class="s-section-title">Memory</div>
<div id="memory-section">
<div class="memory-preview" id="memory-preview">Loading...</div>
<div style="display:flex;gap:8px"><button class="s-btn primary" onclick="openMemoryEditor()">Edit memory</button></div>
</div>
</div>
<div class="s-section">
<div class="s-section-title">Conversations</div>
<div class="s-row">
<div class="s-row-info"><div class="s-label">Stored conversations</div></div>
<div style="font-size:14px;color:var(--text);font-weight:500">${st.conversation_count||0}</div>
</div>
<div class="s-row">
<div class="s-row-info"><div class="s-label">Export current conversation</div><div class="s-desc">Download as markdown</div></div>
<button class="s-btn" onclick="exportConversation()">Export</button>
</div>
<div class="s-row">
<div class="s-row-info"><div class="s-label">Clear all conversations</div><div class="s-desc">Permanently delete history</div></div>
<button class="s-btn danger" onclick="clearAllConversations()">Clear</button>
</div>
</div>
<div class="s-section">
<div class="s-section-title">System</div>
<div class="status-indicator"><div class="dot ${st.aaron_ai==='running'?'green':'red'}"></div><div class="status-name">Aaron AI service</div><div class="status-val">${st.aaron_ai||'unknown'}</div></div>
<div class="status-indicator"><div class="dot ${st.watcher==='running'?'green':'red'}"></div><div class="status-name">File watcher</div><div class="status-val">${st.watcher||'unknown'}</div></div>
<div class="status-indicator"><div class="dot green"></div><div class="status-name">Nextcloud files</div><div class="status-val">${(st.file_count||0).toLocaleString()} files</div></div>
<div class="status-indicator"><div class="dot yellow"></div><div class="status-name">Model</div><div class="status-val">${st.model||'unknown'}</div></div>
</div>`;
API.get('/api/memory').then(data=>{
const el=document.getElementById('memory-preview');
if(el)el.textContent=(data.content||'').split('\n').slice(0,6).join('\n');
});
}
async function toggleSetting(key,value){
state.settings[key]=value;
await API.post('/api/settings',{[key]:value});
applySettings();
renderSettingsPanel();
}
async function updateSetting(key,value){
state.settings[key]=value;
await API.post('/api/settings',{[key]:value});
applySettings();
}
function applySettings(){
const app=document.getElementById('app');
app.setAttribute('data-theme',state.settings.theme||'light');
app.setAttribute('data-font',state.settings.font_size||'medium');
}
async function triggerReindex(){
const btn=event.target;
btn.textContent='Starting...';btn.disabled=true;
await API.post('/api/reindex',{});
btn.textContent='Started';
setTimeout(()=>{btn.textContent='Re-index';btn.disabled=false;},3000);
}
async function openMemoryEditor(){
const data=await API.get('/api/memory');
document.getElementById('memory-section').innerHTML=`
<textarea class="memory-editor" id="memory-editor">${esc(data.content)}</textarea>
<div style="display:flex;gap:8px">
<button class="s-btn primary" onclick="saveMemory()">Save</button>
<button class="s-btn" onclick="renderSettingsPanel()">Cancel</button>
</div>`;
}
async function saveMemory(){
const content=document.getElementById('memory-editor').value;
await API.post('/api/memory',{content});
renderSettingsPanel();
}
function exportConversation(){
if(!state.messages.length){alert('No messages to export.');return;}
let md=`# Conversation Export\n\nExported: ${new Date().toLocaleString()}\n\n---\n\n`;
state.messages.forEach(m=>{
md+=`**${m.role==='user'?'You':'Aaron AI'}**\n\n${m.content}\n\n`;
if(m.sources&&m.sources.length)md+=`*Sources: ${m.sources.join(', ')}*\n\n`;
md+='---\n\n';
});
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([md],{type:'text/markdown'}));
a.download=`conversation-${Date.now()}.md`;
a.click();
}
async function clearAllConversations(){
if(!confirm('Delete all conversations permanently?'))return;
await API.del('/api/conversations');
state.currentId=null;state.messages=[];
await loadConversations();renderMessages();renderSettingsPanel();
}
document.getElementById('input').addEventListener('input',function(){
this.style.height='auto';
this.style.height=Math.min(this.scrollHeight,160)+'px';
});
document.getElementById('input').addEventListener('keydown',function(e){
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}
});
async function init(){
const settings=await API.get('/api/settings');
state.settings=settings;
applySettings();
await loadConversations();
if(state.conversations.length)await loadConversation(state.conversations[0].id);
}
init();
</script>
</body>
</html>