From 5f4c19a18dd7c9d936035f62d3fb8baff76560e1 Mon Sep 17 00:00:00 2001 From: wisecolt Date: Sun, 22 Mar 2026 04:45:43 +0300 Subject: [PATCH] feat: backend orkestrasyonunu ve arac entegrasyonlarini genislet --- backend/app/automation/scheduler.py | 73 ++ backend/app/automation/store.py | 455 ++++++++++++ backend/app/config.py | 12 +- backend/app/db.py | 91 ++- backend/app/llm/ollama_client.py | 314 +++++++- backend/app/llm/planner.py | 43 +- backend/app/main.py | 4 + backend/app/models.py | 49 +- backend/app/orchestrator.py | 1023 ++++++++++++++++++++++++++- backend/app/profile/store.py | 276 ++++++++ backend/app/runtime.py | 6 +- backend/app/second_brain/store.py | 218 ++++++ backend/app/security.py | 4 +- backend/app/static_templates.py | 80 +++ backend/app/telegram/bot.py | 136 +++- backend/app/tools/apple_notes.py | 144 +++- backend/app/tools/base.py | 14 +- backend/app/tools/brave_search.py | 120 +++- backend/app/tools/browser_use.py | 296 ++++++++ backend/app/tools/files.py | 96 ++- backend/app/tools/registry.py | 47 ++ backend/app/tools/second_brain.py | 164 +++++ backend/app/tools/terminal.py | 105 ++- backend/app/tools/web_fetch.py | 59 +- backend/pyproject.toml | 3 +- 25 files changed, 3750 insertions(+), 82 deletions(-) create mode 100644 backend/app/automation/scheduler.py create mode 100644 backend/app/automation/store.py create mode 100644 backend/app/profile/store.py create mode 100644 backend/app/second_brain/store.py create mode 100644 backend/app/static_templates.py create mode 100644 backend/app/tools/browser_use.py create mode 100644 backend/app/tools/registry.py create mode 100644 backend/app/tools/second_brain.py diff --git a/backend/app/automation/scheduler.py b/backend/app/automation/scheduler.py new file mode 100644 index 0000000..8b2a9c2 --- /dev/null +++ b/backend/app/automation/scheduler.py @@ -0,0 +1,73 @@ +import asyncio +from contextlib import suppress +from typing import Callable + +from app.automation.store import AutomationService +from app.db import AutomationORM, session_scope +from app.orchestrator import WiseClawOrchestrator +from app.telegram.bot import TelegramBotService + + +class AutomationScheduler: + def __init__(self, orchestrator_factory: Callable[[], object], telegram_bot: TelegramBotService) -> None: + self.orchestrator_factory = orchestrator_factory + self.telegram_bot = telegram_bot + self._task: asyncio.Task[None] | None = None + self._running = False + + async def start(self) -> None: + if self._task is not None: + return + self._running = True + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + self._running = False + if self._task is not None: + self._task.cancel() + with suppress(asyncio.CancelledError): + await self._task + self._task = None + + async def _loop(self) -> None: + while self._running: + try: + await self._tick() + except Exception: + pass + await asyncio.sleep(30) + + async def _tick(self) -> None: + with session_scope() as session: + service = AutomationService(session) + due_items = service.due_automations() + due_ids = [item.id for item in due_items] + + for automation_id in due_ids: + await self._run_automation(automation_id) + + async def _run_automation(self, automation_id: int) -> None: + with session_scope() as session: + service = AutomationService(session) + item = session.get(AutomationORM, automation_id) + if item is None or item.status != "active": + return + prompt = item.prompt + user_id = item.telegram_user_id + + try: + with self.orchestrator_factory() as session: + orchestrator = WiseClawOrchestrator(session) + result = await orchestrator.handle_text_message(user_id, prompt) + await self.telegram_bot.send_message(user_id, f"⏰ Otomasyon sonucu: {result}") + with session_scope() as session: + service = AutomationService(session) + item = session.get(AutomationORM, automation_id) + if item is not None: + service.mark_run_result(item, result) + except Exception as exc: + with session_scope() as session: + service = AutomationService(session) + item = session.get(AutomationORM, automation_id) + if item is not None: + service.mark_run_error(item, str(exc)) diff --git a/backend/app/automation/store.py b/backend/app/automation/store.py new file mode 100644 index 0000000..4eec286 --- /dev/null +++ b/backend/app/automation/store.py @@ -0,0 +1,455 @@ +import json +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db import AuditLogORM, AutomationORM, AutomationWizardORM +from app.models import AutomationRecord + + +LOCAL_TZ = ZoneInfo("Europe/Istanbul") +WEEKDAY_MAP = { + "pzt": 0, + "pazartesi": 0, + "sal": 1, + "sali": 1, + "çar": 2, + "cars": 2, + "çarşamba": 2, + "carsamba": 2, + "per": 3, + "persembe": 3, + "perşembe": 3, + "cum": 4, + "cuma": 4, + "cts": 5, + "cumartesi": 5, + "paz": 6, + "pazar": 6, +} +WEEKDAY_NAMES = ["Pzt", "Sal", "Cars", "Per", "Cum", "Cts", "Paz"] + + +class AutomationService: + def __init__(self, session: Session) -> None: + self.session = session + + def list_automations(self, telegram_user_id: int | None = None) -> list[AutomationRecord]: + stmt = select(AutomationORM).order_by(AutomationORM.created_at.desc(), AutomationORM.id.desc()) + if telegram_user_id is not None: + stmt = stmt.where(AutomationORM.telegram_user_id == telegram_user_id) + return [self._to_record(item) for item in self.session.scalars(stmt)] + + def start_wizard(self, telegram_user_id: int) -> str: + record = self._get_or_create_wizard(telegram_user_id) + record.step = 0 + record.draft_json = "{}" + record.updated_at = datetime.utcnow() + self.session.add(AuditLogORM(category="automation", message=f"automation:wizard-start:{telegram_user_id}")) + self.session.flush() + return ( + "Yeni otomasyon olusturalim. Istersen herhangi bir adimda /iptal yazabilirsin.\n\n" + "1/6 Otomasyon adi ne olsun?" + ) + + def is_wizard_active(self, telegram_user_id: int) -> bool: + wizard = self.session.get(AutomationWizardORM, telegram_user_id) + return wizard is not None and wizard.step < 6 + + def cancel_wizard(self, telegram_user_id: int) -> str: + wizard = self.session.get(AutomationWizardORM, telegram_user_id) + if wizard is not None: + self.session.delete(wizard) + self.session.add(AuditLogORM(category="automation", message=f"automation:wizard-cancel:{telegram_user_id}")) + self.session.flush() + return "Otomasyon olusturma akisini iptal ettim." + + def answer_wizard(self, telegram_user_id: int, text: str) -> tuple[str, bool]: + wizard = self._get_or_create_wizard(telegram_user_id) + draft = self._load_draft(wizard) + cleaned = text.strip() + + if wizard.step == 0: + draft["name"] = cleaned + wizard.step = 1 + return self._persist_wizard(wizard, draft, "2/6 Bu otomasyon ne yapsin?") + + if wizard.step == 1: + draft["prompt"] = cleaned + wizard.step = 2 + return self._persist_wizard( + wizard, + draft, + "3/6 Hangi siklikla calissin? Su seceneklerden birini yaz: gunluk, haftaici, haftalik, saatlik", + ) + + if wizard.step == 2: + schedule_type = self._parse_schedule_type(cleaned) + if schedule_type is None: + return ("Gecerli bir secim gormedim. Lutfen gunluk, haftaici, haftalik veya saatlik yaz.", False) + draft["schedule_type"] = schedule_type + wizard.step = 3 + if schedule_type == "hourly": + prompt = "4/6 Kac saatte bir calissin? Ornek: 1, 2, 4, 6" + elif schedule_type == "weekly": + prompt = "4/6 Hangi gunlerde calissin? Ornek: Pzt,Cars,Cum" + else: + prompt = "4/6 Saat kacta calissin? 24 saat formatinda yaz. Ornek: 09:00" + return self._persist_wizard(wizard, draft, prompt) + + if wizard.step == 3: + schedule_type = str(draft.get("schedule_type", "daily")) + if schedule_type == "hourly": + interval_hours = self._parse_interval_hours(cleaned) + if interval_hours is None: + return ("Gecerli bir saat araligi gormedim. Lutfen 1 ile 24 arasinda bir sayi yaz.", False) + draft["interval_hours"] = interval_hours + wizard.step = 4 + return self._persist_wizard(wizard, draft, "5/6 Aktif olarak kaydedeyim mi? evet/hayir") + if schedule_type == "weekly": + weekdays = self._parse_weekdays(cleaned) + if not weekdays: + return ("Gunleri anlayamadim. Ornek olarak Pzt,Cars,Cum yazabilirsin.", False) + draft["days_of_week"] = weekdays + wizard.step = 4 + return self._persist_wizard(wizard, draft, "5/6 Saat kacta calissin? 24 saat formatinda yaz. Ornek: 09:00") + + time_of_day = self._parse_time(cleaned) + if time_of_day is None: + return ("Saat formatini anlayamadim. Lutfen 24 saat formatinda HH:MM yaz.", False) + draft["time_of_day"] = time_of_day + wizard.step = 4 + return self._persist_wizard(wizard, draft, "5/6 Aktif olarak kaydedeyim mi? evet/hayir") + + if wizard.step == 4: + schedule_type = str(draft.get("schedule_type", "daily")) + if schedule_type == "weekly" and "time_of_day" not in draft: + time_of_day = self._parse_time(cleaned) + if time_of_day is None: + return ("Saat formatini anlayamadim. Lutfen 24 saat formatinda HH:MM yaz.", False) + draft["time_of_day"] = time_of_day + wizard.step = 5 + summary = self._render_wizard_summary(draft) + return self._persist_wizard(wizard, draft, f"{summary}\n\n6/6 Aktif olarak kaydedeyim mi? evet/hayir") + + active = self._parse_yes_no(cleaned) + if active is None: + return ("Lutfen evet veya hayir yaz.", False) + draft["status"] = "active" if active else "paused" + created = self._create_automation(telegram_user_id, draft) + self.session.delete(wizard) + self.session.add(AuditLogORM(category="automation", message=f"automation:created:{created.id}")) + self.session.flush() + return (self._render_created_message(created), True) + + if wizard.step == 5: + active = self._parse_yes_no(cleaned) + if active is None: + return ("Lutfen evet veya hayir yaz.", False) + draft["status"] = "active" if active else "paused" + created = self._create_automation(telegram_user_id, draft) + self.session.delete(wizard) + self.session.add(AuditLogORM(category="automation", message=f"automation:created:{created.id}")) + self.session.flush() + return (self._render_created_message(created), True) + + return ("Otomasyon wizard durumu gecersiz.", False) + + def render_automation_list(self, telegram_user_id: int) -> str: + automations = self.list_automations(telegram_user_id) + if not automations: + return "Henuz otomasyonun yok. /otomasyon_ekle ile baslayabiliriz." + lines = ["Otomasyonlarin:"] + for item in automations: + next_run = self._format_display_time(item.next_run_at) + lines.append(f"- #{item.id} {item.name} [{item.status}] -> siradaki: {next_run}") + return "\n".join(lines) + + def pause_automation(self, telegram_user_id: int, automation_id: int) -> str: + item = self._get_owned_automation(telegram_user_id, automation_id) + if item is None: + return "Bu ID ile bir otomasyon bulamadim." + item.status = "paused" + item.updated_at = datetime.utcnow() + self.session.add(AuditLogORM(category="automation", message=f"automation:paused:{item.id}")) + self.session.flush() + return f"Otomasyon durduruldu: #{item.id} {item.name}" + + def resume_automation(self, telegram_user_id: int, automation_id: int) -> str: + item = self._get_owned_automation(telegram_user_id, automation_id) + if item is None: + return "Bu ID ile bir otomasyon bulamadim." + item.status = "active" + item.next_run_at = self._compute_next_run(item, from_time=datetime.utcnow()) + item.updated_at = datetime.utcnow() + self.session.add(AuditLogORM(category="automation", message=f"automation:resumed:{item.id}")) + self.session.flush() + return f"Otomasyon tekrar aktif edildi: #{item.id} {item.name}" + + def delete_automation(self, telegram_user_id: int, automation_id: int) -> str: + item = self._get_owned_automation(telegram_user_id, automation_id) + if item is None: + return "Bu ID ile bir otomasyon bulamadim." + name = item.name + self.session.delete(item) + self.session.add(AuditLogORM(category="automation", message=f"automation:deleted:{automation_id}")) + self.session.flush() + return f"Otomasyon silindi: #{automation_id} {name}" + + def due_automations(self, now: datetime | None = None) -> list[AutomationORM]: + current = now or datetime.utcnow() + stmt = ( + select(AutomationORM) + .where(AutomationORM.status == "active") + .where(AutomationORM.next_run_at.is_not(None)) + .where(AutomationORM.next_run_at <= current) + .order_by(AutomationORM.next_run_at.asc(), AutomationORM.id.asc()) + ) + return list(self.session.scalars(stmt)) + + def mark_run_result(self, item: AutomationORM, result: str, ran_at: datetime | None = None) -> None: + run_time = ran_at or datetime.utcnow() + item.last_run_at = run_time + item.last_result = result[:2000] + item.next_run_at = self._compute_next_run(item, from_time=run_time + timedelta(seconds=1)) + item.updated_at = datetime.utcnow() + self.session.add(AuditLogORM(category="automation", message=f"automation:ran:{item.id}")) + self.session.flush() + + def mark_run_error(self, item: AutomationORM, error: str) -> None: + item.last_result = f"ERROR: {error[:1800]}" + item.next_run_at = self._compute_next_run(item, from_time=datetime.utcnow() + timedelta(minutes=5)) + item.updated_at = datetime.utcnow() + self.session.add(AuditLogORM(category="automation", message=f"automation:error:{item.id}:{error[:120]}")) + self.session.flush() + + def _persist_wizard(self, wizard: AutomationWizardORM, draft: dict[str, object], reply: str) -> tuple[str, bool]: + wizard.draft_json = json.dumps(draft, ensure_ascii=False) + wizard.updated_at = datetime.utcnow() + self.session.flush() + return reply, False + + def _get_or_create_wizard(self, telegram_user_id: int) -> AutomationWizardORM: + wizard = self.session.get(AutomationWizardORM, telegram_user_id) + if wizard is None: + wizard = AutomationWizardORM( + telegram_user_id=telegram_user_id, + step=0, + draft_json="{}", + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + self.session.add(wizard) + self.session.flush() + return wizard + + def _load_draft(self, wizard: AutomationWizardORM) -> dict[str, object]: + try: + payload = json.loads(wizard.draft_json) + except json.JSONDecodeError: + return {} + return payload if isinstance(payload, dict) else {} + + def _parse_schedule_type(self, text: str) -> str | None: + lowered = text.strip().lower() + mapping = { + "gunluk": "daily", + "daily": "daily", + "her gun": "daily", + "haftaici": "weekdays", + "hafta içi": "weekdays", + "weekdays": "weekdays", + "haftalik": "weekly", + "haftalık": "weekly", + "weekly": "weekly", + "saatlik": "hourly", + "hourly": "hourly", + } + return mapping.get(lowered) + + def _parse_interval_hours(self, text: str) -> int | None: + try: + value = int(text.strip()) + except ValueError: + return None + if 1 <= value <= 24: + return value + return None + + def _parse_time(self, text: str) -> str | None: + cleaned = text.strip() + if len(cleaned) != 5 or ":" not in cleaned: + return None + hour_text, minute_text = cleaned.split(":", 1) + try: + hour = int(hour_text) + minute = int(minute_text) + except ValueError: + return None + if not (0 <= hour <= 23 and 0 <= minute <= 59): + return None + return f"{hour:02d}:{minute:02d}" + + def _parse_weekdays(self, text: str) -> list[str]: + parts = [part.strip().lower() for part in text.replace("\n", ",").split(",")] + seen: list[int] = [] + for part in parts: + day = WEEKDAY_MAP.get(part) + if day is not None and day not in seen: + seen.append(day) + return [WEEKDAY_NAMES[day] for day in sorted(seen)] + + def _parse_yes_no(self, text: str) -> bool | None: + lowered = text.strip().lower() + if lowered in {"evet", "e", "yes", "y"}: + return True + if lowered in {"hayir", "hayır", "h", "no", "n"}: + return False + return None + + def _render_wizard_summary(self, draft: dict[str, object]) -> str: + schedule_type = str(draft.get("schedule_type", "daily")) + label = { + "daily": "gunluk", + "weekdays": "haftaici", + "weekly": "haftalik", + "hourly": "saatlik", + }.get(schedule_type, schedule_type) + lines = [ + "Ozet:", + f"- Ad: {draft.get('name', '-')}", + f"- Gorev: {draft.get('prompt', '-')}", + f"- Siklik: {label}", + ] + if schedule_type == "hourly": + lines.append(f"- Aralik: {draft.get('interval_hours', '-')} saat") + else: + lines.append(f"- Saat: {draft.get('time_of_day', '-')}") + if schedule_type == "weekly": + days = draft.get("days_of_week", []) + if isinstance(days, list): + lines.append(f"- Gunler: {', '.join(str(item) for item in days)}") + return "\n".join(lines) + + def _render_created_message(self, item: AutomationORM) -> str: + next_run = self._format_display_time(item.next_run_at) + return ( + f"Otomasyon kaydedildi: #{item.id} {item.name}\n" + f"- Durum: {item.status}\n" + f"- Siradaki calisma: {next_run}" + ) + + def _create_automation(self, telegram_user_id: int, draft: dict[str, object]) -> AutomationORM: + schedule_type = str(draft["schedule_type"]) + item = AutomationORM( + telegram_user_id=telegram_user_id, + name=str(draft["name"]), + prompt=str(draft["prompt"]), + schedule_type=schedule_type, + interval_hours=int(draft["interval_hours"]) if draft.get("interval_hours") is not None else None, + time_of_day=str(draft["time_of_day"]) if draft.get("time_of_day") is not None else None, + days_of_week=json.dumps(draft.get("days_of_week", []), ensure_ascii=False), + status=str(draft.get("status", "active")), + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + if item.status == "active": + item.next_run_at = self._compute_next_run(item, from_time=datetime.utcnow()) + self.session.add(item) + self.session.flush() + return item + + def _compute_next_run(self, item: AutomationORM, from_time: datetime) -> datetime: + if item.schedule_type == "hourly": + interval = max(item.interval_hours or 1, 1) + return from_time + timedelta(hours=interval) + + local_now = from_time.replace(tzinfo=UTC).astimezone(LOCAL_TZ) + hour, minute = self._parse_hour_minute(item.time_of_day or "09:00") + + if item.schedule_type == "daily": + return self._to_utc_naive(self._next_local_time(local_now, hour, minute)) + + if item.schedule_type == "weekdays": + candidate = self._next_local_time(local_now, hour, minute) + while candidate.weekday() >= 5: + candidate = candidate + timedelta(days=1) + candidate = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0) + return self._to_utc_naive(candidate) + + days = self._decode_days(item.days_of_week) + if not days: + days = [0] + candidate = self._next_local_time(local_now, hour, minute) + for _ in range(8): + if candidate.weekday() in days: + return self._to_utc_naive(candidate) + candidate = candidate + timedelta(days=1) + candidate = candidate.replace(hour=hour, minute=minute, second=0, microsecond=0) + + return self._to_utc_naive(candidate) + + def _next_local_time(self, local_now: datetime, hour: int, minute: int) -> datetime: + candidate = local_now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if candidate <= local_now: + candidate = candidate + timedelta(days=1) + return candidate + + def _parse_hour_minute(self, value: str) -> tuple[int, int]: + hour_text, minute_text = value.split(":", 1) + return int(hour_text), int(minute_text) + + def _decode_days(self, value: str) -> list[int]: + try: + payload = json.loads(value) + except json.JSONDecodeError: + return [] + result: list[int] = [] + if not isinstance(payload, list): + return result + for item in payload: + label = str(item) + if label in WEEKDAY_NAMES: + result.append(WEEKDAY_NAMES.index(label)) + return result + + def _to_utc_naive(self, local_dt: datetime) -> datetime: + return local_dt.astimezone(UTC).replace(tzinfo=None) + + def _format_display_time(self, value: datetime | None) -> str: + if value is None: + return "hesaplanmadi" + return value.replace(tzinfo=UTC).astimezone(LOCAL_TZ).strftime("%Y-%m-%d %H:%M") + + def _to_record(self, item: AutomationORM) -> AutomationRecord: + days = [] + try: + payload = json.loads(item.days_of_week) + if isinstance(payload, list): + days = [str(day) for day in payload] + except json.JSONDecodeError: + days = [] + return AutomationRecord( + id=item.id, + telegram_user_id=item.telegram_user_id, + name=item.name, + prompt=item.prompt, + schedule_type=item.schedule_type, # type: ignore[arg-type] + interval_hours=item.interval_hours, + time_of_day=item.time_of_day, + days_of_week=days, + status=item.status, # type: ignore[arg-type] + last_run_at=item.last_run_at, + next_run_at=item.next_run_at, + last_result=item.last_result, + created_at=item.created_at, + updated_at=item.updated_at, + ) + + def _get_owned_automation(self, telegram_user_id: int, automation_id: int) -> AutomationORM | None: + item = self.session.get(AutomationORM, automation_id) + if item is None or item.telegram_user_id != telegram_user_id: + return None + return item diff --git a/backend/app/config.py b/backend/app/config.py index 4f6a184..57af586 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -15,14 +15,20 @@ class Settings(BaseSettings): db_url: str = "sqlite:///./wiseclaw.db" admin_host: str = "127.0.0.1" admin_port: int = 8000 - ollama_base_url: str = "http://127.0.0.1:11434" - default_model: str = "qwen3.5:4b" + model_provider: str = "local" + local_base_url: str = "http://127.0.0.1:1234" + local_model: str = "qwen3-vl-8b-instruct-mlx@5bit" + zai_base_url: str = "https://api.z.ai/api/anthropic" + zai_model: str = "glm-5" + anythingllm_base_url: str = "http://127.0.0.1:3001" + anythingllm_workspace_slug: str = "wiseclaw" search_provider: str = "brave" telegram_bot_token: str = Field(default="", repr=False) brave_api_key: str = Field(default="", repr=False) + zai_api_key: str = Field(default="", repr=False) + anythingllm_api_key: str = Field(default="", repr=False) @lru_cache def get_settings() -> Settings: return Settings() - diff --git a/backend/app/db.py b/backend/app/db.py index 023dce2..f0ff4d0 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -8,15 +8,10 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sess from app.config import get_settings -DEFAULT_SETTINGS = { - "terminal_mode": "3", - "search_provider": "brave", - "ollama_base_url": "http://127.0.0.1:11434", - "default_model": "qwen3.5:4b", -} - DEFAULT_TOOLS = { "brave_search": True, + "second_brain": True, + "browser_use": True, "searxng_search": False, "web_fetch": True, "apple_notes": True, @@ -82,7 +77,88 @@ class SecretORM(Base): updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) +class TelegramUserProfileORM(Base): + __tablename__ = "telegram_user_profiles" + + telegram_user_id: Mapped[int] = mapped_column(Integer, primary_key=True) + display_name: Mapped[str | None] = mapped_column(String(255)) + bio: Mapped[str | None] = mapped_column(Text) + occupation: Mapped[str | None] = mapped_column(String(255)) + primary_use_cases: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + answer_priorities: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + tone_preference: Mapped[str | None] = mapped_column(String(100)) + response_length: Mapped[str | None] = mapped_column(String(50)) + language_preference: Mapped[str | None] = mapped_column(String(100)) + workflow_preference: Mapped[str | None] = mapped_column(String(100)) + interests: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + approval_preferences: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + avoid_preferences: Mapped[str | None] = mapped_column(Text) + onboarding_completed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + last_onboarding_step: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + +class AutomationORM(Base): + __tablename__ = "automations" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + telegram_user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + prompt: Mapped[str] = mapped_column(Text, nullable=False) + schedule_type: Mapped[str] = mapped_column(String(50), nullable=False) + interval_hours: Mapped[int | None] = mapped_column(Integer) + time_of_day: Mapped[str | None] = mapped_column(String(20)) + days_of_week: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") + last_run_at: Mapped[datetime | None] = mapped_column(DateTime) + next_run_at: Mapped[datetime | None] = mapped_column(DateTime) + last_result: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + +class AutomationWizardORM(Base): + __tablename__ = "automation_wizards" + + telegram_user_id: Mapped[int] = mapped_column(Integer, primary_key=True) + step: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + draft_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + +class SecondBrainNoteORM(Base): + __tablename__ = "second_brain_notes" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + telegram_user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + source: Mapped[str] = mapped_column(String(50), nullable=False, default="telegram") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + +class SecondBrainCaptureORM(Base): + __tablename__ = "second_brain_captures" + + telegram_user_id: Mapped[int] = mapped_column(Integer, primary_key=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + settings = get_settings() +DEFAULT_SETTINGS = { + "terminal_mode": "3", + "search_provider": settings.search_provider, + "model_provider": settings.model_provider, + "local_base_url": settings.local_base_url, + "local_model": settings.local_model, + "zai_model": settings.zai_model, + "anythingllm_base_url": settings.anythingllm_base_url, + "anythingllm_workspace_slug": settings.anythingllm_workspace_slug, +} + engine = create_engine( settings.db_url, connect_args={"check_same_thread": False} if settings.db_url.startswith("sqlite") else {}, @@ -130,4 +206,3 @@ def session_scope() -> Iterator[Session]: def list_recent_logs(session: Session, limit: int = 10) -> list[str]: stmt = select(AuditLogORM).order_by(AuditLogORM.created_at.desc(), AuditLogORM.id.desc()).limit(limit) return [row.message for row in session.scalars(stmt)] - diff --git a/backend/app/llm/ollama_client.py b/backend/app/llm/ollama_client.py index 1424d18..92d0f09 100644 --- a/backend/app/llm/ollama_client.py +++ b/backend/app/llm/ollama_client.py @@ -1,37 +1,323 @@ -import httpx -from httpx import HTTPError +import asyncio +import json +from typing import Any + +import httpx +from httpx import HTTPError, HTTPStatusError, ReadTimeout + +from app.models import ModelProvider, OllamaStatus -from app.models import OllamaStatus class OllamaClient: - def __init__(self, base_url: str) -> None: + def __init__(self, base_url: str, provider: ModelProvider = "local", api_key: str = "") -> None: self.base_url = base_url.rstrip("/") + self.provider = provider + self.api_key = api_key async def health(self) -> bool: - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.get(f"{self.base_url}/api/tags") - return response.is_success + try: + await self._fetch_models() + except HTTPError: + return False + return True async def status(self, model: str) -> OllamaStatus: + if self.provider == "zai" and not self.api_key.strip(): + return OllamaStatus( + reachable=False, + provider=self.provider, + base_url=self.base_url, + model=model, + message="Z.AI API key is not configured.", + ) try: - async with httpx.AsyncClient(timeout=5.0) as client: - response = await client.get(f"{self.base_url}/api/tags") - response.raise_for_status() + installed_models = await self._fetch_models() except HTTPError as exc: return OllamaStatus( reachable=False, + provider=self.provider, base_url=self.base_url, model=model, - message=f"Ollama unreachable: {exc}", + message=f"LLM endpoint unreachable: {exc}", ) - payload = response.json() - installed_models = [item.get("name", "") for item in payload.get("models", []) if item.get("name")] has_model = model in installed_models return OllamaStatus( reachable=True, + provider=self.provider, base_url=self.base_url, model=model, installed_models=installed_models, - message="Model found." if has_model else "Ollama reachable but model is not installed.", + message="Model found." if has_model else "LLM endpoint reachable but model is not installed.", ) + + async def chat(self, model: str, system_prompt: str, user_message: str) -> str: + result = await self.chat_completion( + model=model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message}, + ], + ) + if result["tool_calls"]: + raise HTTPError("Chat completion requested tools in plain chat mode.") + payload = result["content"].strip() + if not payload: + raise HTTPError("Chat completion returned empty content.") + return payload + + async def chat_completion( + self, + model: str, + messages: list[dict[str, object]], + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + ) -> dict[str, Any]: + self._ensure_provider_ready() + if self.provider == "zai": + return await self._anthropic_chat_completion(model, messages, tools) + + payload: dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": 0.3, + } + if tools: + payload["tools"] = tools + payload["tool_choice"] = tool_choice or "auto" + + endpoint = f"{self.base_url}/chat/completions" if self.provider == "zai" else f"{self.base_url}/v1/chat/completions" + + try: + async with httpx.AsyncClient(timeout=180.0) as client: + response = await self._post_with_retry(client, endpoint, payload) + except ReadTimeout as exc: + raise HTTPError("LLM request timed out after 180 seconds.") from exc + + data = response.json() + choices = data.get("choices", []) + if not choices: + raise HTTPError("Chat completion returned no choices.") + + message = choices[0].get("message", {}) + content = message.get("content", "") + if isinstance(content, list): + text_parts = [part.get("text", "") for part in content if isinstance(part, dict)] + content = "".join(text_parts) + tool_calls = [] + for call in message.get("tool_calls", []) or []: + function = call.get("function", {}) + raw_arguments = function.get("arguments", "{}") + try: + arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments + except json.JSONDecodeError: + arguments = {"raw": raw_arguments} + tool_calls.append( + { + "id": call.get("id", ""), + "name": function.get("name", ""), + "arguments": arguments, + } + ) + return { + "content": str(content or ""), + "tool_calls": tool_calls, + "message": message, + } + + async def _anthropic_chat_completion( + self, + model: str, + messages: list[dict[str, object]], + tools: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + system_prompt, anthropic_messages = self._to_anthropic_messages(messages) + payload: dict[str, Any] = { + "model": model, + "max_tokens": 2048, + "messages": anthropic_messages, + } + if system_prompt: + payload["system"] = system_prompt + anthropic_tools = self._to_anthropic_tools(tools or []) + if anthropic_tools: + payload["tools"] = anthropic_tools + + try: + async with httpx.AsyncClient(timeout=180.0) as client: + response = await self._post_with_retry(client, f"{self.base_url}/v1/messages", payload) + except ReadTimeout as exc: + raise HTTPError("LLM request timed out after 180 seconds.") from exc + + data = response.json() + blocks = data.get("content", []) or [] + text_parts: list[str] = [] + tool_calls: list[dict[str, Any]] = [] + for block in blocks: + if not isinstance(block, dict): + continue + block_type = block.get("type") + if block_type == "text": + text_parts.append(str(block.get("text", ""))) + if block_type == "tool_use": + tool_calls.append( + { + "id": str(block.get("id", "")), + "name": str(block.get("name", "")), + "arguments": block.get("input", {}) if isinstance(block.get("input"), dict) else {}, + } + ) + + return { + "content": "".join(text_parts).strip(), + "tool_calls": tool_calls, + "message": data, + } + + async def _fetch_models(self) -> list[str]: + self._ensure_provider_ready() + async with httpx.AsyncClient(timeout=5.0) as client: + if self.provider == "zai": + response = await client.get(f"{self.base_url}/v1/models", headers=self._headers()) + if response.is_success: + payload = response.json() + return [item.get("id", "") for item in payload.get("data", []) if item.get("id")] + return ["glm-4.7", "glm-5"] + + response = await client.get(f"{self.base_url}/api/tags") + if response.is_success: + payload = response.json() + if isinstance(payload, dict) and "models" in payload: + return [item.get("name", "") for item in payload.get("models", []) if item.get("name")] + + response = await client.get(f"{self.base_url}/v1/models") + response.raise_for_status() + payload = response.json() + return [item.get("id", "") for item in payload.get("data", []) if item.get("id")] + + def _headers(self) -> dict[str, str]: + if self.provider != "zai": + return {} + return { + "x-api-key": self.api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + + def _ensure_provider_ready(self) -> None: + if self.provider == "zai" and not self.api_key.strip(): + raise HTTPError("Z.AI API key is not configured.") + + async def _post_with_retry( + self, + client: httpx.AsyncClient, + endpoint: str, + payload: dict[str, Any], + ) -> httpx.Response: + delays = [0.0, 1.5, 4.0] + last_exc: HTTPStatusError | None = None + + for attempt, delay in enumerate(delays, start=1): + if delay > 0: + await asyncio.sleep(delay) + response = await client.post(endpoint, json=payload, headers=self._headers()) + try: + response.raise_for_status() + return response + except HTTPStatusError as exc: + last_exc = exc + if response.status_code != 429 or attempt == len(delays): + raise self._translate_status_error(exc) from exc + + if last_exc is not None: + raise self._translate_status_error(last_exc) from last_exc + raise HTTPError("LLM request failed.") + + def _translate_status_error(self, exc: HTTPStatusError) -> HTTPError: + status = exc.response.status_code + if status == 429: + provider = "Z.AI" if self.provider == "zai" else "LLM endpoint" + return HTTPError(f"{provider} rate limit reached. Please wait a bit and try again.") + if status == 401: + provider = "Z.AI" if self.provider == "zai" else "LLM endpoint" + return HTTPError(f"{provider} authentication failed. Check the configured API key.") + if status == 404: + return HTTPError("Configured LLM endpoint path was not found.") + return HTTPError(f"LLM request failed with HTTP {status}.") + + def _to_anthropic_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + anthropic_tools: list[dict[str, Any]] = [] + for tool in tools: + function = tool.get("function", {}) if isinstance(tool, dict) else {} + if not isinstance(function, dict): + continue + anthropic_tools.append( + { + "name": str(function.get("name", "")), + "description": str(function.get("description", "")), + "input_schema": function.get("parameters", {"type": "object", "properties": {}}), + } + ) + return [tool for tool in anthropic_tools if tool["name"]] + + def _to_anthropic_messages(self, messages: list[dict[str, object]]) -> tuple[str, list[dict[str, object]]]: + system_parts: list[str] = [] + anthropic_messages: list[dict[str, object]] = [] + + for message in messages: + role = str(message.get("role", "user")) + if role == "system": + content = str(message.get("content", "")).strip() + if content: + system_parts.append(content) + continue + + if role == "tool": + content = str(message.get("content", "")) + tool_use_id = str(message.get("tool_call_id", "")) + tool_result_block = { + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": content, + } + if anthropic_messages and anthropic_messages[-1]["role"] == "user": + existing = anthropic_messages[-1]["content"] + if isinstance(existing, list): + existing.append(tool_result_block) + continue + anthropic_messages.append({"role": "user", "content": [tool_result_block]}) + continue + + content_blocks: list[dict[str, object]] = [] + content = message.get("content", "") + if isinstance(content, str) and content.strip(): + content_blocks.append({"type": "text", "text": content}) + + raw_tool_calls = message.get("tool_calls", []) + if isinstance(raw_tool_calls, list): + for call in raw_tool_calls: + if not isinstance(call, dict): + continue + function = call.get("function", {}) + if not isinstance(function, dict): + continue + arguments = function.get("arguments", {}) + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError: + arguments = {} + content_blocks.append( + { + "type": "tool_use", + "id": str(call.get("id", "")), + "name": str(function.get("name", "")), + "input": arguments if isinstance(arguments, dict) else {}, + } + ) + + if not content_blocks: + continue + anthropic_messages.append({"role": "assistant" if role == "assistant" else "user", "content": content_blocks}) + + return "\n\n".join(part for part in system_parts if part), anthropic_messages diff --git a/backend/app/llm/planner.py b/backend/app/llm/planner.py index 81c4ba8..1f5c1b0 100644 --- a/backend/app/llm/planner.py +++ b/backend/app/llm/planner.py @@ -1,15 +1,48 @@ +from datetime import datetime + from app.models import RuntimeSettings -def build_prompt_context(message: str, runtime: RuntimeSettings, memory: list[str]) -> dict[str, object]: +def build_prompt_context( + message: str, + runtime: RuntimeSettings, + memory: list[str], + workspace_root: str, + profile_preferences: str = "", + second_brain_context: str = "", +) -> dict[str, object]: + tool_names = [tool.name for tool in runtime.tools if tool.enabled] + memory_lines = "\n".join(f"- {item}" for item in memory) if memory else "- No recent memory." + profile_lines = profile_preferences or "- No saved profile preferences." + second_brain_lines = second_brain_context or "- No second-brain context retrieved for this request." + today = datetime.now().strftime("%Y-%m-%d") return { "system": ( "You are WiseClaw, a local-first assistant running on macOS. " - "Use tools carefully and obey terminal safety mode." + "Keep replies concise, practical, and safe. " + f"Enabled tools: {', '.join(tool_names) if tool_names else 'none'}.\n" + f"Today's date: {today}\n" + f"Current workspace root: {workspace_root}\n" + "Relative file paths are relative to the workspace root.\n" + "When the user asks for current information such as today's price, exchange rate, latest news, or current status, do not invent or shift the year. Use today's date above and prefer tools for fresh data.\n" + "If the user asks for the working directory, use the terminal tool with `pwd`.\n" + "If the user names a local file such as README.md, try that relative path first with the files tool.\n" + "If the user asks you to create or update files, use the files tool with action `write`.\n" + "If the user asks you to create a note in Apple Notes, use apple_notes with action `create_note`.\n" + "If the user asks about their saved notes, documents, archive, workspace knowledge, or second brain, use second_brain or the injected second-brain context before answering.\n" + "For a static HTML/CSS/JS app, write the files first, then use the terminal tool to run a local server in the background with a command like `python3 -m http.server 9990 -d `.\n" + "If the user asks you to open, inspect, interact with, or extract information from a website in a real browser, use browser_use.\n" + "If the user asks you to inspect files, browse the web, or run terminal commands, use the matching tool instead of guessing. " + "If a required tool fails or is unavailable, say that clearly and do not pretend you completed the action.\n" + "Retrieved second-brain context for this request:\n" + f"{second_brain_lines}\n" + "Saved user profile preferences:\n" + f"{profile_lines}\n" + "Recent memory:\n" + f"{memory_lines}" ), "message": message, - "model": runtime.default_model, + "model": runtime.local_model if runtime.model_provider == "local" else runtime.zai_model, "memory": memory, - "available_tools": [tool.name for tool in runtime.tools if tool.enabled], + "available_tools": tool_names, } - diff --git a/backend/app/main.py b/backend/app/main.py index da71c94..b83eb38 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.automation.scheduler import AutomationScheduler from app.admin.routes import router as admin_router from app.config import get_settings from app.db import init_db, session_scope @@ -21,6 +22,8 @@ async def lifespan(_: FastAPI): if settings.telegram_bot_token: runtime_services.telegram_bot = TelegramBotService(settings.telegram_bot_token, session_scope) await runtime_services.telegram_bot.start() + runtime_services.automation_scheduler = AutomationScheduler(session_scope, runtime_services.telegram_bot) + await runtime_services.automation_scheduler.start() yield await runtime_services.shutdown() @@ -30,6 +33,7 @@ app = FastAPI(title="WiseClaw", version="0.1.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=["http://127.0.0.1:5173", "http://localhost:5173"], + allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1|192\.168\.\d{1,3}\.\d{1,3})(:\d+)?$", allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/app/models.py b/backend/app/models.py index 0e79ad9..ba416cc 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -6,6 +6,9 @@ from pydantic import BaseModel, Field TerminalMode = Literal[1, 2, 3] SearchProvider = Literal["brave", "searxng"] +ModelProvider = Literal["local", "zai"] +AutomationScheduleType = Literal["daily", "weekdays", "weekly", "hourly"] +AutomationStatus = Literal["active", "paused"] class HealthStatus(BaseModel): @@ -32,14 +35,38 @@ class UserRecord(BaseModel): is_active: bool = True +class UserProfileRecord(BaseModel): + telegram_user_id: int + display_name: str | None = None + bio: str | None = None + occupation: str | None = None + primary_use_cases: list[str] = Field(default_factory=list) + answer_priorities: list[str] = Field(default_factory=list) + tone_preference: str | None = None + response_length: str | None = None + language_preference: str | None = None + workflow_preference: str | None = None + interests: list[str] = Field(default_factory=list) + approval_preferences: list[str] = Field(default_factory=list) + avoid_preferences: str | None = None + onboarding_completed: bool = False + last_onboarding_step: int = 0 + + class RuntimeSettings(BaseModel): terminal_mode: TerminalMode = 3 search_provider: SearchProvider = "brave" - ollama_base_url: str = "http://127.0.0.1:11434" - default_model: str = "qwen3.5:4b" + model_provider: ModelProvider = "local" + local_base_url: str = "http://127.0.0.1:1234" + local_model: str = "qwen3-vl-8b-instruct-mlx@5bit" + zai_model: Literal["glm-4.7", "glm-5"] = "glm-5" + anythingllm_base_url: str = "http://127.0.0.1:3001" + anythingllm_workspace_slug: str = "wiseclaw" tools: list[ToolToggle] = Field( default_factory=lambda: [ ToolToggle(name="brave_search", enabled=True), + ToolToggle(name="second_brain", enabled=True), + ToolToggle(name="browser_use", enabled=True), ToolToggle(name="searxng_search", enabled=False), ToolToggle(name="web_fetch", enabled=True), ToolToggle(name="apple_notes", enabled=True), @@ -65,6 +92,7 @@ class MemoryRecord(BaseModel): class OllamaStatus(BaseModel): reachable: bool + provider: ModelProvider = "local" base_url: str model: str installed_models: list[str] = Field(default_factory=list) @@ -75,3 +103,20 @@ class TelegramStatus(BaseModel): configured: bool polling_active: bool message: str + + +class AutomationRecord(BaseModel): + id: int + telegram_user_id: int + name: str + prompt: str + schedule_type: AutomationScheduleType + interval_hours: int | None = None + time_of_day: str | None = None + days_of_week: list[str] = Field(default_factory=list) + status: AutomationStatus = "active" + last_run_at: datetime | None = None + next_run_at: datetime | None = None + last_result: str | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/app/orchestrator.py b/backend/app/orchestrator.py index dd1e815..5cb9dc9 100644 --- a/backend/app/orchestrator.py +++ b/backend/app/orchestrator.py @@ -1,46 +1,1037 @@ +import json +from pathlib import Path +import re +from datetime import datetime +import unicodedata + +import httpx from sqlalchemy import select from sqlalchemy.orm import Session -from app.db import AuditLogORM, SettingORM, ToolStateORM +from app.config import get_settings +from app.db import AuditLogORM, DEFAULT_TOOLS, SecretORM, SettingORM, ToolStateORM +from app.automation.store import AutomationService +from app.llm.ollama_client import OllamaClient from app.llm.planner import build_prompt_context from app.memory.store import MemoryService +from app.profile.store import UserProfileService +from app.second_brain.store import SecondBrainService from app.models import RuntimeSettings +from app.static_templates import get_game_template_hint from app.telegram.auth import is_authorized +from app.tools.base import Tool +from app.tools.registry import build_tools class WiseClawOrchestrator: def __init__(self, session: Session) -> None: self.session = session self.memory = MemoryService(session) + self.profiles = UserProfileService(session) + self.automations = AutomationService(session) + self.second_brain = SecondBrainService(session) + + async def handle_message_payload(self, telegram_user_id: int, text: str) -> dict[str, object]: + response = await self.handle_text_message(telegram_user_id, text) + return {"text": response, "photos": []} def get_runtime_settings(self) -> RuntimeSettings: settings = { item.key: item.value for item in self.session.scalars(select(SettingORM)) } - tools = list(self.session.scalars(select(ToolStateORM).order_by(ToolStateORM.name.asc()))) + tool_records = { + tool.name: tool.enabled for tool in self.session.scalars(select(ToolStateORM).order_by(ToolStateORM.name.asc())) + } return RuntimeSettings( terminal_mode=int(settings["terminal_mode"]), search_provider=settings["search_provider"], - ollama_base_url=settings["ollama_base_url"], - default_model=settings["default_model"], - tools=[{"name": tool.name, "enabled": tool.enabled} for tool in tools], + model_provider=settings["model_provider"], + local_base_url=settings["local_base_url"], + local_model=settings["local_model"], + zai_model=settings["zai_model"], + anythingllm_base_url=settings["anythingllm_base_url"], + anythingllm_workspace_slug=settings["anythingllm_workspace_slug"], + tools=[{"name": name, "enabled": tool_records.get(name, enabled)} for name, enabled in DEFAULT_TOOLS.items()], ) - def handle_text_message(self, telegram_user_id: int, text: str) -> str: + async def handle_text_message(self, telegram_user_id: int, text: str) -> str: if not is_authorized(self.session, telegram_user_id): return "This Telegram user is not authorized for WiseClaw." + normalized = text.strip() + command_response = self._handle_profile_command(telegram_user_id, normalized) + if command_response is not None: + self.session.commit() + return command_response + + automation_response = self._handle_automation_command(telegram_user_id, normalized) + if automation_response is not None: + self.session.commit() + return automation_response + + second_brain_command = self._handle_second_brain_command(telegram_user_id, normalized) + if second_brain_command is not None: + self.session.commit() + return second_brain_command + + if self.profiles.is_onboarding_active(telegram_user_id): + if normalized.lower() in {"/iptal", "iptal", "cancel"}: + self.session.add( + AuditLogORM(category="profile", message=f"profile:onboarding-cancelled:{telegram_user_id}") + ) + self.session.commit() + return "Tanisma akisini durdurdum. Devam etmek istersen /tanisalim yazabilirsin." + + response, completed = self.profiles.answer_onboarding(telegram_user_id, normalized) + if completed: + summary = self.profiles.profile_memory_summary(telegram_user_id) + if summary: + self.memory.add_item(summary) + self.session.commit() + return response + + if self.automations.is_wizard_active(telegram_user_id): + if normalized.lower() in {"/iptal", "iptal", "cancel"}: + response = self.automations.cancel_wizard(telegram_user_id) + self.session.commit() + return response + response, _completed = self.automations.answer_wizard(telegram_user_id, normalized) + self.session.commit() + return response + + if self.second_brain.is_capture_active(telegram_user_id): + if normalized.lower() in {"/iptal", "iptal", "cancel"}: + response = self.second_brain.cancel_capture(telegram_user_id) + self.session.commit() + return response + workspace_root = Path(__file__).resolve().parents[2] + response = await self.second_brain.save_note_and_sync(telegram_user_id, normalized, workspace_root) + self.session.commit() + return response + self.memory.add_item(f"user:{telegram_user_id}:{text}") - context = build_prompt_context( - message=text, - runtime=self.get_runtime_settings(), - memory=self.memory.latest_items(limit=5), - ) - response = ( - "WiseClaw scaffold received your message.\n\n" - f"Prompt context prepared for model `{context['model']}` with " - f"{len(context['memory'])} memory items." - ) + runtime = self.get_runtime_settings() + try: + response = await self._generate_response(telegram_user_id, text, runtime) + except Exception as exc: + response = self._format_llm_error(exc, runtime.model_provider) + + self.memory.add_item(f"assistant:{response}") self.session.add(AuditLogORM(category="telegram", message=f"telegram:{telegram_user_id}:{text}")) self.session.commit() return response + + async def _generate_response(self, telegram_user_id: int, text: str, runtime: RuntimeSettings) -> str: + try: + return await self._generate_response_once(telegram_user_id, text, runtime) + except Exception as exc: + if self._should_fallback_to_local(exc, runtime): + fallback_runtime = runtime.model_copy(update={"model_provider": "local"}) + fallback_response = await self._generate_response_once(telegram_user_id, text, fallback_runtime) + return ( + "Z.AI erisilemedi, bu istek icin otomatik olarak local modele dustum.\n\n" + f"{fallback_response}" + ) + raise + + async def _generate_response_once(self, telegram_user_id: int, text: str, runtime: RuntimeSettings) -> str: + workspace_root = Path(__file__).resolve().parents[2] + tools = build_tools(runtime, workspace_root, self.session) + second_brain_context = await self._prefetch_second_brain_context(text, tools) + context = build_prompt_context( + message=text, + runtime=runtime, + memory=self.memory.latest_items(limit=5), + workspace_root=str(workspace_root), + profile_preferences=self.profiles.build_prompt_profile(telegram_user_id), + second_brain_context=second_brain_context, + ) + client = self._build_llm_client(runtime) + messages: list[dict[str, object]] = [ + {"role": "system", "content": str(context["system"])}, + {"role": "user", "content": text}, + ] + if self._looks_like_static_app_request(text): + return await self._handle_static_app_request(client, self._active_model(runtime), text, workspace_root, tools) + return await self._run_tool_loop(client, self._active_model(runtime), messages, tools) + + def _handle_profile_command(self, telegram_user_id: int, text: str) -> str | None: + command = text.split(maxsplit=1)[0].lower() + if "@" in command: + command = command.split("@", 1)[0] + if command in {"/tanışalım", "/tanisalim"}: + return self.profiles.start_onboarding(telegram_user_id) + if command in {"/profilim"}: + return self.profiles.render_profile_summary(telegram_user_id) + if command in {"/tercihlerim"}: + return self.profiles.render_preferences_summary(telegram_user_id) + if command in {"/tanışalım_sifirla", "/tanisalim_sifirla"}: + return self.profiles.reset_onboarding(telegram_user_id) + return None + + def _handle_automation_command(self, telegram_user_id: int, text: str) -> str | None: + command_parts = text.split(maxsplit=1) + command = command_parts[0].lower() + if "@" in command: + command = command.split("@", 1)[0] + argument = command_parts[1].strip() if len(command_parts) > 1 else "" + + if command == "/otomasyon_ekle": + return self.automations.start_wizard(telegram_user_id) + if command == "/otomasyonlar": + return self.automations.render_automation_list(telegram_user_id) + if command == "/otomasyon_durdur": + automation_id = self._parse_numeric_argument(argument) + return ( + self.automations.pause_automation(telegram_user_id, automation_id) + if automation_id is not None + else "Kullanim: /otomasyon_durdur " + ) + if command == "/otomasyon_baslat": + automation_id = self._parse_numeric_argument(argument) + return ( + self.automations.resume_automation(telegram_user_id, automation_id) + if automation_id is not None + else "Kullanim: /otomasyon_baslat " + ) + if command == "/otomasyon_sil": + automation_id = self._parse_numeric_argument(argument) + return ( + self.automations.delete_automation(telegram_user_id, automation_id) + if automation_id is not None + else "Kullanim: /otomasyon_sil " + ) + return None + + def _handle_second_brain_command(self, telegram_user_id: int, text: str) -> str | None: + command = text.split(maxsplit=1)[0].lower() + if "@" in command: + command = command.split("@", 1)[0] + if command == "/notlarima_ekle": + return self.second_brain.start_capture(telegram_user_id) + return None + + def _parse_numeric_argument(self, value: str) -> int | None: + try: + return int(value) + except ValueError: + return None + + async def _run_tool_loop( + self, + client: OllamaClient, + model: str, + messages: list[dict[str, object]], + tools: dict[str, Tool], + ) -> str: + tool_defs = [tool.definition() for tool in tools.values()] + user_text = str(messages[-1].get("content", "")) + normalized_user_text = self._normalize_intent_text(user_text) + note_request = self._extract_apple_note_request(user_text) if "apple_notes" in tools else None + preferred_tool = self._preferred_tool_name(user_text, tools) + second_brain_request = self._looks_like_second_brain_request(normalized_user_text) if "second_brain" in tools else False + + if note_request is not None: + if note_request is None: + return "Apple Notes istegini anlayamadim. Lutfen not basligini acikca belirt." + tool_result = await self._execute_tool_call(tools, "apple_notes", note_request) + self._log_tool_event("apple_notes", note_request, tool_result) + if tool_result.get("status") != "ok": + message = str(tool_result.get("message", "Apple Notes command failed.")) + return f"Apple Notes notu olusturamadim: {message}" + title = str(tool_result.get("title", note_request["title"])) + return f'Apple Notes uygulamasinda "{title}" baslikli yeni bir not olusturuldu.' + + if second_brain_request: + second_brain_args = {"query": user_text, "mode": "query"} + tool_result = await self._execute_tool_call(tools, "second_brain", second_brain_args) + self._log_tool_event("second_brain", second_brain_args, tool_result) + if tool_result.get("status") != "ok": + message = str(tool_result.get("message", "AnythingLLM lookup failed.")) + return f"Ikinci beyin baglamini cekemedim: {message}" + return self._render_second_brain_answer(tool_result) + + if preferred_tool == "browser_use": + tool_payload: dict[str, object] = { + "task": user_text, + "max_steps": 20, + } + start_url = self._extract_url(user_text) + if start_url: + tool_payload["start_url"] = start_url + tool_result = await self._execute_tool_call(tools, "browser_use", tool_payload) + self._log_tool_event("browser_use", tool_payload, tool_result) + if tool_result.get("status") != "ok": + message = str(tool_result.get("message", "browser_use failed.")) + return f"Tarayici gorevini tamamlayamadim: {message}" + final_result = str(tool_result.get("final_result", "")).strip() + if final_result: + return final_result + extracted = tool_result.get("extracted_content", []) + if isinstance(extracted, list) and extracted: + return "\n\n".join(str(item) for item in extracted[-3:]) + return "Tarayici gorevi tamamlandi, ancak ozetlenecek bir sonuc uretilmedi." + + if preferred_tool == "brave_search": + brave_args = { + "query": str(messages[-1].get("content", "")), + "count": 5, + "mode": "images" if self._looks_like_image_request(user_text) else "web", + } + brave_args = self._normalize_brave_search_arguments(user_text, brave_args) + tool_result = await self._execute_tool_call( + tools, + preferred_tool, + brave_args, + ) + self._log_tool_event(preferred_tool, brave_args, tool_result) + if tool_result.get("status") == "error": + message = str(tool_result.get("message", "Brave Search failed.")) + return f"Web aramasi yapamadim: {message}" + if brave_args["mode"] == "images": + images = tool_result.get("images", []) + web_result = await self._execute_tool_call( + tools, + preferred_tool, + self._normalize_brave_search_arguments( + user_text, + {"query": brave_args["query"], "count": 5, "mode": "web"}, + ), + ) + self._log_tool_event(preferred_tool, {"query": brave_args["query"], "count": 5, "mode": "web"}, web_result) + if isinstance(images, list) and images: + summary = self._build_brave_combo_summary(images, web_result) + media = self._build_image_media_items(images) + if media: + return self._encode_media_response(summary, media) + return summary + if web_result.get("status") == "ok": + return self._build_brave_combo_summary([], web_result) + messages.append( + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "prefetched-brave-search", + "type": "function", + "function": { + "name": preferred_tool, + "arguments": json.dumps( + { + "query": str(messages[-1].get("content", "")), + "count": 5, + } + ), + }, + } + ], + } + ) + messages.append( + { + "role": "tool", + "tool_call_id": "prefetched-brave-search", + "content": json.dumps(tool_result), + } + ) + + for step in range(4): + completion = await client.chat_completion( + model=model, + messages=messages, + tools=tool_defs or None, + ) + tool_calls = completion["tool_calls"] + + if not tool_calls: + content = completion["content"].strip() + return content or "WiseClaw did not return a response." + + messages.append( + { + "role": "assistant", + "content": completion["content"], + "tool_calls": [ + { + "id": call["id"], + "type": "function", + "function": { + "name": call["name"], + "arguments": json.dumps(call["arguments"]), + }, + } + for call in tool_calls + ], + } + ) + + for call in tool_calls: + normalized_arguments = ( + self._normalize_brave_search_arguments(user_text, call["arguments"]) + if call["name"] == "brave_search" + else call["arguments"] + ) + result = await self._execute_tool_call(tools, call["name"], normalized_arguments) + self._log_tool_event(call["name"], normalized_arguments, result) + messages.append( + { + "role": "tool", + "tool_call_id": call["id"], + "content": json.dumps(result), + } + ) + + return "WiseClaw stopped after too many tool steps." + + async def _execute_tool_call(self, tools: dict[str, Tool], name: str, arguments: dict[str, object]) -> dict[str, object]: + tool = tools.get(name) + if tool is None: + return {"tool": name, "status": "error", "message": f"Tool is not enabled: {name}"} + try: + return await tool.run(arguments) + except Exception as exc: + return {"tool": name, "status": "error", "message": str(exc)} + + def _log_tool_event(self, tool_name: str, arguments: dict[str, object], result: dict[str, object]) -> None: + self.session.add( + AuditLogORM( + category="tool", + message=f"tool:{tool_name}:{json.dumps(arguments, ensure_ascii=False)}", + ) + ) + if tool_name == "brave_search": + self.session.add( + AuditLogORM( + category="tool", + message=f"tool_result:brave_search:{self._truncate_log_payload(result)}", + ) + ) + if tool_name == "second_brain": + self.session.add( + AuditLogORM( + category="tool", + message=f"tool_result:second_brain:{self._truncate_log_payload(result)}", + ) + ) + + def _truncate_log_payload(self, payload: dict[str, object], limit: int = 4000) -> str: + serialized = json.dumps(payload, ensure_ascii=False) + if len(serialized) <= limit: + return serialized + return serialized[: limit - 3] + "..." + + def _looks_like_static_app_request(self, text: str) -> bool: + normalized = text.lower() + has_port = bool(re.search(r":\d{2,5}", normalized)) + has_build_intent = any(term in normalized for term in ("yap", "oluştur", "olustur", "create", "build")) + mentions_stack = "html" in normalized and "css" in normalized and "js" in normalized + mentions_local_app = any( + term in normalized + for term in ( + "localhost", + "localhos", + "yerelde", + "localde", + "responsive", + "mobil", + "mobile", + "oyun", + "game", + "app", + "uygulama", + "website", + "web sitesi", + "site", + ) + ) + return has_port and has_build_intent and (mentions_stack or mentions_local_app) + + async def _handle_static_app_request( + self, + client: OllamaClient, + model: str, + text: str, + workspace_root: Path, + tools: dict[str, Tool], + ) -> str: + files_tool = tools.get("files") + terminal_tool = tools.get("terminal") + if files_tool is None or terminal_tool is None: + return "Static app workflow requires both files and terminal tools to be enabled." + + port_match = re.search(r":(\d{2,5})", text) + port = int(port_match.group(1)) if port_match else 9990 + app_dir = f"generated_apps/app-{port}" + is_game_request = self._looks_like_game_request(text) + game_template_hint = get_game_template_hint(text) if is_game_request else "" + + prompt = ( + "Return only valid JSON with this exact shape: " + '{"summary":"short summary","index_html":"...","style_css":"...","script_js":"..."}.\n' + "Build a polished responsive static web app that satisfies this request.\n" + "Requirements:\n" + "- Use plain HTML, CSS, and JavaScript only.\n" + "- Make it work well on both mobile and desktop browsers.\n" + "- If the request is for a game, include complete gameplay, restart behavior, score display, and clear win/lose or game-over states.\n" + "- For mobile-focused requests, include touch-friendly controls and large tap targets.\n" + "- Keep the UI visually clean and intentional, not placeholder-like.\n" + "- Put all markup in index.html, all styles in style.css, and all behavior in script.js.\n" + "- Ensure the generated files can run directly in a local static server without any build step.\n" + "- Do not leave TODOs, placeholders, or missing logic.\n" + f"{self._game_prompt_requirements(text) if is_game_request else ''}" + f"{game_template_hint}" + "User request:\n" + f"{text}\n" + "Do not include markdown fences." + ) + raw = await client.chat( + model=model, + system_prompt=( + "You generate complete production-ready static web app files. " + "Return JSON only. Prefer strong usability, responsive layout, and complete functionality. " + f"{self._game_system_prompt() if is_game_request else ''}" + ), + user_message=prompt, + ) + spec = await self._extract_json_object(client, model, raw) + + for path, content in ( + (f"{app_dir}/index.html", spec.get("index_html", "")), + (f"{app_dir}/style.css", spec.get("style_css", "")), + (f"{app_dir}/script.js", spec.get("script_js", "")), + ): + result = await files_tool.run({"action": "write", "path": path, "content": content}) + if result.get("status") != "ok": + return f"Failed to write app file {path}: {result.get('message', 'unknown error')}" + + serve = await terminal_tool.run( + { + "command": f"python3 -m http.server {port} -d {app_dir}", + "background": True, + "workdir": ".", + } + ) + if serve.get("status") != "ok": + return f"App files were written, but the local server could not be started: {serve.get('message') or serve.get('reason')}" + + url = f"http://192.168.1.124:{port}/" + verification = await self._verify_url(url) + summary = spec.get("summary", "Static web app created.") + if verification: + return ( + f"{summary}\n\n" + f"Files written to `{workspace_root / app_dir}`.\n" + f"Server started at {url}" + ) + return ( + f"{summary}\n\n" + f"Files written to `{workspace_root / app_dir}`.\n" + f"Server start was requested for {url}, but verification failed. " + f"Log: {serve.get('log_path', 'n/a')}" + ) + + def _looks_like_game_request(self, text: str) -> bool: + lowered = text.lower() + return any( + term in lowered + for term in ( + "snake", + "oyun", + "game", + "arcade", + "tetris", + "pong", + "platformer", + ) + ) + + def _game_system_prompt(self) -> str: + return ( + "When the request is for a game, optimize for complete gameplay, reliable controls, and a satisfying first-run experience." + ) + + def _game_prompt_requirements(self, text: str) -> str: + lowered = text.lower() + lines = [ + "- This is a game request. Build a fully playable game, not a mockup.\n", + "- Prefer a single-screen experience that starts immediately after loading.\n", + "- Include a visible score and a restart action.\n", + "- Add both keyboard controls and touch controls when mobile use is implied.\n", + "- Make touch controls fixed, thumb-friendly, and obvious on small screens.\n", + "- Prevent accidental page scrolling while using touch controls.\n", + "- Keep animation smooth and game state deterministic.\n", + ] + if "snake" in lowered: + lines.extend( + [ + "- For Snake specifically, use a visible grid or board area.\n", + "- Support swipe or large directional buttons on mobile.\n", + "- Prevent immediate 180-degree turns.\n", + "- Speed should feel fair on mobile and desktop.\n", + "- Show current score and best-effort game over feedback.\n", + ] + ) + lines.extend(self._game_engine_prompt_requirements(lowered)) + return "".join(lines) + + def _game_engine_prompt_requirements(self, lowered: str) -> list[str]: + if "three.js" in lowered or "threejs" in lowered or "webgl" in lowered or "3d" in lowered: + return [ + "- Use Three.js via a browser-ready CDN import or module import that works directly in a static server.\n", + "- Build a true 3D scene with camera, lighting, animation loop, and responsive renderer sizing.\n", + "- Keep the scene performant on mobile devices by limiting geometry and draw complexity.\n", + "- Add touch-friendly camera or gameplay controls if the request implies mobile play.\n", + ] + if "phaser" in lowered: + return [ + "- Use Phaser from a browser-ready CDN and implement the game with Phaser scenes.\n", + "- Structure the gameplay with preload/create/update flow and working asset-free primitives if needed.\n", + "- Ensure the canvas resizes well on mobile and desktop.\n", + ] + if "pixi" in lowered or "pixijs" in lowered: + return [ + "- Use PixiJS from a browser-ready CDN and render gameplay through a responsive canvas.\n", + "- Keep sprite logic and animation self-contained in script.js.\n", + ] + if "babylon" in lowered or "babylon.js" in lowered: + return [ + "- Use Babylon.js from a browser-ready CDN with a complete 3D scene and render loop.\n", + "- Include camera, light, and mobile-safe rendering defaults.\n", + ] + if "canvas" in lowered: + return [ + "- Use the HTML canvas as the main gameplay surface.\n", + "- Keep rendering and game loop code explicit and self-contained in script.js.\n", + ] + return [ + "- Prefer plain DOM or canvas unless the user explicitly asked for a specific game library.\n", + ] + + async def _extract_json_object(self, client: OllamaClient, model: str, raw: str) -> dict[str, str]: + start = raw.find("{") + end = raw.rfind("}") + if start == -1 or end == -1 or end <= start: + repaired = await self._repair_json_response(client, model, raw) + start = repaired.find("{") + end = repaired.rfind("}") + if start == -1 or end == -1 or end <= start: + raise ValueError("Model did not return JSON.") + raw = repaired + try: + payload = json.loads(raw[start : end + 1]) + except json.JSONDecodeError: + repaired = await self._repair_json_response(client, model, raw) + start = repaired.find("{") + end = repaired.rfind("}") + if start == -1 or end == -1 or end <= start: + raise ValueError("Model returned malformed JSON.") + payload = json.loads(repaired[start : end + 1]) + if not isinstance(payload, dict): + raise ValueError("Model JSON payload is not an object.") + return payload + + async def _repair_json_response(self, client: OllamaClient, model: str, raw: str) -> str: + repair_prompt = ( + "Repair the following malformed JSON so that it becomes valid JSON with exactly this shape: " + '{"summary":"short summary","index_html":"...","style_css":"...","script_js":"..."}.\n' + "Return JSON only. Do not add markdown fences.\n\n" + f"{raw}" + ) + return await client.chat( + model=model, + system_prompt="You repair malformed JSON. Return valid JSON only.", + user_message=repair_prompt, + ) + + async def _verify_url(self, url: str) -> bool: + try: + async with httpx.AsyncClient(timeout=3.0) as client: + response = await client.get(url) + return response.is_success + except httpx.HTTPError: + return False + + def _preferred_tool_name(self, text: str, tools: dict[str, Tool]) -> str | None: + lowered = self._normalize_intent_text(text) + if "brave_search" in tools and any(keyword in lowered for keyword in ("web", "arama", "search", "internet")): + return "brave_search" + if "browser_use" in tools and self._looks_like_browser_task(lowered): + return "browser_use" + if "apple_notes" in tools and any(keyword in lowered for keyword in ("apple notes", "notlar", "notes", "not oluştur", "yeni not", "note")): + return "apple_notes" + if "files" in tools and any(keyword in lowered for keyword in ("dosya", "readme", ".md", ".py", "klasor", "directory")): + return "files" + if "terminal" in tools and any(keyword in lowered for keyword in ("terminal", "komut", "command", "dizin", "pwd")): + return "terminal" + return None + + def _looks_like_browser_task(self, lowered: str) -> bool: + browser_terms = ("site", "browser", "tarayici", "sayfa", ".com", "http://", "https://") + browser_actions = ( + "tıkla", + "tikla", + "click", + "fill", + "type", + "press", + "listele", + "list", + "find", + "bul", + "extract", + "çıkar", + "cikar", + "getir", + "compare", + "karşılaştır", + "karsilastir", + "ilk 10", + "first 10", + "son 10", + "last 10", + "latest", + "recent", + "fiyat", + "price", + "marka", + "model", + "notify", + "notification", + "notifications", + "bildirim", + "bildirimler", + "open", + "ac", + "aç", + "git", + "go to", + ) + return any(term in lowered for term in browser_terms) and any(term in lowered for term in browser_actions) + + def _looks_like_second_brain_request(self, lowered: str) -> bool: + return any( + term in lowered + for term in ( + "ikinci beyn", + "second brain", + "anythingllm", + "notlarım", + "notlarim", + "dokümanlarım", + "dokumanlarim", + "belgelerim", + "arşivim", + "arsivim", + "workspace'imde", + "workspaceimde", + "kaydettiğim", + "kaydettigim", + ) + ) + + def _looks_like_image_request(self, text: str) -> bool: + lowered = self._normalize_intent_text(text) + return any(term in lowered for term in ("resim", "gorsel", "görsel", "foto", "image", "images", "picture")) + + def _normalize_intent_text(self, text: str) -> str: + lowered = text.casefold() + decomposed = unicodedata.normalize("NFKD", lowered) + return "".join(char for char in decomposed if not unicodedata.combining(char)) + + async def _prefetch_second_brain_context(self, text: str, tools: dict[str, Tool]) -> str: + if "second_brain" not in tools or not self._looks_like_second_brain_request(self._normalize_intent_text(text)): + return "" + payload = {"query": text, "mode": "query"} + result = await self._execute_tool_call(tools, "second_brain", payload) + self._log_tool_event("second_brain", payload, result) + if result.get("status") != "ok": + return f"- Second-brain retrieval failed: {result.get('message', 'Unknown error.')}" + return self._format_second_brain_context(result) + + def _format_second_brain_context(self, result: dict[str, object]) -> str: + lines: list[str] = [] + context = str(result.get("context", "")).strip() + if context: + lines.append(context) + sources = result.get("sources", []) + if isinstance(sources, list) and sources: + lines.append("Sources:") + for item in sources[:5]: + if not isinstance(item, dict): + continue + title = str(item.get("title", "")).strip() or "Untitled source" + url = str(item.get("url", "")).strip() + snippet = str(item.get("snippet", "")).strip() + line = f"- {title}" + if url: + line += f" -> {url}" + lines.append(line) + if snippet: + lines.append(f" {snippet[:240]}") + return "\n".join(lines).strip() + + def _render_second_brain_answer(self, result: dict[str, object]) -> str: + context = str(result.get("context", "")).strip() + if context: + return self._cleanup_second_brain_answer(context) + return "Ikinci beyinden bir baglam buldum ama metin cevabi bos geldi." + + def _cleanup_second_brain_answer(self, text: str) -> str: + cleaned = text.strip() + cleaned = re.sub(r"^patron,\s*notlara göre:\s*", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"^notlara göre:\s*", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"\n{2,}", "\n", cleaned) + lines = [line.strip(" -*") for line in cleaned.splitlines() if line.strip()] + if not lines: + return cleaned + filtered: list[str] = [] + for line in lines: + lowered = line.casefold() + if lowered.startswith("ek not"): + continue + filtered.append(line) + if not filtered: + filtered = lines + if len(filtered) == 1: + return filtered[0] + if len(filtered) >= 2 and any("tarih" in item.casefold() for item in filtered) and any("yer" in item.casefold() for item in filtered): + date_value = "" + place_value = "" + for item in filtered: + lowered = item.casefold() + if "tarih" in lowered and ":" in item: + date_value = item.split(":", 1)[1].strip() + if "yer" in lowered and ":" in item: + place_value = item.split(":", 1)[1].strip() + if date_value and place_value: + return f"{date_value} tarihinde {place_value}'de buluştun." + return filtered[0] + + def _active_model(self, runtime: RuntimeSettings) -> str: + return runtime.local_model if runtime.model_provider == "local" else runtime.zai_model + + def _build_llm_client(self, runtime: RuntimeSettings) -> OllamaClient: + settings = get_settings() + api_key = "" + base_url = runtime.local_base_url + if runtime.model_provider == "zai": + secret = self.session.get(SecretORM, "zai_api_key") + api_key = secret.value if secret else settings.zai_api_key + base_url = settings.zai_base_url + return OllamaClient(base_url=base_url, provider=runtime.model_provider, api_key=api_key) + + def _extract_url(self, text: str) -> str: + match = re.search(r"https?://[^\s)]+", text) + if match: + return match.group(0) + domain_match = re.search(r"\b([a-z0-9-]+\.(?:com|net|org|io|ai|dev|app|co|co\.uk))(?:/[^\s]*)?\b", text, re.IGNORECASE) + if domain_match: + return f"https://{domain_match.group(0)}" + return "" + + def _format_llm_error(self, exc: Exception, provider: str) -> str: + message = str(exc).strip() or "Unknown LLM error." + if "rate limit" in message.lower() or "too many requests" in message.lower() or "429" in message: + if provider == "zai": + return "Z.AI su anda istek sinirina takildi. Biraz bekleyip tekrar deneyin." + return "LLM endpointi su anda cok fazla istek aliyor. Biraz bekleyip tekrar deneyin." + if "authentication failed" in message.lower() or "api key" in message.lower(): + if provider == "zai": + return "Z.AI API key gecersiz ya da eksik gorunuyor. Admin panelden anahtari kontrol edin." + return "LLM kimlik dogrulamasi basarisiz oldu." + if "timed out" in message.lower(): + return "LLM istegi zaman asimina ugradi. Tekrar deneyin." + return f"WiseClaw configured LLM provider ile konusamadi: {message}" + + def _normalize_brave_search_arguments(self, user_text: str, arguments: dict[str, object]) -> dict[str, object]: + normalized = dict(arguments) + query = str(normalized.get("query", "")).strip() + if not query: + return normalized + + user_lower = user_text.lower() + user_has_year = bool(re.search(r"\b20\d{2}\b", user_text)) + query_has_year = bool(re.search(r"\b20\d{2}\b", query)) + current_year = str(datetime.now().year) + current_info_request = any( + term in user_lower + for term in ("bugün", "bugun", "today", "güncel", "guncel", "latest", "current", "son") + ) + + if not user_has_year and query_has_year: + if current_info_request: + query = re.sub(r"\b20\d{2}\b", current_year, query) + else: + query = re.sub(r"\b20\d{2}\b", "", query) + query = re.sub(r"\s{2,}", " ", query).strip() + + normalized["query"] = query + return normalized + + def _build_image_media_items(self, images: list[object], limit: int = 3) -> list[dict[str, str]]: + media: list[dict[str, str]] = [] + for item in images: + if not isinstance(item, dict): + continue + chosen_url = "" + for candidate in (item.get("properties_url"), item.get("thumbnail"), item.get("url")): + url = str(candidate or "").strip() + if url.startswith("http://") or url.startswith("https://"): + chosen_url = url + break + if not chosen_url: + continue + title = str(item.get("title", "")).strip() or "Gorsel" + source = str(item.get("source", "")).strip() + caption = title + if source: + caption = f"{title}\nKaynak: {source}" + media.append({"url": chosen_url, "caption": caption[:900]}) + if len(media) >= limit: + break + return media + + def _build_brave_combo_summary(self, images: list[object], web_result: dict[str, object]) -> str: + lines = [] + if images: + lines.append("Brave gorsel arama sonuclari:") + for item in images[:5]: + if not isinstance(item, dict): + continue + title = str(item.get("title", "")).strip() or "Gorsel" + source = str(item.get("source", "")).strip() + lines.append(f"- {title}" + (f" ({source})" if source else "")) + web_results = web_result.get("results", []) if isinstance(web_result, dict) else [] + if isinstance(web_results, list) and web_results: + if lines: + lines.append("") + lines.append("Web sonuclari:") + for item in web_results[:3]: + if not isinstance(item, dict): + continue + title = str(item.get("title", "")).strip() or "Sonuc" + url = str(item.get("url", "")).strip() + lines.append(f"- {title}" + (f" -> {url}" if url else "")) + return "\n".join(lines) if lines else "Arama sonucu bulundu." + + def _encode_media_response(self, text: str, media: list[dict[str, str]]) -> str: + return "__WC_MEDIA__" + json.dumps({"text": text, "media": media}, ensure_ascii=False) + + def _should_fallback_to_local(self, exc: Exception, runtime: RuntimeSettings) -> bool: + if runtime.model_provider != "zai": + return False + message = str(exc).lower() + if any(token in message for token in ("authentication failed", "api key", "rate limit", "too many requests", "429")): + return False + return any( + token in message + for token in ( + "nodename nor servname provided", + "name or service not known", + "temporary failure in name resolution", + "connecterror", + "all connection attempts failed", + "network is unreachable", + ) + ) + + def _extract_apple_note_request(self, text: str) -> dict[str, object] | None: + match = re.search(r'[“"](.*?)[”"]', text) + if match: + title = match.group(1).strip() + else: + match = re.search(r"(?:başlığıyla|basligiyla|başlıklı|baslikli)\s+(.+?)(?:\s+yeni not|\s+not oluştur|\s+not olustur|$)", text, re.IGNORECASE) + title = match.group(1).strip() if match else "" + + if not title: + return None + + folder = "" + folder_match = re.search( + r"[“\"]?([^”\"\n,]+)[”\"]?\s+(?:klas[oö]r[üu]ne|klas[oö]r[üu]nda|folder(?:üne|une|da|de)?|notlar klas[oö]r[üu]ne)", + text, + re.IGNORECASE, + ) + if folder_match: + folder = folder_match.group(1).strip() + + due = "" + due_match = re.search( + r"(?:son tarih|teslim tarihi|due date|tarih)\s+(?:olarak\s+)?([0-9]{1,2}[./-][0-9]{1,2}[./-][0-9]{2,4}|yarın|yarin|bugün|bugun|haftaya|gelecek hafta)", + text, + re.IGNORECASE, + ) + if due_match: + due = due_match.group(1).strip() + + priority = "" + priority_match = re.search( + r"(?:öncelik|oncelik|priority)\s+(?:olarak\s+)?(yüksek|yuksek|orta|düşük|dusuk|high|medium|low)", + text, + re.IGNORECASE, + ) + if priority_match: + priority = priority_match.group(1).strip() + + tags: list[str] = [] + tags_match = re.search( + r"(?:etiket(?:ler)?|tag(?:ler)?)(?:\s+olarak)?\s+(.+?)(?:,\s*içine|,\s*icine|\.\s*|$)", + text, + re.IGNORECASE, + ) + if tags_match: + tag_source = tags_match.group(1) + tags = [ + re.sub(r"\s+(?:olsun|olsunlar|yaz|ekle)$", "", part.strip(" #,\t ."), flags=re.IGNORECASE) + for part in re.split(r",|\n|;", tag_source) + if part.strip(" #,\t") + ] + + body = "" + body_match = re.search( + r"(?:içine|icine|içerik olarak|icerik olarak|gövdesine|govdesine|şunları yaz|sunlari yaz|şunlari ekle|sunlari ekle)\s*(.+)$", + text, + re.IGNORECASE | re.DOTALL, + ) + if body_match: + raw_body = body_match.group(1).strip() + raw_body = re.sub(r"\s*(?:oluştur|olustur|ekle|yaz)\.?\s*$", "", raw_body, flags=re.IGNORECASE).strip() + body = self._normalize_note_body(raw_body, text) + + body = self._compose_note_body(body=body, due=due, priority=priority, tags=tags) + + note_request: dict[str, object] = { + "action": "create_note", + "title": title, + "body": body, + } + if folder: + note_request["folder"] = folder + return note_request + + def _normalize_note_body(self, raw_body: str, full_text: str) -> str: + bullet_source = raw_body.replace("•", ",").replace(";", ",") + parts = [part.strip(" -\n\r\t") for part in re.split(r",|\n", bullet_source) if part.strip(" -\n\r\t")] + checklist_hint = any(keyword in full_text.lower() for keyword in ("checklist", "görev listesi", "gorev listesi", "yapılacak", "yapilacak", "todo")) + if len(parts) >= 2: + marker = "- [ ]" if checklist_hint else "-" + return "\n".join(f"{marker} {part}" for part in parts) + return raw_body.strip() + + def _compose_note_body(self, body: str, due: str, priority: str, tags: list[str]) -> str: + meta_lines: list[str] = [] + if due: + meta_lines.append(f"Son tarih: {due}") + if priority: + meta_lines.append(f"Öncelik: {priority}") + if tags: + meta_lines.append("Etiketler: " + ", ".join(f"#{tag.lstrip('#')}" for tag in tags)) + + if body and meta_lines: + return "\n".join(meta_lines) + "\n\n" + body + if body: + return body + if meta_lines: + return "\n".join(meta_lines) + return "" diff --git a/backend/app/profile/store.py b/backend/app/profile/store.py new file mode 100644 index 0000000..7c99ca8 --- /dev/null +++ b/backend/app/profile/store.py @@ -0,0 +1,276 @@ +import json +from datetime import datetime + +from sqlalchemy.orm import Session + +from app.db import AuditLogORM, TelegramUserProfileORM +from app.models import UserProfileRecord + + +SKIP_TOKENS = {"pas", "gec", "geç", "skip", "-"} + +ONBOARDING_QUESTIONS: list[dict[str, str]] = [ + {"field": "display_name", "prompt": "1/12 Sana nasıl hitap etmeliyim?"}, + {"field": "bio", "prompt": "2/12 Kısaca kendini nasıl tanıtırsın?"}, + {"field": "occupation", "prompt": "3/12 En çok hangi işle uğraşıyorsun?"}, + {"field": "primary_use_cases", "prompt": "4/12 WiseClaw'ı en çok hangi işler için kullanacaksın? Virgülle ayırabilirsin."}, + {"field": "answer_priorities", "prompt": "5/12 Cevaplarımda en çok neye önem veriyorsun? Örnek: hız, detay, yaratıcılık, teknik doğruluk."}, + {"field": "tone_preference", "prompt": "6/12 Nasıl bir tonda konuşayım?"}, + {"field": "response_length", "prompt": "7/12 Cevaplar kısa mı, orta mı, detaylı mı olsun?"}, + {"field": "language_preference", "prompt": "8/12 Hangi dilde konuşalım?"}, + {"field": "workflow_preference", "prompt": "9/12 İşlerde önce plan mı istersin, yoksa direkt aksiyon mu?"}, + {"field": "interests", "prompt": "10/12 Özellikle ilgilendiğin konular veya hobilerin neler? Virgülle ayırabilirsin."}, + {"field": "approval_preferences", "prompt": "11/12 Onay almadan yapmamamı istediğin şeyler neler? Virgülle ayırabilirsin."}, + {"field": "avoid_preferences", "prompt": "12/12 Özellikle kaçınmamı istediğin bir üslup veya davranış var mı?"}, +] + + +class UserProfileService: + def __init__(self, session: Session) -> None: + self.session = session + + def get_profile(self, telegram_user_id: int) -> UserProfileRecord | None: + record = self.session.get(TelegramUserProfileORM, telegram_user_id) + if record is None: + return None + return self._to_record(record) + + def start_onboarding(self, telegram_user_id: int) -> str: + record = self._get_or_create_profile(telegram_user_id) + record.onboarding_completed = False + record.last_onboarding_step = 0 + record.updated_at = datetime.utcnow() + self.session.add( + AuditLogORM(category="profile", message=f"profile:onboarding-started:{telegram_user_id}") + ) + self.session.flush() + intro = ( + "Ben WiseClaw. Seni daha iyi tanimak ve cevaplarimi sana gore ayarlamak icin 12 kisa soru soracagim.\n" + "Istersen herhangi bir soruya `pas` diyerek gecebilirsin.\n\n" + ) + return intro + ONBOARDING_QUESTIONS[0]["prompt"] + + def reset_onboarding(self, telegram_user_id: int) -> str: + record = self._get_or_create_profile(telegram_user_id) + record.display_name = None + record.bio = None + record.occupation = None + record.primary_use_cases = "[]" + record.answer_priorities = "[]" + record.tone_preference = None + record.response_length = None + record.language_preference = None + record.workflow_preference = None + record.interests = "[]" + record.approval_preferences = "[]" + record.avoid_preferences = None + record.onboarding_completed = False + record.last_onboarding_step = 0 + record.updated_at = datetime.utcnow() + self.session.add( + AuditLogORM(category="profile", message=f"profile:onboarding-reset:{telegram_user_id}") + ) + self.session.flush() + return "Profil sifirlandi. /tanisalim yazarak tekrar baslayabiliriz." + + def is_onboarding_active(self, telegram_user_id: int) -> bool: + record = self.session.get(TelegramUserProfileORM, telegram_user_id) + if record is None: + return False + return not record.onboarding_completed and record.last_onboarding_step < len(ONBOARDING_QUESTIONS) + + def answer_onboarding(self, telegram_user_id: int, text: str) -> tuple[str, bool]: + record = self._get_or_create_profile(telegram_user_id) + step = min(record.last_onboarding_step, len(ONBOARDING_QUESTIONS) - 1) + question = ONBOARDING_QUESTIONS[step] + self._apply_answer(record, question["field"], text) + record.last_onboarding_step = step + 1 + record.updated_at = datetime.utcnow() + + if record.last_onboarding_step >= len(ONBOARDING_QUESTIONS): + record.onboarding_completed = True + self.session.add( + AuditLogORM(category="profile", message=f"profile:onboarding-completed:{telegram_user_id}") + ) + self.session.flush() + return self.render_completion_message(record), True + + self.session.add( + AuditLogORM( + category="profile", + message=f"profile:onboarding-step:{telegram_user_id}:{record.last_onboarding_step}", + ) + ) + self.session.flush() + return ONBOARDING_QUESTIONS[record.last_onboarding_step]["prompt"], False + + def render_profile_summary(self, telegram_user_id: int) -> str: + record = self.session.get(TelegramUserProfileORM, telegram_user_id) + if record is None: + return "Henuz bir profilin yok. /tanisalim yazarak baslayabiliriz." + profile = self._to_record(record) + lines = [ + "Profil ozetin:", + f"- Hitap: {profile.display_name or 'belirtilmedi'}", + f"- Kisa tanitim: {profile.bio or 'belirtilmedi'}", + f"- Ugras alani: {profile.occupation or 'belirtilmedi'}", + f"- Kullanim amaci: {', '.join(profile.primary_use_cases) if profile.primary_use_cases else 'belirtilmedi'}", + f"- Oncelikler: {', '.join(profile.answer_priorities) if profile.answer_priorities else 'belirtilmedi'}", + f"- Ton: {profile.tone_preference or 'belirtilmedi'}", + f"- Cevap uzunlugu: {profile.response_length or 'belirtilmedi'}", + f"- Dil: {profile.language_preference or 'belirtilmedi'}", + f"- Calisma bicimi: {profile.workflow_preference or 'belirtilmedi'}", + f"- Ilgi alanlari: {', '.join(profile.interests) if profile.interests else 'belirtilmedi'}", + f"- Onay beklentileri: {', '.join(profile.approval_preferences) if profile.approval_preferences else 'belirtilmedi'}", + f"- Kacinmami istedigin seyler: {profile.avoid_preferences or 'belirtilmedi'}", + ] + if not profile.onboarding_completed: + lines.append( + f"- Durum: onboarding devam ediyor, sira {profile.last_onboarding_step + 1}/{len(ONBOARDING_QUESTIONS)}" + ) + return "\n".join(lines) + + def render_preferences_summary(self, telegram_user_id: int) -> str: + record = self.session.get(TelegramUserProfileORM, telegram_user_id) + if record is None: + return "Henuz tercihlerin kayitli degil. /tanisalim ile baslayabiliriz." + profile = self._to_record(record) + return "\n".join( + [ + "Tercihlerin:", + f"- Ton: {profile.tone_preference or 'belirtilmedi'}", + f"- Cevap uzunlugu: {profile.response_length or 'belirtilmedi'}", + f"- Dil: {profile.language_preference or 'belirtilmedi'}", + f"- Calisma bicimi: {profile.workflow_preference or 'belirtilmedi'}", + f"- Oncelikler: {', '.join(profile.answer_priorities) if profile.answer_priorities else 'belirtilmedi'}", + f"- Onay beklentileri: {', '.join(profile.approval_preferences) if profile.approval_preferences else 'belirtilmedi'}", + f"- Kacinmami istedigin seyler: {profile.avoid_preferences or 'belirtilmedi'}", + ] + ) + + def build_prompt_profile(self, telegram_user_id: int) -> str: + record = self.session.get(TelegramUserProfileORM, telegram_user_id) + if record is None: + return "" + profile = self._to_record(record) + instructions: list[str] = [] + if profile.display_name: + instructions.append(f"Kullaniciya `{profile.display_name}` diye hitap edebilirsin.") + if profile.language_preference: + instructions.append(f"Varsayilan dili `{profile.language_preference}` olarak kullan.") + if profile.tone_preference: + instructions.append(f"Cevap tonunu su tercihe uydur: {profile.tone_preference}.") + if profile.response_length: + instructions.append(f"Varsayilan cevap uzunlugu tercihi: {profile.response_length}.") + if profile.workflow_preference: + instructions.append(f"Is yapis tarzinda su tercihe uy: {profile.workflow_preference}.") + if profile.answer_priorities: + instructions.append( + "Kullanici su niteliklere oncelik veriyor: " + ", ".join(profile.answer_priorities) + "." + ) + if profile.primary_use_cases: + instructions.append( + "WiseClaw'i en cok su isler icin kullaniyor: " + ", ".join(profile.primary_use_cases) + "." + ) + if profile.interests: + instructions.append( + "Gerekirse ornekleri su ilgi alanlarina yaklastir: " + ", ".join(profile.interests) + "." + ) + if profile.approval_preferences: + instructions.append( + "Su konularda once onay bekle: " + ", ".join(profile.approval_preferences) + "." + ) + if profile.avoid_preferences: + instructions.append(f"Su uslup veya davranislardan kacin: {profile.avoid_preferences}.") + return "\n".join(f"- {item}" for item in instructions) + + def profile_memory_summary(self, telegram_user_id: int) -> str: + record = self.session.get(TelegramUserProfileORM, telegram_user_id) + if record is None: + return "" + profile = self._to_record(record) + parts = [] + if profile.display_name: + parts.append(f"hitap={profile.display_name}") + if profile.language_preference: + parts.append(f"dil={profile.language_preference}") + if profile.tone_preference: + parts.append(f"ton={profile.tone_preference}") + if profile.response_length: + parts.append(f"uzunluk={profile.response_length}") + if profile.workflow_preference: + parts.append(f"calisma={profile.workflow_preference}") + if profile.primary_use_cases: + parts.append("amac=" + ",".join(profile.primary_use_cases[:3])) + return "profile_summary:" + "; ".join(parts) + + def _get_or_create_profile(self, telegram_user_id: int) -> TelegramUserProfileORM: + record = self.session.get(TelegramUserProfileORM, telegram_user_id) + if record is None: + record = TelegramUserProfileORM( + telegram_user_id=telegram_user_id, + primary_use_cases="[]", + answer_priorities="[]", + interests="[]", + approval_preferences="[]", + onboarding_completed=False, + last_onboarding_step=0, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + self.session.add(record) + self.session.flush() + return record + + def _apply_answer(self, record: TelegramUserProfileORM, field: str, answer: str) -> None: + cleaned = answer.strip() + if cleaned.lower() in SKIP_TOKENS: + return + if field in {"primary_use_cases", "answer_priorities", "interests", "approval_preferences"}: + setattr(record, field, json.dumps(self._split_list(cleaned), ensure_ascii=False)) + return + setattr(record, field, cleaned) + + def _split_list(self, value: str) -> list[str]: + parts = [item.strip() for item in value.replace("\n", ",").split(",")] + return [item for item in parts if item] + + def _decode_list(self, value: str) -> list[str]: + try: + payload = json.loads(value) + except json.JSONDecodeError: + return [] + if not isinstance(payload, list): + return [] + return [str(item).strip() for item in payload if str(item).strip()] + + def _to_record(self, record: TelegramUserProfileORM) -> UserProfileRecord: + return UserProfileRecord( + telegram_user_id=record.telegram_user_id, + display_name=record.display_name, + bio=record.bio, + occupation=record.occupation, + primary_use_cases=self._decode_list(record.primary_use_cases), + answer_priorities=self._decode_list(record.answer_priorities), + tone_preference=record.tone_preference, + response_length=record.response_length, + language_preference=record.language_preference, + workflow_preference=record.workflow_preference, + interests=self._decode_list(record.interests), + approval_preferences=self._decode_list(record.approval_preferences), + avoid_preferences=record.avoid_preferences, + onboarding_completed=record.onboarding_completed, + last_onboarding_step=record.last_onboarding_step, + ) + + def render_completion_message(self, record: TelegramUserProfileORM) -> str: + profile = self._to_record(record) + summary = [ + "Seni tanidim ve tercihlerini kaydettim.", + f"- Hitap: {profile.display_name or 'belirtilmedi'}", + f"- Ton: {profile.tone_preference or 'belirtilmedi'}", + f"- Dil: {profile.language_preference or 'belirtilmedi'}", + f"- Cevap uzunlugu: {profile.response_length or 'belirtilmedi'}", + f"- Calisma bicimi: {profile.workflow_preference or 'belirtilmedi'}", + ] + return "\n".join(summary) diff --git a/backend/app/runtime.py b/backend/app/runtime.py index 6daaf64..67687fa 100644 --- a/backend/app/runtime.py +++ b/backend/app/runtime.py @@ -1,14 +1,18 @@ from contextlib import suppress +from app.automation.scheduler import AutomationScheduler from app.telegram.bot import TelegramBotService class RuntimeServices: def __init__(self) -> None: self.telegram_bot: TelegramBotService | None = None + self.automation_scheduler: AutomationScheduler | None = None async def shutdown(self) -> None: + if self.automation_scheduler is not None: + with suppress(Exception): + await self.automation_scheduler.stop() if self.telegram_bot is not None: with suppress(Exception): await self.telegram_bot.stop() - diff --git a/backend/app/second_brain/store.py b/backend/app/second_brain/store.py new file mode 100644 index 0000000..ed94c96 --- /dev/null +++ b/backend/app/second_brain/store.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +import httpx +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.db import AuditLogORM, SecondBrainCaptureORM, SecondBrainNoteORM, SecretORM, SettingORM + + +class SecondBrainService: + FILENAME = "second_brain.md" + + def __init__(self, session: Session) -> None: + self.session = session + + def start_capture(self, telegram_user_id: int) -> str: + record = self.session.get(SecondBrainCaptureORM, telegram_user_id) + if record is None: + record = SecondBrainCaptureORM( + telegram_user_id=telegram_user_id, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + self.session.add(record) + else: + record.updated_at = datetime.utcnow() + self.session.add( + AuditLogORM(category="second_brain", message=f"second_brain:capture-start:{telegram_user_id}") + ) + self.session.flush() + return "Second brain notunu gonder. Iptal etmek istersen /iptal yazabilirsin." + + def is_capture_active(self, telegram_user_id: int) -> bool: + return self.session.get(SecondBrainCaptureORM, telegram_user_id) is not None + + def cancel_capture(self, telegram_user_id: int) -> str: + record = self.session.get(SecondBrainCaptureORM, telegram_user_id) + if record is not None: + self.session.delete(record) + self.session.add( + AuditLogORM(category="second_brain", message=f"second_brain:capture-cancel:{telegram_user_id}") + ) + self.session.flush() + return "Second brain not ekleme akisini durdurdum." + + async def save_note_and_sync(self, telegram_user_id: int, text: str, workspace_root: Path) -> str: + content = text.strip() + if not content: + return "Bos bir not kaydedemem. Lutfen not metnini gonder." + + capture = self.session.get(SecondBrainCaptureORM, telegram_user_id) + if capture is not None: + self.session.delete(capture) + + note = SecondBrainNoteORM( + telegram_user_id=telegram_user_id, + content=content, + source="telegram", + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + self.session.add(note) + self.session.flush() + + markdown_path = self._write_markdown(workspace_root) + sync_result = await self._sync_markdown(markdown_path) + + self.session.add( + AuditLogORM( + category="second_brain", + message=f"second_brain:note-saved:{telegram_user_id}:{note.id}", + ) + ) + self.session.add( + AuditLogORM( + category="second_brain", + message=f"second_brain:sync:{json.dumps(sync_result, ensure_ascii=False)}", + ) + ) + self.session.flush() + + if sync_result["status"] != "ok": + message = str(sync_result.get("message", "Second brain sync failed.")) + return f"Notu kaydettim ama AnythingLLM senkronu basarisiz oldu: {message}" + + return "Notunu kaydettim ve ikinci beynine senkronladim." + + def _write_markdown(self, workspace_root: Path) -> Path: + notes = list( + self.session.scalars( + select(SecondBrainNoteORM).order_by(SecondBrainNoteORM.created_at.asc(), SecondBrainNoteORM.id.asc()) + ) + ) + lines = [ + "# Second Brain", + "", + "WiseClaw tarafindan Telegram notlarindan uretilen senkron belge.", + "", + ] + for note in notes: + timestamp = note.created_at.strftime("%Y-%m-%d %H:%M:%S") + lines.extend( + [ + f"## Note {note.id} - {timestamp}", + f"- Source: {note.source}", + f"- Telegram User: {note.telegram_user_id}", + "", + note.content, + "", + ] + ) + markdown_path = workspace_root / "backend" / self.FILENAME + markdown_path.write_text("\n".join(lines).strip() + "\n", encoding="utf-8") + return markdown_path + + async def _sync_markdown(self, markdown_path: Path) -> dict[str, object]: + settings = get_settings() + runtime_settings = { + item.key: item.value for item in self.session.scalars(select(SettingORM)) + } + base_url = runtime_settings.get("anythingllm_base_url", settings.anythingllm_base_url).rstrip("/") + workspace_slug = runtime_settings.get("anythingllm_workspace_slug", settings.anythingllm_workspace_slug).strip() + secret = self.session.get(SecretORM, "anythingllm_api_key") + api_key = secret.value if secret else settings.anythingllm_api_key + if not base_url: + return {"status": "error", "message": "AnythingLLM base URL is not configured."} + if not workspace_slug: + return {"status": "error", "message": "AnythingLLM workspace slug is not configured."} + if not api_key: + return {"status": "error", "message": "AnythingLLM API key is not configured."} + + headers = {"Authorization": f"Bearer {api_key}"} + try: + async with httpx.AsyncClient(timeout=30.0) as client: + workspace_response = await client.get( + f"{base_url}/api/v1/workspace/{workspace_slug}", + headers=headers, + ) + workspace_response.raise_for_status() + workspace_payload = workspace_response.json() + deletes = self._find_existing_second_brain_docs(workspace_payload) + if deletes: + delete_response = await client.post( + f"{base_url}/api/v1/workspace/{workspace_slug}/update-embeddings", + headers={**headers, "Content-Type": "application/json"}, + json={"deletes": deletes}, + ) + delete_response.raise_for_status() + + with markdown_path.open("rb") as file_handle: + upload_response = await client.post( + f"{base_url}/api/v1/document/upload", + headers=headers, + files={"file": (markdown_path.name, file_handle, "text/markdown")}, + ) + upload_response.raise_for_status() + upload_payload = upload_response.json() + uploaded_location = self._extract_uploaded_location(upload_payload) + if not uploaded_location: + return {"status": "error", "message": "AnythingLLM upload did not return a document location."} + + attach_response = await client.post( + f"{base_url}/api/v1/workspace/{workspace_slug}/update-embeddings", + headers={**headers, "Content-Type": "application/json"}, + json={"adds": [uploaded_location]}, + ) + attach_response.raise_for_status() + except httpx.HTTPError as exc: + return {"status": "error", "message": str(exc)} + + return {"status": "ok", "location": uploaded_location, "deleted": deletes} + + def _find_existing_second_brain_docs(self, workspace_payload: dict[str, object]) -> list[str]: + documents = [] + workspace_items = workspace_payload.get("workspace", []) + if isinstance(workspace_items, list) and workspace_items: + first = workspace_items[0] + if isinstance(first, dict): + documents = first.get("documents", []) + if not isinstance(documents, list): + return [] + + paths: list[str] = [] + for item in documents: + if not isinstance(item, dict): + continue + filename = str(item.get("filename", "")).strip() + docpath = str(item.get("docpath", "")).strip() + metadata_raw = item.get("metadata") + metadata_title = "" + if isinstance(metadata_raw, str): + try: + metadata = json.loads(metadata_raw) + if isinstance(metadata, dict): + metadata_title = str(metadata.get("title", "")).strip() + except json.JSONDecodeError: + metadata_title = "" + if ( + filename.startswith(f"{Path(self.FILENAME).stem}.md-") + or filename.startswith(self.FILENAME) + or metadata_title == self.FILENAME + ) and docpath: + paths.append(docpath) + return paths + + def _extract_uploaded_location(self, payload: dict[str, object]) -> str: + documents = payload.get("documents", []) + if not isinstance(documents, list) or not documents: + return "" + first = documents[0] + if not isinstance(first, dict): + return "" + return str(first.get("location", "")).strip() diff --git a/backend/app/security.py b/backend/app/security.py index a18bf3e..82b1fa7 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -19,6 +19,9 @@ SAFE_COMMAND_PREFIXES = ( "whoami", "uname", "ps", + "python3 -m http.server", + "python -m http.server", + "npm run build", ) APPROVAL_REQUIRED_PREFIXES = ( @@ -74,4 +77,3 @@ def evaluate_terminal_command(command: str, mode: int) -> TerminalDecision: return TerminalDecision(decision="approval", reason="Command needs approval.") return TerminalDecision(decision="approval", reason="Unknown command defaults to approval.") - diff --git a/backend/app/static_templates.py b/backend/app/static_templates.py new file mode 100644 index 0000000..56482a1 --- /dev/null +++ b/backend/app/static_templates.py @@ -0,0 +1,80 @@ +def get_game_template_hint(request_text: str) -> str: + lowered = request_text.lower() + if "three.js" in lowered or "threejs" in lowered or "webgl" in lowered or "3d" in lowered: + return THREE_JS_TEMPLATE_HINT + if "phaser" in lowered: + return PHASER_TEMPLATE_HINT + if "canvas" in lowered or "snake" in lowered or "pong" in lowered or "tetris" in lowered: + return CANVAS_TEMPLATE_HINT + return "" + + +CANVAS_TEMPLATE_HINT = """ +Starter template guidance for a plain canvas game: + +index.html +- Create a centered app shell with: + - a header area for title and score + - a main game canvas + - a mobile controls section with large directional/action buttons + - a restart button + +style.css +- Use a responsive layout that stacks nicely on mobile. +- Keep the canvas visible without horizontal scrolling. +- Add `touch-action: none;` for interactive game controls. +- Use clear visual contrast and large tap targets. + +script.js +- Create explicit game state variables. +- Create a `resizeGame()` function if canvas sizing matters. +- Create a `startGame()` / `resetGame()` flow. +- Create a `gameLoop()` driven by `requestAnimationFrame` or a timed tick. +- Add keyboard listeners and touch/click listeners. +- Keep gameplay fully self-contained without external assets. +""" + + +THREE_JS_TEMPLATE_HINT = """ +Starter template guidance for a Three.js browser game: + +index.html +- Include a UI overlay for score, status, and restart. +- Load Three.js with a browser-safe CDN module import in script.js. + +style.css +- Full-viewport scene layout. +- Overlay HUD pinned above the renderer. +- Mobile-safe action buttons if touch input is needed. + +script.js +- Set up: + - scene + - perspective camera + - renderer sized to the viewport + - ambient + directional light + - resize handler + - animation loop +- Keep geometry lightweight for mobile. +- Use simple primitives and colors instead of relying on asset pipelines. +- Implement gameplay logic on top of the render loop, not just a visual demo. +""" + + +PHASER_TEMPLATE_HINT = """ +Starter template guidance for a Phaser game: + +index.html +- Include a HUD area for score and status. +- Load Phaser from a browser-ready CDN. + +style.css +- Center the game canvas and ensure it scales on mobile. +- Add large touch-friendly controls when needed. + +script.js +- Use a Phaser config with `type`, `width`, `height`, `parent`, `backgroundColor`, and scaling rules. +- Create at least one scene with `preload`, `create`, and `update`. +- Use primitive graphics or generated shapes if no external assets are required. +- Add restart behavior and visible score/status updates outside or inside the Phaser scene. +""" diff --git a/backend/app/telegram/bot.py b/backend/app/telegram/bot.py index 9dfeaf5..ec322bf 100644 --- a/backend/app/telegram/bot.py +++ b/backend/app/telegram/bot.py @@ -1,12 +1,18 @@ +import asyncio +import json +from contextlib import suppress from typing import Any -from telegram import Update +from telegram import BotCommand, InputMediaPhoto, Update +from telegram.constants import ChatAction from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from app.orchestrator import WiseClawOrchestrator class TelegramBotService: + MAX_MESSAGE_LEN = 3500 + def __init__(self, token: str, orchestrator_factory: Any) -> None: self.token = token self.orchestrator_factory = orchestrator_factory @@ -15,15 +21,73 @@ class TelegramBotService: async def process_message(self, telegram_user_id: int, text: str) -> str: with self.orchestrator_factory() as session: orchestrator = WiseClawOrchestrator(session) - return orchestrator.handle_text_message(telegram_user_id=telegram_user_id, text=text) + return await orchestrator.handle_text_message(telegram_user_id=telegram_user_id, text=text) + + async def process_message_payload(self, telegram_user_id: int, text: str) -> dict[str, object]: + with self.orchestrator_factory() as session: + orchestrator = WiseClawOrchestrator(session) + payload = await orchestrator.handle_message_payload(telegram_user_id=telegram_user_id, text=text) + text_value = str(payload.get("text", "")) + if text_value.startswith("__WC_MEDIA__"): + try: + decoded = json.loads(text_value[len("__WC_MEDIA__") :]) + except json.JSONDecodeError: + return {"text": text_value, "media": []} + return { + "text": str(decoded.get("text", "")), + "media": decoded.get("media", []) if isinstance(decoded.get("media"), list) else [], + } + return payload + + async def send_message(self, chat_id: int, text: str) -> None: + if self.application is None: + return + for chunk in self._chunk_message(text): + await self.application.bot.send_message(chat_id=chat_id, text=chunk) + + async def send_media(self, chat_id: int, media: list[dict[str, str]]) -> None: + if self.application is None: + return + clean_media = [item for item in media[:3] if item.get("url")] + if not clean_media: + return + if len(clean_media) == 1: + item = clean_media[0] + try: + await self.application.bot.send_photo(chat_id=chat_id, photo=item["url"], caption=item.get("caption", "")[:1024]) + except Exception: + return + return + media_group = [] + for item in clean_media: + media_group.append(InputMediaPhoto(media=item["url"], caption=item.get("caption", "")[:1024])) + try: + await self.application.bot.send_media_group(chat_id=chat_id, media=media_group) + except Exception: + for item in clean_media: + try: + await self.application.bot.send_photo(chat_id=chat_id, photo=item["url"], caption=item.get("caption", "")[:1024]) + except Exception: + continue async def start(self) -> None: if not self.token: return self.application = Application.builder().token(self.token).build() self.application.add_handler(CommandHandler("start", self._on_start)) + self.application.add_handler(CommandHandler("tanisalim", self._on_command_passthrough)) + self.application.add_handler(CommandHandler("profilim", self._on_command_passthrough)) + self.application.add_handler(CommandHandler("tercihlerim", self._on_command_passthrough)) + self.application.add_handler(CommandHandler("tanisalim_sifirla", self._on_command_passthrough)) + self.application.add_handler(CommandHandler("otomasyon_ekle", self._on_command_passthrough)) + self.application.add_handler(CommandHandler("otomasyonlar", self._on_command_passthrough)) + self.application.add_handler(CommandHandler("otomasyon_durdur", self._on_command_passthrough)) + self.application.add_handler(CommandHandler("otomasyon_baslat", self._on_command_passthrough)) + self.application.add_handler(CommandHandler("otomasyon_sil", self._on_command_passthrough)) + self.application.add_handler(CommandHandler("notlarima_ekle", self._on_command_passthrough)) self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text)) await self.application.initialize() + await self.application.bot.set_my_commands(self._telegram_commands()) await self.application.start() await self.application.updater.start_polling(drop_pending_updates=True) @@ -44,8 +108,72 @@ class TelegramBotService: ) async def _on_text(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if update.message is None or update.effective_user is None or update.message.text is None: + return + typing_task = asyncio.create_task(self._send_typing(update.effective_chat.id, context)) + try: + reply = await self.process_message_payload(update.effective_user.id, update.message.text) + finally: + typing_task.cancel() + with suppress(asyncio.CancelledError): + await typing_task + media = reply.get("media", []) if isinstance(reply, dict) else [] + if isinstance(media, list) and media: + await self.send_media( + update.effective_chat.id, + [item for item in media if isinstance(item, dict)], + ) + text_reply = str(reply.get("text", "")) if isinstance(reply, dict) else str(reply) + for chunk in self._chunk_message(text_reply): + await update.message.reply_text(chunk) + + async def _on_command_passthrough(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: del context if update.message is None or update.effective_user is None or update.message.text is None: return - reply = await self.process_message(update.effective_user.id, update.message.text) - await update.message.reply_text(reply) + reply = await self.process_message_payload(update.effective_user.id, update.message.text) + media = reply.get("media", []) if isinstance(reply, dict) else [] + if isinstance(media, list) and media: + await self.send_media( + update.effective_chat.id, + [item for item in media if isinstance(item, dict)], + ) + text_reply = str(reply.get("text", "")) if isinstance(reply, dict) else str(reply) + for chunk in self._chunk_message(text_reply): + await update.message.reply_text(chunk) + + async def _send_typing(self, chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None: + while True: + await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) + await asyncio.sleep(4) + + def _chunk_message(self, text: str) -> list[str]: + if len(text) <= self.MAX_MESSAGE_LEN: + return [text] + + chunks: list[str] = [] + remaining = text + while len(remaining) > self.MAX_MESSAGE_LEN: + split_at = remaining.rfind("\n", 0, self.MAX_MESSAGE_LEN) + if split_at <= 0: + split_at = self.MAX_MESSAGE_LEN + chunks.append(remaining[:split_at].strip()) + remaining = remaining[split_at:].strip() + if remaining: + chunks.append(remaining) + return chunks + + def _telegram_commands(self) -> list[BotCommand]: + return [ + BotCommand("start", "WiseClaw'i baslat (wc)"), + BotCommand("tanisalim", "12 soruluk tanisma akisini baslat (wc)"), + BotCommand("profilim", "Kayitli profil ozetimi goster (wc)"), + BotCommand("tercihlerim", "Kayitli iletisim tercihlerini goster (wc)"), + BotCommand("tanisalim_sifirla", "Tanisma profilini sifirla (wc)"), + BotCommand("otomasyon_ekle", "Yeni otomasyon wizard'ini baslat (wc)"), + BotCommand("otomasyonlar", "Otomasyon listesini goster (wc)"), + BotCommand("otomasyon_durdur", "Bir otomasyonu durdur: /otomasyon_durdur (wc)"), + BotCommand("otomasyon_baslat", "Bir otomasyonu yeniden baslat: /otomasyon_baslat (wc)"), + BotCommand("otomasyon_sil", "Bir otomasyonu sil: /otomasyon_sil (wc)"), + BotCommand("notlarima_ekle", "Ikinci beyne yeni not ekle (wc)"), + ] diff --git a/backend/app/tools/apple_notes.py b/backend/app/tools/apple_notes.py index 9462d8c..7a4406d 100644 --- a/backend/app/tools/apple_notes.py +++ b/backend/app/tools/apple_notes.py @@ -1,18 +1,150 @@ +import asyncio from typing import Any from app.tools.base import Tool +def _escape_applescript(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def _body_to_notes_html(title: str, body: str) -> str: + if not body: + return title + html_body = body.replace("\n", "
") + return f"{title}

{html_body}" + + class AppleNotesTool(Tool): name = "apple_notes" description = "Create notes in Apple Notes through AppleScript." - async def run(self, payload: dict[str, Any]) -> dict[str, Any]: - title = str(payload.get("title", "")).strip() + def parameters_schema(self) -> dict[str, Any]: return { - "tool": self.name, - "status": "stub", - "title": title, - "message": "Apple Notes integration is not wired yet.", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create_note"], + "description": "The Apple Notes action to perform.", + }, + "title": { + "type": "string", + "description": "Title for the new note.", + }, + "body": { + "type": "string", + "description": "Optional body content for the note.", + }, + "folder": { + "type": "string", + "description": "Optional Notes folder name. Defaults to Notes.", + }, + }, + "required": ["action", "title"], + "additionalProperties": False, } + async def run(self, payload: dict[str, Any]) -> dict[str, Any]: + action = str(payload.get("action", "create_note")).strip() + title = str(payload.get("title", "")).strip() + body = str(payload.get("body", "")).strip() + folder = str(payload.get("folder", "Notes")).strip() or "Notes" + + if action != "create_note": + return { + "tool": self.name, + "status": "error", + "message": f"Unsupported action: {action}", + } + if not title: + return { + "tool": self.name, + "status": "error", + "message": "title is required.", + } + + note_html = _body_to_notes_html(title, body) + script = f''' +tell application "Notes" + activate + if not (exists folder "{_escape_applescript(folder)}") then + make new folder with properties {{name:"{_escape_applescript(folder)}"}} + end if + set targetFolder to folder "{_escape_applescript(folder)}" + set newNote to make new note at targetFolder with properties {{body:"{_escape_applescript(note_html)}"}} + return id of newNote +end tell +'''.strip() + + created = await self._run_osascript(script) + if created["status"] != "ok": + return { + "tool": self.name, + "status": "error", + "action": action, + "title": title, + "folder": folder, + "message": created["message"], + } + + note_id = created["stdout"] + verify_script = f''' +tell application "Notes" + set matchedNotes to every note of folder "{_escape_applescript(folder)}" whose id is "{_escape_applescript(note_id)}" + if (count of matchedNotes) is 0 then + return "NOT_FOUND" + end if + set matchedNote to item 1 of matchedNotes + return name of matchedNote +end tell +'''.strip() + verified = await self._run_osascript(verify_script) + if verified["status"] != "ok": + return { + "tool": self.name, + "status": "error", + "action": action, + "title": title, + "folder": folder, + "note_id": note_id, + "message": f'Note was created but could not be verified: {verified["message"]}', + } + + verified_title = verified["stdout"] + if verified_title == "NOT_FOUND": + return { + "tool": self.name, + "status": "error", + "action": action, + "title": title, + "folder": folder, + "note_id": note_id, + "message": "Note was created but could not be found during verification.", + } + + return { + "tool": self.name, + "status": "ok", + "action": action, + "title": title, + "body": body, + "folder": folder, + "note_id": note_id, + "verified_title": verified_title, + } + + async def _run_osascript(self, script: str) -> dict[str, str]: + process = await asyncio.create_subprocess_exec( + "osascript", + "-e", + script, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + stdout_text = stdout.decode("utf-8", errors="replace").strip() + stderr_text = stderr.decode("utf-8", errors="replace").strip() + if process.returncode != 0: + return {"status": "error", "message": stderr_text or "AppleScript command failed.", "stdout": stdout_text} + return {"status": "ok", "message": "", "stdout": stdout_text} diff --git a/backend/app/tools/base.py b/backend/app/tools/base.py index 20f70c5..89c9cd7 100644 --- a/backend/app/tools/base.py +++ b/backend/app/tools/base.py @@ -6,7 +6,19 @@ class Tool(ABC): name: str description: str + def definition(self) -> dict[str, Any]: + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters_schema(), + }, + } + + def parameters_schema(self) -> dict[str, Any]: + return {"type": "object", "properties": {}} + @abstractmethod async def run(self, payload: dict[str, Any]) -> dict[str, Any]: raise NotImplementedError - diff --git a/backend/app/tools/brave_search.py b/backend/app/tools/brave_search.py index 41ebe61..29e0b44 100644 --- a/backend/app/tools/brave_search.py +++ b/backend/app/tools/brave_search.py @@ -1,3 +1,4 @@ +import httpx from typing import Any from app.tools.base import Tool @@ -7,12 +8,119 @@ class BraveSearchTool(Tool): name = "brave_search" description = "Search the web with Brave Search." - async def run(self, payload: dict[str, Any]) -> dict[str, Any]: - query = str(payload.get("query", "")).strip() + def __init__(self, api_key: str) -> None: + self.api_key = api_key + + def parameters_schema(self) -> dict[str, Any]: return { - "tool": self.name, - "status": "stub", - "query": query, - "message": "Brave Search integration is not wired yet.", + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The web search query.", + }, + "count": { + "type": "integer", + "description": "Optional number of results from 1 to 10.", + "minimum": 1, + "maximum": 10, + }, + "mode": { + "type": "string", + "description": "Search mode: web or images.", + "enum": ["web", "images"], + }, + }, + "required": ["query"], + "additionalProperties": False, } + async def run(self, payload: dict[str, Any]) -> dict[str, Any]: + query = str(payload.get("query", "")).strip() + count = int(payload.get("count", 5) or 5) + count = max(1, min(10, count)) + mode = str(payload.get("mode", "web") or "web").strip().lower() + if mode not in {"web", "images"}: + mode = "web" + + if not query: + return { + "tool": self.name, + "status": "error", + "message": "Query is required.", + } + + if not self.api_key: + return { + "tool": self.name, + "status": "error", + "query": query, + "message": "Brave Search API key is not configured.", + } + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.get( + "https://api.search.brave.com/res/v1/images/search" + if mode == "images" + else "https://api.search.brave.com/res/v1/web/search", + headers={ + "Accept": "application/json", + "Accept-Encoding": "gzip", + "X-Subscription-Token": self.api_key, + }, + params={ + "q": query, + "count": count, + "search_lang": "en", + "country": "us", + }, + ) + response.raise_for_status() + except httpx.HTTPError as exc: + return { + "tool": self.name, + "status": "error", + "query": query, + "message": str(exc), + } + + payload_json = response.json() + if mode == "images": + images = [] + for item in payload_json.get("results", [])[:count]: + images.append( + { + "title": item.get("title", ""), + "url": item.get("url", ""), + "source": item.get("source", ""), + "thumbnail": item.get("thumbnail", {}).get("src", "") if isinstance(item.get("thumbnail"), dict) else "", + "properties_url": item.get("properties", {}).get("url", "") if isinstance(item.get("properties"), dict) else "", + } + ) + return { + "tool": self.name, + "status": "ok", + "mode": mode, + "query": query, + "images": images, + "total_results": len(images), + } + + results = [] + for item in payload_json.get("web", {}).get("results", [])[:count]: + results.append( + { + "title": item.get("title", ""), + "url": item.get("url", ""), + "description": item.get("description", ""), + } + ) + return { + "tool": self.name, + "status": "ok", + "mode": mode, + "query": query, + "results": results, + "total_results": len(results), + } diff --git a/backend/app/tools/browser_use.py b/backend/app/tools/browser_use.py new file mode 100644 index 0000000..a78f0f6 --- /dev/null +++ b/backend/app/tools/browser_use.py @@ -0,0 +1,296 @@ +import asyncio +import json +import os +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import httpx + +from app.config import Settings +from app.models import RuntimeSettings +from app.tools.base import Tool + + +class BrowserUseTool(Tool): + name = "browser_use" + description = ( + "Use the browser-use agent for higher-level real browser tasks such as navigating sites, " + "extracting lists, comparing items, and completing multi-step browsing workflows." + ) + + def __init__(self, workspace_root: Path, runtime: RuntimeSettings, settings: Settings, api_key: str) -> None: + self.workspace_root = workspace_root.resolve() + self.runtime = runtime + self.settings = settings + self.api_key = api_key + self.debug_port = 9223 + (abs(hash(str(self.workspace_root))) % 200) + self.chromium_path = ( + Path.home() + / "Library" + / "Caches" + / "ms-playwright" + / "chromium-1194" + / "chrome-mac" + / "Chromium.app" + / "Contents" + / "MacOS" + / "Chromium" + ) + + def parameters_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "The high-level browser task to complete.", + }, + "start_url": { + "type": "string", + "description": "Optional URL to open first before the agent starts.", + }, + "max_steps": { + "type": "integer", + "description": "Maximum browser-use steps before stopping. Defaults to 20.", + }, + "keep_alive": { + "type": "boolean", + "description": "Keep the browser open after the run finishes.", + }, + "allowed_domains": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional list of allowed domains for the run.", + }, + }, + "required": ["task"], + "additionalProperties": False, + } + + async def run(self, payload: dict[str, Any]) -> dict[str, Any]: + task = str(payload.get("task", "")).strip() + if not task: + return {"tool": self.name, "status": "error", "message": "task is required."} + + start_url = str(payload.get("start_url", "")).strip() + max_steps = int(payload.get("max_steps", 20)) + keep_alive = bool(payload.get("keep_alive", False)) + allowed_domains = self._normalize_domains(payload.get("allowed_domains")) + + if start_url and not allowed_domains: + host = urlparse(start_url).netloc + if host: + allowed_domains = [host] + + llm_error = self._provider_readiness_error() + if llm_error is not None: + return {"tool": self.name, "status": "error", "message": llm_error} + + try: + result = await self._run_agent( + task=self._compose_task(task, start_url), + max_steps=max_steps, + keep_alive=keep_alive, + allowed_domains=allowed_domains, + ) + except Exception as exc: + return { + "tool": self.name, + "status": "error", + "message": str(exc), + } + + return { + "tool": self.name, + "status": "ok" if result["success"] else "error", + **result, + } + + async def _run_agent( + self, + task: str, + max_steps: int, + keep_alive: bool, + allowed_domains: list[str], + ) -> dict[str, Any]: + from browser_use import Agent, Browser, ChatAnthropic, ChatOpenAI + + cdp_url = await self._ensure_persistent_browser() + browser = Browser( + cdp_url=cdp_url, + is_local=True, + keep_alive=True, + allowed_domains=allowed_domains or None, + ) + llm = self._build_llm(ChatAnthropic=ChatAnthropic, ChatOpenAI=ChatOpenAI) + agent = Agent( + task=task, + llm=llm, + browser=browser, + use_vision=True, + enable_planning=False, + max_actions_per_step=3, + display_files_in_done_text=False, + ) + + try: + history = await agent.run(max_steps=max_steps) + final_result = history.final_result() or "" + extracted = history.extracted_content() + errors = [error for error in history.errors() if error] + urls = [url for url in history.urls() if url] + return { + "success": bool(history.is_successful()), + "final_result": final_result, + "extracted_content": extracted[-10:], + "errors": errors[-5:], + "urls": urls[-10:], + "steps": history.number_of_steps(), + "actions": history.action_names()[-20:], + } + finally: + await agent.close() + + def _build_llm(self, ChatAnthropic: Any, ChatOpenAI: Any) -> Any: + if self.runtime.model_provider == "zai": + return ChatAnthropic( + model=self.runtime.zai_model, + api_key=self.api_key, + base_url=self.settings.zai_base_url, + timeout=180.0, + ) + + return ChatOpenAI( + model=self.runtime.local_model, + api_key="lm-studio", + base_url=f"{self.runtime.local_base_url.rstrip('/')}/v1", + timeout=180.0, + ) + + def _provider_readiness_error(self) -> str | None: + if self.runtime.model_provider == "zai" and not self.api_key.strip(): + return "Z.AI API key is not configured." + if self.runtime.model_provider == "local" and not self.runtime.local_base_url.strip(): + return "Local model base URL is not configured." + return None + + def _compose_task(self, task: str, start_url: str) -> str: + instructions = [ + "Work in a real browser on macOS.", + "If the task asks for list extraction, return concise structured text.", + "If a captcha or login wall blocks progress, stop immediately and say that user action is required.", + "Do not click third-party sign-in buttons such as Google, Apple, or GitHub OAuth buttons.", + "Do not open or interact with login popups or OAuth consent windows.", + "If authentication is required, leave the page open in the persistent browser and tell the user to complete login manually, then retry the task.", + "Do not submit irreversible forms or purchases unless the user explicitly asked for it.", + ] + if start_url: + instructions.append(f"Start at this URL first: {start_url}") + instructions.append(task) + return "\n".join(instructions) + + def _normalize_domains(self, value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + def _profile_root(self) -> Path: + profile_root = self.workspace_root / ".wiseclaw" / "browser-use-profile" + profile_root.mkdir(parents=True, exist_ok=True) + (profile_root / "WiseClaw").mkdir(parents=True, exist_ok=True) + return profile_root + + async def _ensure_persistent_browser(self) -> str: + state = self._load_browser_state() + if state and self._pid_is_running(int(state.get("pid", 0))): + cdp_url = await self._fetch_cdp_url(int(state["port"])) + if cdp_url: + return cdp_url + + await self._launch_persistent_browser() + cdp_url = await self._wait_for_cdp_url() + self._save_browser_state({"pid": self._read_pid_file(), "port": self.debug_port}) + return cdp_url + + async def _launch_persistent_browser(self) -> None: + executable = str(self.chromium_path if self.chromium_path.exists() else "Chromium") + profile_root = self._profile_root() + args = [ + executable, + f"--remote-debugging-port={self.debug_port}", + f"--user-data-dir={profile_root}", + "--profile-directory=WiseClaw", + "--no-first-run", + "--no-default-browser-check", + "--start-maximized", + "about:blank", + ] + process = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + start_new_session=True, + ) + self._write_pid_file(process.pid) + + async def _wait_for_cdp_url(self) -> str: + for _ in range(40): + cdp_url = await self._fetch_cdp_url(self.debug_port) + if cdp_url: + return cdp_url + await asyncio.sleep(0.5) + raise RuntimeError("Persistent Chromium browser did not expose a CDP endpoint in time.") + + async def _fetch_cdp_url(self, port: int) -> str: + try: + async with httpx.AsyncClient(timeout=2.0) as client: + response = await client.get(f"http://127.0.0.1:{port}/json/version") + response.raise_for_status() + except httpx.HTTPError: + return "" + payload = response.json() + return str(payload.get("webSocketDebuggerUrl", "")) + + def _browser_state_path(self) -> Path: + return self.workspace_root / ".wiseclaw" / "browser-use-browser.json" + + def _browser_pid_path(self) -> Path: + return self.workspace_root / ".wiseclaw" / "browser-use-browser.pid" + + def _load_browser_state(self) -> dict[str, int] | None: + path = self._browser_state_path() + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + + def _save_browser_state(self, payload: dict[str, int]) -> None: + path = self._browser_state_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload), encoding="utf-8") + + def _write_pid_file(self, pid: int) -> None: + path = self._browser_pid_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(str(pid), encoding="utf-8") + + def _read_pid_file(self) -> int: + path = self._browser_pid_path() + if not path.exists(): + return 0 + try: + return int(path.read_text(encoding="utf-8").strip()) + except ValueError: + return 0 + + def _pid_is_running(self, pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True diff --git a/backend/app/tools/files.py b/backend/app/tools/files.py index c375ea9..d04d1c3 100644 --- a/backend/app/tools/files.py +++ b/backend/app/tools/files.py @@ -6,16 +6,100 @@ from app.tools.base import Tool class FilesTool(Tool): name = "files" - description = "Read and write files within allowed paths." + description = "Read, list, and write files within the workspace." + + def __init__(self, workspace_root: Path) -> None: + self.workspace_root = workspace_root.resolve() + + def parameters_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["read", "list", "write"], + "description": "Use read to read a file, list to list a directory, or write to create/update a file.", + }, + "path": { + "type": "string", + "description": "Absolute or relative path inside the workspace.", + }, + "content": { + "type": "string", + "description": "File content for write operations.", + }, + }, + "required": ["action", "path"], + "additionalProperties": False, + } async def run(self, payload: dict[str, Any]) -> dict[str, Any]: action = str(payload.get("action", "read")).strip() - path = Path(str(payload.get("path", "")).strip()).expanduser() + raw_path = str(payload.get("path", "")).strip() + path = self._resolve_path(raw_path) + + if action == "read": + if not path.exists(): + return {"tool": self.name, "status": "error", "message": f"Path not found: {path}"} + if path.is_dir(): + return {"tool": self.name, "status": "error", "message": f"Path is a directory: {path}"} + content = path.read_text(encoding="utf-8", errors="replace") + return { + "tool": self.name, + "status": "ok", + "action": action, + "path": str(path), + "content": content[:12000], + "truncated": len(content) > 12000, + } + + if action == "list": + if not path.exists(): + return {"tool": self.name, "status": "error", "message": f"Path not found: {path}"} + if not path.is_dir(): + return {"tool": self.name, "status": "error", "message": f"Path is not a directory: {path}"} + entries = [] + for child in sorted(path.iterdir(), key=lambda item: item.name.lower())[:200]: + entries.append( + { + "name": child.name, + "type": "dir" if child.is_dir() else "file", + } + ) + return { + "tool": self.name, + "status": "ok", + "action": action, + "path": str(path), + "entries": entries, + "truncated": len(entries) >= 200, + } + + if action == "write": + content = str(payload.get("content", "")) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return { + "tool": self.name, + "status": "ok", + "action": action, + "path": str(path), + "bytes_written": len(content.encode("utf-8")), + } + return { "tool": self.name, - "status": "stub", - "action": action, - "path": str(path), - "message": "File integration is not wired yet.", + "status": "error", + "message": f"Unsupported action: {action}. Allowed actions are read, list, and write.", } + def _resolve_path(self, raw_path: str) -> Path: + candidate = Path(raw_path).expanduser() + if not candidate.is_absolute(): + candidate = (self.workspace_root / candidate).resolve() + else: + candidate = candidate.resolve() + + if self.workspace_root not in candidate.parents and candidate != self.workspace_root: + raise ValueError(f"Path is outside the workspace: {candidate}") + return candidate diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py new file mode 100644 index 0000000..36953ac --- /dev/null +++ b/backend/app/tools/registry.py @@ -0,0 +1,47 @@ +from pathlib import Path + +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.db import SecretORM +from app.models import RuntimeSettings +from app.tools.apple_notes import AppleNotesTool +from app.tools.browser_use import BrowserUseTool +from app.tools.brave_search import BraveSearchTool +from app.tools.files import FilesTool +from app.tools.second_brain import SecondBrainTool +from app.tools.terminal import TerminalTool +from app.tools.web_fetch import WebFetchTool + + +def build_tools(runtime: RuntimeSettings, workspace_root: Path, session: Session) -> dict[str, object]: + enabled = {tool.name for tool in runtime.tools if tool.enabled} + tools: dict[str, object] = {} + settings = get_settings() + + if "files" in enabled: + tools["files"] = FilesTool(workspace_root) + if "apple_notes" in enabled: + tools["apple_notes"] = AppleNotesTool() + if "browser_use" in enabled: + secret = session.get(SecretORM, "zai_api_key") + api_key = secret.value if secret else settings.zai_api_key + tools["browser_use"] = BrowserUseTool(workspace_root, runtime, settings, api_key) + if "brave_search" in enabled and runtime.search_provider == "brave": + secret = session.get(SecretORM, "brave_api_key") + api_key = secret.value if secret else settings.brave_api_key + tools["brave_search"] = BraveSearchTool(api_key) + if "second_brain" in enabled: + secret = session.get(SecretORM, "anythingllm_api_key") + api_key = secret.value if secret else settings.anythingllm_api_key + tools["second_brain"] = SecondBrainTool( + base_url=runtime.anythingllm_base_url, + workspace_slug=runtime.anythingllm_workspace_slug, + api_key=api_key, + ) + if "web_fetch" in enabled: + tools["web_fetch"] = WebFetchTool() + if "terminal" in enabled: + tools["terminal"] = TerminalTool(runtime.terminal_mode, workspace_root) + + return tools diff --git a/backend/app/tools/second_brain.py b/backend/app/tools/second_brain.py new file mode 100644 index 0000000..1eb8009 --- /dev/null +++ b/backend/app/tools/second_brain.py @@ -0,0 +1,164 @@ +from typing import Any + +import httpx + +from app.tools.base import Tool + + +class SecondBrainTool(Tool): + name = "second_brain" + description = "Search and retrieve context from the configured AnythingLLM workspace." + + def __init__(self, base_url: str, workspace_slug: str, api_key: str) -> None: + self.base_url = base_url.rstrip("/") + self.workspace_slug = workspace_slug.strip().strip("/") + self.api_key = api_key.strip() + + def parameters_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The user question to search in the second brain workspace.", + }, + "mode": { + "type": "string", + "description": "Workspace chat mode. Prefer query for retrieval-focused lookups.", + "enum": ["query", "chat"], + }, + }, + "required": ["query"], + "additionalProperties": False, + } + + async def run(self, payload: dict[str, Any]) -> dict[str, Any]: + query = str(payload.get("query", "")).strip() + mode = str(payload.get("mode", "query") or "query").strip().lower() + if mode not in {"query", "chat"}: + mode = "query" + + if not query: + return {"tool": self.name, "status": "error", "message": "Query is required."} + if not self.base_url: + return {"tool": self.name, "status": "error", "message": "AnythingLLM base URL is not configured."} + if not self.workspace_slug: + return {"tool": self.name, "status": "error", "message": "AnythingLLM workspace slug is not configured."} + if not self.api_key: + return {"tool": self.name, "status": "error", "message": "AnythingLLM API key is not configured."} + + endpoint = f"{self.base_url}/api/v1/workspace/{self.workspace_slug}/chat" + instructed_query = self._build_query_prompt(query, mode) + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + payload_candidates = [ + { + "message": instructed_query, + "mode": mode, + "sessionId": None, + "attachments": [], + }, + { + "message": instructed_query, + "mode": "chat", + "sessionId": None, + "attachments": [], + }, + { + "message": instructed_query, + "mode": "chat", + }, + ] + last_error = "" + response = None + try: + async with httpx.AsyncClient(timeout=30.0) as client: + for request_payload in payload_candidates: + response = await client.post(endpoint, headers=headers, json=request_payload) + if response.is_success: + break + last_error = self._format_error(response) + if response.status_code != 400: + response.raise_for_status() + else: + return { + "tool": self.name, + "status": "error", + "query": query, + "workspace_slug": self.workspace_slug, + "message": last_error or "AnythingLLM request failed.", + } + except httpx.HTTPError as exc: + return { + "tool": self.name, + "status": "error", + "query": query, + "workspace_slug": self.workspace_slug, + "message": str(exc), + } + + data = response.json() if response is not None else {} + text_response = self._extract_text_response(data) + sources = self._extract_sources(data) + return { + "tool": self.name, + "status": "ok", + "query": query, + "mode": mode, + "workspace_slug": self.workspace_slug, + "context": text_response, + "sources": sources, + "raw": data, + } + + def _build_query_prompt(self, query: str, mode: str) -> str: + if mode == "query": + return ( + "Only answer the exact question using the workspace context. " + "Do not add commentary, headings, bullets, extra notes, names, or related reminders. " + "If the answer contains a date and place, return only that information in one short sentence. " + "Question: " + f"{query}" + ) + return query + + def _format_error(self, response: httpx.Response) -> str: + try: + payload = response.json() + except ValueError: + return f"HTTP {response.status_code}" + if isinstance(payload, dict): + for key in ("error", "message"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return f"HTTP {response.status_code}" + + def _extract_text_response(self, data: Any) -> str: + if isinstance(data, dict): + for key in ("textResponse", "response", "answer", "text", "message"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + def _extract_sources(self, data: Any) -> list[dict[str, str]]: + if not isinstance(data, dict): + return [] + raw_sources = data.get("sources", []) + if not isinstance(raw_sources, list): + return [] + sources: list[dict[str, str]] = [] + for item in raw_sources[:6]: + if not isinstance(item, dict): + continue + sources.append( + { + "title": str(item.get("title") or item.get("source") or item.get("url") or "").strip(), + "url": str(item.get("url") or "").strip(), + "snippet": str(item.get("text") or item.get("snippet") or item.get("description") or "").strip(), + } + ) + return sources diff --git a/backend/app/tools/terminal.py b/backend/app/tools/terminal.py index 456c75a..07f4389 100644 --- a/backend/app/tools/terminal.py +++ b/backend/app/tools/terminal.py @@ -1,3 +1,6 @@ +import asyncio +import subprocess +from pathlib import Path from typing import Any from app.security import evaluate_terminal_command @@ -8,17 +11,115 @@ class TerminalTool(Tool): name = "terminal" description = "Run terminal commands under WiseClaw policy." - def __init__(self, terminal_mode: int) -> None: + def __init__(self, terminal_mode: int, workspace_root: Path) -> None: self.terminal_mode = terminal_mode + self.workspace_root = workspace_root.resolve() + + def parameters_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "A single shell command. Only safe approved prefixes run automatically.", + }, + "background": { + "type": "boolean", + "description": "Run the command in the background for long-lived local servers.", + }, + "workdir": { + "type": "string", + "description": "Optional relative workspace directory for the command.", + }, + }, + "required": ["command"], + "additionalProperties": False, + } async def run(self, payload: dict[str, Any]) -> dict[str, Any]: command = str(payload.get("command", "")).strip() + background = bool(payload.get("background", False)) + workdir = self._resolve_workdir(str(payload.get("workdir", "")).strip()) if payload.get("workdir") else self.workspace_root decision = evaluate_terminal_command(command, self.terminal_mode) + if decision.decision != "allow": + return { + "tool": self.name, + "status": "approval_required" if decision.decision == "approval" else "blocked", + "command": command, + "decision": decision.decision, + "reason": decision.reason, + } + + if background: + return self._run_background(command, decision.reason, workdir) + + try: + process = await asyncio.create_subprocess_shell( + command, + cwd=str(workdir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0) + except TimeoutError: + return { + "tool": self.name, + "status": "error", + "command": command, + "decision": decision.decision, + "reason": "Command timed out after 15 seconds.", + } + + stdout_text = stdout.decode("utf-8", errors="replace") + stderr_text = stderr.decode("utf-8", errors="replace") return { "tool": self.name, - "status": "stub", + "status": "ok" if process.returncode == 0 else "error", "command": command, "decision": decision.decision, "reason": decision.reason, + "workdir": str(workdir), + "exit_code": process.returncode, + "stdout": stdout_text[:12000], + "stderr": stderr_text[:12000], + "stdout_truncated": len(stdout_text) > 12000, + "stderr_truncated": len(stderr_text) > 12000, } + def _run_background(self, command: str, reason: str, workdir: Path) -> dict[str, Any]: + logs_dir = self.workspace_root / ".wiseclaw" / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + log_path = logs_dir / f"terminal-{abs(hash((command, str(workdir))))}.log" + log_handle = log_path.open("ab") + process = subprocess.Popen( + command, + cwd=str(workdir), + shell=True, + stdout=log_handle, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + log_handle.close() + return { + "tool": self.name, + "status": "ok", + "command": command, + "decision": "allow", + "reason": reason, + "workdir": str(workdir), + "background": True, + "pid": process.pid, + "log_path": str(log_path), + } + + def _resolve_workdir(self, raw_path: str) -> Path: + candidate = Path(raw_path).expanduser() + if not candidate.is_absolute(): + candidate = (self.workspace_root / candidate).resolve() + else: + candidate = candidate.resolve() + if self.workspace_root not in candidate.parents and candidate != self.workspace_root: + raise ValueError(f"Workdir is outside the workspace: {candidate}") + if not candidate.exists() or not candidate.is_dir(): + raise ValueError(f"Workdir is not a directory: {candidate}") + return candidate diff --git a/backend/app/tools/web_fetch.py b/backend/app/tools/web_fetch.py index b2b457c..31450a1 100644 --- a/backend/app/tools/web_fetch.py +++ b/backend/app/tools/web_fetch.py @@ -1,5 +1,8 @@ +import re from typing import Any +import httpx + from app.tools.base import Tool @@ -7,12 +10,56 @@ class WebFetchTool(Tool): name = "web_fetch" description = "Fetch a webpage and return simplified content." - async def run(self, payload: dict[str, Any]) -> dict[str, Any]: - url = str(payload.get("url", "")).strip() + def parameters_schema(self) -> dict[str, Any]: return { - "tool": self.name, - "status": "stub", - "url": url, - "message": "Web fetch integration is not wired yet.", + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The http or https URL to fetch.", + } + }, + "required": ["url"], + "additionalProperties": False, } + async def run(self, payload: dict[str, Any]) -> dict[str, Any]: + url = str(payload.get("url", "")).strip() + if not url.startswith(("http://", "https://")): + return { + "tool": self.name, + "status": "error", + "url": url, + "message": "Only http and https URLs are allowed.", + } + + try: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: + response = await client.get(url) + response.raise_for_status() + except httpx.HTTPError as exc: + return { + "tool": self.name, + "status": "error", + "url": url, + "message": str(exc), + } + + text = self._simplify_content(response.text) + return { + "tool": self.name, + "status": "ok", + "url": url, + "content_type": response.headers.get("content-type", ""), + "content": text[:12000], + "truncated": len(text) > 12000, + } + + def _simplify_content(self, content: str) -> str: + text = re.sub(r"(?is).*?", " ", content) + text = re.sub(r"(?is).*?", " ", text) + text = re.sub(r"(?s)<[^>]+>", " ", text) + text = re.sub(r" ", " ", text) + text = re.sub(r"&", "&", text) + text = re.sub(r"\s+", " ", text) + return text.strip() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3607b09..0d8b6a9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,8 +15,9 @@ dependencies = [ "sqlalchemy>=2.0.39,<3.0.0", "httpx>=0.28.0,<1.0.0", "python-telegram-bot>=22.0,<23.0", + "browser-use>=0.12.2,<1.0.0", + "anthropic>=0.76.0,<1.0.0", ] [tool.setuptools.packages.find] where = ["."] -