Compare commits

...

6 Commits

55 changed files with 8132 additions and 25 deletions

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
WISECLAW_ENV=development
WISECLAW_DB_URL=sqlite:///./wiseclaw.db
WISECLAW_MODEL_PROVIDER=local
WISECLAW_LOCAL_BASE_URL=http://127.0.0.1:1234
WISECLAW_LOCAL_MODEL=qwen3-vl-8b-instruct-mlx@5bit
WISECLAW_ZAI_BASE_URL=https://api.z.ai/api/anthropic
WISECLAW_ZAI_MODEL=glm-5
WISECLAW_ANYTHINGLLM_BASE_URL=http://127.0.0.1:3001
WISECLAW_ANYTHINGLLM_WORKSPACE_SLUG=wiseclaw
WISECLAW_SEARCH_PROVIDER=brave
WISECLAW_TELEGRAM_BOT_TOKEN=
WISECLAW_BRAVE_API_KEY=
WISECLAW_ZAI_API_KEY=
WISECLAW_ANYTHINGLLM_API_KEY=
WISECLAW_ADMIN_HOST=127.0.0.1
WISECLAW_ADMIN_PORT=8000

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
.venv/
.venv312/
__pycache__/
.pytest_cache/
.mypy_cache/
node_modules/
*.egg-info/
*.tsbuildinfo
vite.config.js
vite.config.d.ts
dist/
build/
.DS_Store
.env
wiseclaw.db
.codex/
.playwright-cli/
.wiseclaw/
backend/.wiseclaw/
backend/tmp/
backend/second_brain.md
generated_apps/
snake/
snake-game/
Yapilacak_Odevler.md

215
README.md
View File

@@ -1,25 +1,148 @@
# WiseClaw # WiseClaw
WiseClaw is a local-first personal assistant for macOS. It runs a FastAPI backend, uses Ollama for local LLM access, exposes a Telegram bot, and includes a React admin panel for settings, logs, and memory management. 🦉 WiseClaw, macOS üzerinde çalışan yerel-öncelikli bir kişisel asistan altyapısıdır. FastAPI backend, Telegram botu, React admin paneli, çoklu LLM sağlayıcı desteği, tarayıcı otomasyonu, araç çağırma, otomasyonlar ve AnythingLLM tabanlı "ikinci beyin" entegrasyonunu aynı projede bir araya getirir.
## Planned capabilities ## ✨ Neler Yapabiliyor?
- Telegram chat with whitelist support - 🤖 Telegram üzerinden konuşma, komut ve araç kullanımı
- Local Ollama integration for `qwen3.5:4b` - 🧠 `/tanisalim` ile kalıcı kullanıcı profili ve iletişim tercihleri
- Brave or SearXNG-backed web search - 🗂️ AnythingLLM tabanlı ikinci beyin sorguları
- Apple Notes integration via AppleScript - 📝 `/notlarima_ekle` ile second brain notu ekleme ve otomatik senkron
- File read/write tools - ⚙️ `/otomasyon_ekle` ile zamanlanmış görev oluşturma
- Terminal execution with policy modes - 🌐 Brave Search ile web ve görsel arama
- SQLite-backed memory, settings, and audit logs - 🧭 `browser_use` ile gerçek tarayıcıda gezinme
- macOS Keychain for secrets - 🍎 Apple Notes üzerinde not oluşturma
- 📁 Dosya okuma/yazma
- 🖥️ Politika tabanlı terminal komut çalıştırma
- 🔀 Global model sağlayıcı seçimi:
- `Local (LM Studio)`
- `Z.AI`
- 📊 Admin panelden ayarlar, loglar, memory, profiller ve otomasyonları yönetme
## Repository layout ## 🏗️ Mimari
- `backend/` FastAPI app and WiseClaw core modules - `backend/`
- `frontend/` React admin panel FastAPI uygulaması, orchestrator, tool'lar, Telegram botu ve scheduler
- `docs/` architecture and rollout notes - `frontend/`
React tabanlı admin panel
- `docs/`
Mimari notlar ve brainstorm kayıtları
## Local development ## 🧩 LLM Sağlayıcıları
WiseClaw tek bir global sağlayıcı ile çalışır:
- 🏠 `Local (LM Studio)`
Yerel OpenAI-uyumlu endpoint üzerinden çalışır
- ☁️ `Z.AI`
Anthropic-uyumlu API üzerinden `glm-4.7` ve `glm-5` modellerini kullanır
Admin panelden aktif sağlayıcı değiştirildiğinde yeni istekler seçili sağlayıcıya gider.
## 🛠️ Başlıca Tool'lar
- `brave_search`
Web ve image search
- `web_fetch`
Tekil URL çekme ve içerik okuma
- `browser_use`
Gerçek browser otomasyonu
- `apple_notes`
Apple Notes not oluşturma
- `files`
Dosya/dizin erişimi
- `terminal`
Güvenlik politikasıyla komut çalıştırma
- `second_brain`
AnythingLLM workspace context sorgulama
## 🧠 İkinci Beyin Akışı
WiseClaw, AnythingLLM'yi ikinci beyin olarak kullanabilir.
### Sorgulama
Telegram'da örnek:
```text
Notlara bak, serkan ile ne zaman ve nerde buluştum?
```
WiseClaw bu isteği `second_brain` tool'una yönlendirir, AnythingLLM workspace'inden bağlam çeker ve kısa cevap üretir.
### Not Ekleme
Telegram akışı:
```text
/notlarima_ekle
```
Ardından gönderilen not:
1. SQLite veritabanına `second_brain` kaydı olarak yazılır
2. [backend/second_brain.md](/Users/wisecolt-macmini/Project/wiseclaw/backend/second_brain.md) dosyası yeniden üretilir
3. Eski `second_brain.md` AnythingLLM workspace'inden kaldırılır
4. Yeni dosya tekrar upload edilip workspace'e bağlanır
Bu yaklaşım belge tabanlı RAG akışına daha uygun olduğu için doğrudan DB -> AnythingLLM yazmaktan daha sağlamdır.
## 💬 Telegram Komutları
- `/start`
- `/tanisalim`
- `/profilim`
- `/tercihlerim`
- `/tanisalim_sifirla`
- `/otomasyon_ekle`
- `/otomasyonlar`
- `/otomasyon_durdur <id>`
- `/otomasyon_baslat <id>`
- `/otomasyon_sil <id>`
- `/notlarima_ekle`
## ⏱️ Otomasyonlar
WiseClaw backend içinde çalışan scheduler ile zamanlanmış görevleri destekler.
Desteklenen ilk sürüm sıklıkları:
- günlük
- hafta içi
- haftalık
- saatlik
Otomasyon sonuçları:
- Telegram'a gönderilir
- audit log'a yazılır
## 🧪 Admin Panel
Admin panelde şunları yönetebilirsin:
- Runtime settings
- Model provider
- Search provider
- Brave / Z.AI / AnythingLLM secret'ları
- Telegram whitelist
- User Profiles
- Automations
- Memory
- Recent Logs
Önemli endpointler:
- `/admin/dashboard`
- `/admin/settings`
- `/admin/users`
- `/admin/profiles`
- `/admin/automations`
- `/admin/memory`
- `/admin/integrations/llm`
- `/admin/integrations/telegram`
## 🚀 Kurulum
### Backend ### Backend
@@ -39,23 +162,65 @@ npm install
npm run dev npm run dev
``` ```
### Smoke checks ## 🔐 Ortam Değişkenleri
```bash [.env.example](/Users/wisecolt-macmini/Project/wiseclaw/.env.example) dosyasını `.env` olarak kopyalayabilirsin.
cd backend
source .venv312/bin/activate
uvicorn app.main:app --reload
```
Then in another shell: Öne çıkan alanlar:
- `WISECLAW_MODEL_PROVIDER`
- `WISECLAW_LOCAL_BASE_URL`
- `WISECLAW_LOCAL_MODEL`
- `WISECLAW_ZAI_BASE_URL`
- `WISECLAW_ZAI_MODEL`
- `WISECLAW_ANYTHINGLLM_BASE_URL`
- `WISECLAW_ANYTHINGLLM_WORKSPACE_SLUG`
- `WISECLAW_TELEGRAM_BOT_TOKEN`
- `WISECLAW_BRAVE_API_KEY`
- `WISECLAW_ZAI_API_KEY`
- `WISECLAW_ANYTHINGLLM_API_KEY`
Not: secret'lar admin panelden daha sonra da kaydedilebilir.
## ✅ Hızlı Kontrol
Backend ayağa kalktıktan sonra:
```bash ```bash
curl http://127.0.0.1:8000/health curl http://127.0.0.1:8000/health
curl http://127.0.0.1:8000/bootstrap curl http://127.0.0.1:8000/bootstrap
curl http://127.0.0.1:8000/admin/integrations/ollama curl http://127.0.0.1:8000/admin/integrations/llm
curl http://127.0.0.1:8000/admin/integrations/telegram curl http://127.0.0.1:8000/admin/integrations/telegram
``` ```
## Environment bootstrap ## 🔁 Restart
Copy `.env.example` to `.env` and fill in only the values you need for the first boot. Secrets that are changed from the admin panel should be written to Keychain, not back to `.env`. Projede tek komutluk restart script'i var:
```bash
cd /Users/wisecolt-macmini/Project/wiseclaw
zsh ./restart.sh
```
Bu script:
- eski backend sürecini kapatır
- yeni `uvicorn` sürecini başlatır
- log'u `.wiseclaw/logs/backend.log` içine yazar
- health check ile ayağa kalktığını doğrular
## 📌 Notlar
- `LM Studio status: Reachable` görünüp `model is not installed` uyarısı alıyorsan, endpoint açık ama seçili model adı yüklü modellerle birebir eşleşmiyor demektir.
- AnythingLLM tarafında görünen workspace adı ile gerçek `slug` farklı olabilir.
- Brave image search sonuçları Telegram'da medya grubu olarak gönderilebilir.
- Bazı browser görevleri captcha/anti-bot nedeniyle manuel müdahale isteyebilir.
## 🧭 Geliştirme Notu
Bu repo hızlı iterasyonla büyüdüğü için bazı alanlarda bilinçli teknik borçlar bulunur. Ana yön şu anda şudur:
- daha sağlam tool routing
- daha iyi approval akışları
- second brain retrieval kalitesini artırma
- admin panel kullanılabilirliğini geliştirme

17
backend/README.md Normal file
View File

@@ -0,0 +1,17 @@
# WiseClaw Backend
FastAPI service for WiseClaw. The backend now includes:
- SQLite persistence through SQLAlchemy
- runtime/admin settings endpoints
- LM Studio integration status endpoint
- Telegram polling runtime scaffold
## Run locally
```bash
python3.12 -m venv .venv312
source .venv312/bin/activate
pip install .
uvicorn app.main:app --reload
```

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,96 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.admin.services import AdminService
from app.config import get_settings as get_app_settings
from app.db import SecretORM, get_session
from app.llm.ollama_client import OllamaClient
from app.models import AutomationRecord, MemoryRecord, OllamaStatus, RuntimeSettings, TelegramStatus, UserProfileRecord, UserRecord
router = APIRouter(prefix="/admin", tags=["admin"])
class SecretPayload(BaseModel):
key: str
value: str
def get_admin_service(session: Session = Depends(get_session)) -> AdminService:
return AdminService(session)
@router.get("/dashboard")
def get_dashboard(service: AdminService = Depends(get_admin_service)):
return service.dashboard()
@router.get("/settings", response_model=RuntimeSettings)
def get_runtime_settings(service: AdminService = Depends(get_admin_service)):
return service.get_runtime_settings()
@router.put("/settings", response_model=RuntimeSettings)
def put_settings(payload: RuntimeSettings, service: AdminService = Depends(get_admin_service)):
return service.update_runtime_settings(payload)
@router.get("/users", response_model=list[UserRecord])
def get_users(service: AdminService = Depends(get_admin_service)):
return service.list_users()
@router.post("/users", response_model=UserRecord)
def post_user(payload: UserRecord, service: AdminService = Depends(get_admin_service)):
return service.save_user(payload)
@router.get("/profiles", response_model=list[UserProfileRecord])
def get_profiles(service: AdminService = Depends(get_admin_service)):
return service.list_user_profiles()
@router.get("/automations", response_model=list[AutomationRecord])
def get_automations(service: AdminService = Depends(get_admin_service)):
return service.list_automations()
@router.get("/memory", response_model=list[MemoryRecord])
def get_memory(service: AdminService = Depends(get_admin_service)):
return service.list_memory()
@router.delete("/memory")
def delete_memory(service: AdminService = Depends(get_admin_service)):
service.clear_memory()
return {"status": "ok"}
@router.get("/secrets/{key}")
def get_secret(key: str, service: AdminService = Depends(get_admin_service)):
return {"key": key, "masked": service.get_secret_mask(key)}
@router.post("/secrets")
def post_secret(payload: SecretPayload, service: AdminService = Depends(get_admin_service)):
service.save_secret(payload.key, payload.value)
return {"status": "ok"}
@router.get("/integrations/llm", response_model=OllamaStatus)
@router.get("/integrations/ollama", response_model=OllamaStatus)
async def get_llm_status(service: AdminService = Depends(get_admin_service)):
runtime = service.get_runtime_settings()
settings = get_app_settings()
secret = service.session.get(SecretORM, "zai_api_key") if runtime.model_provider == "zai" else None
client = OllamaClient(
base_url=runtime.local_base_url if runtime.model_provider == "local" else settings.zai_base_url,
provider=runtime.model_provider,
api_key=secret.value if secret else settings.zai_api_key,
)
return await client.status(runtime.local_model if runtime.model_provider == "local" else runtime.zai_model)
@router.get("/integrations/telegram", response_model=TelegramStatus)
def get_telegram_status(service: AdminService = Depends(get_admin_service)):
return service.telegram_status()

View File

@@ -0,0 +1,215 @@
from datetime import datetime
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db import (
AuditLogORM,
AutomationORM,
AuthorizedUserORM,
DEFAULT_TOOLS,
MemoryItemORM,
SecretORM,
SettingORM,
TelegramUserProfileORM,
ToolStateORM,
list_recent_logs,
)
from app.config import get_settings
from app.models import AutomationRecord, DashboardSnapshot, MemoryRecord, RuntimeSettings, TelegramStatus, ToolToggle, UserProfileRecord, UserRecord
class AdminService:
def __init__(self, session: Session) -> None:
self.session = session
def get_runtime_settings(self) -> RuntimeSettings:
settings = {
item.key: item.value for item in self.session.scalars(select(SettingORM))
}
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"],
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=[ToolToggle(name=name, enabled=tool_records.get(name, enabled)) for name, enabled in DEFAULT_TOOLS.items()],
)
def update_runtime_settings(self, payload: RuntimeSettings) -> RuntimeSettings:
self._save_setting("terminal_mode", str(payload.terminal_mode))
self._save_setting("search_provider", payload.search_provider)
self._save_setting("model_provider", payload.model_provider)
self._save_setting("local_base_url", payload.local_base_url)
self._save_setting("local_model", payload.local_model)
self._save_setting("zai_model", payload.zai_model)
self._save_setting("anythingllm_base_url", payload.anythingllm_base_url)
self._save_setting("anythingllm_workspace_slug", payload.anythingllm_workspace_slug)
for tool in payload.tools:
record = self.session.get(ToolStateORM, tool.name)
if record is None:
self.session.add(ToolStateORM(name=tool.name, enabled=tool.enabled, updated_at=datetime.utcnow()))
else:
record.enabled = tool.enabled
record.updated_at = datetime.utcnow()
self.session.add(AuditLogORM(category="settings", message="settings:runtime-updated"))
self.session.commit()
return self.get_runtime_settings()
def dashboard(self) -> DashboardSnapshot:
return DashboardSnapshot(
settings=self.get_runtime_settings(),
whitelist_count=self.session.scalar(select(func.count()).select_from(AuthorizedUserORM)) or 0,
memory_items=self.session.scalar(select(func.count()).select_from(MemoryItemORM)) or 0,
recent_logs=list_recent_logs(self.session, limit=10),
)
def list_users(self) -> list[UserRecord]:
stmt = select(AuthorizedUserORM).order_by(AuthorizedUserORM.created_at.desc())
return [
UserRecord(
telegram_user_id=user.telegram_user_id,
username=user.username,
display_name=user.display_name,
is_active=user.is_active,
)
for user in self.session.scalars(stmt)
]
def save_user(self, user: UserRecord) -> UserRecord:
record = self.session.get(AuthorizedUserORM, user.telegram_user_id)
if record is None:
record = AuthorizedUserORM(
telegram_user_id=user.telegram_user_id,
username=user.username,
display_name=user.display_name,
is_active=user.is_active,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
self.session.add(record)
else:
record.username = user.username
record.display_name = user.display_name
record.is_active = user.is_active
record.updated_at = datetime.utcnow()
self.session.add(AuditLogORM(category="users", message=f"users:upsert:{user.telegram_user_id}"))
self.session.commit()
return user
def list_user_profiles(self) -> list[UserProfileRecord]:
stmt = select(TelegramUserProfileORM).order_by(TelegramUserProfileORM.updated_at.desc())
profiles: list[UserProfileRecord] = []
for item in self.session.scalars(stmt):
profiles.append(
UserProfileRecord(
telegram_user_id=item.telegram_user_id,
display_name=item.display_name,
bio=item.bio,
occupation=item.occupation,
primary_use_cases=self._decode_list(item.primary_use_cases),
answer_priorities=self._decode_list(item.answer_priorities),
tone_preference=item.tone_preference,
response_length=item.response_length,
language_preference=item.language_preference,
workflow_preference=item.workflow_preference,
interests=self._decode_list(item.interests),
approval_preferences=self._decode_list(item.approval_preferences),
avoid_preferences=item.avoid_preferences,
onboarding_completed=item.onboarding_completed,
last_onboarding_step=item.last_onboarding_step,
)
)
return profiles
def list_automations(self) -> list[AutomationRecord]:
stmt = select(AutomationORM).order_by(AutomationORM.created_at.desc(), AutomationORM.id.desc())
records: list[AutomationRecord] = []
for item in self.session.scalars(stmt):
records.append(
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=self._decode_list(item.days_of_week),
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,
)
)
return records
def list_memory(self) -> list[MemoryRecord]:
stmt = select(MemoryItemORM).order_by(MemoryItemORM.created_at.desc(), MemoryItemORM.id.desc()).limit(50)
return [
MemoryRecord(id=item.id, content=item.content, kind=item.kind, created_at=item.created_at)
for item in self.session.scalars(stmt)
]
def clear_memory(self) -> None:
for item in self.session.scalars(select(MemoryItemORM)):
self.session.delete(item)
self.session.add(AuditLogORM(category="memory", message="memory:cleared"))
self.session.commit()
def get_secret_mask(self, key: str) -> str:
record = self.session.get(SecretORM, key)
value = record.value if record else ""
if len(value) < 4:
return ""
return f"{value[:2]}***{value[-2:]}"
def save_secret(self, key: str, value: str) -> None:
record = self.session.get(SecretORM, key)
if record is None:
self.session.add(SecretORM(key=key, value=value, updated_at=datetime.utcnow()))
else:
record.value = value
record.updated_at = datetime.utcnow()
self.session.add(AuditLogORM(category="secrets", message=f"secrets:updated:{key}"))
self.session.commit()
def _save_setting(self, key: str, value: str) -> None:
record = self.session.get(SettingORM, key)
if record is None:
self.session.add(SettingORM(key=key, value=value, updated_at=datetime.utcnow()))
else:
record.value = value
record.updated_at = datetime.utcnow()
def telegram_status(self) -> TelegramStatus:
settings = get_settings()
configured = bool(settings.telegram_bot_token)
return TelegramStatus(
configured=configured,
polling_active=False,
message="Telegram token is configured. Polling starts when the backend boots."
if configured
else "Telegram token is not configured.",
)
def _decode_list(self, value: str) -> list[str]:
import json
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()]

View File

@@ -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))

View File

@@ -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

34
backend/app/config.py Normal file
View File

@@ -0,0 +1,34 @@
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="WISECLAW_",
extra="ignore",
)
env: str = "development"
db_url: str = "sqlite:///./wiseclaw.db"
admin_host: str = "127.0.0.1"
admin_port: int = 8000
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()

208
backend/app/db.py Normal file
View File

@@ -0,0 +1,208 @@
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, Text, create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
from app.config import get_settings
DEFAULT_TOOLS = {
"brave_search": True,
"second_brain": True,
"browser_use": True,
"searxng_search": False,
"web_fetch": True,
"apple_notes": True,
"files": True,
"terminal": True,
}
class Base(DeclarativeBase):
pass
class SettingORM(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
class ToolStateORM(Base):
__tablename__ = "tool_states"
name: Mapped[str] = mapped_column(String(100), primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
class AuthorizedUserORM(Base):
__tablename__ = "authorized_users"
telegram_user_id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str | None] = mapped_column(String(255))
display_name: Mapped[str | None] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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)
class MemoryItemORM(Base):
__tablename__ = "memory_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
kind: Mapped[str] = mapped_column(String(50), nullable=False, default="message")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
class AuditLogORM(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
category: Mapped[str] = mapped_column(String(50), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
class SecretORM(Base):
__tablename__ = "secrets"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[str] = mapped_column(Text, nullable=False)
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 {},
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def init_db() -> None:
Base.metadata.create_all(bind=engine)
with session_scope() as session:
_seed_defaults(session)
def _seed_defaults(session: Session) -> None:
for key, value in DEFAULT_SETTINGS.items():
if session.get(SettingORM, key) is None:
session.add(SettingORM(key=key, value=value))
for name, enabled in DEFAULT_TOOLS.items():
if session.get(ToolStateORM, name) is None:
session.add(ToolStateORM(name=name, enabled=enabled))
def get_session() -> Iterator[Session]:
session = SessionLocal()
try:
yield session
finally:
session.close()
@contextmanager
def session_scope() -> Iterator[Session]:
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
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)]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,323 @@
import asyncio
import json
from typing import Any
import httpx
from httpx import HTTPError, HTTPStatusError, ReadTimeout
from app.models import ModelProvider, OllamaStatus
class OllamaClient:
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:
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:
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"LLM endpoint unreachable: {exc}",
)
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 "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

View File

@@ -0,0 +1,48 @@
from datetime import datetime
from app.models import RuntimeSettings
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. "
"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 <folder>`.\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.local_model if runtime.model_provider == "local" else runtime.zai_model,
"memory": memory,
"available_tools": tool_names,
}

60
backend/app/main.py Normal file
View File

@@ -0,0 +1,60 @@
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
from app.models import HealthStatus
from app.orchestrator import WiseClawOrchestrator
from app.runtime import RuntimeServices
from app.telegram.bot import TelegramBotService
settings = get_settings()
runtime_services = RuntimeServices()
@asynccontextmanager
async def lifespan(_: FastAPI):
init_db()
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()
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=["*"],
)
app.include_router(admin_router)
@app.get("/health", response_model=HealthStatus)
def health() -> HealthStatus:
return HealthStatus()
@app.get("/bootstrap")
def bootstrap() -> dict[str, object]:
with session_scope() as session:
orchestrator = WiseClawOrchestrator(session)
runtime = orchestrator.get_runtime_settings().model_dump()
return {
"env": settings.env,
"admin_host": settings.admin_host,
"admin_port": settings.admin_port,
"runtime": runtime,
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,17 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db import MemoryItemORM
class MemoryService:
def __init__(self, session: Session) -> None:
self.session = session
def add_item(self, content: str) -> None:
self.session.add(MemoryItemORM(content=content, kind="message"))
self.session.flush()
def latest_items(self, limit: int = 10) -> list[str]:
stmt = select(MemoryItemORM).order_by(MemoryItemORM.created_at.desc(), MemoryItemORM.id.desc()).limit(limit)
return [item.content for item in self.session.scalars(stmt)]

122
backend/app/models.py Normal file
View File

@@ -0,0 +1,122 @@
from datetime import datetime
from typing import Literal
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):
service: str = "wiseclaw"
status: str = "ok"
timestamp: datetime = Field(default_factory=datetime.utcnow)
class SettingRecord(BaseModel):
key: str
value: str
updated_at: datetime = Field(default_factory=datetime.utcnow)
class ToolToggle(BaseModel):
name: str
enabled: bool
class UserRecord(BaseModel):
telegram_user_id: int
username: str | None = None
display_name: str | None = None
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"
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),
ToolToggle(name="files", enabled=True),
ToolToggle(name="terminal", enabled=True),
]
)
class DashboardSnapshot(BaseModel):
settings: RuntimeSettings
whitelist_count: int
memory_items: int
recent_logs: list[str]
class MemoryRecord(BaseModel):
id: int
content: str
kind: str
created_at: datetime
class OllamaStatus(BaseModel):
reachable: bool
provider: ModelProvider = "local"
base_url: str
model: str
installed_models: list[str] = Field(default_factory=list)
message: str
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

1037
backend/app/orchestrator.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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)

18
backend/app/runtime.py Normal file
View File

@@ -0,0 +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()

View File

@@ -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()

79
backend/app/security.py Normal file
View File

@@ -0,0 +1,79 @@
from dataclasses import dataclass
SAFE_COMMAND_PREFIXES = (
"pwd",
"ls",
"cat",
"head",
"tail",
"find",
"rg",
"wc",
"stat",
"git status",
"git diff",
"git log",
"git show",
"date",
"whoami",
"uname",
"ps",
"python3 -m http.server",
"python -m http.server",
"npm run build",
)
APPROVAL_REQUIRED_PREFIXES = (
"curl",
"wget",
"pip",
"npm",
"python",
"python3",
"node",
"git commit",
"git push",
"pkill",
"kill",
"touch",
"echo ",
)
BLOCKED_PATTERNS = (
"sudo ",
"rm -rf",
"chmod ",
"chown ",
";",
"&&",
"||",
"$(",
)
@dataclass
class TerminalDecision:
decision: str
reason: str
def evaluate_terminal_command(command: str, mode: int) -> TerminalDecision:
normalized = command.strip()
if any(pattern in normalized for pattern in BLOCKED_PATTERNS):
return TerminalDecision(decision="blocked", reason="Blocked by hard policy.")
if mode == 1:
return TerminalDecision(decision="allow", reason="Terminal mode 1 auto-runs commands.")
if mode == 2:
return TerminalDecision(decision="approval", reason="Terminal mode 2 requires approval.")
if normalized.startswith(SAFE_COMMAND_PREFIXES):
return TerminalDecision(decision="allow", reason="Safe read-only command.")
if normalized.startswith(APPROVAL_REQUIRED_PREFIXES):
return TerminalDecision(decision="approval", reason="Command needs approval.")
return TerminalDecision(decision="approval", reason="Unknown command defaults to approval.")

View File

@@ -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.
"""

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,8 @@
from sqlalchemy.orm import Session
from app.db import AuthorizedUserORM
def is_authorized(session: Session, telegram_user_id: int) -> bool:
record = session.get(AuthorizedUserORM, telegram_user_id)
return bool(record and record.is_active)

179
backend/app/telegram/bot.py Normal file
View File

@@ -0,0 +1,179 @@
import asyncio
import json
from contextlib import suppress
from typing import Any
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
self.application: Application | None = None
async def process_message(self, telegram_user_id: int, text: str) -> str:
with self.orchestrator_factory() as session:
orchestrator = WiseClawOrchestrator(session)
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)
async def stop(self) -> None:
if self.application is None:
return
await self.application.updater.stop()
await self.application.stop()
await self.application.shutdown()
self.application = None
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
del context
if update.message is None or update.effective_user is None:
return
await update.message.reply_text(
"WiseClaw is online. If your Telegram user is whitelisted, send a message to start."
)
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_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 <id> (wc)"),
BotCommand("otomasyon_baslat", "Bir otomasyonu yeniden baslat: /otomasyon_baslat <id> (wc)"),
BotCommand("otomasyon_sil", "Bir otomasyonu sil: /otomasyon_sil <id> (wc)"),
BotCommand("notlarima_ekle", "Ikinci beyne yeni not ekle (wc)"),
]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +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", "<br>")
return f"{title}<br><br>{html_body}"
class AppleNotesTool(Tool):
name = "apple_notes"
description = "Create notes in Apple Notes through AppleScript."
def parameters_schema(self) -> dict[str, Any]:
return {
"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}

24
backend/app/tools/base.py Normal file
View File

@@ -0,0 +1,24 @@
from abc import ABC, abstractmethod
from typing import Any
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

View File

@@ -0,0 +1,126 @@
import httpx
from typing import Any
from app.tools.base import Tool
class BraveSearchTool(Tool):
name = "brave_search"
description = "Search the web with Brave Search."
def __init__(self, api_key: str) -> None:
self.api_key = api_key
def parameters_schema(self) -> dict[str, Any]:
return {
"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),
}

View File

@@ -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

105
backend/app/tools/files.py Normal file
View File

@@ -0,0 +1,105 @@
from pathlib import Path
from typing import Any
from app.tools.base import Tool
class FilesTool(Tool):
name = "files"
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()
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": "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

View File

@@ -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

View File

@@ -0,0 +1,18 @@
from typing import Any
from app.tools.base import Tool
class SearXNGSearchTool(Tool):
name = "searxng_search"
description = "Search the web through a SearXNG instance."
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
query = str(payload.get("query", "")).strip()
return {
"tool": self.name,
"status": "stub",
"query": query,
"message": "SearXNG integration is not wired yet.",
}

View File

@@ -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

View File

@@ -0,0 +1,125 @@
import asyncio
import subprocess
from pathlib import Path
from typing import Any
from app.security import evaluate_terminal_command
from app.tools.base import Tool
class TerminalTool(Tool):
name = "terminal"
description = "Run terminal commands under WiseClaw policy."
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": "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

View File

@@ -0,0 +1,65 @@
import re
from typing import Any
import httpx
from app.tools.base import Tool
class WebFetchTool(Tool):
name = "web_fetch"
description = "Fetch a webpage and return simplified content."
def parameters_schema(self) -> dict[str, Any]:
return {
"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)<script.*?>.*?</script>", " ", content)
text = re.sub(r"(?is)<style.*?>.*?</style>", " ", text)
text = re.sub(r"(?s)<[^>]+>", " ", text)
text = re.sub(r"&nbsp;", " ", text)
text = re.sub(r"&amp;", "&", text)
text = re.sub(r"\s+", " ", text)
return text.strip()

23
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "wiseclaw-backend"
version = "0.1.0"
description = "FastAPI backend for WiseClaw"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.116.0,<1.0.0",
"uvicorn[standard]>=0.35.0,<1.0.0",
"pydantic-settings>=2.10.0,<3.0.0",
"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 = ["."]

29
docs/architecture.md Normal file
View File

@@ -0,0 +1,29 @@
# WiseClaw Architecture
## Core shape
WiseClaw uses a single FastAPI process with modular tool adapters:
- `telegram`: inbound/outbound bot handling and whitelist checks
- `llm`: LM Studio/OpenAI-compatible client and simple tool-routing planner
- `tools`: search, notes, files, terminal, and fetch tools
- `memory`: SQLite-backed short-term and long-term state
- `admin`: REST API for settings, logs, users, and health
## Security defaults
- Admin panel is localhost-only by default.
- Secrets are modeled separately from normal settings so they can move to Keychain cleanly.
- Terminal mode `3` is policy based:
- safe read-only commands auto-run
- mutating or networked commands require approval
- dangerous commands are blocked
## Next implementation milestones
1. Add SQLAlchemy models and Alembic migrations.
2. Replace placeholder services with real SQLite persistence.
3. Wire Telegram webhook or polling loop.
4. Add LM Studio-driven tool calling.
5. Persist secrets in macOS Keychain.
6. Build audit views and approval flows in the admin panel.

View File

@@ -0,0 +1,83 @@
---
date: 2026-03-21
topic: model-provider-switch
---
# Model Provider Switch
## What We're Building
WiseClaw admin paneline global bir model sağlayıcı seçimi ekliyoruz. Yönetici ister mevcut yerel LM Studio akışını aktif edecek, ister z.ai sağlayıcısına geçip API key ile `glm-4.7` veya `glm-5` modellerini kullanacak.
Bu seçim tüm yeni istekler için ortak runtime ayarı olacak. Yani Telegram, admin testleri ve backend orkestrasyonu seçili sağlayıcıya göre aynı LLM istemcisini kullanacak.
## Why This Approach
En sade ve güvenli çözüm global provider seçimi. Per-user ya da per-chat seçim şu aşamada gereksiz karmaşıklık getirir; secret yönetimi, UI, audit ve hata ayıklama zorlaşır.
z.ai tarafı OpenAI-uyumlu API sunduğu için mevcut istemci mimarisi çok büyük kırılım olmadan genişletilebilir. Bu da LM Studio ile z.ai arasında ortak bir soyutlama kurmayı mantıklı hale getiriyor.
## Approaches Considered
### Approach A: Tek Global Provider Ayarı
Admin panelde provider seçilir, sadece ilgili alanlar görünür, backend seçili provider'a göre çağrı yapar.
Pros:
- En basit kullanıcı deneyimi
- Backend davranışı öngörülebilir
- Secret ve runtime yönetimi kolay
Cons:
- Aynı anda iki farklı provider kullanılamaz
- Deneysel karşılaştırmalar için manuel geçiş gerekir
Best when: Ürün tek bir aktif model hattı ile çalışacaksa
### Approach B: Global Provider + Manual Override Alanı
Global seçim korunur ama bazı akışlarda provider/model override edilebilir.
Pros:
- Daha esnek
- Test ve karşılaştırma kolaylaşır
Cons:
- UI ve backend karmaşıklığı artar
- Hangi isteğin hangi modelle çalıştığı daha az net olur
Best when: Kısa vadede A/B model denemesi yapılacaksa
### Approach C: Ayrı Provider Sekmeleri ve Bağımsız Konfigürasyonlar
Hem local hem z.ai ayarları hep görünür, ama aktif flag ayrı tutulur.
Pros:
- Tüm ayarlar tek ekranda görünür
- Geçişler hızlı olur
Cons:
- UI kalabalıklaşır
- İlk sürüm için gereğinden fazla yapı
Best when: Sık sağlayıcı değişimi bekleniyorsa
## Recommendation
Approach A.
İlk sürüm için en doğru yol bu. Admin panelde:
- `Model Provider`: `local` / `zai`
- `local` seçiliyken: base URL + local model
- `zai` seçiliyken: API key + model dropdown (`glm-4.7`, `glm-5`)
Backend tarafında ortak bir LLM gateway oluşturulmalı. Seçili provider'a göre:
- Local: mevcut LM Studio/OpenAI-compatible endpoint
- Z.AI: z.ai OpenAI-compatible endpoint + bearer/api key
## Key Decisions
- Provider seçimi global olacak: sistem davranışı tek bir aktif modele bağlı kalacak.
- z.ai API key secret olarak saklanacak: normal runtime settings içine düz yazı olarak girmeyecek.
- z.ai model listesi ilk aşamada sabit olacak: `glm-4.7` ve `glm-5`.
- UI conditional olacak: sadece seçili provider'ın alanları gösterilecek.
- Backend provider-aware olacak: mevcut `ollama_base_url/default_model` yaklaşımı daha genel `provider/base_url/model` yapısına genişletilecek.
## Open Questions
- z.ai için sabit bir base URL kullanıp UI'da göstermeyelim mi, yoksa readonly/default bir alan olarak mı gösterelim?
- `glm-4.7` ve `glm-5` dışında gelecekte serbest model adı girişi de desteklenecek mi?
## Next Steps
- `/workflows:plan` seviyesinde implementasyon planına geç

View File

@@ -0,0 +1,33 @@
---
date: 2026-03-22
topic: telegram-onboarding
---
# Telegram Onboarding
## What We're Building
WiseClaw'a Telegram üzerinden `/tanışalım` komutu ile başlayan, 12 soruluk kalıcı bir onboarding sohbeti ekliyoruz. Bu akış kullanıcının adı, kullanım amacı, ton tercihi, dil tercihi, yanıt uzunluğu, çalışma biçimi ve sınırları gibi bilgileri toplar.
Toplanan veriler geçici hafızada değil, SQLite içinde yapılandırılmış bir kullanıcı profili olarak saklanır. Böylece sunucu yeniden başlasa bile WiseClaw aynı kullanıcıyla aynı üslupta konuşmaya devam eder.
## Why This Approach
Alternatif olarak cevapları yalnızca genel memory tablosuna yazmak mümkündü, ancak bu yaklaşım dağınık, kırılgan ve güncellemesi zor olurdu. Ayrı profil + onboarding state modeli daha güvenilir, sorgulanabilir ve kişiselleştirme için daha uygundur.
## Key Decisions
- `/tanışalım` Telegram komutu olacak: onboarding yalnızca istek üzerine veya ilk temas senaryosunda başlatılacak.
- 12 soru tek tek sorulacak: uzun form yerine sohbet hissi korunacak.
- Her cevap anında kaydedilecek: yarıda kalırsa kaldığı yerden devam edilebilecek.
- Veriler ayrı kullanıcı profili tablosunda tutulacak: kalıcı kişiselleştirme için.
- Prompt'a structured profile enjekte edilecek: ton, dil, uzunluk ve çalışma tercihi her cevapta uygulanacak.
- Kısa profil özeti ayrıca memory'ye yazılabilecek: ama asıl kaynak structured profile olacak.
## Open Questions
- İlk mesajda onboarding otomatik mi tetiklensin, yoksa sadece `/tanışalım` ile mi başlasın?
- Admin panelde profil düzenleme ilk sürüme dahil edilsin mi, yoksa yalnızca Telegram komutları yeterli mi?
## Next Steps
- Veri modelini ve onboarding state yapısını ekle
- Telegram command akışını oluştur
- Orchestrator içine onboarding interception ekle
- Prompt kişiselleştirme katmanını bağla
- `/profilim`, `/tercihlerim`, `/tanışalım_sifirla` yardımcı komutlarını ekle

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WiseClaw Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1820
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "wiseclaw-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "^5.8.3",
"vite": "^7.0.0"
}
}

546
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,546 @@
import { FormEvent, useEffect, useState } from "react";
import { api } from "./api";
import type {
AutomationRecord,
DashboardSnapshot,
MemoryRecord,
OllamaStatus,
RuntimeSettings,
TelegramStatus,
UserProfileRecord,
UserRecord,
} from "./types";
const defaultSettings: RuntimeSettings = {
terminal_mode: 3,
search_provider: "brave",
model_provider: "local",
local_base_url: "http://127.0.0.1:1234",
local_model: "qwen3-vl-8b-instruct-mlx@5bit",
zai_model: "glm-5",
anythingllm_base_url: "http://127.0.0.1:3001",
anythingllm_workspace_slug: "wiseclaw",
tools: [
{ name: "brave_search", enabled: true },
{ name: "second_brain", enabled: true },
{ name: "browser_use", enabled: true },
{ name: "searxng_search", enabled: false },
{ name: "web_fetch", enabled: true },
{ name: "apple_notes", enabled: true },
{ name: "files", enabled: true },
{ name: "terminal", enabled: true },
],
};
export function App() {
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null);
const [settings, setSettings] = useState<RuntimeSettings>(defaultSettings);
const [users, setUsers] = useState<UserRecord[]>([]);
const [profiles, setProfiles] = useState<UserProfileRecord[]>([]);
const [automations, setAutomations] = useState<AutomationRecord[]>([]);
const [memory, setMemory] = useState<MemoryRecord[]>([]);
const [secretMask, setSecretMask] = useState("");
const [secretValue, setSecretValue] = useState("");
const [zaiSecretMask, setZaiSecretMask] = useState("");
const [zaiSecretValue, setZaiSecretValue] = useState("");
const [anythingSecretMask, setAnythingSecretMask] = useState("");
const [anythingSecretValue, setAnythingSecretValue] = useState("");
const [ollamaStatus, setOllamaStatus] = useState<OllamaStatus | null>(null);
const [telegramStatus, setTelegramStatus] = useState<TelegramStatus | null>(null);
const [status, setStatus] = useState("Loading WiseClaw admin...");
const providerLabel = settings.model_provider === "local" ? "Local (LM Studio)" : "Z.AI";
const searchProviderLabel = settings.search_provider === "brave" ? "Brave" : "SearXNG";
const llmStatusLabel = settings.model_provider === "local" ? "LM Studio status" : "Z.AI status";
const llmStatusHint =
settings.model_provider === "local"
? "Checking local model endpoint..."
: "Checking remote Z.AI endpoint...";
useEffect(() => {
void load();
}, []);
async function load() {
try {
const [dashboardData, settingsData, userData, profileData, automationData, memoryData, secretData, zaiSecretData, anythingSecretData, ollamaData, telegramData] =
await Promise.all([
api.getDashboard(),
api.getSettings(),
api.getUsers(),
api.getProfiles(),
api.getAutomations(),
api.getMemory(),
api.getSecretMask("brave_api_key"),
api.getSecretMask("zai_api_key"),
api.getSecretMask("anythingllm_api_key"),
api.getOllamaStatus(),
api.getTelegramStatus(),
]);
setDashboard(dashboardData);
setSettings(settingsData);
setUsers(userData);
setProfiles(profileData);
setAutomations(automationData);
setMemory(memoryData);
setSecretMask(secretData.masked);
setZaiSecretMask(zaiSecretData.masked);
setAnythingSecretMask(anythingSecretData.masked);
setOllamaStatus(ollamaData);
setTelegramStatus(telegramData);
setStatus("WiseClaw admin ready.");
} catch (error) {
setStatus(error instanceof Error ? error.message : "Failed to load admin data.");
}
}
async function handleSettingsSubmit(event: FormEvent) {
event.preventDefault();
const saved = await api.saveSettings(settings);
setSettings(saved);
setStatus("Runtime settings saved.");
await load();
}
async function handleSecretSubmit(event: FormEvent) {
event.preventDefault();
if (!secretValue.trim()) {
return;
}
await api.saveSecret("brave_api_key", secretValue.trim());
setSecretValue("");
setStatus("Brave API key updated.");
await load();
}
async function handleZaiSecretSubmit(event: FormEvent) {
event.preventDefault();
if (!zaiSecretValue.trim()) {
return;
}
await api.saveSecret("zai_api_key", zaiSecretValue.trim());
setZaiSecretValue("");
setStatus("Z.AI API key updated.");
await load();
}
async function handleAnythingSecretSubmit(event: FormEvent) {
event.preventDefault();
if (!anythingSecretValue.trim()) {
return;
}
await api.saveSecret("anythingllm_api_key", anythingSecretValue.trim());
setAnythingSecretValue("");
setStatus("AnythingLLM API key updated.");
await load();
}
async function handleAddUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
const telegram_user_id = Number(form.get("telegram_user_id"));
if (!telegram_user_id) {
return;
}
await api.addUser({
telegram_user_id,
username: String(form.get("username") || "") || null,
display_name: String(form.get("display_name") || "") || null,
is_active: true,
});
event.currentTarget.reset();
setStatus("Whitelist user saved.");
await load();
}
async function handleClearMemory() {
await api.clearMemory();
setStatus("Memory cleared.");
await load();
}
return (
<div className="shell">
<aside className="sidebar">
<div className="brand">
<span className="brand-mark">WC</span>
<div>
<h1>WiseClaw</h1>
<p>Local admin console</p>
</div>
</div>
<div className="status-card">
<span>State</span>
<strong>{status}</strong>
</div>
<div className="summary-list">
<div>
<span>Whitelist</span>
<strong>{dashboard?.whitelist_count ?? 0}</strong>
</div>
<div>
<span>Memory items</span>
<strong>{dashboard?.memory_items ?? 0}</strong>
</div>
<div>
<span>Model</span>
<strong>{settings.model_provider === "local" ? settings.local_model : settings.zai_model}</strong>
</div>
</div>
</aside>
<main className="content">
<section className="panel hero">
<div>
<p className="eyebrow">Admin Panel</p>
<h2>Policy, tools, and local secrets in one place.</h2>
</div>
<div className="hero-grid">
<div>
<span>Terminal mode</span>
<strong>{settings.terminal_mode}</strong>
</div>
<div>
<span>Search provider</span>
<strong>{searchProviderLabel}</strong>
</div>
<div>
<span>Provider</span>
<strong>{providerLabel}</strong>
</div>
</div>
<div className="integration-grid">
<div className="integration-card">
<span>{llmStatusLabel}:</span>
<strong>{ollamaStatus?.reachable ? "Reachable" : "Offline"}</strong>
<p>{ollamaStatus?.message || llmStatusHint}</p>
</div>
<div className="integration-card">
<span>Telegram status:</span>
<strong>{telegramStatus?.configured ? "Configured" : "Missing token"}</strong>
<p>{telegramStatus?.message || "Checking..."}</p>
</div>
</div>
</section>
<section className="grid two-up">
<form className="panel" onSubmit={handleSettingsSubmit}>
<div className="panel-head">
<h3>Runtime Settings</h3>
<button type="submit">Save</button>
</div>
<label>
Terminal mode
<select
value={settings.terminal_mode}
onChange={(event) =>
setSettings({
...settings,
terminal_mode: Number(event.target.value) as 1 | 2 | 3,
})
}
>
<option value={1}>1: Full auto</option>
<option value={2}>2: Always ask</option>
<option value={3}>3: Policy based</option>
</select>
</label>
<label>
Model provider
<select
value={settings.model_provider}
onChange={(event) =>
setSettings({
...settings,
model_provider: event.target.value as "local" | "zai",
})
}
>
<option value="local">Local (LM Studio)</option>
<option value="zai">Z.AI</option>
</select>
</label>
<label>
Search provider
<select
value={settings.search_provider}
onChange={(event) =>
setSettings({
...settings,
search_provider: event.target.value as "brave" | "searxng",
})
}
>
<option value="brave">Brave</option>
<option value="searxng">SearXNG</option>
</select>
</label>
<label>
AnythingLLM base URL
<input
value={settings.anythingllm_base_url}
onChange={(event) => setSettings({ ...settings, anythingllm_base_url: event.target.value })}
placeholder="http://127.0.0.1:3001"
/>
</label>
<label>
AnythingLLM workspace slug
<input
value={settings.anythingllm_workspace_slug}
onChange={(event) => setSettings({ ...settings, anythingllm_workspace_slug: event.target.value })}
placeholder="wiseclaw"
/>
</label>
{settings.model_provider === "local" ? (
<>
<label>
LM Studio base URL
<input
value={settings.local_base_url}
onChange={(event) => setSettings({ ...settings, local_base_url: event.target.value })}
/>
</label>
<label>
Local model
<input
value={settings.local_model}
onChange={(event) => setSettings({ ...settings, local_model: event.target.value })}
/>
</label>
</>
) : (
<>
<p className="muted">Z.AI uses the fixed hosted API endpoint and the API key saved below.</p>
<label>
Z.AI model
<select
value={settings.zai_model}
onChange={(event) =>
setSettings({ ...settings, zai_model: event.target.value as "glm-4.7" | "glm-5" })
}
>
<option value="glm-4.7">glm-4.7</option>
<option value="glm-5">glm-5</option>
</select>
</label>
</>
)}
<div className="tool-list">
{settings.tools.map((tool) => (
<label key={tool.name} className="checkbox-row">
<input
type="checkbox"
checked={tool.enabled}
onChange={(event) =>
setSettings({
...settings,
tools: settings.tools.map((item) =>
item.name === tool.name ? { ...item, enabled: event.target.checked } : item,
),
})
}
/>
<span>{tool.name}</span>
</label>
))}
</div>
</form>
<div className="stack">
<form className="panel secret-panel" onSubmit={handleSecretSubmit}>
<div className="panel-head">
<h3>Secrets</h3>
<button type="submit">Update</button>
</div>
<p className="muted">Current Brave key: {secretMask || "not configured"}</p>
<label>
Brave API key
<input
type="password"
value={secretValue}
onChange={(event) => setSecretValue(event.target.value)}
placeholder="Paste a new key"
/>
</label>
</form>
<form className="panel secret-panel" onSubmit={handleZaiSecretSubmit}>
<div className="panel-head">
<h3>Z.AI Secret</h3>
<button type="submit">Update</button>
</div>
<p className="muted">Current Z.AI key: {zaiSecretMask || "not configured"}</p>
<label>
Z.AI API key
<input
type="password"
value={zaiSecretValue}
onChange={(event) => setZaiSecretValue(event.target.value)}
placeholder="Paste a new key"
/>
</label>
</form>
<form className="panel secret-panel" onSubmit={handleAnythingSecretSubmit}>
<div className="panel-head">
<h3>AnythingLLM Secret</h3>
<button type="submit">Update</button>
</div>
<p className="muted">Current AnythingLLM key: {anythingSecretMask || "not configured"}</p>
<label>
AnythingLLM API key
<input
type="password"
value={anythingSecretValue}
onChange={(event) => setAnythingSecretValue(event.target.value)}
placeholder="Paste a new key"
/>
</label>
</form>
<form className="panel" onSubmit={handleAddUser}>
<div className="panel-head">
<h3>Telegram Whitelist</h3>
<button type="submit">Add User</button>
</div>
<label>
Telegram user id
<input name="telegram_user_id" type="number" placeholder="123456789" />
</label>
<label>
Username
<input name="username" placeholder="@username" />
</label>
<label>
Display name
<input name="display_name" placeholder="Wise Operator" />
</label>
<div className="pill-list">
{users.length === 0 ? <span className="muted">No whitelist entries yet.</span> : null}
{users.map((user) => (
<span key={user.telegram_user_id} className="pill">
{user.display_name || user.username || user.telegram_user_id}
</span>
))}
</div>
</form>
</div>
</section>
<section className="grid two-up">
<div className="panel compact-fixed-panel">
<div className="panel-head">
<h3>User Profiles</h3>
</div>
<div className="list compact-scroll-list">
{profiles.length === 0 ? <span className="muted">No onboarding profiles yet.</span> : null}
{profiles.map((profile) => (
<div key={profile.telegram_user_id} className="list-row">
<strong>
{profile.display_name || `User ${profile.telegram_user_id}`} ·{" "}
{profile.onboarding_completed
? "Onboarding complete"
: `Step ${profile.last_onboarding_step + 1}/12`}
</strong>
<div>Telegram ID: {profile.telegram_user_id}</div>
<div>Ton: {profile.tone_preference || "belirtilmedi"}</div>
<div>Dil: {profile.language_preference || "belirtilmedi"}</div>
<div>Cevap uzunluğu: {profile.response_length || "belirtilmedi"}</div>
<div>Çalışma biçimi: {profile.workflow_preference || "belirtilmedi"}</div>
<div>
Kullanım amacı: {profile.primary_use_cases.length ? profile.primary_use_cases.join(", ") : "belirtilmedi"}
</div>
<div>
Öncelikler: {profile.answer_priorities.length ? profile.answer_priorities.join(", ") : "belirtilmedi"}
</div>
<div>
İlgi alanları: {profile.interests.length ? profile.interests.join(", ") : "belirtilmedi"}
</div>
<div>
Onay beklentileri:{" "}
{profile.approval_preferences.length ? profile.approval_preferences.join(", ") : "belirtilmedi"}
</div>
<div>Kaçınılacaklar: {profile.avoid_preferences || "belirtilmedi"}</div>
</div>
))}
</div>
</div>
<div className="panel compact-fixed-panel">
<div className="panel-head">
<h3>Automations</h3>
</div>
<div className="list compact-scroll-list">
{automations.length === 0 ? <span className="muted">No automations yet.</span> : null}
{automations.map((automation) => (
<div key={automation.id} className="list-row automation-row">
<strong>
#{automation.id} {automation.name} · {automation.status}
</strong>
<div>Telegram ID: {automation.telegram_user_id}</div>
<div>Prompt: {automation.prompt}</div>
<div>
Schedule:{" "}
{automation.schedule_type === "hourly"
? `every ${automation.interval_hours || 1} hour(s)`
: automation.schedule_type}
</div>
{automation.time_of_day ? <div>Time: {automation.time_of_day}</div> : null}
{automation.days_of_week.length ? <div>Days: {automation.days_of_week.join(", ")}</div> : null}
<div>Next run: {automation.next_run_at || "not scheduled"}</div>
<div>Last run: {automation.last_run_at || "never"}</div>
<div>Last result: {automation.last_result || "no result yet"}</div>
</div>
))}
</div>
</div>
</section>
<section className="grid two-up">
<div className="panel">
<div className="panel-head">
<h3>Memory</h3>
<button type="button" onClick={handleClearMemory}>
Clear
</button>
</div>
<div className="list scroll-list">
{memory.length === 0 ? <span className="muted">No memory yet.</span> : null}
{memory.map((item, index) => (
<div key={`${item.id}-${index}`} className="list-row">
<strong>{item.kind}</strong>
<div>{item.content}</div>
</div>
))}
</div>
</div>
<div className="panel">
<div className="panel-head">
<h3>Recent Logs</h3>
</div>
<div className="list scroll-list">
{(dashboard?.recent_logs || []).length === 0 ? (
<span className="muted">No recent logs.</span>
) : null}
{(dashboard?.recent_logs || []).map((item, index) => (
<div key={`${item}-${index}`} className="list-row">
{item}
</div>
))}
</div>
</div>
</section>
</main>
</div>
);
}

58
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,58 @@
import type {
AutomationRecord,
DashboardSnapshot,
MemoryRecord,
OllamaStatus,
RuntimeSettings,
TelegramStatus,
UserProfileRecord,
UserRecord,
} from "./types";
const API_BASE = `${window.location.protocol}//${window.location.hostname}:8000`;
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
headers: {
"Content-Type": "application/json",
},
...init,
});
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`);
}
return response.json() as Promise<T>;
}
export const api = {
getDashboard: () => request<DashboardSnapshot>("/admin/dashboard"),
getSettings: () => request<RuntimeSettings>("/admin/settings"),
saveSettings: (payload: RuntimeSettings) =>
request<RuntimeSettings>("/admin/settings", {
method: "PUT",
body: JSON.stringify(payload),
}),
getUsers: () => request<UserRecord[]>("/admin/users"),
getProfiles: () => request<UserProfileRecord[]>("/admin/profiles"),
getAutomations: () => request<AutomationRecord[]>("/admin/automations"),
addUser: (payload: UserRecord) =>
request<UserRecord>("/admin/users", {
method: "POST",
body: JSON.stringify(payload),
}),
getMemory: () => request<MemoryRecord[]>("/admin/memory"),
clearMemory: () =>
request<{ status: string }>("/admin/memory", {
method: "DELETE",
}),
getSecretMask: (key: string) => request<{ key: string; masked: string }>(`/admin/secrets/${key}`),
saveSecret: (key: string, value: string) =>
request<{ status: string }>("/admin/secrets", {
method: "POST",
body: JSON.stringify({ key, value }),
}),
getOllamaStatus: () => request<OllamaStatus>("/admin/integrations/llm"),
getTelegramStatus: () => request<TelegramStatus>("/admin/integrations/telegram"),
};

12
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

384
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,384 @@
:root {
color-scheme: light;
font-family: "IBM Plex Sans", "Avenir Next", sans-serif;
background:
radial-gradient(circle at top left, rgba(255, 209, 102, 0.35), transparent 30%),
radial-gradient(circle at top right, rgba(31, 122, 140, 0.24), transparent 32%),
linear-gradient(180deg, #f5f1e8 0%, #ebe3d2 100%);
color: #1f2421;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
button,
input,
select {
font: inherit;
}
button {
border: 0;
border-radius: 999px;
background: #1f5c66;
color: #f5f1e8;
padding: 0.72rem 1rem;
cursor: pointer;
}
input,
select {
width: 100%;
border: 1px solid rgba(31, 36, 33, 0.14);
border-radius: 16px;
background: rgba(255, 250, 242, 0.95);
padding: 0.85rem 0.9rem;
}
label {
display: grid;
gap: 0.45rem;
font-size: 0.95rem;
color: #36413d;
}
.shell {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
padding: 2rem 1.2rem;
background: rgba(32, 41, 39, 0.92);
color: #f7f2e8;
display: grid;
align-content: start;
gap: 1.2rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.9rem;
}
.brand h1,
.panel h3,
.hero h2 {
margin: 0;
}
.brand p,
.eyebrow,
.muted {
margin: 0;
color: rgba(247, 242, 232, 0.7);
}
.brand-mark {
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
border-radius: 18px;
background: linear-gradient(135deg, #f4a261, #e9c46a);
color: #18201e;
font-weight: 700;
}
.status-card,
.summary-list > div {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 1rem;
}
.summary-list {
display: grid;
gap: 0.75rem;
}
.summary-list span,
.status-card span,
.hero-grid span {
display: block;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.7;
}
.content {
padding: 2rem;
display: grid;
gap: 1.4rem;
min-width: 0;
}
.panel {
background: rgba(255, 250, 242, 0.9);
border: 1px solid rgba(31, 36, 33, 0.1);
border-radius: 28px;
padding: 1.2rem;
backdrop-filter: blur(10px);
box-shadow: 0 20px 60px rgba(72, 64, 39, 0.08);
min-width: 0;
overflow: hidden;
}
.fixed-log-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: calc(80 * 1.4em + 5.5rem);
align-self: start;
}
.compact-fixed-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: 600px;
align-self: start;
}
.hero {
display: grid;
gap: 1.1rem;
}
.hero-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
}
.hero-grid > div {
padding: 1rem;
border-radius: 20px;
background: rgba(233, 196, 106, 0.12);
}
.integration-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.integration-card {
padding: 1rem;
border-radius: 20px;
background: rgba(31, 92, 102, 0.08);
border: 1px solid rgba(31, 92, 102, 0.12);
}
.integration-card span,
.integration-card strong {
display: inline;
}
.integration-card strong {
margin-left: 0.3rem;
}
.integration-card p {
margin-bottom: 0;
color: #4f5b57;
}
.grid.two-up {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.4rem;
min-width: 0;
align-items: start;
}
.stack {
display: grid;
gap: 1.4rem;
min-width: 0;
}
.secret-panel {
padding-top: 0.64rem;
padding-bottom: 0.64rem;
}
.secret-panel .panel-head {
margin-bottom: 0.24rem;
}
.secret-panel label {
gap: 0.2rem;
}
.secret-panel .muted {
margin-bottom: 0.1rem;
}
.secret-panel form,
.secret-panel {
gap: 0.36rem;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.tool-list,
.list,
form {
display: grid;
gap: 0.9rem;
min-width: 0;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.7rem;
}
.checkbox-row input {
width: auto;
}
.pill-list {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.pill {
padding: 0.5rem 0.75rem;
border-radius: 999px;
background: rgba(31, 122, 140, 0.12);
color: #1f5c66;
}
.list-row {
padding: 0.65rem 0.75rem;
border-radius: 18px;
background: rgba(31, 36, 33, 0.05);
font-family: "IBM Plex Mono", "SF Mono", monospace;
font-size: 0.84rem;
line-height: 1.28;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
white-space: pre-wrap;
}
.list {
min-width: 0;
}
.list .list-row strong {
display: block;
margin-bottom: 0.18rem;
}
.automation-row {
height: 250px;
overflow-y: auto;
align-content: start;
scrollbar-width: thin;
scrollbar-color: rgba(31, 92, 102, 0.72) rgba(233, 196, 106, 0.2);
}
.scroll-list {
height: calc(80 * 1.4em);
max-height: calc(80 * 1.4em);
overflow-y: auto;
overflow-x: hidden;
align-content: start;
padding-right: 0.35rem;
scrollbar-width: thin;
scrollbar-color: rgba(31, 92, 102, 0.72) rgba(233, 196, 106, 0.2);
}
.compact-scroll-list {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: 0.35rem;
scrollbar-width: thin;
scrollbar-color: rgba(31, 92, 102, 0.72) rgba(233, 196, 106, 0.2);
}
.scroll-list::-webkit-scrollbar {
width: 12px;
}
.compact-scroll-list::-webkit-scrollbar {
width: 12px;
}
.scroll-list::-webkit-scrollbar-track {
background: rgba(233, 196, 106, 0.18);
border-radius: 999px;
}
.compact-scroll-list::-webkit-scrollbar-track {
background: rgba(233, 196, 106, 0.18);
border-radius: 999px;
}
.scroll-list::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(31, 122, 140, 0.88), rgba(31, 92, 102, 0.72));
border-radius: 999px;
border: 2px solid rgba(255, 250, 242, 0.9);
}
.compact-scroll-list::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(31, 122, 140, 0.88), rgba(31, 92, 102, 0.72));
border-radius: 999px;
border: 2px solid rgba(255, 250, 242, 0.9);
}
.automation-row::-webkit-scrollbar {
width: 10px;
}
.automation-row::-webkit-scrollbar-track {
background: rgba(233, 196, 106, 0.18);
border-radius: 999px;
}
.automation-row::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(31, 122, 140, 0.88), rgba(31, 92, 102, 0.72));
border-radius: 999px;
border: 2px solid rgba(255, 250, 242, 0.9);
}
.scroll-list::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(31, 122, 140, 1), rgba(31, 92, 102, 0.88));
}
@media (max-width: 960px) {
.shell,
.grid.two-up,
.hero-grid {
grid-template-columns: 1fr;
}
.sidebar {
padding-bottom: 0;
}
.content {
padding-top: 0;
}
}

87
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,87 @@
export type ToolToggle = {
name: string;
enabled: boolean;
};
export type RuntimeSettings = {
terminal_mode: 1 | 2 | 3;
search_provider: "brave" | "searxng";
model_provider: "local" | "zai";
local_base_url: string;
local_model: string;
zai_model: "glm-4.7" | "glm-5";
anythingllm_base_url: string;
anythingllm_workspace_slug: string;
tools: ToolToggle[];
};
export type DashboardSnapshot = {
settings: RuntimeSettings;
whitelist_count: number;
memory_items: number;
recent_logs: string[];
};
export type UserRecord = {
telegram_user_id: number;
username?: string | null;
display_name?: string | null;
is_active: boolean;
};
export type UserProfileRecord = {
telegram_user_id: number;
display_name?: string | null;
bio?: string | null;
occupation?: string | null;
primary_use_cases: string[];
answer_priorities: string[];
tone_preference?: string | null;
response_length?: string | null;
language_preference?: string | null;
workflow_preference?: string | null;
interests: string[];
approval_preferences: string[];
avoid_preferences?: string | null;
onboarding_completed: boolean;
last_onboarding_step: number;
};
export type AutomationRecord = {
id: number;
telegram_user_id: number;
name: string;
prompt: string;
schedule_type: "daily" | "weekdays" | "weekly" | "hourly";
interval_hours?: number | null;
time_of_day?: string | null;
days_of_week: string[];
status: "active" | "paused";
last_run_at?: string | null;
next_run_at?: string | null;
last_result?: string | null;
created_at: string;
updated_at: string;
};
export type MemoryRecord = {
id: number;
content: string;
kind: string;
created_at: string;
};
export type OllamaStatus = {
reachable: boolean;
provider: "local" | "zai";
base_url: string;
model: string;
installed_models: string[];
message: string;
};
export type TelegramStatus = {
configured: boolean;
polling_active: boolean;
message: string;
};

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

10
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
});

58
restart.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/bin/zsh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
BACKEND_DIR="$ROOT_DIR/backend"
LOG_DIR="$ROOT_DIR/.wiseclaw/logs"
PID_FILE="$ROOT_DIR/.wiseclaw/backend.pid"
LOG_FILE="$LOG_DIR/backend.log"
HEALTH_URL="http://127.0.0.1:8000/health"
mkdir -p "$LOG_DIR"
stop_existing() {
if [[ -f "$PID_FILE" ]]; then
local old_pid
old_pid="$(cat "$PID_FILE" 2>/dev/null || true)"
if [[ -n "${old_pid:-}" ]] && kill -0 "$old_pid" 2>/dev/null; then
kill "$old_pid" 2>/dev/null || true
sleep 1
fi
rm -f "$PID_FILE"
fi
pkill -f "uvicorn app.main:app --host 0.0.0.0 --port 8000" >/dev/null 2>&1 || true
}
start_backend() {
(
cd "$BACKEND_DIR"
exec /bin/zsh -lc 'set -a; source .env >/dev/null 2>&1; exec .venv312/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000'
) >"$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
}
wait_for_health() {
local attempt
for attempt in {1..20}; do
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
return 1
}
stop_existing
start_backend
if wait_for_health; then
echo "WiseClaw backend restarted."
echo "PID: $(cat "$PID_FILE")"
echo "Log: $LOG_FILE"
exit 0
fi
echo "WiseClaw backend failed to start. Check log: $LOG_FILE" >&2
exit 1