Add persistent conversations and update checks
This commit is contained in:
parent
a4b7ef034a
commit
12bed7b6ba
141
duck_core/api.py
141
duck_core/api.py
|
|
@ -13,6 +13,7 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
from duck_core.approvals.service import ApprovalService
|
from duck_core.approvals.service import ApprovalService
|
||||||
from duck_core.config import get_settings
|
from duck_core.config import get_settings
|
||||||
|
from duck_core.conversations.store import ConversationStore
|
||||||
from duck_core.events.store import EventStore
|
from duck_core.events.store import EventStore
|
||||||
from duck_core.experience.recorder import ExperienceRecorder
|
from duck_core.experience.recorder import ExperienceRecorder
|
||||||
from duck_core.memory.vector_memory import EmbeddingsUnavailableError, VectorMemory
|
from duck_core.memory.vector_memory import EmbeddingsUnavailableError, VectorMemory
|
||||||
|
|
@ -26,15 +27,22 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
conversation_id: str | None = None
|
||||||
workspace: str | None = None
|
workspace: str | None = None
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationRequest(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
workspace: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ContinueRequest(BaseModel):
|
class ContinueRequest(BaseModel):
|
||||||
approval_id: str
|
approval_id: str
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
get_settings.cache_clear()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if settings.api_host == "0.0.0.0":
|
if settings.api_host == "0.0.0.0":
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -49,6 +57,7 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
task_store = TaskStore(settings.db_path)
|
task_store = TaskStore(settings.db_path)
|
||||||
event_store = EventStore(settings.db_path)
|
event_store = EventStore(settings.db_path)
|
||||||
|
conversations = ConversationStore(settings.db_path)
|
||||||
model_client = ModelClient()
|
model_client = ModelClient()
|
||||||
approvals = ApprovalService(settings.db_path)
|
approvals = ApprovalService(settings.db_path)
|
||||||
runtime = RuntimeLoop(task_store, event_store, model_client, approval_service=approvals)
|
runtime = RuntimeLoop(task_store, event_store, model_client, approval_service=approvals)
|
||||||
|
|
@ -60,6 +69,7 @@ def create_app() -> FastAPI:
|
||||||
async def startup() -> None:
|
async def startup() -> None:
|
||||||
await task_store.init()
|
await task_store.init()
|
||||||
await event_store.init()
|
await event_store.init()
|
||||||
|
await conversations.init()
|
||||||
await approvals.init()
|
await approvals.init()
|
||||||
await experience.init()
|
await experience.init()
|
||||||
|
|
||||||
|
|
@ -108,8 +118,69 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
@app.post("/v1/chat")
|
@app.post("/v1/chat")
|
||||||
async def chat(body: ChatRequest) -> dict[str, Any]:
|
async def chat(body: ChatRequest) -> dict[str, Any]:
|
||||||
result = await runtime.run_chat(body.message, body.workspace or settings.workspace, body.debug)
|
conversation = await conversations.ensure(
|
||||||
return result.__dict__
|
body.conversation_id,
|
||||||
|
title_from_message(body.message),
|
||||||
|
body.workspace or settings.workspace,
|
||||||
|
)
|
||||||
|
history = await conversation_history(conversation.conversation_id)
|
||||||
|
result = await runtime.run_chat(
|
||||||
|
body.message,
|
||||||
|
conversation.workspace,
|
||||||
|
body.debug,
|
||||||
|
history_messages=history,
|
||||||
|
)
|
||||||
|
await conversations.add_message(
|
||||||
|
conversation.conversation_id,
|
||||||
|
"user",
|
||||||
|
body.message,
|
||||||
|
task_id=result.task_id,
|
||||||
|
status=result.status,
|
||||||
|
)
|
||||||
|
await conversations.add_message(
|
||||||
|
conversation.conversation_id,
|
||||||
|
"assistant",
|
||||||
|
result.final_response,
|
||||||
|
reasoning_content=result.reasoning_content,
|
||||||
|
task_id=result.task_id,
|
||||||
|
status=result.status,
|
||||||
|
)
|
||||||
|
return {**result.__dict__, "conversation_id": conversation.conversation_id}
|
||||||
|
|
||||||
|
async def conversation_history(conversation_id: str) -> list[dict[str, str]]:
|
||||||
|
messages = await conversations.list_messages(conversation_id, limit=20)
|
||||||
|
return [
|
||||||
|
{"role": message.role, "content": message.content}
|
||||||
|
for message in messages
|
||||||
|
if message.role in {"user", "assistant"} and message.content
|
||||||
|
]
|
||||||
|
|
||||||
|
def title_from_message(message: str) -> str:
|
||||||
|
title = " ".join(message.strip().split())
|
||||||
|
return title[:60] or "New chat"
|
||||||
|
|
||||||
|
@app.get("/v1/conversations")
|
||||||
|
async def list_conversations() -> list[dict[str, Any]]:
|
||||||
|
return [conversation.model_dump() for conversation in await conversations.list()]
|
||||||
|
|
||||||
|
@app.post("/v1/conversations")
|
||||||
|
async def create_conversation(body: ConversationRequest) -> dict[str, Any]:
|
||||||
|
conversation = await conversations.create(
|
||||||
|
body.title or "New chat",
|
||||||
|
body.workspace or settings.workspace,
|
||||||
|
)
|
||||||
|
return conversation.model_dump()
|
||||||
|
|
||||||
|
@app.get("/v1/conversations/{conversation_id}")
|
||||||
|
async def get_conversation(conversation_id: str) -> dict[str, Any]:
|
||||||
|
conversation = await conversations.get(conversation_id)
|
||||||
|
if conversation is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
messages = await conversations.list_messages(conversation_id)
|
||||||
|
return {
|
||||||
|
**conversation.model_dump(),
|
||||||
|
"messages": [message.model_dump() for message in messages],
|
||||||
|
}
|
||||||
|
|
||||||
def sse(event: str, payload: dict[str, Any]) -> str:
|
def sse(event: str, payload: dict[str, Any]) -> str:
|
||||||
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||||
|
|
@ -128,16 +199,23 @@ def create_app() -> FastAPI:
|
||||||
@app.post("/v1/chat/stream")
|
@app.post("/v1/chat/stream")
|
||||||
async def chat_stream(body: ChatRequest) -> StreamingResponse:
|
async def chat_stream(body: ChatRequest) -> StreamingResponse:
|
||||||
async def generator():
|
async def generator():
|
||||||
|
conversation = await conversations.ensure(
|
||||||
|
body.conversation_id,
|
||||||
|
title_from_message(body.message),
|
||||||
|
body.workspace or settings.workspace,
|
||||||
|
)
|
||||||
|
history = await conversation_history(conversation.conversation_id)
|
||||||
task = await task_store.create_task(
|
task = await task_store.create_task(
|
||||||
body.message, body.workspace or settings.workspace, body.debug
|
body.message, conversation.workspace, body.debug
|
||||||
)
|
)
|
||||||
task_event = await event_store.append(
|
task_event = await event_store.append(
|
||||||
task.task_id,
|
task.task_id,
|
||||||
"task_created",
|
"task_created",
|
||||||
{
|
{
|
||||||
"message": body.message,
|
"message": body.message,
|
||||||
"workspace": body.workspace or settings.workspace,
|
"workspace": conversation.workspace,
|
||||||
"debug": body.debug,
|
"debug": body.debug,
|
||||||
|
"conversation_id": conversation.conversation_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
yield sse("task_created", task_event.model_dump())
|
yield sse("task_created", task_event.model_dump())
|
||||||
|
|
@ -145,9 +223,9 @@ def create_app() -> FastAPI:
|
||||||
reasoning_parts: list[str] = []
|
reasoning_parts: list[str] = []
|
||||||
content_parts: list[str] = []
|
content_parts: list[str] = []
|
||||||
try:
|
try:
|
||||||
messages = runtime.context_builder.build_basic_messages(task)
|
messages = runtime.context_builder.build_basic_messages(task, history)
|
||||||
tool_observations = await runtime._run_action_loop(
|
tool_observations = await runtime._run_action_loop(
|
||||||
task.task_id, messages, body.workspace or settings.workspace
|
task.task_id, messages, conversation.workspace
|
||||||
)
|
)
|
||||||
async for tool_event in emit_tool_events(task.task_id, task_event.sequence):
|
async for tool_event in emit_tool_events(task.task_id, task_event.sequence):
|
||||||
yield tool_event
|
yield tool_event
|
||||||
|
|
@ -158,10 +236,25 @@ def create_app() -> FastAPI:
|
||||||
"task_waiting_for_approval",
|
"task_waiting_for_approval",
|
||||||
{"observations": tool_observations},
|
{"observations": tool_observations},
|
||||||
)
|
)
|
||||||
|
await conversations.add_message(
|
||||||
|
conversation.conversation_id,
|
||||||
|
"user",
|
||||||
|
body.message,
|
||||||
|
task_id=task.task_id,
|
||||||
|
status="waiting_for_approval",
|
||||||
|
)
|
||||||
|
await conversations.add_message(
|
||||||
|
conversation.conversation_id,
|
||||||
|
"assistant",
|
||||||
|
"Waiting for approval.",
|
||||||
|
task_id=task.task_id,
|
||||||
|
status="waiting_for_approval",
|
||||||
|
)
|
||||||
yield sse(
|
yield sse(
|
||||||
"done",
|
"done",
|
||||||
{
|
{
|
||||||
"task_id": task.task_id,
|
"task_id": task.task_id,
|
||||||
|
"conversation_id": conversation.conversation_id,
|
||||||
"status": "waiting_for_approval",
|
"status": "waiting_for_approval",
|
||||||
"final_response": "Waiting for approval.",
|
"final_response": "Waiting for approval.",
|
||||||
"reasoning_content": None,
|
"reasoning_content": None,
|
||||||
|
|
@ -213,6 +306,21 @@ def create_app() -> FastAPI:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await task_store.complete_task(task.task_id, content)
|
await task_store.complete_task(task.task_id, content)
|
||||||
|
await conversations.add_message(
|
||||||
|
conversation.conversation_id,
|
||||||
|
"user",
|
||||||
|
body.message,
|
||||||
|
task_id=task.task_id,
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
|
await conversations.add_message(
|
||||||
|
conversation.conversation_id,
|
||||||
|
"assistant",
|
||||||
|
content,
|
||||||
|
reasoning_content=reasoning_content,
|
||||||
|
task_id=task.task_id,
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
await event_store.append(
|
await event_store.append(
|
||||||
task.task_id,
|
task.task_id,
|
||||||
"task_completed",
|
"task_completed",
|
||||||
|
|
@ -225,6 +333,7 @@ def create_app() -> FastAPI:
|
||||||
"done",
|
"done",
|
||||||
{
|
{
|
||||||
"task_id": task.task_id,
|
"task_id": task.task_id,
|
||||||
|
"conversation_id": conversation.conversation_id,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"final_response": content,
|
"final_response": content,
|
||||||
"reasoning_content": reasoning_content,
|
"reasoning_content": reasoning_content,
|
||||||
|
|
@ -297,6 +406,7 @@ def create_app() -> FastAPI:
|
||||||
raise HTTPException(status_code=404, detail="Approval not found for task")
|
raise HTTPException(status_code=404, detail="Approval not found for task")
|
||||||
if approval.decision is None:
|
if approval.decision is None:
|
||||||
raise HTTPException(status_code=409, detail="Approval is still pending")
|
raise HTTPException(status_code=409, detail="Approval is still pending")
|
||||||
|
conversation_id = await conversations.get_conversation_id_for_task(task_id)
|
||||||
|
|
||||||
async def generator():
|
async def generator():
|
||||||
reasoning_parts: list[str] = []
|
reasoning_parts: list[str] = []
|
||||||
|
|
@ -329,10 +439,19 @@ def create_app() -> FastAPI:
|
||||||
"task_waiting_for_approval",
|
"task_waiting_for_approval",
|
||||||
{"observations": tool_observations},
|
{"observations": tool_observations},
|
||||||
)
|
)
|
||||||
|
if conversation_id:
|
||||||
|
await conversations.add_message(
|
||||||
|
conversation_id,
|
||||||
|
"assistant",
|
||||||
|
"Waiting for approval.",
|
||||||
|
task_id=task_id,
|
||||||
|
status="waiting_for_approval",
|
||||||
|
)
|
||||||
yield sse(
|
yield sse(
|
||||||
"done",
|
"done",
|
||||||
{
|
{
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
"status": "waiting_for_approval",
|
"status": "waiting_for_approval",
|
||||||
"final_response": "Waiting for approval.",
|
"final_response": "Waiting for approval.",
|
||||||
"reasoning_content": None,
|
"reasoning_content": None,
|
||||||
|
|
@ -378,6 +497,15 @@ def create_app() -> FastAPI:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await task_store.complete_task(task_id, content)
|
await task_store.complete_task(task_id, content)
|
||||||
|
if conversation_id:
|
||||||
|
await conversations.add_message(
|
||||||
|
conversation_id,
|
||||||
|
"assistant",
|
||||||
|
content,
|
||||||
|
reasoning_content=reasoning_content,
|
||||||
|
task_id=task_id,
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
await event_store.append(
|
await event_store.append(
|
||||||
task_id,
|
task_id,
|
||||||
"task_completed",
|
"task_completed",
|
||||||
|
|
@ -390,6 +518,7 @@ def create_app() -> FastAPI:
|
||||||
"done",
|
"done",
|
||||||
{
|
{
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"final_response": content,
|
"final_response": content,
|
||||||
"reasoning_content": reasoning_content,
|
"reasoning_content": reasoning_content,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,15 @@ from duck_core.tasks.state import TaskState
|
||||||
|
|
||||||
|
|
||||||
class ContextBuilder:
|
class ContextBuilder:
|
||||||
def build_basic_messages(self, task: TaskState) -> list[dict[str, str]]:
|
def build_basic_messages(
|
||||||
|
self,
|
||||||
|
task: TaskState,
|
||||||
|
history_messages: list[dict[str, str]] | None = None,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
return [
|
return [
|
||||||
|
*(history_messages or []),
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": task.user_message,
|
"content": task.user_message,
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
__all__ = ["ConversationStore"]
|
||||||
|
|
||||||
|
from duck_core.conversations.store import ConversationStore
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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 Conversation(BaseModel):
|
||||||
|
id: int | None = None
|
||||||
|
conversation_id: str
|
||||||
|
title: str
|
||||||
|
workspace: str
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationMessage(BaseModel):
|
||||||
|
id: int | None = None
|
||||||
|
conversation_id: str
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
reasoning_content: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationStore:
|
||||||
|
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 conversations (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
conversation_id text not null unique,
|
||||||
|
title text not null,
|
||||||
|
workspace text not null,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
create table if not exists conversation_messages (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
conversation_id text not null,
|
||||||
|
role text not null,
|
||||||
|
content text not null,
|
||||||
|
reasoning_content text,
|
||||||
|
task_id text,
|
||||||
|
status text,
|
||||||
|
created_at text not null
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def create(self, title: str, workspace: str) -> Conversation:
|
||||||
|
await self.init()
|
||||||
|
now = utc_now()
|
||||||
|
conversation_id = f"chat_{uuid4().hex[:12]}"
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""
|
||||||
|
insert into conversations(conversation_id, title, workspace, created_at, updated_at)
|
||||||
|
values (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(conversation_id, title, workspace, now, now),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
row_id = cursor.lastrowid
|
||||||
|
return Conversation(
|
||||||
|
id=row_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
title=title,
|
||||||
|
workspace=workspace,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def ensure(
|
||||||
|
self, conversation_id: str | None, title: str, workspace: str
|
||||||
|
) -> Conversation:
|
||||||
|
if conversation_id:
|
||||||
|
existing = await self.get(conversation_id)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
return await self.create(title, workspace)
|
||||||
|
|
||||||
|
async def get(self, conversation_id: str) -> Conversation | None:
|
||||||
|
await self.init()
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"select * from conversations where conversation_id = ?", (conversation_id,)
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return self._row_to_conversation(row) if row else None
|
||||||
|
|
||||||
|
async def list(self, limit: int = 50) -> list[Conversation]:
|
||||||
|
await self.init()
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(
|
||||||
|
"select * from conversations order by updated_at desc limit ?", (limit,)
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [self._row_to_conversation(row) for row in rows]
|
||||||
|
|
||||||
|
async def add_message(
|
||||||
|
self,
|
||||||
|
conversation_id: str,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
reasoning_content: str | None = None,
|
||||||
|
task_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
) -> ConversationMessage:
|
||||||
|
await self.init()
|
||||||
|
now = utc_now()
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""
|
||||||
|
insert into conversation_messages(
|
||||||
|
conversation_id, role, content, reasoning_content, task_id, status, created_at
|
||||||
|
) values (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(conversation_id, role, content, reasoning_content, task_id, status, now),
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"update conversations set updated_at = ? where conversation_id = ?",
|
||||||
|
(now, conversation_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
row_id = cursor.lastrowid
|
||||||
|
return ConversationMessage(
|
||||||
|
id=row_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
role=role,
|
||||||
|
content=content,
|
||||||
|
reasoning_content=reasoning_content,
|
||||||
|
task_id=task_id,
|
||||||
|
status=status,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_messages(
|
||||||
|
self, conversation_id: str, limit: int | None = None
|
||||||
|
) -> list[ConversationMessage]:
|
||||||
|
await self.init()
|
||||||
|
sql = "select * from conversation_messages where conversation_id = ? order by id"
|
||||||
|
params: tuple[Any, ...] = (conversation_id,)
|
||||||
|
if limit is not None:
|
||||||
|
sql = (
|
||||||
|
"select * from (select * from conversation_messages where conversation_id = ? "
|
||||||
|
"order by id desc limit ?) order by id"
|
||||||
|
)
|
||||||
|
params = (conversation_id, limit)
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute(sql, params)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [self._row_to_message(row) for row in rows]
|
||||||
|
|
||||||
|
async def get_conversation_id_for_task(self, task_id: str) -> str | None:
|
||||||
|
await self.init()
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"""
|
||||||
|
select conversation_id from conversation_messages
|
||||||
|
where task_id = ?
|
||||||
|
order by id
|
||||||
|
limit 1
|
||||||
|
""",
|
||||||
|
(task_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
def _row_to_conversation(self, row: aiosqlite.Row) -> Conversation:
|
||||||
|
return Conversation(
|
||||||
|
id=row["id"],
|
||||||
|
conversation_id=row["conversation_id"],
|
||||||
|
title=row["title"],
|
||||||
|
workspace=row["workspace"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _row_to_message(self, row: aiosqlite.Row) -> ConversationMessage:
|
||||||
|
return ConversationMessage(
|
||||||
|
id=row["id"],
|
||||||
|
conversation_id=row["conversation_id"],
|
||||||
|
role=row["role"],
|
||||||
|
content=row["content"],
|
||||||
|
reasoning_content=row["reasoning_content"],
|
||||||
|
task_id=row["task_id"],
|
||||||
|
status=row["status"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
|
@ -37,7 +37,11 @@ class RuntimeLoop:
|
||||||
self.max_tool_iterations = max_tool_iterations
|
self.max_tool_iterations = max_tool_iterations
|
||||||
|
|
||||||
async def run_chat(
|
async def run_chat(
|
||||||
self, message: str, workspace: str | None = None, debug: bool = False
|
self,
|
||||||
|
message: str,
|
||||||
|
workspace: str | None = None,
|
||||||
|
debug: bool = False,
|
||||||
|
history_messages: list[dict[str, str]] | None = None,
|
||||||
) -> ChatResult:
|
) -> ChatResult:
|
||||||
task = await self.task_store.create_task(message, workspace, debug)
|
task = await self.task_store.create_task(message, workspace, debug)
|
||||||
await self.event_store.append(
|
await self.event_store.append(
|
||||||
|
|
@ -46,7 +50,7 @@ class RuntimeLoop:
|
||||||
{"message": message, "workspace": workspace, "debug": debug},
|
{"message": message, "workspace": workspace, "debug": debug},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
messages = self.context_builder.build_basic_messages(task)
|
messages = self.context_builder.build_basic_messages(task, history_messages)
|
||||||
tool_observations = await self._run_action_loop(task.task_id, messages, workspace)
|
tool_observations = await self._run_action_loop(task.task_id, messages, workspace)
|
||||||
if any(observation.get("requires_approval") for observation in tool_observations):
|
if any(observation.get("requires_approval") for observation in tool_observations):
|
||||||
await self.task_store.waiting_for_approval(task.task_id)
|
await self.task_store.waiting_for_approval(task.task_id)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from duck_core.tools.base import Tool, ToolResult
|
||||||
from duck_core.tools.file_read import FileReadTool
|
from duck_core.tools.file_read import FileReadTool
|
||||||
from duck_core.tools.file_write import FileWriteTool
|
from duck_core.tools.file_write import FileWriteTool
|
||||||
from duck_core.tools.list_dir import ListDirTool
|
from duck_core.tools.list_dir import ListDirTool
|
||||||
|
from duck_core.tools.os_update_check import OsUpdateCheckTool
|
||||||
from duck_core.tools.search_files import SearchFilesTool
|
from duck_core.tools.search_files import SearchFilesTool
|
||||||
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
|
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ class ToolGateway:
|
||||||
FileWriteTool(workspace),
|
FileWriteTool(workspace),
|
||||||
ListDirTool(workspace),
|
ListDirTool(workspace),
|
||||||
SearchFilesTool(workspace),
|
SearchFilesTool(workspace),
|
||||||
|
OsUpdateCheckTool(workspace),
|
||||||
ShellExecSafeTool(workspace),
|
ShellExecSafeTool(workspace),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from duck_core.tools.base import ToolResult
|
||||||
|
|
||||||
|
|
||||||
|
class OsUpdateCheckTool:
|
||||||
|
name = "os_update_check"
|
||||||
|
risk_level = "low"
|
||||||
|
|
||||||
|
def __init__(self, workspace: str, timeout_seconds: int = 60, max_lines: int = 300):
|
||||||
|
self.workspace = workspace
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
self.max_lines = max_lines
|
||||||
|
|
||||||
|
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||||
|
if shutil.which("apt"):
|
||||||
|
return self._run_apt()
|
||||||
|
return ToolResult(
|
||||||
|
ok=False,
|
||||||
|
error="No supported package manager found for update checks.",
|
||||||
|
metadata={"supported_package_managers": ["apt"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_apt(self) -> ToolResult:
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["apt", "list", "--upgradable"],
|
||||||
|
cwd=self.workspace,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=self.timeout_seconds,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.SubprocessError as exc:
|
||||||
|
return ToolResult(ok=False, error=str(exc), metadata={"package_manager": "apt"})
|
||||||
|
|
||||||
|
lines = completed.stdout.splitlines()
|
||||||
|
package_lines = [line for line in lines if "/" in line and "upgradable from:" in line]
|
||||||
|
output_lines = lines[: self.max_lines]
|
||||||
|
truncated = len(lines) > self.max_lines
|
||||||
|
if truncated:
|
||||||
|
output_lines.append(f"... truncated after {self.max_lines} lines")
|
||||||
|
|
||||||
|
return ToolResult(
|
||||||
|
ok=completed.returncode == 0,
|
||||||
|
output="\n".join(output_lines),
|
||||||
|
error=completed.stderr or None,
|
||||||
|
metadata={
|
||||||
|
"package_manager": "apt",
|
||||||
|
"command": "apt list --upgradable",
|
||||||
|
"returncode": completed.returncode,
|
||||||
|
"upgradable_count": len(package_lines),
|
||||||
|
"refreshed_cache": False,
|
||||||
|
"truncated": truncated,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -58,9 +58,10 @@ class ShellExecSafeTool:
|
||||||
async def run(self, args: dict[str, Any]) -> ToolResult:
|
async def run(self, args: dict[str, Any]) -> ToolResult:
|
||||||
command = str(args.get("command", "")).strip()
|
command = str(args.get("command", "")).strip()
|
||||||
approved = bool(args.get("_approved"))
|
approved = bool(args.get("_approved"))
|
||||||
allowed, reason = self._is_allowed(command, approved=approved)
|
allowed, reason, blocked = self._is_allowed(command, approved=approved)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
return ToolResult(ok=False, error=reason, metadata={"requires_approval": True})
|
metadata = {"blocked": True} if blocked else {"requires_approval": True}
|
||||||
|
return ToolResult(ok=False, error=reason, metadata=metadata)
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
command,
|
command,
|
||||||
|
|
@ -80,19 +81,29 @@ class ShellExecSafeTool:
|
||||||
metadata={"returncode": completed.returncode, "command": command},
|
metadata={"returncode": completed.returncode, "command": command},
|
||||||
)
|
)
|
||||||
|
|
||||||
def _is_allowed(self, command: str, approved: bool = False) -> tuple[bool, str | None]:
|
def _is_allowed(
|
||||||
|
self, command: str, approved: bool = False
|
||||||
|
) -> tuple[bool, str | None, bool]:
|
||||||
if not command:
|
if not command:
|
||||||
return False, "Empty command"
|
return False, "Empty command", False
|
||||||
lowered = command.lower()
|
lowered = command.lower()
|
||||||
for blocked in BLOCKLIST:
|
|
||||||
if lowered.startswith(blocked.lower()) or blocked.lower() in lowered:
|
|
||||||
return False, f"Command is blocked: {blocked}"
|
|
||||||
if approved:
|
|
||||||
return True, None
|
|
||||||
parts = shlex.split(command)
|
parts = shlex.split(command)
|
||||||
|
for blocked in BLOCKLIST:
|
||||||
|
if self._matches_blocked_command(lowered, parts, blocked):
|
||||||
|
return False, f"Command is blocked: {blocked}", True
|
||||||
|
if approved:
|
||||||
|
return True, None, False
|
||||||
prefix1 = parts[0] if parts else ""
|
prefix1 = parts[0] if parts else ""
|
||||||
prefix2 = " ".join(parts[:2])
|
prefix2 = " ".join(parts[:2])
|
||||||
prefix3 = " ".join(parts[:3])
|
prefix3 = " ".join(parts[:3])
|
||||||
if prefix1 in ALLOWLIST or prefix2 in ALLOWLIST or prefix3 in ALLOWLIST:
|
if prefix1 in ALLOWLIST or prefix2 in ALLOWLIST or prefix3 in ALLOWLIST:
|
||||||
return True, None
|
return True, None, False
|
||||||
return False, "Command is outside allowlist and requires approval"
|
return False, "Command is outside allowlist and requires approval", False
|
||||||
|
|
||||||
|
def _matches_blocked_command(
|
||||||
|
self, lowered_command: str, parts: list[str], blocked: str
|
||||||
|
) -> bool:
|
||||||
|
lowered_blocked = blocked.lower()
|
||||||
|
if " " in lowered_blocked or "|" in lowered_blocked:
|
||||||
|
return lowered_command.startswith(lowered_blocked) or lowered_blocked in lowered_command
|
||||||
|
return bool(parts) and parts[0].lower() == lowered_blocked
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
const state = {
|
const state = {
|
||||||
running: false,
|
running: false,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
currentConversationId: "",
|
||||||
|
conversations: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function jsonFetch(url, options) {
|
async function jsonFetch(url, options) {
|
||||||
|
|
@ -52,6 +54,35 @@ function addMessage(role, content, meta = "", options = {}) {
|
||||||
return article;
|
return article;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearMessages() {
|
||||||
|
const messages = document.querySelector("#messages");
|
||||||
|
if (messages) messages.innerHTML = "";
|
||||||
|
const events = document.querySelector("#events");
|
||||||
|
if (events) events.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConversationHeader(conversation) {
|
||||||
|
const title = document.querySelector("#chat-title");
|
||||||
|
const subtitle = document.querySelector("#chat-subtitle");
|
||||||
|
if (title) title.textContent = conversation?.title || "Chat";
|
||||||
|
if (subtitle) {
|
||||||
|
subtitle.textContent = conversation?.workspace
|
||||||
|
? `Workspace: ${conversation.workspace}`
|
||||||
|
: "Messages are processed by the local Qwen role mapping through Duck Core.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStoredMessage(message) {
|
||||||
|
const article = addMessage(
|
||||||
|
message.role,
|
||||||
|
message.content,
|
||||||
|
message.status || "saved",
|
||||||
|
{reasoning: message.role === "assistant" && Boolean(message.reasoning_content)},
|
||||||
|
);
|
||||||
|
if (message.reasoning_content) finishInlineReasoning(article, message.reasoning_content);
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
function createInlineReasoning() {
|
function createInlineReasoning() {
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
section.className = "message-reasoning is-collapsed";
|
section.className = "message-reasoning is-collapsed";
|
||||||
|
|
@ -112,6 +143,9 @@ function formatToolCommand(tool, args) {
|
||||||
if (tool === "shell_exec_safe") return `$ ${args.command || tool}`;
|
if (tool === "shell_exec_safe") return `$ ${args.command || tool}`;
|
||||||
if (tool === "file_read") return `$ file_read ${args.path || ""}`.trim();
|
if (tool === "file_read") return `$ file_read ${args.path || ""}`.trim();
|
||||||
if (tool === "file_write") return `$ file_write ${args.path || ""}`.trim();
|
if (tool === "file_write") return `$ file_write ${args.path || ""}`.trim();
|
||||||
|
if (tool === "list_dir") return `$ list_dir ${args.path || "."}`.trim();
|
||||||
|
if (tool === "search_files") return `$ search_files ${args.query || ""}`.trim();
|
||||||
|
if (tool === "os_update_check") return "$ os_update_check";
|
||||||
return `$ ${tool || "tool"}`;
|
return `$ ${tool || "tool"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,6 +397,9 @@ async function handleAssistantStreamEvent(pending, name, data, context) {
|
||||||
if (data.task_id) context.taskId = data.task_id;
|
if (data.task_id) context.taskId = data.task_id;
|
||||||
if (name === "task_created") {
|
if (name === "task_created") {
|
||||||
context.taskId = data.task_id;
|
context.taskId = data.task_id;
|
||||||
|
if (data.payload?.conversation_id) {
|
||||||
|
state.currentConversationId = data.payload.conversation_id;
|
||||||
|
}
|
||||||
setStatus("#task-status", data.task_id, "warn");
|
setStatus("#task-status", data.task_id, "warn");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -396,6 +433,7 @@ async function handleAssistantStreamEvent(pending, name, data, context) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (name === "done") {
|
if (name === "done") {
|
||||||
|
if (data.conversation_id) state.currentConversationId = data.conversation_id;
|
||||||
if (!context.contentStarted) {
|
if (!context.contentStarted) {
|
||||||
setMessagePending(pending, data.final_response || "No final content returned.");
|
setMessagePending(pending, data.final_response || "No final content returned.");
|
||||||
}
|
}
|
||||||
|
|
@ -403,6 +441,7 @@ async function handleAssistantStreamEvent(pending, name, data, context) {
|
||||||
setStatus("#task-status", data.task_id, data.status === "completed" ? "ok" : "warn");
|
setStatus("#task-status", data.task_id, data.status === "completed" ? "ok" : "warn");
|
||||||
finishInlineReasoning(pending, data.reasoning_content);
|
finishInlineReasoning(pending, data.reasoning_content);
|
||||||
await refreshEvents(data.task_id);
|
await refreshEvents(data.task_id);
|
||||||
|
await refreshConversations();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (name === "error") {
|
if (name === "error") {
|
||||||
|
|
@ -434,6 +473,59 @@ async function continueAfterInlineApproval(article, taskId, approvalId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshConversations() {
|
||||||
|
const list = document.querySelector("#conversation-list");
|
||||||
|
if (!list) return;
|
||||||
|
state.conversations = await jsonFetch("/v1/conversations");
|
||||||
|
list.innerHTML = "";
|
||||||
|
if (!state.conversations.length) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "conversation-empty";
|
||||||
|
empty.textContent = "No saved chats.";
|
||||||
|
list.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const conversation of state.conversations) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "conversation-item";
|
||||||
|
button.dataset.conversationId = conversation.conversation_id;
|
||||||
|
button.classList.toggle("active", conversation.conversation_id === state.currentConversationId);
|
||||||
|
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = conversation.title;
|
||||||
|
const workspace = document.createElement("span");
|
||||||
|
workspace.textContent = conversation.workspace;
|
||||||
|
button.append(title, workspace);
|
||||||
|
list.append(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectConversation(conversationId) {
|
||||||
|
const conversation = await jsonFetch(`/v1/conversations/${conversationId}`);
|
||||||
|
state.currentConversationId = conversation.conversation_id;
|
||||||
|
document.querySelector("#workspace").value = conversation.workspace;
|
||||||
|
setConversationHeader(conversation);
|
||||||
|
clearMessages();
|
||||||
|
if (!conversation.messages.length) {
|
||||||
|
addMessage("assistant", "Новый чат готов.", "ready");
|
||||||
|
} else {
|
||||||
|
for (const message of conversation.messages) addStoredMessage(message);
|
||||||
|
}
|
||||||
|
await refreshConversations();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewConversation() {
|
||||||
|
const workspace = document.querySelector("#workspace").value || "./workspace";
|
||||||
|
const conversation = await jsonFetch("/v1/conversations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({title: "New chat", workspace}),
|
||||||
|
});
|
||||||
|
await selectConversation(conversation.conversation_id);
|
||||||
|
setStatus("#task-status", "none");
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
if (state.running) return;
|
if (state.running) return;
|
||||||
const input = document.querySelector("#message");
|
const input = document.querySelector("#message");
|
||||||
|
|
@ -451,6 +543,7 @@ async function sendMessage() {
|
||||||
try {
|
try {
|
||||||
await streamChat({
|
await streamChat({
|
||||||
message,
|
message,
|
||||||
|
conversation_id: state.currentConversationId || null,
|
||||||
workspace: document.querySelector("#workspace").value,
|
workspace: document.querySelector("#workspace").value,
|
||||||
debug: document.querySelector("#debug").checked,
|
debug: document.querySelector("#debug").checked,
|
||||||
}, async ({name, data}) => {
|
}, async ({name, data}) => {
|
||||||
|
|
@ -500,11 +593,14 @@ function bindChat() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.querySelector("#new-chat")?.addEventListener("click", () => {
|
document.querySelector("#new-chat")?.addEventListener("click", () => {
|
||||||
const messages = document.querySelector("#messages");
|
createNewConversation().catch(console.error);
|
||||||
messages.innerHTML = "";
|
});
|
||||||
addMessage("assistant", "Новая сессия готова.", "ready");
|
document.querySelector("#reload-chat")?.addEventListener("click", () => {
|
||||||
document.querySelector("#events").innerHTML = "";
|
if (state.currentConversationId) selectConversation(state.currentConversationId).catch(console.error);
|
||||||
setStatus("#task-status", "none");
|
});
|
||||||
|
document.querySelector("#conversation-list")?.addEventListener("click", (event) => {
|
||||||
|
const item = event.target.closest("[data-conversation-id]");
|
||||||
|
if (item) selectConversation(item.dataset.conversationId).catch(console.error);
|
||||||
});
|
});
|
||||||
document.querySelector("#messages")?.addEventListener("click", (event) => {
|
document.querySelector("#messages")?.addEventListener("click", (event) => {
|
||||||
const approvalButton = event.target.closest("[data-inline-approval-action]");
|
const approvalButton = event.target.closest("[data-inline-approval-action]");
|
||||||
|
|
@ -617,3 +713,8 @@ document.querySelector("#memory-search")?.addEventListener("click", async () =>
|
||||||
bindChat();
|
bindChat();
|
||||||
checkRuntime();
|
checkRuntime();
|
||||||
loadSimplePages().catch(console.error);
|
loadSimplePages().catch(console.error);
|
||||||
|
refreshConversations().then(() => {
|
||||||
|
if (state.conversations[0]) {
|
||||||
|
return selectConversation(state.conversations[0].conversation_id);
|
||||||
|
}
|
||||||
|
}).catch(console.error);
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,8 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-panel,
|
.settings-panel,
|
||||||
.status-panel {
|
.status-panel,
|
||||||
|
.conversation-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|
@ -243,11 +244,79 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-panel h2,
|
.settings-panel h2,
|
||||||
.status-panel h2 {
|
.status-panel h2,
|
||||||
|
.conversation-panel h2 {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #f8fafc;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #e5edf7;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item:hover,
|
||||||
|
.conversation-item.active {
|
||||||
|
background: var(--sidebar-soft);
|
||||||
|
border-color: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item strong,
|
||||||
|
.conversation-item span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item strong {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item span,
|
||||||
|
.conversation-empty {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-empty {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@
|
||||||
<a href="/experience">Experience</a>
|
<a href="/experience">Experience</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<section class="conversation-panel" aria-labelledby="conversations-title">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2 id="conversations-title">Chats</h2>
|
||||||
|
<button id="new-chat" type="button" title="New chat">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="conversation-list" class="conversation-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings-panel" aria-labelledby="settings-title">
|
<section class="settings-panel" aria-labelledby="settings-title">
|
||||||
<h2 id="settings-title">Session</h2>
|
<h2 id="settings-title">Session</h2>
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -59,10 +67,10 @@
|
||||||
<main class="chat-shell">
|
<main class="chat-shell">
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Chat</h2>
|
<h2 id="chat-title">Chat</h2>
|
||||||
<p>Messages are processed by the local Qwen role mapping through Duck Core.</p>
|
<p id="chat-subtitle">Messages are processed by the local Qwen role mapping through Duck Core.</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="new-chat" class="secondary-button" type="button">New Chat</button>
|
<button id="reload-chat" class="secondary-button" type="button">Reload</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section id="messages" class="messages" aria-live="polite">
|
<section id="messages" class="messages" aria-live="polite">
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ Available tools:
|
||||||
Args: {"path": "."}
|
Args: {"path": "."}
|
||||||
- search_files: search text inside files in the current workspace.
|
- search_files: search text inside files in the current workspace.
|
||||||
Args: {"query": "text to find", "path": ".", "glob": "*.py"}
|
Args: {"query": "text to find", "path": ".", "glob": "*.py"}
|
||||||
|
- os_update_check: check available operating system package updates without
|
||||||
|
refreshing package manager caches and without sudo.
|
||||||
|
Args: {}
|
||||||
- shell_exec_safe: run a safe allowlisted shell command in the current workspace.
|
- shell_exec_safe: run a safe allowlisted shell command in the current workspace.
|
||||||
Args: {"command": "pwd"}
|
Args: {"command": "pwd"}
|
||||||
|
|
||||||
|
|
@ -21,3 +24,6 @@ follow-up information. Return actions=[] when the observations are sufficient
|
||||||
for the thinker to answer.
|
for the thinker to answer.
|
||||||
Use only the listed tools. Keep actions minimal and directly tied to the user's
|
Use only the listed tools. Keep actions minimal and directly tied to the user's
|
||||||
request. Do not invent tool names.
|
request. Do not invent tool names.
|
||||||
|
For user requests like "check updates in the system", use os_update_check.
|
||||||
|
Do not use apt update, apt-get update, sudo, su, or package installation commands
|
||||||
|
for update checks.
|
||||||
|
|
|
||||||
|
|
@ -173,3 +173,7 @@ def test_continue_stream_executes_approved_tool_and_streams_answer(tmp_path, mon
|
||||||
assert "event: content_delta" in body
|
assert "event: content_delta" in body
|
||||||
assert "continued after approval" in body
|
assert "continued after approval" in body
|
||||||
assert "event: done" in body
|
assert "event: done" in body
|
||||||
|
conversation_id = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', initial_body).group(1)
|
||||||
|
conversation = client.get(f"/v1/conversations/{conversation_id}").json()
|
||||||
|
assert conversation["messages"][-1]["content"] == "continued after approval"
|
||||||
|
assert conversation["messages"][-1]["status"] == "completed"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from duck_core.api import create_app
|
||||||
|
from duck_core.model_client import ModelResponse
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversations_api_stores_different_workspaces(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||||
|
client = TestClient(create_app())
|
||||||
|
|
||||||
|
first = client.post(
|
||||||
|
"/v1/conversations",
|
||||||
|
json={"title": "Project A", "workspace": "/tmp/project-a"},
|
||||||
|
).json()
|
||||||
|
second = client.post(
|
||||||
|
"/v1/conversations",
|
||||||
|
json={"title": "Project B", "workspace": "/tmp/project-b"},
|
||||||
|
).json()
|
||||||
|
listed = client.get("/v1/conversations").json()
|
||||||
|
|
||||||
|
assert first["conversation_id"] != second["conversation_id"]
|
||||||
|
assert {item["workspace"] for item in listed} >= {"/tmp/project-a", "/tmp/project-b"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_api_persists_messages_in_conversation(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||||
|
|
||||||
|
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
||||||
|
if role == "action":
|
||||||
|
return ModelResponse(
|
||||||
|
role=role,
|
||||||
|
model="local-main",
|
||||||
|
content=json.dumps(
|
||||||
|
{
|
||||||
|
"kind": "action_directive",
|
||||||
|
"intent": "answer directly",
|
||||||
|
"risk_level": "none",
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
reasoning_content=None,
|
||||||
|
raw={},
|
||||||
|
latency_ms=1.0,
|
||||||
|
)
|
||||||
|
return ModelResponse(
|
||||||
|
role=role,
|
||||||
|
model="local-main",
|
||||||
|
content="Первый ответ сохранен.",
|
||||||
|
reasoning_content="short reasoning",
|
||||||
|
raw={},
|
||||||
|
latency_ms=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
||||||
|
client = TestClient(create_app())
|
||||||
|
conversation = client.post(
|
||||||
|
"/v1/conversations",
|
||||||
|
json={"title": "Saved chat", "workspace": str(tmp_path)},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/v1/chat",
|
||||||
|
json={
|
||||||
|
"conversation_id": conversation["conversation_id"],
|
||||||
|
"message": "Запомни это сообщение",
|
||||||
|
"debug": True,
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
loaded = client.get(f"/v1/conversations/{conversation['conversation_id']}").json()
|
||||||
|
|
||||||
|
assert response["conversation_id"] == conversation["conversation_id"]
|
||||||
|
assert loaded["workspace"] == str(tmp_path)
|
||||||
|
assert [message["role"] for message in loaded["messages"]] == ["user", "assistant"]
|
||||||
|
assert loaded["messages"][0]["content"] == "Запомни это сообщение"
|
||||||
|
assert loaded["messages"][1]["content"] == "Первый ответ сохранен."
|
||||||
|
assert loaded["messages"][1]["reasoning_content"] == "short reasoning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversation_history_is_sent_to_model(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||||
|
seen_thinker_messages = []
|
||||||
|
|
||||||
|
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
||||||
|
if role == "action":
|
||||||
|
return ModelResponse(
|
||||||
|
role=role,
|
||||||
|
model="local-main",
|
||||||
|
content=json.dumps(
|
||||||
|
{
|
||||||
|
"kind": "action_directive",
|
||||||
|
"intent": "answer directly",
|
||||||
|
"risk_level": "none",
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
reasoning_content=None,
|
||||||
|
raw={},
|
||||||
|
latency_ms=1.0,
|
||||||
|
)
|
||||||
|
seen_thinker_messages.append(messages)
|
||||||
|
return ModelResponse(
|
||||||
|
role=role,
|
||||||
|
model="local-main",
|
||||||
|
content=f"answer {len(seen_thinker_messages)}",
|
||||||
|
reasoning_content=None,
|
||||||
|
raw={},
|
||||||
|
latency_ms=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
||||||
|
client = TestClient(create_app())
|
||||||
|
conversation = client.post(
|
||||||
|
"/v1/conversations",
|
||||||
|
json={"title": "History", "workspace": str(tmp_path)},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/v1/chat",
|
||||||
|
json={"conversation_id": conversation["conversation_id"], "message": "first"},
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/v1/chat",
|
||||||
|
json={"conversation_id": conversation["conversation_id"], "message": "second"},
|
||||||
|
)
|
||||||
|
|
||||||
|
second_call_content = [message["content"] for message in seen_thinker_messages[-1]]
|
||||||
|
assert second_call_content == ["first", "answer 1", "second"]
|
||||||
|
|
@ -101,6 +101,41 @@ class FakeMultiStepToolModelClient:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUpdateCheckModelClient:
|
||||||
|
async def chat(self, role, messages):
|
||||||
|
if role == "action":
|
||||||
|
actions = []
|
||||||
|
if not any("tool_observations" in message["content"] for message in messages):
|
||||||
|
actions = [{"tool": "os_update_check", "args": {}, "reason": "Check OS updates"}]
|
||||||
|
return ModelResponse(
|
||||||
|
role=role,
|
||||||
|
model="local-main",
|
||||||
|
content=json.dumps(
|
||||||
|
{
|
||||||
|
"kind": "action_directive",
|
||||||
|
"intent": "check system updates",
|
||||||
|
"risk_level": "low",
|
||||||
|
"actions": actions,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
reasoning_content=None,
|
||||||
|
raw={},
|
||||||
|
latency_ms=5.0,
|
||||||
|
)
|
||||||
|
assert role == "thinker"
|
||||||
|
observation_text = "\n".join(message["content"] for message in messages)
|
||||||
|
assert "os_update_check" in observation_text
|
||||||
|
assert "requires_approval" not in observation_text
|
||||||
|
return ModelResponse(
|
||||||
|
role=role,
|
||||||
|
model="local-main",
|
||||||
|
content="Updates checked without approval loop.",
|
||||||
|
reasoning_content=None,
|
||||||
|
raw={},
|
||||||
|
latency_ms=12.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_executes_action_directive_tool_and_finishes_with_observation(tmp_path):
|
async def test_runtime_executes_action_directive_tool_and_finishes_with_observation(tmp_path):
|
||||||
(tmp_path / "note.txt").write_text("hello from tool")
|
(tmp_path / "note.txt").write_text("hello from tool")
|
||||||
|
|
@ -142,6 +177,25 @@ async def test_runtime_runs_multiple_tool_steps_before_final_answer(tmp_path):
|
||||||
assert finished_tools == ["list_dir", "file_read"]
|
assert finished_tools == ["list_dir", "file_read"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runtime_checks_system_updates_without_approval_loop(tmp_path):
|
||||||
|
db_path = str(tmp_path / "duck.sqlite3")
|
||||||
|
task_store = TaskStore(db_path)
|
||||||
|
event_store = EventStore(db_path)
|
||||||
|
loop = RuntimeLoop(task_store, event_store, FakeUpdateCheckModelClient())
|
||||||
|
|
||||||
|
result = await loop.run_chat("Привет. Проверь обновления в системе", str(tmp_path), debug=True)
|
||||||
|
events = await event_store.list_events(result.task_id)
|
||||||
|
|
||||||
|
assert result.status == "completed"
|
||||||
|
assert not any(event.event_type == "tool_approval_requested" for event in events)
|
||||||
|
assert any(
|
||||||
|
event.event_type == "tool_call_finished"
|
||||||
|
and event.payload["tool"] == "os_update_check"
|
||||||
|
for event in events
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeApprovalModelClient:
|
class FakeApprovalModelClient:
|
||||||
async def chat(self, role, messages):
|
async def chat(self, role, messages):
|
||||||
if role == "action":
|
if role == "action":
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import pytest
|
||||||
from duck_core.tools.file_read import FileReadTool
|
from duck_core.tools.file_read import FileReadTool
|
||||||
from duck_core.tools.file_write import FileWriteTool
|
from duck_core.tools.file_write import FileWriteTool
|
||||||
from duck_core.tools.gateway import ToolGateway
|
from duck_core.tools.gateway import ToolGateway
|
||||||
|
from duck_core.tools.os_update_check import OsUpdateCheckTool
|
||||||
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
|
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -26,9 +27,15 @@ async def test_shell_tool_blocks_dangerous_commands(tmp_path):
|
||||||
|
|
||||||
allowed = await shell.run({"command": "pwd"})
|
allowed = await shell.run({"command": "pwd"})
|
||||||
blocked = await shell.run({"command": "rm -rf ."})
|
blocked = await shell.run({"command": "rm -rf ."})
|
||||||
|
sudo = await shell.run({"command": "sudo apt update"})
|
||||||
|
|
||||||
assert allowed.ok is True
|
assert allowed.ok is True
|
||||||
assert blocked.ok is False
|
assert blocked.ok is False
|
||||||
|
assert blocked.metadata.get("requires_approval") is not True
|
||||||
|
assert blocked.metadata["blocked"] is True
|
||||||
|
assert sudo.ok is False
|
||||||
|
assert sudo.metadata.get("requires_approval") is not True
|
||||||
|
assert sudo.metadata["blocked"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -76,3 +83,26 @@ async def test_tool_gateway_searches_file_contents(tmp_path):
|
||||||
assert "src/app.py:1:duck tool gateway" in result.output
|
assert "src/app.py:1:duck tool gateway" in result.output
|
||||||
assert result.metadata["matches"] == 1
|
assert result.metadata["matches"] == 1
|
||||||
assert escaped.ok is False
|
assert escaped.ok is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_os_update_check_reports_apt_upgradable_packages(monkeypatch, tmp_path):
|
||||||
|
class Completed:
|
||||||
|
returncode = 0
|
||||||
|
stdout = "Listing...\\nbootlogd/stable 3.14-4 amd64 [upgradable from: 3.06-4]\\n"
|
||||||
|
stderr = "WARNING: apt does not have a stable CLI interface.\\n"
|
||||||
|
|
||||||
|
monkeypatch.setattr("duck_core.tools.os_update_check.shutil.which", lambda name: "/usr/bin/apt")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"duck_core.tools.os_update_check.subprocess.run",
|
||||||
|
lambda *args, **kwargs: Completed(),
|
||||||
|
)
|
||||||
|
tool = OsUpdateCheckTool(str(tmp_path))
|
||||||
|
|
||||||
|
result = await tool.run({})
|
||||||
|
|
||||||
|
assert result.ok is True
|
||||||
|
assert "bootlogd" in result.output
|
||||||
|
assert result.metadata["package_manager"] == "apt"
|
||||||
|
assert result.metadata["upgradable_count"] == 1
|
||||||
|
assert result.metadata["refreshed_cache"] is False
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue