Initial commit — Cortex API + Inara identity
Cortex: FastAPI backend serving Inara via Claude/Gemini CLI backends. Includes SSE streaming chat, session persistence, Google Chat webhook handler, and Docker support. Inara: Identity files (persona, soul, protocols, memory, context tiers) mounted read-only into the container at runtime. Features in initial cut: - /chat endpoint with SSE keepalive + LLM fallback - Session store with rolling history window - Markdown rendering, copy-to-clipboard, links open in new tab - Stacked right-column input controls (height selector, enter toggle, note mode with public/private) — semi-hidden until textarea grows - /note endpoint for injecting public context into session history - Docker Compose config (local dev runs natively; Docker for server) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7
cortex/.dockerignore
Normal file
7
cortex/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
data/
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
33
cortex/.env.example
Normal file
33
cortex/.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# Auth is handled by the claude CLI (claude setup-token) — no API key needed here.
|
||||
# ANTHROPIC_API_KEY=only_needed_if_switching_to_sdk
|
||||
|
||||
# Path to the inara/ identity directory — relative to cortex/ or absolute
|
||||
INARA_DIR=../inara
|
||||
|
||||
# Path for persistent JSON session files
|
||||
SESSIONS_DIR=./data/sessions
|
||||
|
||||
# LLM defaults
|
||||
DEFAULT_MODEL=claude-sonnet-4-6
|
||||
DEFAULT_TIER=2
|
||||
|
||||
# Session rolling window — number of messages to keep (user + assistant pairs)
|
||||
# 40 = 20 turns
|
||||
MAX_HISTORY_MESSAGES=40
|
||||
|
||||
# Per-backend timeouts (seconds)
|
||||
# Gemini is generous — it frequently takes 30-60s under load
|
||||
# Local models may need time to load into VRAM before first response
|
||||
TIMEOUT_CLAUDE=60
|
||||
TIMEOUT_GEMINI=120
|
||||
TIMEOUT_LOCAL=300
|
||||
|
||||
# Google Chat — must respond within 30s or Chat shows an error to the user
|
||||
GOOGLE_CHAT_TIMEOUT=25
|
||||
# Backend pinned for Google Chat (claude recommended — more reliable within 25s)
|
||||
GOOGLE_CHAT_BACKEND=claude
|
||||
# TODO: add GOOGLE_CHAT_TOKEN for request verification once endpoint is public
|
||||
|
||||
# Server
|
||||
PORT=8000
|
||||
HOST=0.0.0.0
|
||||
27
cortex/Dockerfile
Normal file
27
cortex/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Node.js (needed by claude CLI) and Claude/Gemini CLIs
|
||||
# Claude CLI is installed via npm; Gemini CLI likewise
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm install -g @anthropic-ai/claude-code @google/gemini-cli \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python deps
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# App source
|
||||
COPY . .
|
||||
|
||||
# Inara identity dir is mounted at runtime (see docker-compose.yml)
|
||||
# Sessions dir is also a named volume
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
41
cortex/config.py
Normal file
41
cortex/config.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
anthropic_api_key: str | None = None # not used — claude CLI handles auth
|
||||
inara_dir: Path = Path("../inara")
|
||||
sessions_dir: Path = Path("./data/sessions")
|
||||
default_model: str = "claude-sonnet-4-6"
|
||||
default_tier: int = 2
|
||||
max_history_messages: int = 40 # rolling window — 20 turns (user + assistant)
|
||||
primary_backend: str = "claude" # "claude" or "gemini" — other is always fallback
|
||||
|
||||
# Per-backend timeouts in seconds
|
||||
timeout_claude: int = 60
|
||||
timeout_gemini: int = 120 # frequently slow under load
|
||||
timeout_local: int = 300 # local models may need to load first
|
||||
|
||||
# Google Chat must receive a response within 30s or shows an error to the user
|
||||
google_chat_timeout: int = 25
|
||||
# Backend forced for Google Chat — Claude is more reliable within the 25s deadline
|
||||
google_chat_backend: str = "claude"
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
def inara_path(self) -> Path:
|
||||
"""Resolve inara_dir relative to this file's location if not absolute."""
|
||||
if self.inara_dir.is_absolute():
|
||||
return self.inara_dir
|
||||
return (Path(__file__).parent / self.inara_dir).resolve()
|
||||
|
||||
def sessions_path(self) -> Path:
|
||||
"""Resolve sessions_dir relative to this file's location if not absolute."""
|
||||
if self.sessions_dir.is_absolute():
|
||||
return self.sessions_dir
|
||||
return (Path(__file__).parent / self.sessions_dir).resolve()
|
||||
|
||||
|
||||
settings = Settings()
|
||||
52
cortex/context_loader.py
Normal file
52
cortex/context_loader.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from pathlib import Path
|
||||
from config import settings
|
||||
|
||||
|
||||
# Files loaded per tier — mirrors CONTEXT_TIERS.md
|
||||
TIER_FILES: dict[int, list[str]] = {
|
||||
1: ["SOUL.md", "IDENTITY.md"], # + USER.md summary only
|
||||
2: ["SOUL.md", "IDENTITY.md", "USER.md", "MEMORY.md", "PROTOCOLS.md"],
|
||||
3: ["SOUL.md", "IDENTITY.md", "USER.md", "MEMORY.md", "PROTOCOLS.md"],
|
||||
4: ["SOUL.md", "IDENTITY.md", "USER.md", "MEMORY.md", "PROTOCOLS.md"],
|
||||
}
|
||||
|
||||
# Lines of USER.md to include at Tier 1 (just identity + what he cares about)
|
||||
TIER_1_USER_LINES = 30
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
if path.exists():
|
||||
return path.read_text()
|
||||
return f"[missing: {path.name}]"
|
||||
|
||||
|
||||
def load_context(tier: int = 2) -> str:
|
||||
inara_dir = settings.inara_path()
|
||||
parts = []
|
||||
|
||||
files = TIER_FILES.get(tier, TIER_FILES[2])
|
||||
|
||||
for filename in files:
|
||||
path = inara_dir / filename
|
||||
if not path.exists():
|
||||
continue
|
||||
|
||||
if filename == "USER.md" and tier == 1:
|
||||
# Tier 1: include only the first N lines
|
||||
lines = path.read_text().splitlines()[:TIER_1_USER_LINES]
|
||||
content = "\n".join(lines)
|
||||
else:
|
||||
content = path.read_text()
|
||||
|
||||
parts.append(f"--- {filename} ---\n{content}")
|
||||
|
||||
if tier >= 3:
|
||||
# Add recent session logs
|
||||
sessions_dir = inara_dir / "sessions"
|
||||
if sessions_dir.exists():
|
||||
count = 2 if tier == 3 else 7
|
||||
session_files = sorted(sessions_dir.glob("*.md"), reverse=True)[:count]
|
||||
for sf in session_files:
|
||||
parts.append(f"--- Session: {sf.name} ---\n{sf.read_text()}")
|
||||
|
||||
return "\n\n".join(parts)
|
||||
183
cortex/llm_client.py
Normal file
183
cortex/llm_client.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Track active Gemini process group IDs so we can kill them on shutdown
|
||||
_active_pgroups: set[int] = set()
|
||||
|
||||
|
||||
def _register_pgroup(pid: int) -> None:
|
||||
_active_pgroups.add(pid)
|
||||
|
||||
|
||||
def _unregister_pgroup(pid: int) -> None:
|
||||
_active_pgroups.discard(pid)
|
||||
|
||||
|
||||
async def cleanup() -> None:
|
||||
"""Kill any lingering Gemini process groups. Call from lifespan shutdown."""
|
||||
for pid in list(_active_pgroups):
|
||||
try:
|
||||
os.killpg(pid, signal.SIGKILL)
|
||||
logger.info("Shutdown: killed Gemini process group %d", pid)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
_active_pgroups.clear()
|
||||
|
||||
|
||||
async def complete(
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
max_tokens: int = 2048,
|
||||
) -> tuple[str, str]:
|
||||
"""Returns (response_text, actual_backend_used)."""
|
||||
if model in ("claude", "gemini"):
|
||||
primary = model
|
||||
else:
|
||||
primary = settings.primary_backend
|
||||
|
||||
fallback = "gemini" if primary == "claude" else "claude"
|
||||
|
||||
try:
|
||||
response = await _dispatch(primary, system_prompt, messages, model)
|
||||
return response, primary
|
||||
except Exception as e:
|
||||
logger.warning("%s failed (%s) — falling back to %s", primary, e, fallback)
|
||||
response = await _dispatch(fallback, system_prompt, messages, None)
|
||||
return response, fallback
|
||||
|
||||
|
||||
async def _dispatch(
|
||||
backend: str,
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
model: str | None,
|
||||
) -> str:
|
||||
if backend == "gemini":
|
||||
return await _gemini(system_prompt, messages)
|
||||
return await _claude(system_prompt, messages, model)
|
||||
|
||||
|
||||
async def _claude(system_prompt: str, messages: list[dict], model: str | None) -> str:
|
||||
cmd = [
|
||||
"claude", "--print",
|
||||
"--no-session-persistence",
|
||||
"--output-format", "text",
|
||||
]
|
||||
if model and model not in ("claude", "gemini"):
|
||||
cmd.extend(["--model", model])
|
||||
if system_prompt:
|
||||
cmd.extend(["--system-prompt", system_prompt])
|
||||
cmd.append(_build_conversation(messages))
|
||||
return await _run(cmd, timeout=settings.timeout_claude)
|
||||
|
||||
|
||||
async def _gemini(system_prompt: str, messages: list[dict]) -> str:
|
||||
# Gemini CLI spawns MCP child processes that keep stdout pipes open after responding.
|
||||
# start_new_session=True puts the whole tree in its own process group so
|
||||
# os.killpg kills everything at once on timeout.
|
||||
cmd = [
|
||||
"gemini",
|
||||
"--output-format", "text",
|
||||
"--extensions", "", # disable all extensions — prevents MCP child processes
|
||||
"-p", _build_prompt(system_prompt, messages),
|
||||
]
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError("gemini not found in PATH")
|
||||
|
||||
_register_pgroup(proc.pid)
|
||||
timeout = settings.timeout_gemini
|
||||
try:
|
||||
stdout_bytes, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
raw = stdout_bytes.decode()
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
raise RuntimeError(f"Gemini timed out after {timeout}s")
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
_unregister_pgroup(proc.pid)
|
||||
|
||||
clean = _clean_gemini_output(raw)
|
||||
if not clean:
|
||||
raise RuntimeError("Gemini returned an empty response")
|
||||
return clean
|
||||
|
||||
|
||||
# Lines Gemini CLI writes to stdout that are not part of the actual response
|
||||
_GEMINI_NOISE = (
|
||||
"Loaded cached credentials",
|
||||
"Loading extension:",
|
||||
"Server '",
|
||||
"Listening for",
|
||||
"Model is overloaded",
|
||||
"High demand",
|
||||
"Retrying",
|
||||
"retrying",
|
||||
"429",
|
||||
"quota",
|
||||
)
|
||||
|
||||
|
||||
def _clean_gemini_output(text: str) -> str:
|
||||
lines = [
|
||||
line for line in text.splitlines()
|
||||
if not any(line.strip().startswith(p) for p in _GEMINI_NOISE)
|
||||
]
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
async def _run(cmd: list[str], timeout: int = 60) -> str:
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"
|
||||
raise RuntimeError(f"{cmd[0]} failed: {detail}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _build_conversation(messages: list[dict]) -> str:
|
||||
"""Conversation only — used for Claude (system prompt passed separately)."""
|
||||
parts = []
|
||||
prior = messages[:-1]
|
||||
if prior:
|
||||
history_lines = []
|
||||
for msg in prior:
|
||||
label = "Scott" if msg["role"] == "user" else "Inara"
|
||||
history_lines.append(f"{label}: {msg['content']}")
|
||||
parts.append("<conversation>\n" + "\n\n".join(history_lines) + "\n</conversation>")
|
||||
parts.append(messages[-1]["content"] if messages else "")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _build_prompt(system_prompt: str, messages: list[dict]) -> str:
|
||||
"""Full prompt with system context embedded — used for Gemini."""
|
||||
parts = []
|
||||
if system_prompt:
|
||||
parts.append(f"<system>\n{system_prompt}\n</system>")
|
||||
parts.append(_build_conversation(messages))
|
||||
return "\n\n".join(parts)
|
||||
41
cortex/main.py
Normal file
41
cortex/main.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
import uvicorn
|
||||
|
||||
from config import settings
|
||||
from routers import chat, google_chat
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
from llm_client import cleanup
|
||||
await cleanup()
|
||||
|
||||
|
||||
app = FastAPI(title="Cortex Dispatcher", lifespan=lifespan)
|
||||
|
||||
app.include_router(chat.router)
|
||||
app.include_router(google_chat.router)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=True,
|
||||
)
|
||||
7
cortex/requirements.txt
Normal file
7
cortex/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# anthropic SDK not needed — using claude CLI subprocess for auth
|
||||
# anthropic>=0.40.0
|
||||
0
cortex/routers/__init__.py
Normal file
0
cortex/routers/__init__.py
Normal file
140
cortex/routers/chat.py
Normal file
140
cortex/routers/chat.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from context_loader import load_context
|
||||
from llm_client import complete
|
||||
from session_logger import log_turn
|
||||
from session_store import load as load_session, save as save_session, list_all
|
||||
from config import settings
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
session_id: str | None = None
|
||||
tier: int | None = None
|
||||
model: str | None = None # "claude" or "gemini" to override; None = use primary_backend
|
||||
|
||||
|
||||
class BackendRequest(BaseModel):
|
||||
primary: str # "claude" or "gemini"
|
||||
|
||||
|
||||
class NoteRequest(BaseModel):
|
||||
session_id: str
|
||||
note: str
|
||||
|
||||
|
||||
async def _stream_chat(req: ChatRequest):
|
||||
"""
|
||||
SSE generator: sends keepalive events every 3s while the LLM works,
|
||||
then sends the final response. Keeps the browser connection alive
|
||||
regardless of how long the backend takes.
|
||||
|
||||
Event types:
|
||||
data: {"type": "keepalive"}
|
||||
data: {"type": "response", "response": "...", "session_id": "...",
|
||||
"backend": "...", "fallback_used": bool}
|
||||
data: {"type": "error", "message": "..."}
|
||||
"""
|
||||
session_id = req.session_id or str(uuid.uuid4())[:8]
|
||||
tier = req.tier or settings.default_tier
|
||||
|
||||
system_prompt = load_context(tier)
|
||||
history = load_session(session_id)
|
||||
history.append({"role": "user", "content": req.message})
|
||||
|
||||
task = asyncio.create_task(complete(
|
||||
system_prompt=system_prompt,
|
||||
messages=history,
|
||||
model=req.model,
|
||||
))
|
||||
|
||||
try:
|
||||
# Ping the browser every 3s so it doesn't drop the connection
|
||||
while not task.done():
|
||||
yield 'data: {"type":"keepalive"}\n\n'
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=3)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except Exception:
|
||||
break
|
||||
|
||||
try:
|
||||
response_text, actual_backend = task.result()
|
||||
history.append({"role": "assistant", "content": response_text})
|
||||
save_session(session_id, history)
|
||||
log_turn(session_id, req.message, response_text)
|
||||
|
||||
requested = req.model or settings.primary_backend
|
||||
payload = {
|
||||
"type": "response",
|
||||
"response": response_text,
|
||||
"session_id": session_id,
|
||||
"backend": actual_backend,
|
||||
"fallback_used": actual_backend != requested,
|
||||
}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||
|
||||
finally:
|
||||
# Ensure the LLM task is cancelled if the generator is torn down
|
||||
# (e.g. client disconnect or server shutdown). This propagates
|
||||
# CancelledError into _gemini() which kills the process group.
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
|
||||
@router.post("/chat")
|
||||
async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
return StreamingResponse(
|
||||
_stream_chat(req),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/backend")
|
||||
async def get_backend() -> dict:
|
||||
other = "gemini" if settings.primary_backend == "claude" else "claude"
|
||||
return {"primary": settings.primary_backend, "fallback": other}
|
||||
|
||||
|
||||
@router.post("/backend")
|
||||
async def set_backend(req: BackendRequest) -> dict:
|
||||
if req.primary not in ("claude", "gemini"):
|
||||
raise HTTPException(status_code=400, detail="primary must be 'claude' or 'gemini'")
|
||||
settings.primary_backend = req.primary
|
||||
other = "gemini" if req.primary == "claude" else "claude"
|
||||
return {"primary": settings.primary_backend, "fallback": other}
|
||||
|
||||
|
||||
@router.get("/history/{session_id}")
|
||||
async def get_history(session_id: str) -> dict:
|
||||
return {"session_id": session_id, "messages": load_session(session_id)}
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def list_sessions() -> dict:
|
||||
return {"sessions": list_all()}
|
||||
|
||||
|
||||
@router.post("/note")
|
||||
async def add_note(req: NoteRequest) -> dict:
|
||||
"""Inject a public note into session history so the LLM sees it next turn."""
|
||||
history = load_session(req.session_id)
|
||||
history.append({"role": "user", "content": f"[NOTE] {req.note}"})
|
||||
save_session(req.session_id, history)
|
||||
return {"ok": True, "session_id": req.session_id}
|
||||
74
cortex/routers/google_chat.py
Normal file
74
cortex/routers/google_chat.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from context_loader import load_context
|
||||
from llm_client import complete
|
||||
from session_logger import log_turn
|
||||
from session_store import load as load_session, save as save_session
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels/google-chat")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def receive(request: Request):
|
||||
body = await request.json()
|
||||
event_type = body.get("type")
|
||||
|
||||
if event_type == "ADDED_TO_SPACE":
|
||||
space_type = body.get("space", {}).get("type", "")
|
||||
greeting = "✨ Hello! I'm Inara. Send me a message and I'll do my best to help."
|
||||
if space_type == "DM":
|
||||
greeting = "✨ Hello! I'm Inara. What can I help you with?"
|
||||
return {"text": greeting}
|
||||
|
||||
if event_type == "REMOVED_FROM_SPACE":
|
||||
return Response(status_code=200)
|
||||
|
||||
if event_type != "MESSAGE":
|
||||
return Response(status_code=200)
|
||||
|
||||
message = body.get("message", {})
|
||||
sender = message.get("sender", {})
|
||||
space = body.get("space", {})
|
||||
|
||||
# argumentText strips the @BotName mention in Spaces; fall back to full text in DMs
|
||||
user_text = (message.get("argumentText") or message.get("text", "")).strip()
|
||||
if not user_text:
|
||||
return Response(status_code=200)
|
||||
|
||||
sender_display = sender.get("displayName", "User")
|
||||
space_name = space.get("name", "unknown")
|
||||
space_type = space.get("type", "")
|
||||
|
||||
# Session keyed per space — one conversation per DM or Space
|
||||
session_id = "gc_" + space_name.replace("/", "_")
|
||||
|
||||
logger.info("Google Chat message from %s in %s (%s)", sender_display, space_name, space_type)
|
||||
|
||||
system_prompt = load_context(settings.default_tier)
|
||||
history = load_session(session_id)
|
||||
history.append({"role": "user", "content": user_text})
|
||||
|
||||
try:
|
||||
response_text, actual_backend = await asyncio.wait_for(
|
||||
complete(
|
||||
system_prompt=system_prompt,
|
||||
messages=history,
|
||||
model=settings.google_chat_backend,
|
||||
),
|
||||
timeout=settings.google_chat_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Google Chat request timed out for session %s", session_id)
|
||||
return {"text": "⏳ Still thinking — this is taking a bit longer than usual. Try again in a moment."}
|
||||
except Exception as e:
|
||||
logger.error("Google Chat error for session %s: %s", session_id, e)
|
||||
return {"text": f"⚠️ Something went wrong on my end. Try again shortly."}
|
||||
|
||||
history.append({"role": "assistant", "content": response_text})
|
||||
save_session(session_id, history)
|
||||
log_turn(session_id, user_text, response_text)
|
||||
|
||||
return {"text": response_text}
|
||||
22
cortex/session_logger.py
Normal file
22
cortex/session_logger.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from config import settings
|
||||
|
||||
|
||||
def log_turn(session_id: str, user_msg: str, assistant_msg: str) -> None:
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
sessions_dir = settings.inara_path() / "sessions"
|
||||
sessions_dir.mkdir(exist_ok=True)
|
||||
|
||||
log_file = sessions_dir / f"{today}.md"
|
||||
timestamp = datetime.now().strftime("%H:%M")
|
||||
is_new = not log_file.exists()
|
||||
|
||||
with open(log_file, "a") as f:
|
||||
if is_new:
|
||||
f.write(f"# Session Log — {today}\n")
|
||||
f.write(
|
||||
f"\n### [{timestamp}] `{session_id}`\n"
|
||||
f"**Scott:** {user_msg}\n\n"
|
||||
f"**Inara:** {assistant_msg}\n"
|
||||
)
|
||||
50
cortex/session_store.py
Normal file
50
cortex/session_store.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from config import settings
|
||||
|
||||
|
||||
def _path(session_id: str) -> Path:
|
||||
d = settings.sessions_path()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d / f"{session_id}.json"
|
||||
|
||||
|
||||
def load(session_id: str) -> list[dict]:
|
||||
path = _path(session_id)
|
||||
if not path.exists():
|
||||
return []
|
||||
return json.loads(path.read_text()).get("messages", [])
|
||||
|
||||
|
||||
def save(session_id: str, messages: list[dict]) -> None:
|
||||
path = _path(session_id)
|
||||
existing = json.loads(path.read_text()) if path.exists() else {}
|
||||
|
||||
# Enforce rolling window
|
||||
windowed = messages[-settings.max_history_messages:]
|
||||
|
||||
path.write_text(json.dumps({
|
||||
"session_id": session_id,
|
||||
"created": existing.get("created", datetime.now().isoformat()),
|
||||
"updated": datetime.now().isoformat(),
|
||||
"messages": windowed,
|
||||
}, indent=2))
|
||||
|
||||
|
||||
def list_all() -> list[dict]:
|
||||
d = settings.sessions_path()
|
||||
if not d.exists():
|
||||
return []
|
||||
results = []
|
||||
for f in sorted(d.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
data = json.loads(f.read_text())
|
||||
results.append({
|
||||
"session_id": data["session_id"],
|
||||
"updated": data.get("updated"),
|
||||
"message_count": len(data.get("messages", [])),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
891
cortex/static/index.html
Normal file
891
cortex/static/index.html
Normal file
@@ -0,0 +1,891 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cortex — Inara</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>✨</text></svg>">
|
||||
<script src="/static/marked.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0d0a14;
|
||||
--surface: #16101f;
|
||||
--border: #2d1f3d;
|
||||
--user-bg: #5c1528;
|
||||
--user-border: #7a1f36;
|
||||
--inara-bg: #1e1530;
|
||||
--inara-border: #3d2a55;
|
||||
--accent: #c4935a;
|
||||
--text: #e8e0f0;
|
||||
--muted: #6b5a80;
|
||||
--error-bg: #3b0f0f;
|
||||
--error-border: #7f1d1d;
|
||||
--error-text: #fca5a5;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 12px 20px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-emoji {
|
||||
font-size: 1.6rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: scale(1) rotate(0deg); opacity: 1; }
|
||||
25% { transform: scale(1.2) rotate(-12deg); opacity: 0.7; }
|
||||
75% { transform: scale(1.2) rotate(12deg); opacity: 0.7; }
|
||||
100% { transform: scale(1) rotate(0deg); opacity: 1; }
|
||||
}
|
||||
|
||||
.header-emoji.processing { animation: shimmer 0.75s ease-in-out infinite; }
|
||||
|
||||
header .name { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
|
||||
header .subtitle { font-size: 0.78rem; color: var(--muted); }
|
||||
|
||||
.hdr-btn {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.hdr-btn:hover { border-color: var(--muted); color: var(--text); }
|
||||
|
||||
#backend-toggle.gemini { border-color: #2a4a2a; color: #6abf6a; }
|
||||
#sessions-btn { margin-left: auto; }
|
||||
|
||||
/* Sessions panel */
|
||||
#sessions-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 20px;
|
||||
width: 300px;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
#sessions-panel.open { display: block; }
|
||||
|
||||
.session-item {
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-item:last-child { border-bottom: none; }
|
||||
.session-item:hover { background: var(--bg); }
|
||||
.session-item.new { color: var(--accent); justify-content: center; }
|
||||
|
||||
.session-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
#messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 75%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.55;
|
||||
word-wrap: break-word;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.message.user { white-space: pre-wrap; }
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
background: var(--user-bg);
|
||||
border: 1px solid var(--user-border);
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--inara-bg);
|
||||
border: 1px solid var(--inara-border);
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
/* Markdown rendering inside assistant messages */
|
||||
.message.assistant p { margin: 0 0 0.6em; }
|
||||
.message.assistant p:last-child { margin-bottom: 0; }
|
||||
.message.assistant ul,
|
||||
.message.assistant ol { margin: 0.4em 0 0.6em 1.4em; padding: 0; }
|
||||
.message.assistant li { margin-bottom: 0.2em; }
|
||||
.message.assistant h1,
|
||||
.message.assistant h2,
|
||||
.message.assistant h3 { margin: 0.8em 0 0.3em; font-weight: 600;
|
||||
color: var(--accent); line-height: 1.3; }
|
||||
.message.assistant h1 { font-size: 1.1em; }
|
||||
.message.assistant h2 { font-size: 1.0em; }
|
||||
.message.assistant h3 { font-size: 0.95em; }
|
||||
.message.assistant strong { color: var(--text); font-weight: 600; }
|
||||
.message.assistant em { color: var(--accent); font-style: italic; }
|
||||
.message.assistant a { color: var(--accent); text-decoration: underline; }
|
||||
.message.assistant hr { border: none; border-top: 1px solid var(--border);
|
||||
margin: 0.8em 0; }
|
||||
.message.assistant blockquote {
|
||||
border-left: 3px solid var(--border);
|
||||
margin: 0.5em 0;
|
||||
padding: 0.2em 0.8em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.message.assistant code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.88em;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
}
|
||||
.message.assistant pre {
|
||||
background: rgba(0,0,0,0.35);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.message.assistant pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.message.system {
|
||||
align-self: center;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
align-self: flex-start;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
.message.thinking { color: var(--muted); font-style: italic; }
|
||||
|
||||
/* Copy button */
|
||||
.message.assistant { position: relative; }
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 7px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.message.assistant:hover .copy-btn { opacity: 1; }
|
||||
.copy-btn:hover { color: var(--text); border-color: var(--muted); }
|
||||
.copy-btn.copied { color: #6abf6a; border-color: #2a4a2a; }
|
||||
|
||||
/* Note messages */
|
||||
.message.note-private {
|
||||
align-self: flex-end;
|
||||
background: rgba(100, 70, 5, 0.15);
|
||||
border: 1px dashed rgba(180, 130, 40, 0.45);
|
||||
border-bottom-right-radius: 3px;
|
||||
font-size: 0.9rem;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.message.note-public {
|
||||
align-self: flex-end;
|
||||
background: rgba(5, 70, 70, 0.15);
|
||||
border: 1px dashed rgba(40, 170, 150, 0.45);
|
||||
border-bottom-right-radius: 3px;
|
||||
font-size: 0.9rem;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.note-label {
|
||||
display: block;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.message.note-private .note-label { color: #c9a84c; }
|
||||
.message.note-public .note-label { color: #4abfb0; }
|
||||
.message.note-private .note-content { color: #c9a84c; white-space: pre-wrap; }
|
||||
.message.note-public .note-content { color: #4abfb0; white-space: pre-wrap; }
|
||||
|
||||
/* ── Input area ────────────────────────────────────────────── */
|
||||
#input-area {
|
||||
padding: 14px 20px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
#input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
line-height: 1.4;
|
||||
overflow-y: auto;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
#input:focus { outline: none; border-color: var(--muted); }
|
||||
|
||||
#input.note-mode { border-color: rgba(180, 130, 40, 0.55); }
|
||||
#input.note-mode:focus { border-color: rgba(180, 130, 40, 0.85); }
|
||||
#input.note-mode.public { border-color: rgba(40, 170, 150, 0.55); }
|
||||
#input.note-mode.public:focus { border-color: rgba(40, 170, 150, 0.85); }
|
||||
|
||||
/* Right column — all controls stacked, fixed width */
|
||||
#right-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
/* Semi-hidden controls: height selector row */
|
||||
#height-row {
|
||||
display: none; /* shown by JS when content > 3 lines */
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#height-row span {
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#height-sel {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#height-sel:focus { outline: none; border-color: var(--muted); }
|
||||
|
||||
/* Semi-hidden: enter-mode toggle */
|
||||
#enter-toggle {
|
||||
display: none; /* shown by JS when content > 3 lines */
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
#enter-toggle:hover { border-color: var(--muted); color: var(--text); }
|
||||
|
||||
/* Note type toggle — only visible in note mode */
|
||||
#note-type-btn {
|
||||
display: none;
|
||||
background: var(--bg);
|
||||
border: 1px solid rgba(180, 130, 40, 0.4);
|
||||
border-radius: 5px;
|
||||
color: rgba(180, 130, 40, 0.85);
|
||||
font-size: 0.68rem;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
#note-type-btn.public {
|
||||
border-color: rgba(40, 170, 150, 0.4);
|
||||
color: rgba(40, 170, 150, 0.85);
|
||||
}
|
||||
|
||||
#note-type-btn:hover { opacity: 0.75; }
|
||||
|
||||
/* Note button */
|
||||
#note-btn {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
#note-btn:hover { border-color: var(--muted); color: var(--text); }
|
||||
#note-btn.active { border-color: rgba(180, 130, 40, 0.6); color: #c9a84c; }
|
||||
#note-btn.active.public { border-color: rgba(40, 170, 150, 0.6); color: #4abfb0; }
|
||||
|
||||
/* Send button */
|
||||
#send {
|
||||
background: var(--user-bg);
|
||||
border: 1px solid var(--user-border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
#send:hover { background: var(--user-border); }
|
||||
#send:disabled { background: var(--surface); color: var(--muted);
|
||||
border-color: var(--border); cursor: not-allowed; }
|
||||
|
||||
#session-id {
|
||||
font-size: 0.7rem;
|
||||
color: var(--border);
|
||||
padding: 0 20px 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="header-emoji">✨</span>
|
||||
<div>
|
||||
<div class="name">Inara</div>
|
||||
<div class="subtitle">Cortex · Local</div>
|
||||
</div>
|
||||
<button id="sessions-btn" class="hdr-btn">Sessions</button>
|
||||
<button id="backend-toggle" class="hdr-btn" title="Click to switch primary backend">claude</button>
|
||||
|
||||
<div id="sessions-panel"></div>
|
||||
</header>
|
||||
|
||||
<div id="messages"></div>
|
||||
<div id="session-id"></div>
|
||||
|
||||
<div id="input-area">
|
||||
<textarea id="input" rows="1" placeholder="Message Inara… (Ctrl+Enter to send)" autofocus></textarea>
|
||||
<div id="right-col">
|
||||
<!-- Semi-hidden: appear when content > ~3 lines -->
|
||||
<div id="height-row">
|
||||
<span>↕</span>
|
||||
<select id="height-sel">
|
||||
<option value="120">5 lines</option>
|
||||
<option value="240">10 lines</option>
|
||||
<option value="480">20 lines</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="enter-toggle" title="Toggle send shortcut">⌃↵</button>
|
||||
<!-- Note mode controls -->
|
||||
<button id="note-type-btn">private</button>
|
||||
<button id="note-btn">Note</button>
|
||||
<button id="send">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const messagesEl = document.getElementById('messages');
|
||||
const inputEl = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('send');
|
||||
const sessionEl = document.getElementById('session-id');
|
||||
const headerEmoji = document.querySelector('.header-emoji');
|
||||
const backendToggle = document.getElementById('backend-toggle');
|
||||
const sessionsBtn = document.getElementById('sessions-btn');
|
||||
const sessionsPanel = document.getElementById('sessions-panel');
|
||||
const heightRow = document.getElementById('height-row');
|
||||
const heightSel = document.getElementById('height-sel');
|
||||
const enterToggle = document.getElementById('enter-toggle');
|
||||
const noteTypeBtnEl = document.getElementById('note-type-btn');
|
||||
const noteBtnEl = document.getElementById('note-btn');
|
||||
|
||||
let sessionId = null;
|
||||
let primaryBackend = 'claude';
|
||||
|
||||
// ── Enter toggle ─────────────────────────────────────────────
|
||||
// Default: Ctrl+Enter sends. Stored in localStorage.
|
||||
let ctrlEnterMode = localStorage.getItem('ctrlEnterSend') !== 'false';
|
||||
|
||||
function updateEnterToggleUI() {
|
||||
enterToggle.textContent = ctrlEnterMode ? '⌃↵' : '↵';
|
||||
enterToggle.title = ctrlEnterMode
|
||||
? 'Ctrl+Enter sends — click for Enter mode'
|
||||
: 'Enter sends — click for Ctrl+Enter mode';
|
||||
updateInputPlaceholder();
|
||||
}
|
||||
|
||||
enterToggle.addEventListener('click', () => {
|
||||
ctrlEnterMode = !ctrlEnterMode;
|
||||
localStorage.setItem('ctrlEnterSend', ctrlEnterMode);
|
||||
updateEnterToggleUI();
|
||||
});
|
||||
|
||||
// ── Textarea height ──────────────────────────────────────────
|
||||
let maxHeight = parseInt(localStorage.getItem('maxHeight') || '120');
|
||||
|
||||
function syncHeight() {
|
||||
inputEl.style.height = 'auto';
|
||||
inputEl.style.maxHeight = maxHeight + 'px';
|
||||
const sh = inputEl.scrollHeight;
|
||||
inputEl.style.height = Math.min(sh, maxHeight) + 'px';
|
||||
// Show semi-hidden controls when content exceeds ~3 lines or a larger max is set
|
||||
const showExtras = sh > 80 || maxHeight > 120;
|
||||
heightRow.style.display = showExtras ? 'flex' : 'none';
|
||||
enterToggle.style.display = showExtras ? 'block' : 'none';
|
||||
}
|
||||
|
||||
heightSel.value = String(maxHeight);
|
||||
heightSel.addEventListener('change', () => {
|
||||
maxHeight = parseInt(heightSel.value);
|
||||
localStorage.setItem('maxHeight', maxHeight);
|
||||
syncHeight();
|
||||
});
|
||||
|
||||
// ── Note mode ────────────────────────────────────────────────
|
||||
let noteMode = false;
|
||||
let notePublic = false;
|
||||
|
||||
function updateInputMode() {
|
||||
if (noteMode) {
|
||||
noteBtnEl.classList.add('active');
|
||||
noteTypeBtnEl.style.display = 'block';
|
||||
sendBtn.textContent = 'Add Note';
|
||||
inputEl.classList.add('note-mode');
|
||||
if (notePublic) {
|
||||
inputEl.classList.add('public');
|
||||
noteBtnEl.classList.add('public');
|
||||
noteTypeBtnEl.textContent = 'public';
|
||||
noteTypeBtnEl.classList.add('public');
|
||||
} else {
|
||||
inputEl.classList.remove('public');
|
||||
noteBtnEl.classList.remove('public');
|
||||
noteTypeBtnEl.textContent = 'private';
|
||||
noteTypeBtnEl.classList.remove('public');
|
||||
}
|
||||
} else {
|
||||
noteBtnEl.classList.remove('active', 'public');
|
||||
noteTypeBtnEl.style.display = 'none';
|
||||
sendBtn.textContent = 'Send';
|
||||
inputEl.classList.remove('note-mode', 'public');
|
||||
}
|
||||
updateInputPlaceholder();
|
||||
}
|
||||
|
||||
function updateInputPlaceholder() {
|
||||
if (noteMode) {
|
||||
inputEl.placeholder = notePublic
|
||||
? 'Public note — LLM sees this next turn…'
|
||||
: 'Private note — only you see this…';
|
||||
} else {
|
||||
inputEl.placeholder = ctrlEnterMode
|
||||
? 'Message Inara… (Ctrl+Enter to send)'
|
||||
: 'Message Inara…';
|
||||
}
|
||||
}
|
||||
|
||||
noteBtnEl.addEventListener('click', () => {
|
||||
noteMode = !noteMode;
|
||||
updateInputMode();
|
||||
inputEl.focus();
|
||||
});
|
||||
|
||||
noteTypeBtnEl.addEventListener('click', () => {
|
||||
notePublic = !notePublic;
|
||||
updateInputMode();
|
||||
});
|
||||
|
||||
// ── Backend toggle ───────────────────────────────────────────
|
||||
|
||||
fetch('/backend').then(r => r.json()).then(d => setBackendUI(d.primary));
|
||||
|
||||
function setBackendUI(backend) {
|
||||
primaryBackend = backend;
|
||||
backendToggle.textContent = backend;
|
||||
backendToggle.className = 'hdr-btn' + (backend === 'gemini' ? ' gemini' : '');
|
||||
}
|
||||
|
||||
backendToggle.addEventListener('click', async () => {
|
||||
const next = primaryBackend === 'claude' ? 'gemini' : 'claude';
|
||||
const res = await fetch('/backend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ primary: next }),
|
||||
});
|
||||
const d = await res.json();
|
||||
setBackendUI(d.primary);
|
||||
addMessage('system', `Backend: ${d.primary} (fallback: ${d.fallback})`);
|
||||
});
|
||||
|
||||
// ── Sessions panel ───────────────────────────────────────────
|
||||
|
||||
sessionsBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (sessionsPanel.classList.contains('open')) {
|
||||
sessionsPanel.classList.remove('open');
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/sessions');
|
||||
const data = await res.json();
|
||||
renderPanel(data.sessions);
|
||||
sessionsPanel.classList.add('open');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!sessionsPanel.contains(e.target) && e.target !== sessionsBtn) {
|
||||
sessionsPanel.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
function renderPanel(sessions) {
|
||||
sessionsPanel.innerHTML = '';
|
||||
|
||||
const newItem = makeItem('new', '+ New session', '');
|
||||
newItem.addEventListener('click', () => {
|
||||
sessionId = null;
|
||||
messagesEl.innerHTML = '';
|
||||
sessionEl.textContent = '';
|
||||
addMessage('system', 'New session');
|
||||
sessionsPanel.classList.remove('open');
|
||||
inputEl.focus();
|
||||
});
|
||||
sessionsPanel.appendChild(newItem);
|
||||
|
||||
if (!sessions.length) {
|
||||
const empty = makeItem('', 'No sessions yet', '');
|
||||
empty.style.cursor = 'default';
|
||||
empty.style.color = 'var(--muted)';
|
||||
sessionsPanel.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of sessions) {
|
||||
const item = makeItem(
|
||||
s.session_id === sessionId ? 'active' : '',
|
||||
s.session_id,
|
||||
`${s.message_count} msgs · ${timeAgo(s.updated)}`
|
||||
);
|
||||
item.addEventListener('click', () => resumeSession(s.session_id));
|
||||
sessionsPanel.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function makeItem(cls, label, meta) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'session-item' + (cls ? ' ' + cls : '');
|
||||
|
||||
const idEl = document.createElement('span');
|
||||
idEl.className = cls === 'new' ? '' : 'session-id';
|
||||
idEl.textContent = label;
|
||||
item.appendChild(idEl);
|
||||
|
||||
if (meta) {
|
||||
const metaEl = document.createElement('span');
|
||||
metaEl.className = 'session-meta';
|
||||
metaEl.textContent = meta;
|
||||
item.appendChild(metaEl);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async function resumeSession(id) {
|
||||
const res = await fetch(`/history/${id}`);
|
||||
const data = await res.json();
|
||||
|
||||
messagesEl.innerHTML = '';
|
||||
sessionId = id;
|
||||
sessionEl.textContent = `session: ${id}`;
|
||||
|
||||
for (const msg of data.messages) {
|
||||
addMessage(msg.role === 'user' ? 'user' : 'assistant', msg.content);
|
||||
}
|
||||
|
||||
addMessage('system', `Resumed session ${id}`);
|
||||
sessionsPanel.classList.remove('open');
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return '?';
|
||||
const mins = Math.floor((Date.now() - new Date(iso)) / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
// ── Chat ─────────────────────────────────────────────────────
|
||||
|
||||
function addMessage(role, text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `message ${role}`;
|
||||
|
||||
if (role === 'assistant' && typeof marked !== 'undefined') {
|
||||
div.dataset.raw = text;
|
||||
div.innerHTML = marked.parse(text);
|
||||
div.querySelectorAll('a').forEach(a => {
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
});
|
||||
div.appendChild(makeCopyBtn(div));
|
||||
} else if (role === 'note-private' || role === 'note-public') {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'note-label';
|
||||
label.textContent = role === 'note-private' ? '◦ private note' : '◦ context note';
|
||||
const content = document.createElement('span');
|
||||
content.className = 'note-content';
|
||||
content.textContent = text;
|
||||
div.appendChild(label);
|
||||
div.appendChild(content);
|
||||
} else {
|
||||
div.textContent = text;
|
||||
}
|
||||
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
function setMessageText(div, role, text) {
|
||||
if (role === 'assistant' && typeof marked !== 'undefined') {
|
||||
div.dataset.raw = text;
|
||||
div.innerHTML = marked.parse(text);
|
||||
div.querySelectorAll('a').forEach(a => {
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
});
|
||||
div.appendChild(makeCopyBtn(div));
|
||||
} else {
|
||||
div.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
function makeCopyBtn(div) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'copy-btn';
|
||||
btn.textContent = 'copy';
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const text = div.dataset.raw || '';
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
btn.textContent = '✓';
|
||||
btn.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'copy';
|
||||
btn.classList.remove('copied');
|
||||
}, 1500);
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function addNote() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
inputEl.value = '';
|
||||
syncHeight();
|
||||
|
||||
if (!notePublic) {
|
||||
// Private: UI only, never sent to backend
|
||||
addMessage('note-private', text);
|
||||
return;
|
||||
}
|
||||
|
||||
// Public: show in UI and persist to session so LLM sees it next turn
|
||||
if (!sessionId) {
|
||||
addMessage('system', 'Start a conversation first before adding a public note.');
|
||||
return;
|
||||
}
|
||||
|
||||
addMessage('note-public', text);
|
||||
try {
|
||||
const res = await fetch('/note', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId, note: text }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (err) {
|
||||
addMessage('system', `Note save failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text || sendBtn.disabled) return;
|
||||
|
||||
inputEl.value = '';
|
||||
syncHeight();
|
||||
sendBtn.disabled = true;
|
||||
headerEmoji.classList.add('processing');
|
||||
|
||||
addMessage('user', text);
|
||||
const thinkingDiv = addMessage('assistant thinking', '✨ thinking…');
|
||||
|
||||
try {
|
||||
const res = await fetch('/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text, session_id: sessionId }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
if (data.type === 'keepalive') continue;
|
||||
|
||||
if (data.type === 'response') {
|
||||
sessionId = data.session_id;
|
||||
sessionEl.textContent = `session: ${sessionId}`;
|
||||
thinkingDiv.className = 'message assistant';
|
||||
setMessageText(thinkingDiv, 'assistant', data.response);
|
||||
if (data.fallback_used) {
|
||||
addMessage('system',
|
||||
`⚡ ${primaryBackend} unavailable — answered by ${data.backend}`);
|
||||
}
|
||||
} else if (data.type === 'error') {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
thinkingDiv.className = 'message error';
|
||||
thinkingDiv.textContent = `Error: ${err.message}`;
|
||||
}
|
||||
|
||||
headerEmoji.classList.remove('processing');
|
||||
sendBtn.disabled = false;
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', () => {
|
||||
if (noteMode) addNote(); else sendMessage();
|
||||
});
|
||||
|
||||
inputEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const shouldSend = ctrlEnterMode ? (e.ctrlKey || e.metaKey) : !e.shiftKey;
|
||||
if (shouldSend) {
|
||||
e.preventDefault();
|
||||
if (noteMode) addNote(); else sendMessage();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
inputEl.addEventListener('input', syncHeight);
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────
|
||||
updateEnterToggleUI();
|
||||
syncHeight();
|
||||
addMessage('system', 'Session started');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
69
cortex/static/marked.min.js
vendored
Normal file
69
cortex/static/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user