Initial commit with development environment instructions
This commit is contained in:
390
hedgeagent.py
Normal file
390
hedgeagent.py
Normal 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()
|
||||
Reference in New Issue
Block a user