Add persistent conversations and update checks

This commit is contained in:
2026-05-20 07:48:51 +08:00
parent a4b7ef034a
commit 12bed7b6ba
16 changed files with 854 additions and 31 deletions
+135 -6
View File
@@ -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,
+7 -2
View File
@@ -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,
}
},
]
+3
View File
@@ -0,0 +1,3 @@
__all__ = ["ConversationStore"]
from duck_core.conversations.store import ConversationStore
+210
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"],
)
+6 -2
View File
@@ -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)
+2
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_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),
]
)
+58
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,
},
)
+22 -11
View File
@@ -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
+106 -5
View File
@@ -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);
+71 -2
View File
@@ -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;
+11 -3
View File
@@ -25,6 +25,14 @@
<a href="/experience">Experience</a>
</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">
<h2 id="settings-title">Session</h2>
<label>
@@ -59,10 +67,10 @@
<main class="chat-shell">
<header class="chat-header">
<div>
<h2>Chat</h2>
<p>Messages are processed by the local Qwen role mapping through Duck Core.</p>
<h2 id="chat-title">Chat</h2>
<p id="chat-subtitle">Messages are processed by the local Qwen role mapping through Duck Core.</p>
</div>
<button id="new-chat" class="secondary-button" type="button">New Chat</button>
<button id="reload-chat" class="secondary-button" type="button">Reload</button>
</header>
<section id="messages" class="messages" aria-live="polite">