Compare commits
3 Commits
1f00d27bdb
...
d07bc365f5
| Author | SHA1 | Date | |
|---|---|---|---|
| d07bc365f5 | |||
| 62add37d9d | |||
| df1924b772 |
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
WISECLAW_ENV=development
|
||||
WISECLAW_DB_URL=sqlite:///./wiseclaw.db
|
||||
WISECLAW_OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
WISECLAW_DEFAULT_MODEL=qwen3.5:4b
|
||||
WISECLAW_SEARCH_PROVIDER=brave
|
||||
WISECLAW_TELEGRAM_BOT_TOKEN=
|
||||
WISECLAW_BRAVE_API_KEY=
|
||||
WISECLAW_ADMIN_HOST=127.0.0.1
|
||||
WISECLAW_ADMIN_PORT=8000
|
||||
|
||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
.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
|
||||
17
backend/README.md
Normal file
17
backend/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# WiseClaw Backend
|
||||
|
||||
FastAPI service for WiseClaw. The backend now includes:
|
||||
|
||||
- SQLite persistence through SQLAlchemy
|
||||
- runtime/admin settings endpoints
|
||||
- Ollama 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
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/app/admin/__init__.py
Normal file
1
backend/app/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
78
backend/app/admin/routes.py
Normal file
78
backend/app/admin/routes.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.services import AdminService
|
||||
from app.db import get_session
|
||||
from app.llm.ollama_client import OllamaClient
|
||||
from app.models import MemoryRecord, OllamaStatus, RuntimeSettings, TelegramStatus, 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_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("/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/ollama", response_model=OllamaStatus)
|
||||
async def get_ollama_status(service: AdminService = Depends(get_admin_service)):
|
||||
runtime = service.get_runtime_settings()
|
||||
client = OllamaClient(runtime.ollama_base_url)
|
||||
return await client.status(runtime.default_model)
|
||||
|
||||
|
||||
@router.get("/integrations/telegram", response_model=TelegramStatus)
|
||||
def get_telegram_status(service: AdminService = Depends(get_admin_service)):
|
||||
return service.telegram_status()
|
||||
142
backend/app/admin/services.py
Normal file
142
backend/app/admin/services.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import (
|
||||
AuditLogORM,
|
||||
AuthorizedUserORM,
|
||||
MemoryItemORM,
|
||||
SecretORM,
|
||||
SettingORM,
|
||||
ToolStateORM,
|
||||
list_recent_logs,
|
||||
)
|
||||
from app.config import get_settings
|
||||
from app.models import DashboardSnapshot, MemoryRecord, RuntimeSettings, TelegramStatus, ToolToggle, 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))
|
||||
}
|
||||
tools = list(self.session.scalars(select(ToolStateORM).order_by(ToolStateORM.name.asc())))
|
||||
return RuntimeSettings(
|
||||
terminal_mode=int(settings["terminal_mode"]),
|
||||
search_provider=settings["search_provider"],
|
||||
ollama_base_url=settings["ollama_base_url"],
|
||||
default_model=settings["default_model"],
|
||||
tools=[ToolToggle(name=tool.name, enabled=tool.enabled) for tool in tools],
|
||||
)
|
||||
|
||||
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("ollama_base_url", payload.ollama_base_url)
|
||||
self._save_setting("default_model", payload.default_model)
|
||||
|
||||
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_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.",
|
||||
)
|
||||
28
backend/app/config.py
Normal file
28
backend/app/config.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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
|
||||
ollama_base_url: str = "http://127.0.0.1:11434"
|
||||
default_model: str = "qwen3.5:4b"
|
||||
search_provider: str = "brave"
|
||||
telegram_bot_token: str = Field(default="", repr=False)
|
||||
brave_api_key: str = Field(default="", repr=False)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
133
backend/app/db.py
Normal file
133
backend/app/db.py
Normal file
@@ -0,0 +1,133 @@
|
||||
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_SETTINGS = {
|
||||
"terminal_mode": "3",
|
||||
"search_provider": "brave",
|
||||
"ollama_base_url": "http://127.0.0.1:11434",
|
||||
"default_model": "qwen3.5:4b",
|
||||
}
|
||||
|
||||
DEFAULT_TOOLS = {
|
||||
"brave_search": True,
|
||||
"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)
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
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)]
|
||||
|
||||
1
backend/app/llm/__init__.py
Normal file
1
backend/app/llm/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
37
backend/app/llm/ollama_client.py
Normal file
37
backend/app/llm/ollama_client.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import httpx
|
||||
from httpx import HTTPError
|
||||
|
||||
from app.models import OllamaStatus
|
||||
|
||||
class OllamaClient:
|
||||
def __init__(self, base_url: str) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
||||
async def health(self) -> bool:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
return response.is_success
|
||||
|
||||
async def status(self, model: str) -> OllamaStatus:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
response.raise_for_status()
|
||||
except HTTPError as exc:
|
||||
return OllamaStatus(
|
||||
reachable=False,
|
||||
base_url=self.base_url,
|
||||
model=model,
|
||||
message=f"Ollama unreachable: {exc}",
|
||||
)
|
||||
|
||||
payload = response.json()
|
||||
installed_models = [item.get("name", "") for item in payload.get("models", []) if item.get("name")]
|
||||
has_model = model in installed_models
|
||||
return OllamaStatus(
|
||||
reachable=True,
|
||||
base_url=self.base_url,
|
||||
model=model,
|
||||
installed_models=installed_models,
|
||||
message="Model found." if has_model else "Ollama reachable but model is not installed.",
|
||||
)
|
||||
15
backend/app/llm/planner.py
Normal file
15
backend/app/llm/planner.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from app.models import RuntimeSettings
|
||||
|
||||
|
||||
def build_prompt_context(message: str, runtime: RuntimeSettings, memory: list[str]) -> dict[str, object]:
|
||||
return {
|
||||
"system": (
|
||||
"You are WiseClaw, a local-first assistant running on macOS. "
|
||||
"Use tools carefully and obey terminal safety mode."
|
||||
),
|
||||
"message": message,
|
||||
"model": runtime.default_model,
|
||||
"memory": memory,
|
||||
"available_tools": [tool.name for tool in runtime.tools if tool.enabled],
|
||||
}
|
||||
|
||||
56
backend/app/main.py
Normal file
56
backend/app/main.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
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()
|
||||
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_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,
|
||||
}
|
||||
1
backend/app/memory/__init__.py
Normal file
1
backend/app/memory/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
17
backend/app/memory/store.py
Normal file
17
backend/app/memory/store.py
Normal 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)]
|
||||
77
backend/app/models.py
Normal file
77
backend/app/models.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
TerminalMode = Literal[1, 2, 3]
|
||||
SearchProvider = Literal["brave", "searxng"]
|
||||
|
||||
|
||||
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 RuntimeSettings(BaseModel):
|
||||
terminal_mode: TerminalMode = 3
|
||||
search_provider: SearchProvider = "brave"
|
||||
ollama_base_url: str = "http://127.0.0.1:11434"
|
||||
default_model: str = "qwen3.5:4b"
|
||||
tools: list[ToolToggle] = Field(
|
||||
default_factory=lambda: [
|
||||
ToolToggle(name="brave_search", 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
|
||||
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
|
||||
46
backend/app/orchestrator.py
Normal file
46
backend/app/orchestrator.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import AuditLogORM, SettingORM, ToolStateORM
|
||||
from app.llm.planner import build_prompt_context
|
||||
from app.memory.store import MemoryService
|
||||
from app.models import RuntimeSettings
|
||||
from app.telegram.auth import is_authorized
|
||||
|
||||
|
||||
class WiseClawOrchestrator:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
self.memory = MemoryService(session)
|
||||
|
||||
def get_runtime_settings(self) -> RuntimeSettings:
|
||||
settings = {
|
||||
item.key: item.value for item in self.session.scalars(select(SettingORM))
|
||||
}
|
||||
tools = list(self.session.scalars(select(ToolStateORM).order_by(ToolStateORM.name.asc())))
|
||||
return RuntimeSettings(
|
||||
terminal_mode=int(settings["terminal_mode"]),
|
||||
search_provider=settings["search_provider"],
|
||||
ollama_base_url=settings["ollama_base_url"],
|
||||
default_model=settings["default_model"],
|
||||
tools=[{"name": tool.name, "enabled": tool.enabled} for tool in tools],
|
||||
)
|
||||
|
||||
def handle_text_message(self, telegram_user_id: int, text: str) -> str:
|
||||
if not is_authorized(self.session, telegram_user_id):
|
||||
return "This Telegram user is not authorized for WiseClaw."
|
||||
|
||||
self.memory.add_item(f"user:{telegram_user_id}:{text}")
|
||||
context = build_prompt_context(
|
||||
message=text,
|
||||
runtime=self.get_runtime_settings(),
|
||||
memory=self.memory.latest_items(limit=5),
|
||||
)
|
||||
response = (
|
||||
"WiseClaw scaffold received your message.\n\n"
|
||||
f"Prompt context prepared for model `{context['model']}` with "
|
||||
f"{len(context['memory'])} memory items."
|
||||
)
|
||||
self.session.add(AuditLogORM(category="telegram", message=f"telegram:{telegram_user_id}:{text}"))
|
||||
self.session.commit()
|
||||
return response
|
||||
14
backend/app/runtime.py
Normal file
14
backend/app/runtime.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from contextlib import suppress
|
||||
|
||||
from app.telegram.bot import TelegramBotService
|
||||
|
||||
|
||||
class RuntimeServices:
|
||||
def __init__(self) -> None:
|
||||
self.telegram_bot: TelegramBotService | None = None
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self.telegram_bot is not None:
|
||||
with suppress(Exception):
|
||||
await self.telegram_bot.stop()
|
||||
|
||||
77
backend/app/security.py
Normal file
77
backend/app/security.py
Normal file
@@ -0,0 +1,77 @@
|
||||
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",
|
||||
)
|
||||
|
||||
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.")
|
||||
|
||||
1
backend/app/telegram/__init__.py
Normal file
1
backend/app/telegram/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
8
backend/app/telegram/auth.py
Normal file
8
backend/app/telegram/auth.py
Normal 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)
|
||||
51
backend/app/telegram/bot.py
Normal file
51
backend/app/telegram/bot.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from typing import Any
|
||||
|
||||
from telegram import Update
|
||||
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
|
||||
|
||||
from app.orchestrator import WiseClawOrchestrator
|
||||
|
||||
|
||||
class TelegramBotService:
|
||||
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 orchestrator.handle_text_message(telegram_user_id=telegram_user_id, text=text)
|
||||
|
||||
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(MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_text))
|
||||
await self.application.initialize()
|
||||
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:
|
||||
del context
|
||||
if update.message is None or update.effective_user is None or update.message.text is None:
|
||||
return
|
||||
reply = await self.process_message(update.effective_user.id, update.message.text)
|
||||
await update.message.reply_text(reply)
|
||||
1
backend/app/tools/__init__.py
Normal file
1
backend/app/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
18
backend/app/tools/apple_notes.py
Normal file
18
backend/app/tools/apple_notes.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Any
|
||||
|
||||
from app.tools.base import Tool
|
||||
|
||||
|
||||
class AppleNotesTool(Tool):
|
||||
name = "apple_notes"
|
||||
description = "Create notes in Apple Notes through AppleScript."
|
||||
|
||||
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
title = str(payload.get("title", "")).strip()
|
||||
return {
|
||||
"tool": self.name,
|
||||
"status": "stub",
|
||||
"title": title,
|
||||
"message": "Apple Notes integration is not wired yet.",
|
||||
}
|
||||
|
||||
12
backend/app/tools/base.py
Normal file
12
backend/app/tools/base.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Tool(ABC):
|
||||
name: str
|
||||
description: str
|
||||
|
||||
@abstractmethod
|
||||
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
18
backend/app/tools/brave_search.py
Normal file
18
backend/app/tools/brave_search.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Any
|
||||
|
||||
from app.tools.base import Tool
|
||||
|
||||
|
||||
class BraveSearchTool(Tool):
|
||||
name = "brave_search"
|
||||
description = "Search the web with Brave Search."
|
||||
|
||||
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
query = str(payload.get("query", "")).strip()
|
||||
return {
|
||||
"tool": self.name,
|
||||
"status": "stub",
|
||||
"query": query,
|
||||
"message": "Brave Search integration is not wired yet.",
|
||||
}
|
||||
|
||||
21
backend/app/tools/files.py
Normal file
21
backend/app/tools/files.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.tools.base import Tool
|
||||
|
||||
|
||||
class FilesTool(Tool):
|
||||
name = "files"
|
||||
description = "Read and write files within allowed paths."
|
||||
|
||||
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
action = str(payload.get("action", "read")).strip()
|
||||
path = Path(str(payload.get("path", "")).strip()).expanduser()
|
||||
return {
|
||||
"tool": self.name,
|
||||
"status": "stub",
|
||||
"action": action,
|
||||
"path": str(path),
|
||||
"message": "File integration is not wired yet.",
|
||||
}
|
||||
|
||||
18
backend/app/tools/searxng_search.py
Normal file
18
backend/app/tools/searxng_search.py
Normal 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.",
|
||||
}
|
||||
|
||||
24
backend/app/tools/terminal.py
Normal file
24
backend/app/tools/terminal.py
Normal file
@@ -0,0 +1,24 @@
|
||||
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) -> None:
|
||||
self.terminal_mode = terminal_mode
|
||||
|
||||
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
command = str(payload.get("command", "")).strip()
|
||||
decision = evaluate_terminal_command(command, self.terminal_mode)
|
||||
return {
|
||||
"tool": self.name,
|
||||
"status": "stub",
|
||||
"command": command,
|
||||
"decision": decision.decision,
|
||||
"reason": decision.reason,
|
||||
}
|
||||
|
||||
18
backend/app/tools/web_fetch.py
Normal file
18
backend/app/tools/web_fetch.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from typing import Any
|
||||
|
||||
from app.tools.base import Tool
|
||||
|
||||
|
||||
class WebFetchTool(Tool):
|
||||
name = "web_fetch"
|
||||
description = "Fetch a webpage and return simplified content."
|
||||
|
||||
async def run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
url = str(payload.get("url", "")).strip()
|
||||
return {
|
||||
"tool": self.name,
|
||||
"status": "stub",
|
||||
"url": url,
|
||||
"message": "Web fetch integration is not wired yet.",
|
||||
}
|
||||
|
||||
22
backend/pyproject.toml
Normal file
22
backend/pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[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",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
|
||||
30
docs/architecture.md
Normal file
30
docs/architecture.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# WiseClaw Architecture
|
||||
|
||||
## Core shape
|
||||
|
||||
WiseClaw uses a single FastAPI process with modular tool adapters:
|
||||
|
||||
- `telegram`: inbound/outbound bot handling and whitelist checks
|
||||
- `llm`: Ollama 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 Ollama-driven tool calling.
|
||||
5. Persist secrets in macOS Keychain.
|
||||
6. Build audit views and approval flows in the admin panel.
|
||||
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
1820
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
338
frontend/src/App.tsx
Normal file
338
frontend/src/App.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "./api";
|
||||
import type {
|
||||
DashboardSnapshot,
|
||||
MemoryRecord,
|
||||
OllamaStatus,
|
||||
RuntimeSettings,
|
||||
TelegramStatus,
|
||||
UserRecord,
|
||||
} from "./types";
|
||||
|
||||
const defaultSettings: RuntimeSettings = {
|
||||
terminal_mode: 3,
|
||||
search_provider: "brave",
|
||||
ollama_base_url: "http://127.0.0.1:11434",
|
||||
default_model: "qwen3.5:4b",
|
||||
tools: [
|
||||
{ name: "brave_search", 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 [memory, setMemory] = useState<MemoryRecord[]>([]);
|
||||
const [secretMask, setSecretMask] = useState("");
|
||||
const [secretValue, setSecretValue] = useState("");
|
||||
const [ollamaStatus, setOllamaStatus] = useState<OllamaStatus | null>(null);
|
||||
const [telegramStatus, setTelegramStatus] = useState<TelegramStatus | null>(null);
|
||||
const [status, setStatus] = useState("Loading WiseClaw admin...");
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [dashboardData, settingsData, userData, memoryData, secretData, ollamaData, telegramData] =
|
||||
await Promise.all([
|
||||
api.getDashboard(),
|
||||
api.getSettings(),
|
||||
api.getUsers(),
|
||||
api.getMemory(),
|
||||
api.getSecretMask("brave_api_key"),
|
||||
api.getOllamaStatus(),
|
||||
api.getTelegramStatus(),
|
||||
]);
|
||||
setDashboard(dashboardData);
|
||||
setSettings(settingsData);
|
||||
setUsers(userData);
|
||||
setMemory(memoryData);
|
||||
setSecretMask(secretData.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 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.default_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>{settings.search_provider}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Ollama</span>
|
||||
<strong>{settings.ollama_base_url}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="integration-grid">
|
||||
<div className="integration-card">
|
||||
<span>Ollama status</span>
|
||||
<strong>{ollamaStatus?.reachable ? "Reachable" : "Offline"}</strong>
|
||||
<p>{ollamaStatus?.message || "Checking..."}</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>
|
||||
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>
|
||||
Ollama base URL
|
||||
<input
|
||||
value={settings.ollama_base_url}
|
||||
onChange={(event) => setSettings({ ...settings, ollama_base_url: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Default model
|
||||
<input
|
||||
value={settings.default_model}
|
||||
onChange={(event) => setSettings({ ...settings, default_model: event.target.value })}
|
||||
/>
|
||||
</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" 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" 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">
|
||||
<div className="panel-head">
|
||||
<h3>Memory</h3>
|
||||
<button type="button" onClick={handleClearMemory}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="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">
|
||||
{(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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/api.ts
Normal file
54
frontend/src/api.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
DashboardSnapshot,
|
||||
MemoryRecord,
|
||||
OllamaStatus,
|
||||
RuntimeSettings,
|
||||
TelegramStatus,
|
||||
UserRecord,
|
||||
} from "./types";
|
||||
|
||||
const API_BASE = "http://127.0.0.1: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"),
|
||||
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/ollama"),
|
||||
getTelegramStatus: () => request<TelegramStatus>("/admin/integrations/telegram"),
|
||||
};
|
||||
12
frontend/src/main.tsx
Normal file
12
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
|
||||
240
frontend/src/styles.css
Normal file
240
frontend/src/styles.css
Normal file
@@ -0,0 +1,240 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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 p {
|
||||
margin-bottom: 0;
|
||||
color: #4f5b57;
|
||||
}
|
||||
|
||||
.grid.two-up {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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.8rem 0.9rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(31, 36, 33, 0.05);
|
||||
font-family: "IBM Plex Mono", "SF Mono", monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.shell,
|
||||
.grid.two-up,
|
||||
.hero-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
47
frontend/src/types.ts
Normal file
47
frontend/src/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type ToolToggle = {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeSettings = {
|
||||
terminal_mode: 1 | 2 | 3;
|
||||
search_provider: "brave" | "searxng";
|
||||
ollama_base_url: string;
|
||||
default_model: 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 MemoryRecord = {
|
||||
id: number;
|
||||
content: string;
|
||||
kind: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type OllamaStatus = {
|
||||
reachable: boolean;
|
||||
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
22
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal 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
10
frontend/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user