Initial commit with development environment instructions

This commit is contained in:
Joakim Svensson
2025-12-29 21:34:17 +00:00
commit a1e85e7db5
7 changed files with 631 additions and 0 deletions

390
hedgeagent.py Normal file
View File

@@ -0,0 +1,390 @@
import os
import re
import time
import threading
import queue
import requests
from flask import Flask, request, jsonify
from playwright.sync_api import sync_playwright
EMAIL = os.environ.get("HEDGEDOC_EMAIL")
PASSWORD = os.environ.get("HEDGEDOC_PASSWORD")
BASE_URL = os.environ.get("HEDGEDOC_BASE_URL", "https://hedgedoc.sa6anw.se")
# LLM Configuration
LLM_API_KEY = os.environ.get("LLM_API_KEY")
LLM_MODEL = os.environ.get("LLM_MODEL", "openai/gpt-4o-mini")
LLM_API_BASE = "https://openrouter.ai/api/v1/chat/completions"
AGENT_BLOCK_RE = re.compile(r"<!--Agent\n(.*?)\n-->", re.DOTALL)
NOTES_FILE = "active_notes.txt"
# Shared Queues and Flask
new_notes_queue = queue.Queue()
app = Flask(__name__)
# ---------- LLM Integration ----------
def call_llm(context_text, chat_history):
if not LLM_API_KEY:
return "Error: LLM_API_KEY not set"
system_prompt = (
"You are a helpful assistant integrated into a HedgeDoc markdown document.\n\n"
"DOCUMENT CONTEXT (everything outside the chat block):\n"
f"{context_text}\n\n"
"Instructions: Use the document context to answer questions. Be concise and maintain markdown formatting. "
"Each chat block in the document is independent."
)
messages = [{"role": "system", "content": system_prompt}]
for line in chat_history.splitlines():
if line.startswith("User: "):
messages.append({"role": "user", "content": line[6:].strip()})
elif line.startswith("Agent: "):
messages.append({"role": "assistant", "content": line[7:].strip()})
try:
response = requests.post(
LLM_API_BASE,
headers={
"Authorization": f"Bearer {LLM_API_KEY}",
"Content-Type": "application/json"
},
json={
"model": LLM_MODEL,
"messages": messages
},
timeout=60
)
response.raise_for_status()
return response.json()['choices'][0]['message']['content']
except Exception as e:
return f"Error calling LLM: {e}"
# ---------- Data Persistence ----------
def load_persisted_notes():
if not os.path.exists(NOTES_FILE):
return []
with open(NOTES_FILE, "r") as f:
return list(set(line.strip() for line in f if line.strip()))
def persist_note(url):
existing = load_persisted_notes()
if url not in existing:
with open(NOTES_FILE, "a") as f:
f.write(f"{url}\n")
def remove_note_from_persistence(url):
existing = load_persisted_notes()
if url in existing:
with open(NOTES_FILE, "w") as f:
for line in existing:
if line != url:
f.write(f"{line}\n")
# ---------- Web API ----------
@app.route('/')
def index():
from flask import render_template
return render_template('index.html')
@app.route('/add_note', methods=['POST'])
def add_note():
data = request.get_json()
if not data or 'note_id' not in data:
return jsonify({"error": "Missing note_id"}), 400
note_id = data['note_id']
url = note_id if note_id.startswith("http") else f"{BASE_URL}/{note_id}"
if "?edit" not in url:
url += "?edit"
persist_note(url)
new_notes_queue.put(url)
return jsonify({"status": "added", "url": url}), 200
def start_web_server():
app.run(host='0.0.0.0', port=5000, debug=False, use_reloader=False)
# ---------- Hedgedoc Logic ----------
def login(page):
try:
print(f"[+] Logging in to {BASE_URL}...")
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
sign_in_btn = page.wait_for_selector('button:has-text("Sign in")', timeout=10000)
if sign_in_btn:
sign_in_btn.click()
page.wait_for_selector('input[name="email"]', timeout=5000)
page.fill('input[name="email"]', EMAIL)
page.fill('input[name="password"]', PASSWORD)
page.click('form button[type="submit"]')
page.wait_for_load_state("networkidle")
print("[✓] Login successful")
except Exception as e:
print(f"[!] Login failed: {e}")
def is_logged_in(page):
try:
sign_in_visible = page.is_visible('button:has-text("Sign in")', timeout=2000)
return not sign_in_visible
except:
return False
def ensure_logged_in(context):
temp_page = context.new_page()
try:
temp_page.goto(BASE_URL, wait_until="domcontentloaded", timeout=10000)
if not is_logged_in(temp_page):
print("[!] Session expired or not logged in. Re-logging...")
login(temp_page)
return True
return False
except Exception as e:
print(f"[!] Error checking login status: {e}")
return False
finally:
temp_page.close()
def inject_event_bridge(page):
try:
page.evaluate("""
() => {
if (window.__agentBridgeInstalled) return;
window.__agentBridgeInstalled = true;
window.__agentEvents = [];
const cm = document.querySelector('.CodeMirror').CodeMirror;
cm.on("changes", (cm, changes) => {
window.__agentEvents.push({ ts: Date.now(), value: cm.getValue() });
});
}
""")
except Exception as e:
print(f"[!] Failed to inject event bridge: {e}")
def ensure_agent_block(page):
try:
text = page.evaluate("() => document.querySelector('.CodeMirror').CodeMirror.getValue()")
if "<!--Agent" not in text:
print(f"[+] Creating initial Agent block on {page.url}")
page.evaluate("""
() => {
const cm = document.querySelector('.CodeMirror').CodeMirror;
cm.replaceRange("\\n\\n<!--Agent\\n-> \\n-->", { line: cm.lineCount(), ch: 0 });
}
""")
except Exception as e:
print(f"[!] Error in ensure_agent_block: {e}")
def find_prompts(text: str):
prompts = []
context_parts = AGENT_BLOCK_RE.split(text)
full_context = "\n".join(part.strip() for part in context_parts if part.strip())
for m in AGENT_BLOCK_RE.finditer(text):
inner = m.group(1)
lines = inner.splitlines()
if not lines: continue
for i in range(len(lines) - 1, -1, -1):
if lines[i].strip().startswith("->"):
if i + 1 < len(lines) and lines[-1].strip() == "":
prompt_part = lines[i][2:].strip()
extra_lines = lines[i+1:-1]
full_prompt = "\n".join([prompt_part] + extra_lines).strip()
if full_prompt:
prompts.append({
"context": full_context,
"history": "\n".join(lines[:i]),
"prompt": full_prompt,
"start": m.start(),
"end": m.end(),
"original_lines": lines[:i]
})
break
return prompts
# ---------- Main Loop ----------
def main():
if not EMAIL or not PASSWORD:
print("[!] Error: HEDGEDOC_EMAIL and HEDGEDOC_PASSWORD must be set.")
return
threading.Thread(target=start_web_server, daemon=True).start()
print("[✓] Web API listening on port 5000")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
auth_page = context.new_page()
login(auth_page)
auth_page.close()
pages = []
def setup_note_page(url):
try:
print(f"[+] Opening note: {url}")
page = context.new_page()
page.goto(url, wait_until="networkidle", timeout=30000)
page.keyboard.press("Control+Alt+E")
page.wait_for_selector(".CodeMirror", timeout=15000)
inject_event_bridge(page)
ensure_agent_block(page)
return page
except Exception as e:
print(f"[!] Failed to track {url}: {e}")
return None
for url in load_persisted_notes():
pg = setup_note_page(url)
if pg: pages.append(pg)
print(f"[✓] Monitoring {len(pages)} notes. Ready.")
last_login_check = time.time()
while True:
if time.time() - last_login_check > 600:
if ensure_logged_in(context):
print("[✓] Re-authentication successful.")
last_login_check = time.time()
try:
# 1. Handle new notes queue
try:
while True:
url = new_notes_queue.get_nowait()
if any(p.url.split('?')[0] == url.split('?')[0] for p in pages if not p.is_closed()):
continue
pg = setup_note_page(url)
if pg: pages.append(pg)
except queue.Empty:
pass
# 2. Process active pages
active_pages = []
any_activity = False
for page in pages:
if page.is_closed(): continue
try:
content = page.evaluate("() => document.querySelector('.CodeMirror').CodeMirror.getValue()")
except Exception:
print(f"[!] Cannot access page {page.url}, checking session...")
if not is_logged_in(page):
ensure_logged_in(context)
page.reload(wait_until="networkidle")
continue
active_pages.append(page)
continue
if "<!--Agent" not in content:
print(f"[-] Releasing {page.url}")
remove_note_from_persistence(page.url)
page.close()
continue
active_pages.append(page)
num_events = page.evaluate("() => (window.__agentEvents || []).length")
if num_events and num_events > 0:
any_activity = True
event = page.evaluate("() => window.__agentEvents.shift()")
prompts = find_prompts(event["value"])
for p in prompts:
# a. Show thinking state
thinking_text = f"<!--Agent\n" + "\n".join(p["original_lines"] + [f"User: {p['prompt']}", "Agent: 🧠 Tänker..."]) + "\n-->"
page.evaluate("""
(up) => {
const cm = document.querySelector('.CodeMirror').CodeMirror;
const doc = cm.getDoc();
const from = doc.posFromIndex(up.start);
const to = doc.posFromIndex(up.end);
doc.replaceRange(up.new_text, from, to);
}
""", {"start": p["start"], "end": p["end"], "new_text": thinking_text})
# b. Call LLM asychronously
response_queue = queue.Queue()
def llm_worker():
try:
res = call_llm(p["context"], p["history"] + f"\nUser: {p['prompt']}")
response_queue.put(("success", res))
except Exception as e:
response_queue.put(("error", str(e)))
threading.Thread(target=llm_worker, daemon=True).start()
# c. Animation loop
dots = ""
start_time = time.time()
last_dot_time = start_time
current_block_text = thinking_text
while True:
try:
status, ai_response = response_queue.get(timeout=0.5)
break
except queue.Empty:
now = time.time()
if now - start_time > 60:
status, ai_response = "error", "Timeout: Modellen svarade inte i tid."
break
if now - last_dot_time >= 5:
dots += "."
last_dot_time = now
new_thinking = f"<!--Agent\n" + "\n".join(p["original_lines"] + [f"User: {p['prompt']}", f"Agent: 🧠 Tänker{dots}"]) + "\n-->"
page.evaluate("""
(up) => {
const cm = document.querySelector('.CodeMirror').CodeMirror;
const doc = cm.getDoc();
const from = doc.posFromIndex(up.start);
const fullText = doc.getValue();
const endIdx = fullText.indexOf("-->", up.start);
const to = (endIdx !== -1) ? doc.posFromIndex(endIdx + 3) : doc.posFromIndex(up.start + up.oldLen);
doc.replaceRange(up.new_text, from, to);
}
""", {"start": p["start"], "oldLen": len(current_block_text), "new_text": new_thinking})
current_block_text = new_thinking
# d. Show final response
if status == "error":
ai_response = f"❌ Fel: {ai_response}"
final_text = f"<!--Agent\n" + "\n".join(p["original_lines"] + [f"User: {p['prompt']}", f"Agent: {ai_response}", "-> "]) + "\n-->"
page.evaluate("""
(up) => {
const cm = document.querySelector('.CodeMirror').CodeMirror;
const doc = cm.getDoc();
const from = doc.posFromIndex(up.start);
const fullText = doc.getValue();
const endIdx = fullText.indexOf("-->", up.start);
const to = (endIdx !== -1) ? doc.posFromIndex(endIdx + 3) : doc.posFromIndex(up.start + up.oldLen);
doc.replaceRange(up.new_text, from, to);
const lines = up.new_text.split('\\n');
let foundLine = -1;
for(let i=lines.length-1; i>=0; i--) {
if(lines[i].includes("-> ")) {
foundLine = from.line + i;
break;
}
}
if (foundLine !== -1) {
cm.setCursor({line: foundLine, ch: 3});
}
cm.focus();
}
""", {"start": p["start"], "oldLen": len(current_block_text), "new_text": final_text})
pages = active_pages
if not any_activity:
time.sleep(0.5)
except Exception as e:
print(f"[!] Loop error: {e}")
time.sleep(1)
if __name__ == "__main__":
main()