Add persistent conversations and update checks

This commit is contained in:
mirivlad 2026-05-20 07:48:51 +08:00
parent a4b7ef034a
commit 12bed7b6ba
16 changed files with 854 additions and 31 deletions

View File

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

View File

@ -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,
} },
] ]

View File

@ -0,0 +1,3 @@
__all__ = ["ConversationStore"]
from duck_core.conversations.store import ConversationStore

View File

@ -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"],
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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