Replace repository with DuckLM runtime
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,348 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
|
||||
from duck_core.approvals.service import ApprovalService
|
||||
from duck_core.config import get_settings
|
||||
from duck_core.events.store import EventStore
|
||||
from duck_core.experience.recorder import ExperienceRecorder
|
||||
from duck_core.memory.vector_memory import EmbeddingsUnavailableError, VectorMemory
|
||||
from duck_core.model_client import ModelClient
|
||||
from duck_core.runtime_loop import RuntimeLoop
|
||||
from duck_core.skills.registry import SkillRegistry
|
||||
from duck_core.tasks.store import TaskStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
workspace: str | None = None
|
||||
debug: bool = False
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
if settings.api_host == "0.0.0.0":
|
||||
logger.warning(
|
||||
"DuckLM API is listening on 0.0.0.0. This may expose local tool execution endpoints."
|
||||
)
|
||||
Path(settings.workspace).mkdir(parents=True, exist_ok=True)
|
||||
Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="DuckLM", version="0.1.0")
|
||||
templates = Jinja2Templates(directory="duck_core/web/templates")
|
||||
app.mount("/static", StaticFiles(directory="duck_core/web/static"), name="static")
|
||||
|
||||
task_store = TaskStore(settings.db_path)
|
||||
event_store = EventStore(settings.db_path)
|
||||
model_client = ModelClient()
|
||||
approvals = ApprovalService(settings.db_path)
|
||||
runtime = RuntimeLoop(task_store, event_store, model_client, approval_service=approvals)
|
||||
skills = SkillRegistry("skills")
|
||||
experience = ExperienceRecorder(settings.db_path)
|
||||
memory = VectorMemory(settings.qdrant_url, embeddings_base_url=None)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
await task_store.init()
|
||||
await event_store.init()
|
||||
await approvals.init()
|
||||
await experience.init()
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request, "index.html")
|
||||
|
||||
@app.get("/approvals", response_class=HTMLResponse)
|
||||
async def approvals_page(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request, "approvals.html")
|
||||
|
||||
@app.get("/skills", response_class=HTMLResponse)
|
||||
async def skills_page(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request, "skills.html")
|
||||
|
||||
@app.get("/memory", response_class=HTMLResponse)
|
||||
async def memory_page(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request, "memory.html")
|
||||
|
||||
@app.get("/experience", response_class=HTMLResponse)
|
||||
async def experience_page(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request, "experience.html")
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/v1/status")
|
||||
async def status() -> dict[str, Any]:
|
||||
return {
|
||||
"name": "DuckLM",
|
||||
"version": "0.1.0",
|
||||
"api_host": settings.api_host,
|
||||
"api_port": settings.api_port,
|
||||
"workspace": settings.workspace,
|
||||
"db_path": settings.db_path,
|
||||
}
|
||||
|
||||
@app.get("/v1/models/roles")
|
||||
async def roles() -> dict[str, Any]:
|
||||
return model_client.list_roles()
|
||||
|
||||
@app.get("/v1/models/ping")
|
||||
async def models_ping() -> dict[str, Any]:
|
||||
return await model_client.ping()
|
||||
|
||||
@app.post("/v1/chat")
|
||||
async def chat(body: ChatRequest) -> dict[str, Any]:
|
||||
result = await runtime.run_chat(body.message, body.workspace or settings.workspace, body.debug)
|
||||
return result.__dict__
|
||||
|
||||
def sse(event: str, payload: dict[str, Any]) -> str:
|
||||
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
async def emit_tool_events(task_id: str, after_sequence: int):
|
||||
events = await event_store.list_events(task_id)
|
||||
visible_types = {
|
||||
"tool_call_started",
|
||||
"tool_call_finished",
|
||||
"tool_approval_requested",
|
||||
}
|
||||
for event in events:
|
||||
if event.sequence > after_sequence and event.event_type in visible_types:
|
||||
yield sse(event.event_type, event.model_dump())
|
||||
|
||||
@app.post("/v1/chat/stream")
|
||||
async def chat_stream(body: ChatRequest) -> StreamingResponse:
|
||||
async def generator():
|
||||
task = await task_store.create_task(
|
||||
body.message, body.workspace or settings.workspace, body.debug
|
||||
)
|
||||
task_event = await event_store.append(
|
||||
task.task_id,
|
||||
"task_created",
|
||||
{
|
||||
"message": body.message,
|
||||
"workspace": body.workspace or settings.workspace,
|
||||
"debug": body.debug,
|
||||
},
|
||||
)
|
||||
yield sse("task_created", task_event.model_dump())
|
||||
|
||||
reasoning_parts: list[str] = []
|
||||
content_parts: list[str] = []
|
||||
try:
|
||||
messages = runtime.context_builder.build_basic_messages(task)
|
||||
tool_observations = await runtime._run_action_tools(
|
||||
task.task_id, messages, body.workspace or settings.workspace
|
||||
)
|
||||
async for tool_event in emit_tool_events(task.task_id, task_event.sequence):
|
||||
yield tool_event
|
||||
if any(observation.get("requires_approval") for observation in tool_observations):
|
||||
await task_store.waiting_for_approval(task.task_id)
|
||||
await event_store.append(
|
||||
task.task_id,
|
||||
"task_waiting_for_approval",
|
||||
{"observations": tool_observations},
|
||||
)
|
||||
yield sse(
|
||||
"done",
|
||||
{
|
||||
"task_id": task.task_id,
|
||||
"status": "waiting_for_approval",
|
||||
"final_response": "Waiting for approval.",
|
||||
"reasoning_content": None,
|
||||
},
|
||||
)
|
||||
return
|
||||
if tool_observations:
|
||||
messages = [
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tool_observations:\n"
|
||||
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
|
||||
},
|
||||
]
|
||||
await event_store.append(task.task_id, "model_call_started", {"role": "thinker"})
|
||||
async for chunk in model_client.stream_chat("thinker", messages):
|
||||
delta = str(chunk.get("delta") or "")
|
||||
if chunk.get("type") == "reasoning_delta":
|
||||
reasoning_parts.append(delta)
|
||||
yield sse(
|
||||
"reasoning_delta",
|
||||
{"task_id": task.task_id, "delta": delta},
|
||||
)
|
||||
elif chunk.get("type") == "content_delta":
|
||||
content_parts.append(delta)
|
||||
yield sse(
|
||||
"content_delta",
|
||||
{"task_id": task.task_id, "delta": delta},
|
||||
)
|
||||
|
||||
content = "".join(content_parts)
|
||||
reasoning_content = "".join(reasoning_parts) or None
|
||||
await event_store.append(
|
||||
task.task_id,
|
||||
"cognition_response",
|
||||
{
|
||||
"role": "thinker",
|
||||
"content": content,
|
||||
"reasoning_content": reasoning_content,
|
||||
},
|
||||
)
|
||||
await event_store.append(
|
||||
task.task_id,
|
||||
"model_call_finished",
|
||||
{
|
||||
"role": "thinker",
|
||||
"model": model_client.get_role_config("thinker").model,
|
||||
},
|
||||
)
|
||||
await task_store.complete_task(task.task_id, content)
|
||||
await event_store.append(
|
||||
task.task_id,
|
||||
"task_completed",
|
||||
{
|
||||
"final_response": content,
|
||||
"reasoning_content": reasoning_content,
|
||||
},
|
||||
)
|
||||
yield sse(
|
||||
"done",
|
||||
{
|
||||
"task_id": task.task_id,
|
||||
"status": "completed",
|
||||
"final_response": content,
|
||||
"reasoning_content": reasoning_content,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
await task_store.fail_task(task.task_id, str(exc))
|
||||
await event_store.append(task.task_id, "task_failed", {"error": str(exc)})
|
||||
yield sse(
|
||||
"error",
|
||||
{
|
||||
"task_id": task.task_id,
|
||||
"status": "failed",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
return StreamingResponse(generator(), media_type="text/event-stream")
|
||||
|
||||
@app.post("/v1/tasks")
|
||||
async def create_task(body: ChatRequest) -> dict[str, Any]:
|
||||
task = await task_store.create_task(body.message, body.workspace or settings.workspace, body.debug)
|
||||
await event_store.append(task.task_id, "task_created", body.model_dump())
|
||||
return task.model_dump()
|
||||
|
||||
@app.get("/v1/tasks")
|
||||
async def list_tasks() -> list[dict[str, Any]]:
|
||||
return [task.model_dump() for task in await task_store.list_tasks()]
|
||||
|
||||
@app.get("/v1/tasks/{task_id}")
|
||||
async def get_task(task_id: str) -> dict[str, Any]:
|
||||
task = await task_store.get_task(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task.model_dump()
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/events")
|
||||
async def get_events(task_id: str) -> list[dict[str, Any]]:
|
||||
return [event.model_dump() for event in await event_store.list_events(task_id)]
|
||||
|
||||
@app.get("/v1/tasks/{task_id}/stream")
|
||||
async def stream_events(task_id: str) -> StreamingResponse:
|
||||
async def generator():
|
||||
sent = 0
|
||||
for _ in range(30):
|
||||
events = await event_store.list_events(task_id)
|
||||
for event in events[sent:]:
|
||||
yield f"data: {json.dumps(event.model_dump())}\n\n"
|
||||
sent = len(events)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return StreamingResponse(generator(), media_type="text/event-stream")
|
||||
|
||||
@app.post("/v1/tasks/{task_id}/continue")
|
||||
async def continue_task(task_id: str) -> dict[str, str]:
|
||||
task = await task_store.get_task(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
await task_store.update_status(task_id, "running")
|
||||
await event_store.append(task_id, "task_continued", {})
|
||||
return {"status": "running"}
|
||||
|
||||
@app.post("/v1/tasks/{task_id}/cancel")
|
||||
async def cancel_task(task_id: str) -> dict[str, str]:
|
||||
await task_store.cancel_task(task_id)
|
||||
await event_store.append(task_id, "task_cancelled", {})
|
||||
return {"status": "cancelled"}
|
||||
|
||||
@app.get("/v1/approvals/pending")
|
||||
async def pending_approvals() -> list[dict[str, Any]]:
|
||||
return [approval.model_dump() for approval in await approvals.pending()]
|
||||
|
||||
@app.post("/v1/approvals/{approval_id}/allow_once")
|
||||
async def allow_once(approval_id: str) -> dict[str, str]:
|
||||
await approvals.allow_once(approval_id)
|
||||
return {"status": "allowed_once"}
|
||||
|
||||
@app.post("/v1/approvals/{approval_id}/allow_forever")
|
||||
async def allow_forever(approval_id: str) -> dict[str, str]:
|
||||
await approvals.allow_forever(approval_id)
|
||||
return {"status": "allowed_forever"}
|
||||
|
||||
@app.post("/v1/approvals/{approval_id}/deny")
|
||||
async def deny(approval_id: str) -> dict[str, str]:
|
||||
await approvals.deny(approval_id)
|
||||
return {"status": "denied"}
|
||||
|
||||
@app.get("/v1/skills")
|
||||
async def list_skills() -> list[dict[str, Any]]:
|
||||
return [skill.model_dump() for skill in skills.load_skills()]
|
||||
|
||||
@app.get("/v1/skills/{skill_id}")
|
||||
async def get_skill(skill_id: str) -> dict[str, Any]:
|
||||
skill = skills.get_skill(skill_id)
|
||||
if skill is None:
|
||||
raise HTTPException(status_code=404, detail="Skill not found")
|
||||
return skill.model_dump()
|
||||
|
||||
@app.get("/v1/experience")
|
||||
async def list_experience() -> list[dict[str, Any]]:
|
||||
return [record.model_dump() for record in await experience.list_records()]
|
||||
|
||||
@app.get("/v1/experience/{record_id}")
|
||||
async def get_experience(record_id: int) -> dict[str, Any]:
|
||||
record = await experience.get_record(record_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="Experience record not found")
|
||||
return record.model_dump()
|
||||
|
||||
@app.get("/v1/memory/search")
|
||||
async def search_memory(q: str) -> dict[str, Any]:
|
||||
try:
|
||||
return {"results": await memory.search_memory(q)}
|
||||
except EmbeddingsUnavailableError as exc:
|
||||
return {"results": [], "warning": str(exc)}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
settings = get_settings()
|
||||
uvicorn.run("duck_core.api:app", host=settings.api_host, port=settings.api_port, reload=False)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import aiosqlite
|
||||
from pydantic import BaseModel
|
||||
|
||||
from duck_core.tasks.store import utc_now
|
||||
|
||||
|
||||
class Approval(BaseModel):
|
||||
id: int | None = None
|
||||
approval_id: str
|
||||
task_id: str
|
||||
action_hash: str
|
||||
normalized_action: dict[str, Any]
|
||||
status: str
|
||||
decision: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
def normalize_action(action: dict[str, Any]) -> str:
|
||||
return json.dumps(action, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def action_hash(action: dict[str, Any]) -> str:
|
||||
return hashlib.sha256(normalize_action(action).encode()).hexdigest()
|
||||
|
||||
|
||||
class ApprovalService:
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
async def init(self) -> None:
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
create table if not exists approvals (
|
||||
id integer primary key autoincrement,
|
||||
approval_id text not null unique,
|
||||
task_id text not null,
|
||||
action_hash text not null,
|
||||
normalized_action_json text not null,
|
||||
status text not null,
|
||||
decision text,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def create_pending(self, task_id: str, action: dict[str, Any]) -> Approval:
|
||||
await self.init()
|
||||
now = utc_now()
|
||||
approval_id = f"approval_{uuid4().hex[:12]}"
|
||||
normalized = normalize_action(action)
|
||||
digest = action_hash(action)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
insert into approvals(
|
||||
approval_id, task_id, action_hash, normalized_action_json,
|
||||
status, created_at, updated_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(approval_id, task_id, digest, normalized, "pending", now, now),
|
||||
)
|
||||
await db.commit()
|
||||
row_id = cursor.lastrowid
|
||||
return Approval(
|
||||
id=row_id,
|
||||
approval_id=approval_id,
|
||||
task_id=task_id,
|
||||
action_hash=digest,
|
||||
normalized_action=action,
|
||||
status="pending",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
async def pending(self) -> list[Approval]:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"select * from approvals where status = 'pending' order by created_at"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_approval(row) for row in rows]
|
||||
|
||||
async def allow_once(self, approval_id: str) -> None:
|
||||
await self._decide(approval_id, "resolved", "allow_once")
|
||||
|
||||
async def allow_forever(self, approval_id: str) -> None:
|
||||
await self._decide(approval_id, "allowed_forever", "allow_forever")
|
||||
|
||||
async def deny(self, approval_id: str) -> None:
|
||||
await self._decide(approval_id, "resolved", "deny")
|
||||
|
||||
async def is_allowed_forever(self, action: dict[str, Any]) -> bool:
|
||||
await self.init()
|
||||
digest = action_hash(action)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
select 1 from approvals
|
||||
where action_hash = ? and status = 'allowed_forever'
|
||||
limit 1
|
||||
""",
|
||||
(digest,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row is not None
|
||||
|
||||
async def _decide(self, approval_id: str, status: str, decision: str) -> None:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
update approvals set status = ?, decision = ?, updated_at = ?
|
||||
where approval_id = ?
|
||||
""",
|
||||
(status, decision, utc_now(), approval_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
def _row_to_approval(self, row: aiosqlite.Row) -> Approval:
|
||||
return Approval(
|
||||
id=row["id"],
|
||||
approval_id=row["approval_id"],
|
||||
task_id=row["task_id"],
|
||||
action_hash=row["action_hash"],
|
||||
normalized_action=json.loads(row["normalized_action_json"]),
|
||||
status=row["status"],
|
||||
decision=row["decision"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
llama_server_bin: str = "llama-server"
|
||||
main_model_path: str = "./models/Qwen3.6/nonMTP/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"
|
||||
main_port: int = 8081
|
||||
ctx_size: int = 65536
|
||||
n_gpu_layers: str = "auto"
|
||||
host: str = "127.0.0.1"
|
||||
api_host: str = "127.0.0.1"
|
||||
api_port: int = 8000
|
||||
workspace: str = "./workspace"
|
||||
db_path: str = "./data/duck.sqlite3"
|
||||
max_input_tokens: int = 49152
|
||||
max_recent_events_tokens: int = 12000
|
||||
max_memory_tokens: int = 8000
|
||||
max_skill_tokens: int = 6000
|
||||
qdrant_url: str = "http://127.0.0.1:6333"
|
||||
skip_live_llm_tests: int = 0
|
||||
|
||||
@property
|
||||
def db_file(self) -> Path:
|
||||
return Path(self.db_path)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
load_dotenv()
|
||||
return Settings(
|
||||
llama_server_bin=os.getenv("DUCK_LLAMA_SERVER_BIN", "llama-server"),
|
||||
main_model_path=os.getenv(
|
||||
"DUCK_MAIN_MODEL_PATH",
|
||||
"./models/Qwen3.6/nonMTP/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf",
|
||||
),
|
||||
main_port=int(os.getenv("DUCK_MAIN_PORT", "8081")),
|
||||
ctx_size=int(os.getenv("DUCK_CTX_SIZE", "65536")),
|
||||
n_gpu_layers=os.getenv("DUCK_N_GPU_LAYERS", "auto"),
|
||||
host=os.getenv("DUCK_HOST", "127.0.0.1"),
|
||||
api_host=os.getenv("DUCK_API_HOST", "127.0.0.1"),
|
||||
api_port=int(os.getenv("DUCK_API_PORT", "8000")),
|
||||
workspace=os.getenv("DUCK_WORKSPACE", "./workspace"),
|
||||
db_path=os.getenv("DUCK_DB_PATH", "./data/duck.sqlite3"),
|
||||
max_input_tokens=int(os.getenv("DUCK_MAX_INPUT_TOKENS", "49152")),
|
||||
max_recent_events_tokens=int(os.getenv("DUCK_MAX_RECENT_EVENTS_TOKENS", "12000")),
|
||||
max_memory_tokens=int(os.getenv("DUCK_MAX_MEMORY_TOKENS", "8000")),
|
||||
max_skill_tokens=int(os.getenv("DUCK_MAX_SKILL_TOKENS", "6000")),
|
||||
qdrant_url=os.getenv("QDRANT_URL", "http://127.0.0.1:6333"),
|
||||
skip_live_llm_tests=int(os.getenv("DUCK_SKIP_LIVE_LLM_TESTS", "0")),
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from duck_core.tasks.state import TaskState
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
def build_basic_messages(self, task: TaskState) -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"role": "user",
|
||||
"content": task.user_message,
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiosqlite
|
||||
from pydantic import BaseModel
|
||||
|
||||
from duck_core.tasks.store import utc_now
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
id: int
|
||||
task_id: str
|
||||
sequence: int
|
||||
event_type: str
|
||||
payload: dict[str, Any]
|
||||
created_at: str
|
||||
|
||||
|
||||
class EventStore:
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
async def init(self) -> None:
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
create table if not exists events (
|
||||
id integer primary key autoincrement,
|
||||
task_id text not null,
|
||||
sequence integer not null,
|
||||
event_type text not null,
|
||||
payload_json text not null,
|
||||
created_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
create unique index if not exists idx_events_task_sequence
|
||||
on events(task_id, sequence)
|
||||
"""
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def append(self, task_id: str, event_type: str, payload: dict[str, Any]) -> Event:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"select coalesce(max(sequence), 0) + 1 from events where task_id = ?",
|
||||
(task_id,),
|
||||
)
|
||||
sequence = (await cursor.fetchone())[0]
|
||||
created_at = utc_now()
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
insert into events(task_id, sequence, event_type, payload_json, created_at)
|
||||
values (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(task_id, sequence, event_type, json.dumps(payload), created_at),
|
||||
)
|
||||
await db.commit()
|
||||
event_id = cursor.lastrowid
|
||||
return Event(
|
||||
id=event_id,
|
||||
task_id=task_id,
|
||||
sequence=sequence,
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
async def list_events(self, task_id: str) -> list[Event]:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"select * from events where task_id = ? order by sequence", (task_id,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
Event(
|
||||
id=row["id"],
|
||||
task_id=row["task_id"],
|
||||
sequence=row["sequence"],
|
||||
event_type=row["event_type"],
|
||||
payload=json.loads(row["payload_json"]),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
from pydantic import BaseModel
|
||||
|
||||
from duck_core.tasks.store import utc_now
|
||||
|
||||
|
||||
class ExperienceRecord(BaseModel):
|
||||
id: int | None = None
|
||||
task_id: str
|
||||
skill_id: str | None = None
|
||||
summary: str
|
||||
result: str
|
||||
what_worked: list[str] = []
|
||||
what_failed: list[str] = []
|
||||
reusable_lesson: str | None = None
|
||||
suggested_skill_patch: str | None = None
|
||||
confidence: float | None = None
|
||||
created_at: str
|
||||
|
||||
|
||||
class ExperienceRecorder:
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
async def init(self) -> None:
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
create table if not exists experience_records (
|
||||
id integer primary key autoincrement,
|
||||
task_id text not null,
|
||||
skill_id text,
|
||||
summary text not null,
|
||||
result text not null,
|
||||
what_worked_json text,
|
||||
what_failed_json text,
|
||||
reusable_lesson text,
|
||||
suggested_skill_patch text,
|
||||
confidence real,
|
||||
created_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def record(
|
||||
self,
|
||||
task_id: str,
|
||||
summary: str,
|
||||
result: str,
|
||||
skill_id: str | None = None,
|
||||
what_worked: list[str] | None = None,
|
||||
what_failed: list[str] | None = None,
|
||||
reusable_lesson: str | None = None,
|
||||
suggested_skill_patch: str | None = None,
|
||||
confidence: float | None = None,
|
||||
) -> ExperienceRecord:
|
||||
await self.init()
|
||||
now = utc_now()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
insert into experience_records(
|
||||
task_id, skill_id, summary, result, what_worked_json,
|
||||
what_failed_json, reusable_lesson, suggested_skill_patch,
|
||||
confidence, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
skill_id,
|
||||
summary,
|
||||
result,
|
||||
json.dumps(what_worked or []),
|
||||
json.dumps(what_failed or []),
|
||||
reusable_lesson,
|
||||
suggested_skill_patch,
|
||||
confidence,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
row_id = cursor.lastrowid
|
||||
if suggested_skill_patch and skill_id:
|
||||
self.write_skill_update_proposal(task_id, skill_id, suggested_skill_patch)
|
||||
return ExperienceRecord(
|
||||
id=row_id,
|
||||
task_id=task_id,
|
||||
skill_id=skill_id,
|
||||
summary=summary,
|
||||
result=result,
|
||||
what_worked=what_worked or [],
|
||||
what_failed=what_failed or [],
|
||||
reusable_lesson=reusable_lesson,
|
||||
suggested_skill_patch=suggested_skill_patch,
|
||||
confidence=confidence,
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
async def list_records(self) -> list[ExperienceRecord]:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"select * from experience_records order by created_at desc"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_record(row) for row in rows]
|
||||
|
||||
async def get_record(self, record_id: int) -> ExperienceRecord | None:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"select * from experience_records where id = ?", (record_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return self._row_to_record(row) if row else None
|
||||
|
||||
def write_skill_update_proposal(self, task_id: str, skill_id: str, patch: str) -> Path:
|
||||
directory = Path("skills/_proposals")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{utc_now().replace(':', '').replace('+', '_')}_{skill_id}.patch.md"
|
||||
path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"# Skill update proposal",
|
||||
"",
|
||||
f"Skill: {skill_id}",
|
||||
"",
|
||||
"## Reason",
|
||||
"",
|
||||
"Reflection suggested a reusable skill improvement.",
|
||||
"",
|
||||
"## Proposed changes",
|
||||
"",
|
||||
patch,
|
||||
"",
|
||||
"## Evidence",
|
||||
"",
|
||||
f"Task id: {task_id}",
|
||||
"",
|
||||
"## Risk",
|
||||
"",
|
||||
"Low.",
|
||||
"",
|
||||
"## Requires human approval",
|
||||
"",
|
||||
"Yes.",
|
||||
]
|
||||
)
|
||||
)
|
||||
return path
|
||||
|
||||
def _row_to_record(self, row: aiosqlite.Row) -> ExperienceRecord:
|
||||
return ExperienceRecord(
|
||||
id=row["id"],
|
||||
task_id=row["task_id"],
|
||||
skill_id=row["skill_id"],
|
||||
summary=row["summary"],
|
||||
result=row["result"],
|
||||
what_worked=json.loads(row["what_worked_json"] or "[]"),
|
||||
what_failed=json.loads(row["what_failed_json"] or "[]"),
|
||||
reusable_lesson=row["reusable_lesson"],
|
||||
suggested_skill_patch=row["suggested_skill_patch"],
|
||||
confidence=row["confidence"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MemoryDecision(BaseModel):
|
||||
should_store: bool
|
||||
memory_type: str
|
||||
summary: str
|
||||
importance: float
|
||||
metadata: dict[str, str] = {}
|
||||
|
||||
|
||||
class MemoryPolicy:
|
||||
async def classify(self, summary: str, task_id: str) -> MemoryDecision:
|
||||
return MemoryDecision(
|
||||
should_store=False,
|
||||
memory_type="event",
|
||||
summary=summary,
|
||||
importance=0.0,
|
||||
metadata={"task_id": task_id, "source": "stub_policy"},
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class EmbeddingsUnavailableError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class VectorMemory:
|
||||
def __init__(
|
||||
self,
|
||||
qdrant_url: str,
|
||||
collection_name: str = "duck_memory",
|
||||
embeddings_base_url: str | None = "http://127.0.0.1:8081/v1",
|
||||
):
|
||||
self.qdrant_url = qdrant_url.rstrip("/")
|
||||
self.collection_name = collection_name
|
||||
self.embeddings_base_url = embeddings_base_url.rstrip("/") if embeddings_base_url else None
|
||||
|
||||
async def add_memory(self, text: str, metadata: dict[str, Any] | None = None) -> str:
|
||||
vector = await self._embed(text)
|
||||
point_id = str(uuid4())
|
||||
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
|
||||
await client.put(
|
||||
f"{self.qdrant_url}/collections/{self.collection_name}",
|
||||
json={"vectors": {"size": len(vector), "distance": "Cosine"}},
|
||||
)
|
||||
response = await client.put(
|
||||
f"{self.qdrant_url}/collections/{self.collection_name}/points",
|
||||
json={
|
||||
"points": [
|
||||
{
|
||||
"id": point_id,
|
||||
"vector": vector,
|
||||
"payload": {"text": text, **(metadata or {})},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return point_id
|
||||
|
||||
async def search_memory(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
|
||||
vector = await self._embed(query)
|
||||
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
|
||||
response = await client.post(
|
||||
f"{self.qdrant_url}/collections/{self.collection_name}/points/search",
|
||||
json={"vector": vector, "limit": limit, "with_payload": True},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json().get("result", [])
|
||||
|
||||
async def _embed(self, text: str) -> list[float]:
|
||||
if not self.embeddings_base_url:
|
||||
raise EmbeddingsUnavailableError(
|
||||
"Embeddings endpoint is not configured; vector memory is explicit stub."
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
|
||||
response = await client.post(
|
||||
f"{self.embeddings_base_url}/embeddings",
|
||||
json={"model": "local-main", "input": text},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise EmbeddingsUnavailableError(
|
||||
f"Embeddings endpoint unavailable: HTTP {response.status_code}"
|
||||
)
|
||||
data = response.json()["data"][0]["embedding"]
|
||||
return [float(value) for value in data]
|
||||
@@ -0,0 +1,217 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleConfig:
|
||||
role: str
|
||||
provider: str
|
||||
base_url: str
|
||||
model: str
|
||||
purpose: str
|
||||
structured_output: bool
|
||||
temperature: float
|
||||
max_output_tokens: int
|
||||
system_prompt: str
|
||||
response_schema: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelResponse:
|
||||
role: str
|
||||
model: str
|
||||
content: str
|
||||
reasoning_content: str | None
|
||||
raw: dict[str, Any]
|
||||
latency_ms: float
|
||||
prompt_tokens: int | None = None
|
||||
completion_tokens: int | None = None
|
||||
total_tokens: int | None = None
|
||||
|
||||
|
||||
class ModelClient:
|
||||
def __init__(self, config_path: str = "config/models.yaml", timeout: float = 120.0):
|
||||
self.config_path = Path(config_path)
|
||||
self.timeout = timeout
|
||||
data = yaml.safe_load(self.config_path.read_text())
|
||||
self.default_provider = data["default_provider"]
|
||||
self._roles = {
|
||||
role: RoleConfig(role=role, **settings)
|
||||
for role, settings in data["models"].items()
|
||||
}
|
||||
|
||||
def list_roles(self) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
role: {
|
||||
"provider": cfg.provider,
|
||||
"base_url": cfg.base_url,
|
||||
"model": cfg.model,
|
||||
"purpose": cfg.purpose,
|
||||
"structured_output": cfg.structured_output,
|
||||
"temperature": cfg.temperature,
|
||||
"max_output_tokens": cfg.max_output_tokens,
|
||||
"system_prompt": cfg.system_prompt,
|
||||
"response_schema": cfg.response_schema,
|
||||
}
|
||||
for role, cfg in self._roles.items()
|
||||
}
|
||||
|
||||
def get_role_config(self, role: str) -> RoleConfig:
|
||||
try:
|
||||
return self._roles[role]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"Unknown model role: {role}") from exc
|
||||
|
||||
def _system_message(self, cfg: RoleConfig) -> dict[str, str] | None:
|
||||
path = Path(cfg.system_prompt)
|
||||
if not path.exists():
|
||||
return None
|
||||
return {"role": "system", "content": path.read_text()}
|
||||
|
||||
def _response_format(
|
||||
self, cfg: RoleConfig, response_format: dict[str, Any] | None
|
||||
) -> dict[str, Any] | None:
|
||||
if response_format is not None:
|
||||
return response_format
|
||||
if not cfg.structured_output:
|
||||
return None
|
||||
if cfg.response_schema and Path(cfg.response_schema).exists():
|
||||
schema = json.loads(Path(cfg.response_schema).read_text())
|
||||
return {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "action_directive", "schema": schema, "strict": True},
|
||||
}
|
||||
return {"type": "json_object"}
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
role: str,
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
) -> ModelResponse:
|
||||
cfg = self.get_role_config(role)
|
||||
outbound = list(messages)
|
||||
system_message = self._system_message(cfg)
|
||||
if system_message and not any(message["role"] == "system" for message in outbound):
|
||||
outbound.insert(0, system_message)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": cfg.model,
|
||||
"messages": outbound,
|
||||
"temperature": cfg.temperature if temperature is None else temperature,
|
||||
"max_tokens": cfg.max_output_tokens if max_output_tokens is None else max_output_tokens,
|
||||
}
|
||||
fmt = self._response_format(cfg, response_format)
|
||||
if fmt is not None:
|
||||
payload["response_format"] = fmt
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
|
||||
response = await client.post(f"{cfg.base_url}/chat/completions", json=payload)
|
||||
response.raise_for_status()
|
||||
raw = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise ConnectionError(f"Model backend unavailable for role {role}: {exc}") from exc
|
||||
|
||||
latency_ms = (time.perf_counter() - start) * 1000
|
||||
usage = raw.get("usage") or {}
|
||||
message = raw.get("choices", [{}])[0].get("message", {})
|
||||
content = message.get("content") or ""
|
||||
reasoning_content = message.get("reasoning_content")
|
||||
logger.info("model role=%s model=%s latency_ms=%.1f usage=%s", role, cfg.model, latency_ms, usage)
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model=cfg.model,
|
||||
content=content,
|
||||
reasoning_content=reasoning_content,
|
||||
raw=raw,
|
||||
latency_ms=latency_ms,
|
||||
prompt_tokens=usage.get("prompt_tokens"),
|
||||
completion_tokens=usage.get("completion_tokens"),
|
||||
total_tokens=usage.get("total_tokens"),
|
||||
)
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
role: str,
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
):
|
||||
cfg = self.get_role_config(role)
|
||||
outbound = list(messages)
|
||||
system_message = self._system_message(cfg)
|
||||
if system_message and not any(message["role"] == "system" for message in outbound):
|
||||
outbound.insert(0, system_message)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": cfg.model,
|
||||
"messages": outbound,
|
||||
"temperature": cfg.temperature if temperature is None else temperature,
|
||||
"max_tokens": cfg.max_output_tokens if max_output_tokens is None else max_output_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
fmt = self._response_format(cfg, response_format)
|
||||
if fmt is not None:
|
||||
payload["response_format"] = fmt
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{cfg.base_url}/chat/completions", json=payload
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
raw_data = line.removeprefix("data: ").strip()
|
||||
if raw_data == "[DONE]":
|
||||
break
|
||||
if not raw_data:
|
||||
continue
|
||||
chunk = json.loads(raw_data)
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
reasoning_delta = delta.get("reasoning_content")
|
||||
content_delta = delta.get("content")
|
||||
if reasoning_delta:
|
||||
yield {"type": "reasoning_delta", "delta": reasoning_delta}
|
||||
if content_delta:
|
||||
yield {"type": "content_delta", "delta": content_delta}
|
||||
except httpx.HTTPError as exc:
|
||||
raise ConnectionError(f"Model backend unavailable for role {role}: {exc}") from exc
|
||||
|
||||
async def ping(self) -> dict[str, Any]:
|
||||
results: dict[str, Any] = {}
|
||||
async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client:
|
||||
for role, cfg in self._roles.items():
|
||||
try:
|
||||
started = time.perf_counter()
|
||||
response = await client.get(f"{cfg.base_url}/models")
|
||||
response.raise_for_status()
|
||||
results[role] = {
|
||||
"ok": True,
|
||||
"base_url": cfg.base_url,
|
||||
"model": cfg.model,
|
||||
"latency_ms": round((time.perf_counter() - started) * 1000, 1),
|
||||
}
|
||||
except httpx.HTTPError as exc:
|
||||
results[role] = {
|
||||
"ok": False,
|
||||
"base_url": cfg.base_url,
|
||||
"model": cfg.model,
|
||||
"error": str(exc),
|
||||
}
|
||||
return results
|
||||
@@ -0,0 +1,29 @@
|
||||
from duck_core.experience.recorder import ExperienceRecorder, ExperienceRecord
|
||||
from duck_core.model_client import ModelClient
|
||||
|
||||
|
||||
class Reflection:
|
||||
def __init__(self, model_client: ModelClient, recorder: ExperienceRecorder):
|
||||
self.model_client = model_client
|
||||
self.recorder = recorder
|
||||
|
||||
async def reflect(self, task_id: str, transcript: str) -> ExperienceRecord:
|
||||
response = await self.model_client.chat(
|
||||
"critic",
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Reflect on this DuckLM task. Cover outcome, waste, JSON/tool issues, "
|
||||
f"and reusable lesson.\n\n{transcript}"
|
||||
),
|
||||
}
|
||||
],
|
||||
)
|
||||
return await self.recorder.record(
|
||||
task_id=task_id,
|
||||
summary=response.content[:500],
|
||||
result="unknown",
|
||||
reusable_lesson=response.content,
|
||||
confidence=0.5,
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from duck_core.approvals.service import ApprovalService
|
||||
from duck_core.context_builder import ContextBuilder
|
||||
from duck_core.events.store import EventStore
|
||||
from duck_core.model_client import ModelClient
|
||||
from duck_core.tasks.store import TaskStore
|
||||
from duck_core.tools.gateway import ToolGateway
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatResult:
|
||||
task_id: str
|
||||
status: str
|
||||
final_response: str
|
||||
reasoning_content: str | None = None
|
||||
|
||||
|
||||
class RuntimeLoop:
|
||||
def __init__(
|
||||
self,
|
||||
task_store: TaskStore,
|
||||
event_store: EventStore,
|
||||
model_client: ModelClient | None = None,
|
||||
context_builder: ContextBuilder | None = None,
|
||||
approval_service: ApprovalService | None = None,
|
||||
):
|
||||
self.task_store = task_store
|
||||
self.event_store = event_store
|
||||
self.model_client = model_client or ModelClient()
|
||||
self.context_builder = context_builder or ContextBuilder()
|
||||
self.approval_service = approval_service
|
||||
|
||||
async def run_chat(
|
||||
self, message: str, workspace: str | None = None, debug: bool = False
|
||||
) -> ChatResult:
|
||||
task = await self.task_store.create_task(message, workspace, debug)
|
||||
await self.event_store.append(
|
||||
task.task_id,
|
||||
"task_created",
|
||||
{"message": message, "workspace": workspace, "debug": debug},
|
||||
)
|
||||
try:
|
||||
messages = self.context_builder.build_basic_messages(task)
|
||||
tool_observations = await self._run_action_tools(task.task_id, messages, workspace)
|
||||
if any(observation.get("requires_approval") for observation in tool_observations):
|
||||
await self.task_store.waiting_for_approval(task.task_id)
|
||||
await self.event_store.append(
|
||||
task.task_id,
|
||||
"task_waiting_for_approval",
|
||||
{"observations": tool_observations},
|
||||
)
|
||||
return ChatResult(
|
||||
task_id=task.task_id,
|
||||
status="waiting_for_approval",
|
||||
final_response="Waiting for approval.",
|
||||
reasoning_content=None,
|
||||
)
|
||||
if tool_observations:
|
||||
messages = [
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": "tool_observations:\n"
|
||||
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
|
||||
},
|
||||
]
|
||||
await self.event_store.append(
|
||||
task.task_id, "model_call_started", {"role": "thinker"}
|
||||
)
|
||||
response = await self.model_client.chat("thinker", messages)
|
||||
await self.event_store.append(
|
||||
task.task_id,
|
||||
"cognition_response",
|
||||
{
|
||||
"role": response.role,
|
||||
"content": response.content,
|
||||
"reasoning_content": response.reasoning_content,
|
||||
},
|
||||
)
|
||||
await self.event_store.append(
|
||||
task.task_id,
|
||||
"model_call_finished",
|
||||
{
|
||||
"role": response.role,
|
||||
"model": response.model,
|
||||
"latency_ms": response.latency_ms,
|
||||
"prompt_tokens": response.prompt_tokens,
|
||||
"completion_tokens": response.completion_tokens,
|
||||
"total_tokens": response.total_tokens,
|
||||
},
|
||||
)
|
||||
await self.task_store.complete_task(task.task_id, response.content)
|
||||
await self.event_store.append(
|
||||
task.task_id,
|
||||
"task_completed",
|
||||
{
|
||||
"final_response": response.content,
|
||||
"reasoning_content": response.reasoning_content,
|
||||
},
|
||||
)
|
||||
return ChatResult(
|
||||
task_id=task.task_id,
|
||||
status="completed",
|
||||
final_response=response.content,
|
||||
reasoning_content=response.reasoning_content,
|
||||
)
|
||||
except Exception as exc:
|
||||
await self.task_store.fail_task(task.task_id, str(exc))
|
||||
await self.event_store.append(
|
||||
task.task_id, "task_failed", {"error": str(exc)}
|
||||
)
|
||||
return ChatResult(
|
||||
task_id=task.task_id,
|
||||
status="failed",
|
||||
final_response=str(exc),
|
||||
reasoning_content=None,
|
||||
)
|
||||
|
||||
async def _run_action_tools(
|
||||
self, task_id: str, messages: list[dict[str, str]], workspace: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
await self.event_store.append(task_id, "model_call_started", {"role": "action"})
|
||||
response = await self.model_client.chat("action", messages)
|
||||
directive = json.loads(response.content)
|
||||
except Exception as exc:
|
||||
await self.event_store.append(
|
||||
task_id,
|
||||
"action_directive_failed",
|
||||
{"error": str(exc)},
|
||||
)
|
||||
return []
|
||||
|
||||
await self.event_store.append(task_id, "action_directive", directive)
|
||||
actions = directive.get("actions") or []
|
||||
if not isinstance(actions, list) or not actions:
|
||||
return []
|
||||
|
||||
gateway = ToolGateway.default(workspace or ".")
|
||||
observations: list[dict[str, Any]] = []
|
||||
for index, action in enumerate(actions, start=1):
|
||||
if not isinstance(action, dict):
|
||||
observations.append(
|
||||
{"index": index, "ok": False, "error": "Action must be an object"}
|
||||
)
|
||||
continue
|
||||
tool_name = str(action.get("tool", ""))
|
||||
await self.event_store.append(
|
||||
task_id,
|
||||
"tool_call_started",
|
||||
{"index": index, "tool": tool_name, "args": action.get("args") or {}},
|
||||
)
|
||||
result = await gateway.run_action(action)
|
||||
result_payload = result.model_dump()
|
||||
if result.metadata.get("requires_approval"):
|
||||
approval = None
|
||||
if self.approval_service is not None:
|
||||
approval = await self.approval_service.create_pending(task_id, action)
|
||||
await self.event_store.append(
|
||||
task_id,
|
||||
"tool_approval_requested",
|
||||
{
|
||||
"index": index,
|
||||
"tool": tool_name,
|
||||
"action": action,
|
||||
"approval_id": approval.approval_id if approval else None,
|
||||
"reason": result.error,
|
||||
},
|
||||
)
|
||||
observations.append(
|
||||
{
|
||||
"index": index,
|
||||
"tool": tool_name,
|
||||
"reason": action.get("reason"),
|
||||
"requires_approval": True,
|
||||
"approval_id": approval.approval_id if approval else None,
|
||||
"result": result_payload,
|
||||
}
|
||||
)
|
||||
break
|
||||
await self.event_store.append(
|
||||
task_id,
|
||||
"tool_call_finished",
|
||||
{"index": index, "tool": tool_name, "result": result_payload},
|
||||
)
|
||||
observations.append(
|
||||
{
|
||||
"index": index,
|
||||
"tool": tool_name,
|
||||
"reason": action.get("reason"),
|
||||
"result": result_payload,
|
||||
}
|
||||
)
|
||||
return observations
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["kind", "intent", "risk_level", "actions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["action_directive"]
|
||||
},
|
||||
"intent": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"risk_level": {
|
||||
"type": "string",
|
||||
"enum": ["none", "low", "medium", "high", "critical"]
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"minItems": 0,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["tool", "args"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tool": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"args": {
|
||||
"type": "object"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"memory_hints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"expected_observations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"stop_reason": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Skill(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
version: int
|
||||
tags: list[str] = []
|
||||
required_tools: list[str] = []
|
||||
risk_level: str = "low"
|
||||
inputs: list[str] = []
|
||||
outputs: list[str] = []
|
||||
success_criteria: list[str] = []
|
||||
procedure: str = ""
|
||||
examples: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class SkillCandidate(BaseModel):
|
||||
skill: Skill
|
||||
score: float
|
||||
reason: str
|
||||
|
||||
|
||||
class SkillRegistry:
|
||||
def __init__(self, skills_dir: str = "skills"):
|
||||
self.skills_dir = Path(skills_dir)
|
||||
self._cache: dict[str, Skill] | None = None
|
||||
|
||||
def load_skills(self) -> list[Skill]:
|
||||
skills: dict[str, Skill] = {}
|
||||
if not self.skills_dir.exists():
|
||||
self._cache = {}
|
||||
return []
|
||||
for path in sorted(self.skills_dir.glob("*/skill.yaml")):
|
||||
data = yaml.safe_load(path.read_text()) or {}
|
||||
root = path.parent
|
||||
data["procedure"] = self._read_optional(root / "procedure.md")
|
||||
data["examples"] = self._read_optional(root / "examples.md")
|
||||
data["notes"] = self._read_optional(root / "notes.md")
|
||||
skill = Skill(**data)
|
||||
skills[skill.id] = skill
|
||||
self._cache = skills
|
||||
return list(skills.values())
|
||||
|
||||
def get_skill(self, skill_id: str) -> Skill | None:
|
||||
if self._cache is None:
|
||||
self.load_skills()
|
||||
return (self._cache or {}).get(skill_id)
|
||||
|
||||
async def find_candidate_skills(self, user_request: str, limit: int = 3) -> list[SkillCandidate]:
|
||||
terms = set(user_request.lower().split())
|
||||
candidates: list[SkillCandidate] = []
|
||||
for skill in self.load_skills():
|
||||
haystack = " ".join([skill.title, skill.description, " ".join(skill.tags)]).lower()
|
||||
score = sum(1 for term in terms if term in haystack)
|
||||
if score:
|
||||
candidates.append(
|
||||
SkillCandidate(skill=skill, score=float(score), reason="keyword match")
|
||||
)
|
||||
return sorted(candidates, key=lambda item: item.score, reverse=True)[:limit]
|
||||
|
||||
def _read_optional(self, path: Path) -> str:
|
||||
return path.read_text() if path.exists() else ""
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TaskState(BaseModel):
|
||||
task_id: str
|
||||
status: str
|
||||
user_message: str
|
||||
workspace: str | None = None
|
||||
debug: bool = False
|
||||
final_response: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
@@ -0,0 +1,115 @@
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from duck_core.tasks.state import TaskState
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
class TaskStore:
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
async def init(self) -> None:
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
create table if not exists tasks (
|
||||
task_id text primary key,
|
||||
status text not null,
|
||||
user_message text not null,
|
||||
workspace text,
|
||||
debug integer not null default 0,
|
||||
final_response text,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def create_task(self, user_message: str, workspace: str | None, debug: bool) -> TaskState:
|
||||
await self.init()
|
||||
now = utc_now()
|
||||
task_id = f"task_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}_{uuid4().hex[:8]}"
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
insert into tasks(task_id, status, user_message, workspace, debug, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(task_id, "running", user_message, workspace, int(debug), now, now),
|
||||
)
|
||||
await db.commit()
|
||||
return TaskState(
|
||||
task_id=task_id,
|
||||
status="running",
|
||||
user_message=user_message,
|
||||
workspace=workspace,
|
||||
debug=debug,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
async def update_status(
|
||||
self, task_id: str, status: str, final_response: str | None = None
|
||||
) -> None:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
update tasks
|
||||
set status = ?, final_response = coalesce(?, final_response), updated_at = ?
|
||||
where task_id = ?
|
||||
""",
|
||||
(status, final_response, utc_now(), task_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def complete_task(self, task_id: str, final_response: str) -> None:
|
||||
await self.update_status(task_id, "completed", final_response)
|
||||
|
||||
async def fail_task(self, task_id: str, message: str) -> None:
|
||||
await self.update_status(task_id, "failed", message)
|
||||
|
||||
async def cancel_task(self, task_id: str) -> None:
|
||||
await self.update_status(task_id, "cancelled")
|
||||
|
||||
async def waiting_for_approval(self, task_id: str) -> None:
|
||||
await self.update_status(task_id, "waiting_for_approval")
|
||||
|
||||
async def get_task(self, task_id: str) -> TaskState | None:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("select * from tasks where task_id = ?", (task_id,))
|
||||
row = await cursor.fetchone()
|
||||
return self._row_to_task(row) if row else None
|
||||
|
||||
async def list_tasks(self, limit: int = 50) -> list[TaskState]:
|
||||
await self.init()
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute(
|
||||
"select * from tasks order by created_at desc limit ?", (limit,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_task(row) for row in rows]
|
||||
|
||||
def _row_to_task(self, row: aiosqlite.Row) -> TaskState:
|
||||
return TaskState(
|
||||
task_id=row["task_id"],
|
||||
status=row["status"],
|
||||
user_message=row["user_message"],
|
||||
workspace=row["workspace"],
|
||||
debug=bool(row["debug"]),
|
||||
final_response=row["final_response"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ToolResult(BaseModel):
|
||||
ok: bool
|
||||
output: str | None = None
|
||||
error: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class Tool(Protocol):
|
||||
name: str
|
||||
risk_level: str
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
...
|
||||
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
|
||||
|
||||
|
||||
class FileReadTool:
|
||||
name = "file_read"
|
||||
risk_level = "low"
|
||||
|
||||
def __init__(self, workspace: str, max_bytes: int = 1_000_000):
|
||||
self.workspace = workspace
|
||||
self.max_bytes = max_bytes
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
raw_path = str(args.get("path", ""))
|
||||
try:
|
||||
path = resolve_workspace_path(self.workspace, raw_path)
|
||||
except WorkspacePathError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
if self._requires_approval(path):
|
||||
return ToolResult(ok=False, error=f"Reading {raw_path} requires explicit approval")
|
||||
if not path.is_file():
|
||||
return ToolResult(ok=False, error=f"File not found: {raw_path}")
|
||||
if path.stat().st_size > self.max_bytes:
|
||||
return ToolResult(ok=False, error=f"File exceeds max size: {self.max_bytes}")
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
output=path.read_text(errors="replace"),
|
||||
metadata={"path": str(path), "bytes_read": path.stat().st_size},
|
||||
)
|
||||
|
||||
def _requires_approval(self, path: Path) -> bool:
|
||||
parts = set(path.parts)
|
||||
return path.name == ".env" or ".ssh" in parts or str(path) == "/etc/shadow"
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
|
||||
|
||||
|
||||
class FileWriteTool:
|
||||
name = "file_write"
|
||||
risk_level = "medium"
|
||||
|
||||
def __init__(self, workspace: str):
|
||||
self.workspace = workspace
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
raw_path = str(args.get("path", ""))
|
||||
content = str(args.get("content", ""))
|
||||
overwrite = bool(args.get("overwrite", False))
|
||||
try:
|
||||
path = resolve_workspace_path(self.workspace, raw_path)
|
||||
except WorkspacePathError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
if path.exists() and not overwrite:
|
||||
return ToolResult(
|
||||
ok=False,
|
||||
error="Refusing to overwrite existing file without overwrite=true or approval",
|
||||
metadata={"path": str(path)},
|
||||
)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existed = path.exists()
|
||||
path.write_text(content)
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
output=f"Wrote {raw_path}",
|
||||
metadata={
|
||||
"path": str(path),
|
||||
"bytes_written": len(content.encode()),
|
||||
"created": not existed,
|
||||
"updated": existed,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import Tool, ToolResult
|
||||
from duck_core.tools.file_read import FileReadTool
|
||||
from duck_core.tools.file_write import FileWriteTool
|
||||
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
|
||||
|
||||
|
||||
class ToolGateway:
|
||||
def __init__(self, tools: list[Tool]):
|
||||
self.tools = {tool.name: tool for tool in tools}
|
||||
|
||||
@classmethod
|
||||
def default(cls, workspace: str) -> "ToolGateway":
|
||||
return cls(
|
||||
[
|
||||
FileReadTool(workspace),
|
||||
FileWriteTool(workspace),
|
||||
ShellExecSafeTool(workspace),
|
||||
]
|
||||
)
|
||||
|
||||
async def run_action(self, action: dict[str, Any]) -> ToolResult:
|
||||
tool_name = str(action.get("tool", ""))
|
||||
tool = self.tools.get(tool_name)
|
||||
if tool is None:
|
||||
return ToolResult(ok=False, error=f"Unknown tool: {tool_name}")
|
||||
args = action.get("args") or {}
|
||||
if not isinstance(args, dict):
|
||||
return ToolResult(ok=False, error="Tool args must be an object")
|
||||
return await tool.run(args)
|
||||
@@ -0,0 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class WorkspacePathError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_workspace_path(workspace: str, relative_path: str) -> Path:
|
||||
root = Path(workspace).resolve()
|
||||
path = (root / relative_path).resolve()
|
||||
if root != path and root not in path.parents:
|
||||
raise WorkspacePathError(f"Path escapes workspace: {relative_path}")
|
||||
return path
|
||||
@@ -0,0 +1,95 @@
|
||||
import shlex
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from duck_core.tools.base import ToolResult
|
||||
|
||||
|
||||
ALLOWLIST = {
|
||||
"pwd",
|
||||
"ls",
|
||||
"cat",
|
||||
"head",
|
||||
"tail",
|
||||
"grep",
|
||||
"find",
|
||||
"pytest",
|
||||
"python -m pytest",
|
||||
"python3 -m pytest",
|
||||
"git status",
|
||||
"git diff",
|
||||
"git log",
|
||||
}
|
||||
|
||||
BLOCKLIST = {
|
||||
"rm",
|
||||
"sudo",
|
||||
"su",
|
||||
"dd",
|
||||
"mkfs",
|
||||
"mount",
|
||||
"umount",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"poweroff",
|
||||
"systemctl",
|
||||
"service",
|
||||
"apt install",
|
||||
"apt remove",
|
||||
"pacman -S",
|
||||
"pacman -R",
|
||||
"pip install",
|
||||
"npm install -g",
|
||||
"chmod -R",
|
||||
"chown -R",
|
||||
"curl | sh",
|
||||
"wget | sh",
|
||||
}
|
||||
|
||||
|
||||
class ShellExecSafeTool:
|
||||
name = "shell_exec_safe"
|
||||
risk_level = "medium"
|
||||
|
||||
def __init__(self, workspace: str, timeout_seconds: int = 30):
|
||||
self.workspace = workspace
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||
command = str(args.get("command", "")).strip()
|
||||
allowed, reason = self._is_allowed(command)
|
||||
if not allowed:
|
||||
return ToolResult(ok=False, error=reason, metadata={"requires_approval": True})
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=self.workspace,
|
||||
shell=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=self.timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.SubprocessError as exc:
|
||||
return ToolResult(ok=False, error=str(exc))
|
||||
return ToolResult(
|
||||
ok=completed.returncode == 0,
|
||||
output=completed.stdout,
|
||||
error=completed.stderr if completed.returncode else None,
|
||||
metadata={"returncode": completed.returncode, "command": command},
|
||||
)
|
||||
|
||||
def _is_allowed(self, command: str) -> tuple[bool, str | None]:
|
||||
if not command:
|
||||
return False, "Empty command"
|
||||
lowered = command.lower()
|
||||
for blocked in BLOCKLIST:
|
||||
if lowered.startswith(blocked.lower()) or blocked.lower() in lowered:
|
||||
return False, f"Command is blocked: {blocked}"
|
||||
parts = shlex.split(command)
|
||||
prefix1 = parts[0] if parts else ""
|
||||
prefix2 = " ".join(parts[:2])
|
||||
prefix3 = " ".join(parts[:3])
|
||||
if prefix1 in ALLOWLIST or prefix2 in ALLOWLIST or prefix3 in ALLOWLIST:
|
||||
return True, None
|
||||
return False, "Command is outside allowlist and requires approval"
|
||||
@@ -0,0 +1,510 @@
|
||||
const state = {
|
||||
running: false,
|
||||
messages: [],
|
||||
};
|
||||
|
||||
async function jsonFetch(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function escapeText(value) {
|
||||
return String(value ?? "");
|
||||
}
|
||||
|
||||
function setStatus(id, text, tone = "neutral") {
|
||||
const node = document.querySelector(id);
|
||||
if (!node) return;
|
||||
node.textContent = text;
|
||||
node.dataset.tone = tone;
|
||||
}
|
||||
|
||||
function addMessage(role, content, meta = "", options = {}) {
|
||||
const list = document.querySelector("#messages");
|
||||
if (!list) return;
|
||||
|
||||
const article = document.createElement("article");
|
||||
article.className = `message ${role}`;
|
||||
|
||||
const avatar = document.createElement("div");
|
||||
avatar.className = "avatar";
|
||||
avatar.textContent = role === "user" ? "U" : "D";
|
||||
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "bubble";
|
||||
|
||||
const messageMeta = document.createElement("div");
|
||||
messageMeta.className = "message-meta";
|
||||
messageMeta.innerHTML = `<strong>${role === "user" ? "You" : "DuckLM"}</strong><span>${escapeText(meta)}</span>`;
|
||||
|
||||
const text = document.createElement("p");
|
||||
text.textContent = content;
|
||||
|
||||
bubble.append(messageMeta);
|
||||
if (role === "assistant" && options.reasoning) {
|
||||
bubble.append(createInlineReasoning());
|
||||
}
|
||||
bubble.append(text);
|
||||
article.append(avatar, bubble);
|
||||
list.append(article);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
return article;
|
||||
}
|
||||
|
||||
function createInlineReasoning() {
|
||||
const section = document.createElement("section");
|
||||
section.className = "message-reasoning is-collapsed";
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.className = "message-reasoning-toggle";
|
||||
button.type = "button";
|
||||
button.setAttribute("aria-expanded", "false");
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.textContent = "Размышление";
|
||||
const status = document.createElement("span");
|
||||
status.className = "message-reasoning-status";
|
||||
status.textContent = "streaming";
|
||||
button.append(title, status);
|
||||
|
||||
const body = document.createElement("pre");
|
||||
body.hidden = true;
|
||||
body.textContent = "";
|
||||
|
||||
section.append(button, body);
|
||||
return section;
|
||||
}
|
||||
|
||||
function createToolTerminal(eventPayload) {
|
||||
const payload = eventPayload.payload || eventPayload;
|
||||
const args = payload.args || {};
|
||||
const terminal = document.createElement("section");
|
||||
terminal.className = "tool-terminal";
|
||||
terminal.dataset.toolIndex = String(payload.index || "");
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "tool-terminal-header";
|
||||
|
||||
const dots = document.createElement("span");
|
||||
dots.className = "terminal-dots";
|
||||
dots.innerHTML = "<i></i><i></i><i></i>";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "tool-terminal-title";
|
||||
title.textContent = formatToolCommand(payload.tool, args);
|
||||
|
||||
const status = document.createElement("span");
|
||||
status.className = "tool-terminal-status";
|
||||
status.textContent = "running";
|
||||
|
||||
header.append(dots, title, status);
|
||||
|
||||
const body = document.createElement("pre");
|
||||
body.className = "tool-terminal-body";
|
||||
body.textContent = formatToolStart(payload.tool, args);
|
||||
|
||||
terminal.append(header, body);
|
||||
return terminal;
|
||||
}
|
||||
|
||||
function formatToolCommand(tool, args) {
|
||||
if (tool === "shell_exec_safe") return `$ ${args.command || tool}`;
|
||||
if (tool === "file_read") return `$ file_read ${args.path || ""}`.trim();
|
||||
if (tool === "file_write") return `$ file_write ${args.path || ""}`.trim();
|
||||
return `$ ${tool || "tool"}`;
|
||||
}
|
||||
|
||||
function formatToolStart(tool, args) {
|
||||
const lines = [formatToolCommand(tool, args)];
|
||||
const serializedArgs = JSON.stringify(args || {}, null, 2);
|
||||
if (serializedArgs !== "{}") lines.push(serializedArgs);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendToolTerminal(article, eventPayload) {
|
||||
const paragraph = article?.querySelector("p");
|
||||
const terminal = createToolTerminal(eventPayload);
|
||||
paragraph?.before(terminal);
|
||||
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
|
||||
}
|
||||
|
||||
function updateToolTerminal(article, eventPayload) {
|
||||
const payload = eventPayload.payload || eventPayload;
|
||||
const terminal = article?.querySelector(`.tool-terminal[data-tool-index="${payload.index || ""}"]`);
|
||||
const body = terminal?.querySelector(".tool-terminal-body");
|
||||
const status = terminal?.querySelector(".tool-terminal-status");
|
||||
const result = payload.result || {};
|
||||
if (!body || !status) return;
|
||||
terminal.classList.toggle("is-error", !result.ok);
|
||||
status.textContent = result.ok ? "ok" : "error";
|
||||
|
||||
const parts = [body.textContent.trim()];
|
||||
if (result.output) parts.push("\nstdout\n" + result.output.trimEnd());
|
||||
if (result.error) parts.push("\nstderr\n" + result.error.trimEnd());
|
||||
if (result.metadata && Object.keys(result.metadata).length) {
|
||||
parts.push("\nmetadata\n" + JSON.stringify(result.metadata, null, 2));
|
||||
}
|
||||
body.textContent = parts.join("\n");
|
||||
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
|
||||
}
|
||||
|
||||
function appendApprovalTerminal(article, eventPayload) {
|
||||
const payload = eventPayload.payload || eventPayload;
|
||||
appendToolTerminal(article, {
|
||||
payload: {
|
||||
index: payload.index,
|
||||
tool: payload.tool,
|
||||
args: payload.action?.args || {},
|
||||
},
|
||||
});
|
||||
const terminal = article?.querySelector(`.tool-terminal[data-tool-index="${payload.index || ""}"]`);
|
||||
const body = terminal?.querySelector(".tool-terminal-body");
|
||||
const status = terminal?.querySelector(".tool-terminal-status");
|
||||
terminal?.classList.add("is-waiting");
|
||||
if (status) status.textContent = "approval";
|
||||
if (body) body.textContent += `\n\napproval required\n${payload.reason || ""}`;
|
||||
}
|
||||
|
||||
function setMessagePending(article, text) {
|
||||
const paragraph = article?.querySelector("p");
|
||||
if (paragraph) paragraph.textContent = text;
|
||||
}
|
||||
|
||||
function appendMessageText(article, delta) {
|
||||
const paragraph = article?.querySelector("p");
|
||||
if (!paragraph) return;
|
||||
paragraph.textContent += delta;
|
||||
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
|
||||
}
|
||||
|
||||
function appendInlineReasoning(article, delta) {
|
||||
const block = article?.querySelector(".message-reasoning");
|
||||
const body = block?.querySelector("pre");
|
||||
const status = block?.querySelector(".message-reasoning-status");
|
||||
if (!body) return;
|
||||
body.textContent += delta;
|
||||
if (status) status.textContent = "streaming";
|
||||
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
|
||||
}
|
||||
|
||||
function finishInlineReasoning(article, reasoning) {
|
||||
const block = article?.querySelector(".message-reasoning");
|
||||
const body = block?.querySelector("pre");
|
||||
const status = block?.querySelector(".message-reasoning-status");
|
||||
if (!body) return;
|
||||
body.textContent = reasoning?.trim() || body.textContent.trim() || "Размышления не были получены.";
|
||||
if (status) status.textContent = "done";
|
||||
}
|
||||
|
||||
async function refreshEvents(taskId) {
|
||||
const events = await jsonFetch(`/v1/tasks/${taskId}/events`);
|
||||
const list = document.querySelector("#events");
|
||||
if (!list) return events;
|
||||
|
||||
list.innerHTML = "";
|
||||
for (const event of events) {
|
||||
const item = document.createElement("li");
|
||||
const title = document.createElement("strong");
|
||||
const detail = document.createElement("span");
|
||||
title.textContent = `${event.sequence}. ${event.event_type}`;
|
||||
detail.textContent = summarizeEvent(event.payload);
|
||||
item.append(title, detail);
|
||||
list.appendChild(item);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function summarizeEvent(payload) {
|
||||
if (!payload || typeof payload !== "object") return "";
|
||||
if (payload.role && payload.latency_ms) {
|
||||
return `${payload.role} · ${Math.round(payload.latency_ms)} ms`;
|
||||
}
|
||||
if (payload.content) {
|
||||
return payload.content.slice(0, 140);
|
||||
}
|
||||
if (payload.final_response) {
|
||||
return payload.final_response.slice(0, 140);
|
||||
}
|
||||
if (payload.error) {
|
||||
return payload.error;
|
||||
}
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function toggleInlineReasoning(button) {
|
||||
const block = button.closest(".message-reasoning");
|
||||
const body = block?.querySelector("pre");
|
||||
if (!block || !body) return;
|
||||
const expanded = button.getAttribute("aria-expanded") === "true";
|
||||
button.setAttribute("aria-expanded", String(!expanded));
|
||||
body.hidden = expanded;
|
||||
block.classList.toggle("is-collapsed", expanded);
|
||||
}
|
||||
|
||||
function parseSseBlock(block) {
|
||||
const event = {name: "message", data: ""};
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("event:")) event.name = line.slice(6).trim();
|
||||
if (line.startsWith("data:")) event.data += line.slice(5).trimStart();
|
||||
}
|
||||
if (!event.data) return null;
|
||||
return {name: event.name, data: JSON.parse(event.data)};
|
||||
}
|
||||
|
||||
async function streamChat(payload, onEvent) {
|
||||
const response = await fetch("/v1/chat/stream", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
if (!response.body) throw new Error("Streaming response is not available in this browser.");
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const {value, done} = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
const blocks = buffer.split("\n\n");
|
||||
buffer = blocks.pop() || "";
|
||||
for (const block of blocks) {
|
||||
const event = parseSseBlock(block);
|
||||
if (event) await onEvent(event);
|
||||
}
|
||||
}
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) {
|
||||
const event = parseSseBlock(buffer);
|
||||
if (event) await onEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (state.running) return;
|
||||
const input = document.querySelector("#message");
|
||||
const message = input.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
state.running = true;
|
||||
document.querySelector("#run").disabled = true;
|
||||
setStatus("#task-status", "running", "warn");
|
||||
addMessage("user", message, "submitted");
|
||||
input.value = "";
|
||||
const pending = addMessage("assistant", "", "thinking", {reasoning: true});
|
||||
let taskId = "";
|
||||
let contentStarted = false;
|
||||
|
||||
try {
|
||||
await streamChat({
|
||||
message,
|
||||
workspace: document.querySelector("#workspace").value,
|
||||
debug: document.querySelector("#debug").checked,
|
||||
}, async ({name, data}) => {
|
||||
if (data.task_id) taskId = data.task_id;
|
||||
if (name === "task_created") {
|
||||
taskId = data.task_id;
|
||||
setStatus("#task-status", taskId, "warn");
|
||||
return;
|
||||
}
|
||||
if (name === "reasoning_delta") {
|
||||
pending.querySelector(".message-meta span").textContent = "reasoning";
|
||||
appendInlineReasoning(pending, data.delta || "");
|
||||
return;
|
||||
}
|
||||
if (name === "tool_call_started") {
|
||||
pending.querySelector(".message-meta span").textContent = "tool";
|
||||
appendToolTerminal(pending, data);
|
||||
return;
|
||||
}
|
||||
if (name === "tool_call_finished") {
|
||||
pending.querySelector(".message-meta span").textContent = "tool";
|
||||
updateToolTerminal(pending, data);
|
||||
return;
|
||||
}
|
||||
if (name === "tool_approval_requested") {
|
||||
pending.querySelector(".message-meta span").textContent = "approval";
|
||||
appendApprovalTerminal(pending, data);
|
||||
return;
|
||||
}
|
||||
if (name === "content_delta") {
|
||||
if (!contentStarted) {
|
||||
contentStarted = true;
|
||||
setMessagePending(pending, "");
|
||||
}
|
||||
pending.querySelector(".message-meta span").textContent = "answering";
|
||||
appendMessageText(pending, data.delta || "");
|
||||
return;
|
||||
}
|
||||
if (name === "done") {
|
||||
if (!contentStarted) {
|
||||
setMessagePending(pending, data.final_response || "No final content returned.");
|
||||
}
|
||||
pending.querySelector(".message-meta span").textContent = data.status;
|
||||
setStatus("#task-status", data.task_id, data.status === "completed" ? "ok" : "warn");
|
||||
finishInlineReasoning(pending, data.reasoning_content);
|
||||
await refreshEvents(data.task_id);
|
||||
return;
|
||||
}
|
||||
if (name === "error") {
|
||||
throw new Error(data.error || "Stream failed.");
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (!taskId) input.value = message;
|
||||
setMessagePending(pending, error.message);
|
||||
pending.querySelector(".message-meta span").textContent = "failed";
|
||||
setStatus("#task-status", "failed", "bad");
|
||||
if (taskId) await refreshEvents(taskId);
|
||||
} finally {
|
||||
state.running = false;
|
||||
document.querySelector("#run").disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRuntime() {
|
||||
try {
|
||||
await jsonFetch("/health");
|
||||
setStatus("#api-status", "online", "ok");
|
||||
} catch {
|
||||
setStatus("#api-status", "offline", "bad");
|
||||
}
|
||||
|
||||
try {
|
||||
const roles = await jsonFetch("/v1/models/ping");
|
||||
const ok = Object.values(roles).every((item) => item.ok);
|
||||
setStatus("#model-status", ok ? "online" : "degraded", ok ? "ok" : "warn");
|
||||
} catch {
|
||||
setStatus("#model-status", "offline", "bad");
|
||||
}
|
||||
}
|
||||
|
||||
function bindChat() {
|
||||
const composer = document.querySelector("#composer");
|
||||
const input = document.querySelector("#message");
|
||||
composer?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
});
|
||||
input?.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
document.querySelector("#new-chat")?.addEventListener("click", () => {
|
||||
const messages = document.querySelector("#messages");
|
||||
messages.innerHTML = "";
|
||||
addMessage("assistant", "Новая сессия готова.", "ready");
|
||||
document.querySelector("#events").innerHTML = "";
|
||||
setStatus("#task-status", "none");
|
||||
});
|
||||
document.querySelector("#messages")?.addEventListener("click", (event) => {
|
||||
const button = event.target.closest(".message-reasoning-toggle");
|
||||
if (button) toggleInlineReasoning(button);
|
||||
});
|
||||
document.querySelector("#debug")?.addEventListener("change", (event) => {
|
||||
document.querySelector("#debug-panel").hidden = !event.target.checked;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSimplePages() {
|
||||
const skills = document.querySelector("#skills");
|
||||
if (skills) skills.textContent = JSON.stringify(await jsonFetch("/v1/skills"), null, 2);
|
||||
const experience = document.querySelector("#experience");
|
||||
if (experience) experience.textContent = JSON.stringify(await jsonFetch("/v1/experience"), null, 2);
|
||||
const approvals = document.querySelector("#approvals");
|
||||
if (approvals) await renderApprovals(approvals);
|
||||
}
|
||||
|
||||
async function renderApprovals(container) {
|
||||
const approvals = await jsonFetch("/v1/approvals/pending");
|
||||
container.innerHTML = "";
|
||||
if (!approvals.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "empty-state";
|
||||
empty.textContent = "No pending approvals.";
|
||||
container.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const approval of approvals) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "approval-card";
|
||||
card.dataset.approvalId = approval.approval_id;
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "approval-card-header";
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = approval.normalized_action?.tool || "Tool action";
|
||||
const status = document.createElement("span");
|
||||
status.textContent = approval.status;
|
||||
header.append(title, status);
|
||||
|
||||
const meta = document.createElement("dl");
|
||||
meta.className = "approval-meta";
|
||||
meta.append(metaRow("Task", approval.task_id));
|
||||
meta.append(metaRow("Approval", approval.approval_id));
|
||||
meta.append(metaRow("Created", approval.created_at));
|
||||
|
||||
const action = document.createElement("pre");
|
||||
action.className = "approval-action";
|
||||
action.textContent = JSON.stringify(approval.normalized_action, null, 2);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "approval-actions";
|
||||
actions.append(
|
||||
approvalButton("Allow once", "allow_once"),
|
||||
approvalButton("Allow forever", "allow_forever"),
|
||||
approvalButton("Deny", "deny", "danger"),
|
||||
);
|
||||
|
||||
card.append(header, meta, action, actions);
|
||||
container.append(card);
|
||||
}
|
||||
}
|
||||
|
||||
function metaRow(label, value) {
|
||||
const row = document.createElement("div");
|
||||
const dt = document.createElement("dt");
|
||||
const dd = document.createElement("dd");
|
||||
dt.textContent = label;
|
||||
dd.textContent = value || "";
|
||||
row.append(dt, dd);
|
||||
return row;
|
||||
}
|
||||
|
||||
function approvalButton(label, action, tone = "") {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
button.dataset.approvalAction = action;
|
||||
if (tone) button.dataset.tone = tone;
|
||||
return button;
|
||||
}
|
||||
|
||||
document.querySelector("#approvals")?.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-approval-action]");
|
||||
if (!button) return;
|
||||
const card = button.closest(".approval-card");
|
||||
const approvalId = card?.dataset.approvalId;
|
||||
if (!approvalId) return;
|
||||
|
||||
button.disabled = true;
|
||||
const action = button.dataset.approvalAction;
|
||||
await jsonFetch(`/v1/approvals/${approvalId}/${action}`, {method: "POST"});
|
||||
await renderApprovals(document.querySelector("#approvals"));
|
||||
});
|
||||
|
||||
document.querySelector("#memory-search")?.addEventListener("click", async () => {
|
||||
const q = document.querySelector("#memory-query").value;
|
||||
document.querySelector("#memory-results").textContent =
|
||||
JSON.stringify(await jsonFetch(`/v1/memory/search?q=${encodeURIComponent(q)}`), null, 2);
|
||||
});
|
||||
|
||||
bindChat();
|
||||
checkRuntime();
|
||||
loadSimplePages().catch(console.error);
|
||||
@@ -0,0 +1,673 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #eef2f6;
|
||||
--sidebar: #111827;
|
||||
--sidebar-soft: #1f2937;
|
||||
--panel: #ffffff;
|
||||
--panel-strong: #f8fafc;
|
||||
--text: #111827;
|
||||
--muted: #64748b;
|
||||
--border: #d7dee8;
|
||||
--accent: #1f6feb;
|
||||
--accent-strong: #174ea6;
|
||||
--ok: #12805c;
|
||||
--warn: #b7791f;
|
||||
--bad: #b42318;
|
||||
--shadow: 0 18px 50px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.simple-page {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.simple-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.simple-header h1,
|
||||
.simple-header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.simple-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.simple-header p {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.approval-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.approval-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.approval-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.approval-card h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.approval-card-header span {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: #fef3c7;
|
||||
color: #854d0e;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.approval-meta {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.approval-meta div {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.approval-meta dd {
|
||||
max-width: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.approval-action {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: #0f172a;
|
||||
border-radius: 8px;
|
||||
color: #d1fae5;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.approval-actions button {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.approval-actions button[data-tone="danger"] {
|
||||
background: var(--bad);
|
||||
}
|
||||
|
||||
.approval-actions button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
button, input, textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 292px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
min-height: 100vh;
|
||||
padding: 22px;
|
||||
background: var(--sidebar);
|
||||
color: #e5edf7;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.brand-mark, .avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
background: #f8fafc;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.brand h1, .brand p,
|
||||
.chat-header h2, .chat-header p,
|
||||
.settings-panel h2, .status-panel h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
margin-top: 2px;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.side-nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.side-nav a {
|
||||
color: #cbd5e1;
|
||||
text-decoration: none;
|
||||
padding: 10px 12px;
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.side-nav a:hover,
|
||||
.side-nav a.active {
|
||||
background: var(--sidebar-soft);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.settings-panel,
|
||||
.status-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.settings-panel h2,
|
||||
.status-panel h2 {
|
||||
font-size: 13px;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 11px 12px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar input {
|
||||
border-color: rgba(255,255,255,0.16);
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #e5edf7;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
[data-tone="ok"] { color: #86efac; }
|
||||
[data-tone="warn"] { color: #fde68a; }
|
||||
[data-tone="bad"] { color: #fca5a5; }
|
||||
|
||||
.chat-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto auto;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.chat-header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.chat-header p {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.secondary-button,
|
||||
.composer button {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: #edf2f7;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
grid-template-columns: minmax(0, 1fr) 36px;
|
||||
}
|
||||
|
||||
.message.user .avatar {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.message.assistant .avatar {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.message.user .bubble {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 12px 14px;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.bubble p {
|
||||
margin: 8px 0 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-reasoning {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 9px 10px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.message-reasoning.is-collapsed {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.message-reasoning-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.message-reasoning-status {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.message-reasoning pre {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tool-terminal {
|
||||
margin-top: 10px;
|
||||
overflow: hidden;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.tool-terminal-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 34px;
|
||||
padding: 8px 10px;
|
||||
background: #111827;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
.terminal-dots {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.terminal-dots i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.terminal-dots i:nth-child(1) { background: #ef4444; }
|
||||
.terminal-dots i:nth-child(2) { background: #f59e0b; }
|
||||
.terminal-dots i:nth-child(3) { background: #22c55e; }
|
||||
|
||||
.tool-terminal-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #d1d5db;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-terminal-status {
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #1d4ed8;
|
||||
color: #dbeafe;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tool-terminal.is-error .tool-terminal-status {
|
||||
background: #7f1d1d;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.tool-terminal.is-waiting .tool-terminal-status {
|
||||
background: #854d0e;
|
||||
color: #fef3c7;
|
||||
}
|
||||
|
||||
.tool-terminal-body {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
color: #d1fae5;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-meta strong {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.debug-column {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.debug-column h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
pre,
|
||||
#events {
|
||||
margin: 0;
|
||||
max-height: 170px;
|
||||
overflow: auto;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#events {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
#events li strong,
|
||||
#events li span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#events li span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
min-height: 86px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#composer-hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.composer button {
|
||||
min-width: 96px;
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.composer button:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.composer button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.chat-shell {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.chat-header,
|
||||
.debug-panel,
|
||||
.composer-actions {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DuckLM Approvals</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="simple-page">
|
||||
<header class="simple-header">
|
||||
<div>
|
||||
<h1>Approvals</h1>
|
||||
<p>Review pending local tool actions before DuckLM continues.</p>
|
||||
</div>
|
||||
<a class="secondary-button" href="/">Back to Chat</a>
|
||||
</header>
|
||||
<section id="approvals" class="approval-list" aria-live="polite"></section>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>DuckLM Experience</title><link rel="stylesheet" href="/static/style.css"></head><body><main class="shell"><h1>Experience</h1><pre id="experience"></pre><script src="/static/app.js"></script></main></body></html>
|
||||
@@ -0,0 +1,99 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DuckLM WebChat</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">D</div>
|
||||
<div>
|
||||
<h1>DuckLM</h1>
|
||||
<p>Local cognitive runtime</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="side-nav" aria-label="DuckLM sections">
|
||||
<a href="/" class="active">Chat</a>
|
||||
<a href="/approvals">Approvals</a>
|
||||
<a href="/skills">Skills</a>
|
||||
<a href="/memory">Memory</a>
|
||||
<a href="/experience">Experience</a>
|
||||
</nav>
|
||||
|
||||
<section class="settings-panel" aria-labelledby="settings-title">
|
||||
<h2 id="settings-title">Session</h2>
|
||||
<label>
|
||||
Workspace
|
||||
<input id="workspace" value="./workspace" autocomplete="off">
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input id="debug" type="checkbox" checked>
|
||||
<span>Show reasoning and events</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="status-panel" aria-labelledby="status-title">
|
||||
<h2 id="status-title">Runtime</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>API</dt>
|
||||
<dd id="api-status">checking</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Model</dt>
|
||||
<dd id="model-status">checking</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last task</dt>
|
||||
<dd id="task-status">none</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="chat-shell">
|
||||
<header class="chat-header">
|
||||
<div>
|
||||
<h2>Chat</h2>
|
||||
<p>Messages are processed by the local Qwen role mapping through Duck Core.</p>
|
||||
</div>
|
||||
<button id="new-chat" class="secondary-button" type="button">New Chat</button>
|
||||
</header>
|
||||
|
||||
<section id="messages" class="messages" aria-live="polite">
|
||||
<article class="message assistant">
|
||||
<div class="avatar">D</div>
|
||||
<div class="bubble">
|
||||
<div class="message-meta">
|
||||
<strong>DuckLM</strong>
|
||||
<span>ready</span>
|
||||
</div>
|
||||
<p>Готов. Напиши задачу, я отправлю её в локальный runtime и покажу ответ, reasoning и timeline.</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="debug-panel" class="debug-panel">
|
||||
<div class="debug-column">
|
||||
<h3>Event Timeline</h3>
|
||||
<ol id="events"></ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form id="composer" class="composer">
|
||||
<textarea id="message" rows="3" placeholder="Напиши сообщение DuckLM...">Скажи коротко, что ты DuckLM</textarea>
|
||||
<div class="composer-actions">
|
||||
<span id="composer-hint">Enter sends, Shift+Enter inserts a new line</span>
|
||||
<button id="run" type="submit">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>DuckLM Memory</title><link rel="stylesheet" href="/static/style.css"></head><body><main class="shell"><h1>Memory</h1><input id="memory-query" placeholder="Search memory"><button id="memory-search">Search</button><pre id="memory-results"></pre><script src="/static/app.js"></script></main></body></html>
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>DuckLM Skills</title><link rel="stylesheet" href="/static/style.css"></head><body><main class="shell"><h1>Skills</h1><pre id="skills"></pre><script src="/static/app.js"></script></main></body></html>
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>DuckLM Task</title><link rel="stylesheet" href="/static/style.css"></head><body><main class="shell"><h1>Task</h1><pre id="task"></pre></main></body></html>
|
||||
Reference in New Issue
Block a user