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

Chats

+ +
+
+
+

Session