diff --git a/duck_core/api.py b/duck_core/api.py
index 41dac97..baa3eb7 100644
--- a/duck_core/api.py
+++ b/duck_core/api.py
@@ -13,6 +13,7 @@ from pydantic import BaseModel
from duck_core.approvals.service import ApprovalService
from duck_core.config import get_settings
+from duck_core.conversations.store import ConversationStore
from duck_core.events.store import EventStore
from duck_core.experience.recorder import ExperienceRecorder
from duck_core.memory.vector_memory import EmbeddingsUnavailableError, VectorMemory
@@ -26,15 +27,22 @@ logger = logging.getLogger(__name__)
class ChatRequest(BaseModel):
message: str
+ conversation_id: str | None = None
workspace: str | None = None
debug: bool = False
+class ConversationRequest(BaseModel):
+ title: str | None = None
+ workspace: str | None = None
+
+
class ContinueRequest(BaseModel):
approval_id: str
def create_app() -> FastAPI:
+ get_settings.cache_clear()
settings = get_settings()
if settings.api_host == "0.0.0.0":
logger.warning(
@@ -49,6 +57,7 @@ def create_app() -> FastAPI:
task_store = TaskStore(settings.db_path)
event_store = EventStore(settings.db_path)
+ conversations = ConversationStore(settings.db_path)
model_client = ModelClient()
approvals = ApprovalService(settings.db_path)
runtime = RuntimeLoop(task_store, event_store, model_client, approval_service=approvals)
@@ -60,6 +69,7 @@ def create_app() -> FastAPI:
async def startup() -> None:
await task_store.init()
await event_store.init()
+ await conversations.init()
await approvals.init()
await experience.init()
@@ -108,8 +118,69 @@ def create_app() -> FastAPI:
@app.post("/v1/chat")
async def chat(body: ChatRequest) -> dict[str, Any]:
- result = await runtime.run_chat(body.message, body.workspace or settings.workspace, body.debug)
- return result.__dict__
+ conversation = await conversations.ensure(
+ 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:
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")
async def chat_stream(body: ChatRequest) -> StreamingResponse:
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(
- body.message, body.workspace or settings.workspace, body.debug
+ body.message, conversation.workspace, body.debug
)
task_event = await event_store.append(
task.task_id,
"task_created",
{
"message": body.message,
- "workspace": body.workspace or settings.workspace,
+ "workspace": conversation.workspace,
"debug": body.debug,
+ "conversation_id": conversation.conversation_id,
},
)
yield sse("task_created", task_event.model_dump())
@@ -145,9 +223,9 @@ def create_app() -> FastAPI:
reasoning_parts: list[str] = []
content_parts: list[str] = []
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(
- 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):
yield tool_event
@@ -158,10 +236,25 @@ def create_app() -> FastAPI:
"task_waiting_for_approval",
{"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(
"done",
{
"task_id": task.task_id,
+ "conversation_id": conversation.conversation_id,
"status": "waiting_for_approval",
"final_response": "Waiting for approval.",
"reasoning_content": None,
@@ -213,6 +306,21 @@ def create_app() -> FastAPI:
},
)
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(
task.task_id,
"task_completed",
@@ -225,6 +333,7 @@ def create_app() -> FastAPI:
"done",
{
"task_id": task.task_id,
+ "conversation_id": conversation.conversation_id,
"status": "completed",
"final_response": content,
"reasoning_content": reasoning_content,
@@ -297,6 +406,7 @@ def create_app() -> FastAPI:
raise HTTPException(status_code=404, detail="Approval not found for task")
if approval.decision is None:
raise HTTPException(status_code=409, detail="Approval is still pending")
+ conversation_id = await conversations.get_conversation_id_for_task(task_id)
async def generator():
reasoning_parts: list[str] = []
@@ -329,10 +439,19 @@ def create_app() -> FastAPI:
"task_waiting_for_approval",
{"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(
"done",
{
"task_id": task_id,
+ "conversation_id": conversation_id,
"status": "waiting_for_approval",
"final_response": "Waiting for approval.",
"reasoning_content": None,
@@ -378,6 +497,15 @@ def create_app() -> FastAPI:
},
)
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(
task_id,
"task_completed",
@@ -390,6 +518,7 @@ def create_app() -> FastAPI:
"done",
{
"task_id": task_id,
+ "conversation_id": conversation_id,
"status": "completed",
"final_response": content,
"reasoning_content": reasoning_content,
diff --git a/duck_core/context_builder.py b/duck_core/context_builder.py
index 7f14ea7..961745a 100644
--- a/duck_core/context_builder.py
+++ b/duck_core/context_builder.py
@@ -2,10 +2,15 @@ from duck_core.tasks.state import TaskState
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 [
+ *(history_messages or []),
{
"role": "user",
"content": task.user_message,
- }
+ },
]
diff --git a/duck_core/conversations/__init__.py b/duck_core/conversations/__init__.py
new file mode 100644
index 0000000..32cad08
--- /dev/null
+++ b/duck_core/conversations/__init__.py
@@ -0,0 +1,3 @@
+__all__ = ["ConversationStore"]
+
+from duck_core.conversations.store import ConversationStore
diff --git a/duck_core/conversations/store.py b/duck_core/conversations/store.py
new file mode 100644
index 0000000..7b9b499
--- /dev/null
+++ b/duck_core/conversations/store.py
@@ -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"],
+ )
diff --git a/duck_core/runtime_loop.py b/duck_core/runtime_loop.py
index 072910a..552ffe7 100644
--- a/duck_core/runtime_loop.py
+++ b/duck_core/runtime_loop.py
@@ -37,7 +37,11 @@ class RuntimeLoop:
self.max_tool_iterations = max_tool_iterations
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:
task = await self.task_store.create_task(message, workspace, debug)
await self.event_store.append(
@@ -46,7 +50,7 @@ class RuntimeLoop:
{"message": message, "workspace": workspace, "debug": debug},
)
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)
if any(observation.get("requires_approval") for observation in tool_observations):
await self.task_store.waiting_for_approval(task.task_id)
diff --git a/duck_core/tools/gateway.py b/duck_core/tools/gateway.py
index b4d0a09..28df56b 100644
--- a/duck_core/tools/gateway.py
+++ b/duck_core/tools/gateway.py
@@ -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_write import FileWriteTool
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.shell_exec_safe import ShellExecSafeTool
@@ -20,6 +21,7 @@ class ToolGateway:
FileWriteTool(workspace),
ListDirTool(workspace),
SearchFilesTool(workspace),
+ OsUpdateCheckTool(workspace),
ShellExecSafeTool(workspace),
]
)
diff --git a/duck_core/tools/os_update_check.py b/duck_core/tools/os_update_check.py
new file mode 100644
index 0000000..2555733
--- /dev/null
+++ b/duck_core/tools/os_update_check.py
@@ -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,
+ },
+ )
diff --git a/duck_core/tools/shell_exec_safe.py b/duck_core/tools/shell_exec_safe.py
index daeb9d4..d06836d 100644
--- a/duck_core/tools/shell_exec_safe.py
+++ b/duck_core/tools/shell_exec_safe.py
@@ -58,9 +58,10 @@ class ShellExecSafeTool:
async def run(self, args: dict[str, Any]) -> ToolResult:
command = str(args.get("command", "")).strip()
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:
- 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:
completed = subprocess.run(
command,
@@ -80,19 +81,29 @@ class ShellExecSafeTool:
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:
- return False, "Empty command"
+ return False, "Empty command", False
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)
+ 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 ""
prefix2 = " ".join(parts[:2])
prefix3 = " ".join(parts[:3])
if prefix1 in ALLOWLIST or prefix2 in ALLOWLIST or prefix3 in ALLOWLIST:
- return True, None
- return False, "Command is outside allowlist and requires approval"
+ return True, None, False
+ 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
diff --git a/duck_core/web/static/app.js b/duck_core/web/static/app.js
index 7535f5a..2481901 100644
--- a/duck_core/web/static/app.js
+++ b/duck_core/web/static/app.js
@@ -1,6 +1,8 @@
const state = {
running: false,
messages: [],
+ currentConversationId: "",
+ conversations: [],
};
async function jsonFetch(url, options) {
@@ -52,6 +54,35 @@ function addMessage(role, content, meta = "", options = {}) {
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() {
const section = document.createElement("section");
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 === "file_read") return `$ file_read ${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"}`;
}
@@ -363,6 +397,9 @@ async function handleAssistantStreamEvent(pending, name, data, context) {
if (data.task_id) context.taskId = data.task_id;
if (name === "task_created") {
context.taskId = data.task_id;
+ if (data.payload?.conversation_id) {
+ state.currentConversationId = data.payload.conversation_id;
+ }
setStatus("#task-status", data.task_id, "warn");
return;
}
@@ -396,6 +433,7 @@ async function handleAssistantStreamEvent(pending, name, data, context) {
return;
}
if (name === "done") {
+ if (data.conversation_id) state.currentConversationId = data.conversation_id;
if (!context.contentStarted) {
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");
finishInlineReasoning(pending, data.reasoning_content);
await refreshEvents(data.task_id);
+ await refreshConversations();
return;
}
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() {
if (state.running) return;
const input = document.querySelector("#message");
@@ -451,6 +543,7 @@ async function sendMessage() {
try {
await streamChat({
message,
+ conversation_id: state.currentConversationId || null,
workspace: document.querySelector("#workspace").value,
debug: document.querySelector("#debug").checked,
}, async ({name, data}) => {
@@ -500,11 +593,14 @@ function bindChat() {
}
});
document.querySelector("#new-chat")?.addEventListener("click", () => {
- const messages = document.querySelector("#messages");
- messages.innerHTML = "";
- addMessage("assistant", "Новая сессия готова.", "ready");
- document.querySelector("#events").innerHTML = "";
- setStatus("#task-status", "none");
+ createNewConversation().catch(console.error);
+ });
+ document.querySelector("#reload-chat")?.addEventListener("click", () => {
+ if (state.currentConversationId) selectConversation(state.currentConversationId).catch(console.error);
+ });
+ 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) => {
const approvalButton = event.target.closest("[data-inline-approval-action]");
@@ -617,3 +713,8 @@ document.querySelector("#memory-search")?.addEventListener("click", async () =>
bindChat();
checkRuntime();
loadSimplePages().catch(console.error);
+refreshConversations().then(() => {
+ if (state.conversations[0]) {
+ return selectConversation(state.conversations[0].conversation_id);
+ }
+}).catch(console.error);
diff --git a/duck_core/web/static/style.css b/duck_core/web/static/style.css
index def1cc7..a972cbe 100644
--- a/duck_core/web/static/style.css
+++ b/duck_core/web/static/style.css
@@ -233,7 +233,8 @@ button {
}
.settings-panel,
-.status-panel {
+.status-panel,
+.conversation-panel {
display: grid;
gap: 12px;
padding: 14px;
@@ -243,11 +244,79 @@ button {
}
.settings-panel h2,
-.status-panel h2 {
+.status-panel h2,
+.conversation-panel h2 {
font-size: 13px;
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 {
display: grid;
gap: 7px;
diff --git a/duck_core/web/templates/index.html b/duck_core/web/templates/index.html
index 8ffdd9b..0529cad 100644
--- a/duck_core/web/templates/index.html
+++ b/duck_core/web/templates/index.html
@@ -25,6 +25,14 @@
Experience
+
+
Session