fix: UI/UX 修复 + 暗色主题 + async generator 防御

- App.vue: 重构 bootstrapBackend 流程,新增 retryBootstrap 重试入口
- SplashScreen.vue: 错误状态显示「重试」按钮
- system.py: /system/resources 移除 SYSTEM_CONFIG 权限依赖,避免 dev 模式 401
- react.py + gateway.py: 新增 _ensure_async_iterable helper 防御
  'async for requires aiter, got coroutine'
- theme.ts: Ant Design colorTextLightSolid 映射到 --text-inverse
  修复暗色主题下所有 primary 按钮白底白字
- ChatSidebar.vue: 新建对话按钮兜底深色文字
- SystemMonitorPanel.vue: 服务状态区域间距优化
- chat.ts + portal.py + sqlite_conversation_store.py: 会话标题派生修复
  解决点击对话标题变成"对话"的问题
- app.py: Serve 模式自动创建 default agent
- Tauri src-tauri/: 完整 Tauri 客户端配置 (icons, capabilities, Cargo)
This commit is contained in:
TraeAI 2026-06-20 23:35:57 +08:00
parent 44bc27c9b3
commit d245f2e3d8
36 changed files with 5468 additions and 67 deletions

View File

@ -303,3 +303,36 @@ class SqliteConversationStore:
) -> None: ) -> None:
"""No-op for SQLite store — data is already persisted in the database.""" """No-op for SQLite store — data is already persisted in the database."""
# Nothing to do; all data lives in SQLite and is loaded on demand. # Nothing to do; all data lives in SQLite and is loaded on demand.
async def get_first_user_message(self, conversation_id: str) -> ChatMessage | None:
"""Get the first user message of a conversation (authoritative source for title).
Reads directly from SQLite so the title is correct even when the
in-memory cache has not been populated or has an empty `messages`
list (e.g. after a server restart).
"""
db = await self._ensure_db()
try:
cursor = await db.execute(
"SELECT role, content, timestamp, metadata FROM messages "
"WHERE conversation_id = ? AND role = 'user' AND content != '' "
"ORDER BY id ASC LIMIT 1",
(conversation_id,),
)
row = await cursor.fetchone()
if not row:
return None
meta: dict[str, Any] = {}
try:
meta = json.loads(row["metadata"]) if row["metadata"] else {}
except (json.JSONDecodeError, TypeError):
pass
return ChatMessage(
role=row["role"],
content=row["content"],
timestamp=self._str_to_dt(row["timestamp"]),
metadata=meta,
)
except Exception as e:
logger.warning(f"get_first_user_message failed for {conversation_id}: {e}")
return None

View File

@ -45,6 +45,71 @@ class ReActStep:
tokens: int = 0 tokens: int = 0
async def _ensure_async_iterable(obj: Any, label: str = "<obj>"):
"""Defensive helper: ensure the given object is an async iterable.
Guards against the recurring ``'async for' requires an object with
__aiter__ method, got coroutine`` error. This error happens when an
``async def`` function that *should* yield values ends up returning a
coroutine object instead of an async generator typically because
every code path through the function exits before the first ``yield``
(e.g. early ``raise``) and a misbehaving caller in some Python
versions or a specific runtime configuration treats it as a coroutine.
This helper accepts either:
- An async iterable (async generator) returned as-is.
- An awaitable that resolves to an async iterable awaited, then yielded.
- Anything else raises a clear, actionable error naming ``label``.
Use it like::
async for chunk in _ensure_async_iterable(
some_func_that_should_stream(), label="some_func"
):
...
Args:
obj: The object returned by calling an ``async def`` function.
label: A short human-readable name used in error messages to help
locate the source of the bug.
Yields:
Items from the resolved async iterable.
Raises:
TypeError: If ``obj`` is neither an async iterable nor an
awaitable that resolves to one. The error message names
``label`` so the offending call site is easy to find.
"""
# Case 1: already an async iterable (the normal case).
if hasattr(obj, "__aiter__"):
async for item in obj:
yield item
return
# Case 2: an awaitable that hasn't been awaited yet (the bug we're
# guarding against). Awaiting it should produce an async iterable.
if asyncio.iscoroutine(obj) or asyncio.isfuture(obj):
resolved = await obj
if hasattr(resolved, "__aiter__"):
async for item in resolved:
yield item
return
raise TypeError(
f"{label}: awaited value is not async iterable "
f"(got {type(resolved).__name__})"
)
# Case 3: anything else — surface a clear, actionable error rather
# than the cryptic CPython ``TypeError: 'async for' requires...``.
raise TypeError(
f"{label}: expected an async iterable, got {type(obj).__name__}. "
f"This usually means the called function returned a coroutine "
f"instead of an async generator — check that it contains at "
f"least one reachable ``yield`` statement."
)
@dataclass @dataclass
class ReActResult: class ReActResult:
"""ReAct 执行结果""" """ReAct 执行结果"""
@ -911,12 +976,15 @@ class ReActEngine:
stream_tool_calls: list[Any] = [] stream_tool_calls: list[Any] = []
stream_model = model stream_model = model
async for chunk in self._llm_gateway.chat_stream( async for chunk in _ensure_async_iterable(
messages=conversation, self._llm_gateway.chat_stream(
model=model, messages=conversation,
agent_name=agent_name, model=model,
task_type=task_type, agent_name=agent_name,
tools=tool_schemas, task_type=task_type,
tools=tool_schemas,
),
label=f"llm_gateway.chat_stream(model={model!r})",
): ):
if chunk.content: if chunk.content:
stream_content_chunks.append(chunk.content) stream_content_chunks.append(chunk.content)

View File

@ -1,5 +1,6 @@
"""LLM Gateway - 统一 LLM 调用入口""" """LLM Gateway - 统一 LLM 调用入口"""
import asyncio
import logging import logging
import time import time
from typing import Any from typing import Any
@ -317,7 +318,28 @@ class LLMGateway:
final_model = model_name final_model = model_name
try: try:
async for chunk in provider.chat_stream(stream_request): stream_obj = provider.chat_stream(stream_request)
# Defensive: guard against misconfigured providers (e.g. an
# AsyncMock in tests, or a future refactor that accidentally
# turns chat_stream into a regular ``async def``) that return
# a coroutine instead of an async generator. The original
# cryptic error ``'async for' requires an object with
# __aiter__ method, got coroutine`` becomes a clear,
# actionable message naming the offending provider+model.
if asyncio.iscoroutine(stream_obj):
logger.error(
f"Provider '{model_name}'.chat_stream returned a "
f"coroutine instead of an async generator. "
f"Check that the method is defined as "
f"``async def chat_stream(...): ...; yield ...``."
)
raise TypeError(
f"Provider '{model_name}' returned a coroutine "
f"from chat_stream() — expected an async "
f"generator. This indicates a provider "
f"implementation bug."
)
async for chunk in stream_obj:
chunk_yielded = True chunk_yielded = True
if chunk.content: if chunk.content:
total_content += chunk.content total_content += chunk.content

View File

@ -168,9 +168,10 @@ async def lifespan(app: FastAPI):
await _conversation_store.restore_from_store() await _conversation_store.restore_from_store()
# In GUI mode, ensure a default chat agent exists with memory + tools # Ensure a default chat agent exists with memory + tools (both GUI and serve modes)
gui_mode = os.environ.get("AGENTKIT_GUI_MODE") # Previously this only ran in GUI mode, which caused 'Agent default not found' errors
if gui_mode and not app.state.agent_pool.list_agents(): # when the frontend tried to create a chat session via REST/WebSocket on a serve-mode server.
if not app.state.agent_pool.list_agents():
from agentkit.core.config_driven import AgentConfig from agentkit.core.config_driven import AgentConfig
from agentkit.memory.profile import MemoryStore from agentkit.memory.profile import MemoryStore
from agentkit.tools.memory_tool import MemoryTool from agentkit.tools.memory_tool import MemoryTool

View File

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
ureq = { version = "2.10", features = ["json"] }
tauri = { version = "2.11.2", features = [] }
tauri-plugin-log = "2"

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -0,0 +1,131 @@
use std::sync::Mutex;
/// Tauri 2.x IPC commands for AgentKit desktop client.
///
/// Architecture: Tauri client connects to a remote AgentKit server (not a
/// local sidecar). The Rust shell acts as a thin shim that exposes the
/// remote server's connection info to the frontend, and proxies health
/// checks through the local network.
///
/// Frontend contract (see src/api/tauri.ts):
/// - start_backend() -> u16 (remote port the frontend should use)
/// - get_backend_port() -> u16
/// - stop_backend() -> ()
/// - check_backend_health() -> bool (proxied HTTP GET to remote /api/v1/health)
/// - get_backend_url() -> String (full base URL, e.g. http://127.0.0.1:8000)
/// Remote server connection info. Defaults match the local dev server
/// (``agentkit serve --port 8000``). Override at compile time via
/// ``AGENTKIT_REMOTE_HOST`` / ``AGENTKIT_REMOTE_PORT``.
const REMOTE_HOST: &str = match option_env!("AGENTKIT_REMOTE_HOST") {
Some(h) => h,
None => "127.0.0.1",
};
const REMOTE_PORT_STR: &str = match option_env!("AGENTKIT_REMOTE_PORT") {
Some(p) => p,
None => "8000",
};
/// Parse the port at first use (avoids non-const fn call in constant).
fn remote_port() -> u16 {
REMOTE_PORT_STR.parse().unwrap_or(8000)
}
/// Global state: tracks whether the "backend" is considered started.
/// In our remote-server model this is a one-shot init flag.
#[derive(Default)]
struct BackendState {
started: Mutex<bool>,
}
/// Start the backend connection. Returns the remote server's port.
///
/// In a local-sidecar design this would spawn the Python process and
/// return its bound port. In the remote-server model we simply record
/// that initialization completed and return the configured remote port.
#[tauri::command]
fn start_backend(state: tauri::State<BackendState>) -> Result<u16, String> {
let mut started = state.started.lock().map_err(|e| e.to_string())?;
*started = true;
let port = remote_port();
log::info!("start_backend: connecting to remote server {REMOTE_HOST}:{port}");
Ok(port)
}
/// Get the current backend port. Errors if start_backend hasn't been
/// called yet (matches the original sidecar contract).
#[tauri::command]
fn get_backend_port(state: tauri::State<BackendState>) -> Result<u16, String> {
let started = state.started.lock().map_err(|e| e.to_string())?;
if !*started {
return Err("Backend not started. Call start_backend() first.".to_string());
}
Ok(remote_port())
}
/// Stop the backend connection. In the remote-server model this is a
/// no-op (we don't own the server lifecycle) but we still clear the
/// started flag so a subsequent start_backend() can re-initialize.
#[tauri::command]
fn stop_backend(state: tauri::State<BackendState>) -> Result<(), String> {
let mut started = state.started.lock().map_err(|e| e.to_string())?;
*started = false;
log::info!("stop_backend: cleared connection state");
Ok(())
}
/// Probe the remote server's health endpoint. Returns true if it
/// responds with 200 OK to ``GET /api/v1/health`` within 3 seconds.
///
/// We use a synchronous HTTP client (ureq) here to keep the Tauri
/// command body simple and avoid pulling in tokio/reqwest for a single
/// health probe. The probe runs in a worker thread automatically.
#[tauri::command]
fn check_backend_health() -> Result<bool, String> {
let url = format!("http://{REMOTE_HOST}:{}/api/v1/health", remote_port());
log::debug!("check_backend_health: GET {url}");
match ureq::get(&url).timeout(std::time::Duration::from_secs(3)).call() {
Ok(resp) if resp.status() == 200 => Ok(true),
Ok(resp) => {
log::warn!("check_backend_health: non-200 status {}", resp.status());
Ok(false)
}
Err(e) => {
log::warn!("check_backend_health: {e}");
Ok(false)
}
}
}
/// Return the full base URL the frontend should use to reach the
/// remote server. Convenience command that the frontend could use
/// instead of constructing the URL from get_backend_port().
#[tauri::command]
fn get_backend_url() -> Result<String, String> {
Ok(format!("http://{REMOTE_HOST}:{}", remote_port()))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(BackendState::default())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
start_backend,
get_backend_port,
stop_backend,
check_backend_health,
get_backend_url,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}

View File

@ -0,0 +1,40 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "AgentKit",
"version": "0.1.0",
"identifier": "com.fischer.agentkit",
"build": {
"frontendDist": "../static",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build:frontend"
},
"app": {
"windows": [
{
"title": "AgentKit Desktop",
"width": 1280,
"height": 800,
"minWidth": 1024,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@ -1,6 +1,11 @@
<template> <template>
<a-config-provider :locale="zhCN" :theme="themeStore.antThemeConfig"> <a-config-provider :locale="zhCN" :theme="themeStore.antThemeConfig">
<SplashScreen v-if="loading" :status="loadingStatus" :error="loadError" /> <SplashScreen
v-if="loading"
:status="loadingStatus"
:error="loadError"
:on-retry="retryBootstrap"
/>
<router-view v-else /> <router-view v-else />
</a-config-provider> </a-config-provider>
</template> </template>
@ -41,17 +46,7 @@ onMounted(async () => {
if (isTauri()) { if (isTauri()) {
try { try {
loadingStatus.value = '正在启动后端...' await bootstrapBackend()
await startBackend()
loadingStatus.value = '正在配置连接...'
await initApiBaseURL()
loadingStatus.value = '正在检查服务...'
const healthy = await checkBackendHealth()
if (healthy) {
loading.value = false
} else {
loadError.value = '后端服务健康检查失败'
}
} catch (err: unknown) { } catch (err: unknown) {
loadError.value = err instanceof Error ? err.message : String(err) loadError.value = err instanceof Error ? err.message : String(err)
} }
@ -59,6 +54,37 @@ onMounted(async () => {
loading.value = false loading.value = false
} }
}) })
/** Run the Tauri-side backend bootstrap (start + init URL + health check).
*
* Exposed to the template so the user can click "重试" when the initial
* health check fails (e.g. the Python server was not up at the moment
* the window opened). */
async function bootstrapBackend(): Promise<void> {
loadingStatus.value = '正在启动后端...'
await startBackend()
loadingStatus.value = '正在配置连接...'
await initApiBaseURL()
loadingStatus.value = '正在检查服务...'
const healthy = await checkBackendHealth()
if (healthy) {
loadError.value = ''
loading.value = false
} else {
loadError.value = '后端服务健康检查失败,请确认 agentkit serve 已在 8000 端口运行后点击重试。'
}
}
/** User-triggered retry from the error screen. */
async function retryBootstrap(): Promise<void> {
loadError.value = ''
loading.value = true
try {
await bootstrapBackend()
} catch (err: unknown) {
loadError.value = err instanceof Error ? err.message : String(err)
}
}
</script> </script>
<style> <style>

View File

@ -105,6 +105,22 @@ function formatRelativeTime(dateStr: string): string {
border-bottom: 1px solid var(--border-color-split); border-bottom: 1px solid var(--border-color-split);
} }
/* Fix: dark theme primary button has light bg (--color-primary=#fbfbfa)
* in dark mode, so default white text is invisible. Force dark text in
* dark theme to keep "新建对话" readable. */
.chat-sidebar__header :deep(.ant-btn-primary) {
color: var(--text-inverse);
}
[data-theme="dark"] .chat-sidebar__header :deep(.ant-btn-primary) {
color: #1a1a1a;
}
[data-theme="dark"] .chat-sidebar__header :deep(.ant-btn-primary:hover),
[data-theme="dark"] .chat-sidebar__header :deep(.ant-btn-primary:focus) {
color: #1a1a1a;
}
.chat-sidebar__list { .chat-sidebar__list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;

View File

@ -3,13 +3,16 @@
<div class="splash-content"> <div class="splash-content">
<div class="splash-logo">Fischer AgentKit</div> <div class="splash-logo">Fischer AgentKit</div>
<div class="splash-subtitle">AI Agent Framework</div> <div class="splash-subtitle">AI Agent Framework</div>
<div class="splash-progress"> <div v-if="!error" class="splash-progress">
<div class="splash-progress-bar"> <div class="splash-progress-bar">
<div class="splash-progress-inner"></div> <div class="splash-progress-inner"></div>
</div> </div>
</div> </div>
<div class="splash-status">{{ status }}</div> <div v-if="!error" class="splash-status">{{ status }}</div>
<div v-if="error" class="splash-error">{{ error }}</div> <div v-if="error" class="splash-error">
<div class="splash-error-text">{{ error }}</div>
<button v-if="onRetry" class="splash-retry" @click="onRetry">重试</button>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -18,6 +21,7 @@
defineProps<{ defineProps<{
status: string status: string
error?: string error?: string
onRetry?: () => void | Promise<void>
}>() }>()
</script> </script>
@ -74,9 +78,33 @@ defineProps<{
} }
.splash-error { .splash-error {
margin-top: 12px; margin-top: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.splash-error-text {
font-size: 12px; font-size: 12px;
color: var(--color-error, #ff4d4f); color: var(--color-error, #ff4d4f);
max-width: 300px; max-width: 320px;
text-align: center; text-align: center;
line-height: 1.5;
}
.splash-retry {
padding: 6px 18px;
font-size: 13px;
font-weight: 500;
color: #fff;
background: var(--color-primary, #6366f1);
border: 1px solid var(--color-primary, #6366f1);
border-radius: 6px;
cursor: pointer;
transition: opacity 0.15s ease, transform 0.1s ease;
}
.splash-retry:hover {
opacity: 0.85;
}
.splash-retry:active {
transform: scale(0.97);
} }
</style> </style>

View File

@ -232,10 +232,11 @@ onUnmounted(() => {
.system-monitor__tabs { .system-monitor__tabs {
display: flex; display: flex;
gap: var(--space-4); gap: var(--space-4);
padding: 12px 16px; padding: 14px 16px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-size: var(--font-sm); font-size: var(--font-sm);
color: var(--text-tertiary); color: var(--text-tertiary);
flex-shrink: 0;
} }
.system-monitor__tab { .system-monitor__tab {
@ -243,7 +244,7 @@ onUnmounted(() => {
align-items: center; align-items: center;
gap: 5px; gap: 5px;
cursor: pointer; cursor: pointer;
padding: 4px 0; padding: 6px 0;
border: none; border: none;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
background: transparent; background: transparent;
@ -286,43 +287,69 @@ onUnmounted(() => {
.system-monitor__body { .system-monitor__body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 16px; padding: 18px 16px;
} }
.system-monitor__section-title { .system-monitor__section-title {
font-size: var(--font-sm); font-size: var(--font-sm);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
color: var(--text-primary); color: var(--text-primary);
margin: 16px 0 10px; margin: 18px 0 12px;
display: flex;
align-items: center;
gap: 6px;
} }
.system-monitor__section-title:first-child { .system-monitor__section-title:first-child {
margin-top: 0; margin-top: 0;
} }
.system-monitor__section-title::before {
content: '';
display: inline-block;
width: 3px;
height: 12px;
background: var(--accent-team);
border-radius: 2px;
}
/* 系统概览区4 个指标卡片 + 分隔线 */
.system-monitor__metrics { .system-monitor__metrics {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 10px;
padding-bottom: 22px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border-color);
} }
.system-monitor__metric { .system-monitor__metric {
padding: 12px; padding: 14px 12px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--bg-secondary);
transition: border-color 0.15s;
}
.system-monitor__metric:hover {
border-color: var(--color-primary-light);
} }
.system-monitor__metric-label { .system-monitor__metric-label {
font-size: 11px; font-size: 11px;
color: var(--text-tertiary); color: var(--text-tertiary);
margin-bottom: 4px; margin-bottom: 6px;
display: flex;
align-items: center;
gap: 4px;
} }
.system-monitor__metric-value { .system-monitor__metric-value {
font-size: 18px; font-size: 20px;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 2px; margin-bottom: 4px;
line-height: 1.2;
} }
.system-monitor__metric-value.status-healthy { .system-monitor__metric-value.status-healthy {
@ -341,6 +368,7 @@ onUnmounted(() => {
.system-monitor__metric-delta { .system-monitor__metric-delta {
font-size: 10px; font-size: 10px;
color: var(--text-tertiary); color: var(--text-tertiary);
line-height: 1.4;
} }
.system-monitor__metric-delta.up { .system-monitor__metric-delta.up {
@ -351,28 +379,46 @@ onUnmounted(() => {
color: var(--accent-team); color: var(--accent-team);
} }
/* 分区容器:左右两列独立分块 */
.system-monitor__split { .system-monitor__split {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 16px; gap: 22px;
margin-top: 6px;
} }
.system-monitor__column {
display: flex;
flex-direction: column;
min-width: 0;
}
/* 服务状态区:与上方 metrics 区域有明显的呼吸间距 */
.system-monitor__services { .system-monitor__services {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 0;
padding: 6px 14px;
background: var(--bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
margin-top: 4px;
} }
.system-monitor__service { .system-monitor__service {
display: grid; display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto auto; grid-template-columns: 10px minmax(0, 1fr) auto auto;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
padding: 5px 0; padding: 10px 0;
font-size: var(--font-sm); font-size: var(--font-sm);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.system-monitor__service:last-child {
border-bottom: none;
}
.system-monitor__service-name, .system-monitor__service-name,
.system-monitor__service-status, .system-monitor__service-status,
.system-monitor__service-time { .system-monitor__service-time {
@ -382,27 +428,34 @@ onUnmounted(() => {
} }
.system-monitor__service-dot { .system-monitor__service-dot {
width: 6px; width: 8px;
height: 6px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--text-placeholder); background: var(--text-placeholder);
box-shadow: 0 0 0 2px var(--bg-secondary);
} }
.system-monitor__service-dot.ok { .system-monitor__service-dot.ok {
background: var(--color-success); background: var(--color-success);
box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.12);
} }
.system-monitor__service-dot.error { .system-monitor__service-dot.error {
background: var(--color-error); background: var(--color-error);
box-shadow: 0 0 0 2px rgba(245, 34, 45, 0.12);
} }
.system-monitor__service-name { .system-monitor__service-name {
color: var(--text-primary); color: var(--text-primary);
font-weight: var(--font-weight-medium, 500);
} }
.system-monitor__service-status { .system-monitor__service-status {
color: var(--color-success); color: var(--color-success);
font-size: 11px; font-size: 11px;
padding: 1px 6px;
border-radius: 3px;
background: rgba(82, 196, 26, 0.08);
} }
.system-monitor__service-time { .system-monitor__service-time {
@ -412,7 +465,7 @@ onUnmounted(() => {
} }
.system-monitor__chart-placeholder { .system-monitor__chart-placeholder {
height: 80px; min-height: 96px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -422,6 +475,8 @@ onUnmounted(() => {
font-size: var(--font-xs); font-size: var(--font-xs);
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin: 8px 0; border: 1px dashed var(--border-color);
margin-top: 4px;
padding: 12px;
} }
</style> </style>

View File

@ -75,16 +75,30 @@ export const useChatStore = defineStore('chat', () => {
const fullConv = await apiClient.getConversation(id) const fullConv = await apiClient.getConversation(id)
if (conv) { if (conv) {
conv.messages = fullConv.messages || [] conv.messages = fullConv.messages || []
conv.title = fullConv.title || conv.title // P0 #1 fix: never let the server's placeholder title ("对话")
// overwrite a real title we already have locally. If the server
// returns the placeholder, keep whatever the list view had.
const serverTitle = fullConv.title || ''
const localTitle = conv.title || ''
const isServerPlaceholder =
serverTitle === '对话' || serverTitle.trim() === ''
const isLocalReal = localTitle && localTitle !== '新对话' && localTitle !== '对话'
if (serverTitle && !isServerPlaceholder) {
conv.title = serverTitle
} else if (!isLocalReal) {
// Both sides have no real title — accept server's placeholder
conv.title = serverTitle || localTitle || '对话'
}
conv.created_at = fullConv.created_at || conv.created_at conv.created_at = fullConv.created_at || conv.created_at
conv.updated_at = fullConv.updated_at || conv.updated_at conv.updated_at = fullConv.updated_at || conv.updated_at
} else { } else {
// P1 #7 fix: If the conversation is not in the local list (e.g. // P1 #7 fix: If the conversation is not in the local list (e.g.
// after a page refresh), add it from the fetched data instead // after a page refresh), add it from the fetched data instead
// of silently discarding the result. // of silently discarding the result.
const serverTitle = fullConv.title || '新对话'
conversations.value.unshift({ conversations.value.unshift({
id: fullConv.id || id, id: fullConv.id || id,
title: fullConv.title || '新对话', title: serverTitle === '对话' ? '新对话' : serverTitle,
messages: fullConv.messages || [], messages: fullConv.messages || [],
created_at: fullConv.created_at || new Date().toISOString(), created_at: fullConv.created_at || new Date().toISOString(),
updated_at: fullConv.updated_at || new Date().toISOString(), updated_at: fullConv.updated_at || new Date().toISOString(),

View File

@ -100,6 +100,14 @@ export const useThemeStore = defineStore('theme', () => {
colorTextSecondary: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'), colorTextSecondary: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'),
colorTextTertiary: readToken('--text-tertiary', isDark ? '#9b9b9a' : '#6b6b6a'), colorTextTertiary: readToken('--text-tertiary', isDark ? '#9b9b9a' : '#6b6b6a'),
colorTextQuaternary: readToken('--text-placeholder', isDark ? '#6b6b6a' : '#9b9b9a'), colorTextQuaternary: readToken('--text-placeholder', isDark ? '#6b6b6a' : '#9b9b9a'),
// Text rendered ON top of ``colorPrimary`` (primary buttons, active
// tab indicators, etc.). Ant Design hardcodes this to #fff in both
// light and dark themes, which clashes with our design: in dark
// mode ``--color-primary`` is a near-white pill (#fbfbfa) and we
// want dark text on it. Source the value from --text-inverse so
// light and dark themes stay consistent with the rest of the
// design tokens.
colorTextLightSolid: readToken('--text-inverse', isDark ? '#1a1a1a' : '#ffffff'),
colorBgContainer: readToken('--bg-primary', isDark ? '#1a1a1a' : '#ffffff'), colorBgContainer: readToken('--bg-primary', isDark ? '#1a1a1a' : '#ffffff'),
colorBgLayout: readToken('--bg-secondary', isDark ? '#1f1f1f' : '#fbfbfa'), colorBgLayout: readToken('--bg-secondary', isDark ? '#1f1f1f' : '#fbfbfa'),
colorBgElevated: readToken('--bg-elevated', isDark ? '#252525' : '#ffffff'), colorBgElevated: readToken('--bg-elevated', isDark ? '#252525' : '#ffffff'),

View File

@ -656,40 +656,71 @@ async def get_capabilities(req: Request, _auth: None = Depends(_verify_api_key))
@router.get("/portal/conversations") @router.get("/portal/conversations")
async def list_conversations(limit: int = 20, _auth: None = Depends(_verify_api_key)): async def list_conversations(limit: int = 20, _auth: None = Depends(_verify_api_key)):
"""List recent conversations.""" """List recent conversations.
For each conversation, derive the title from the first user message
read directly from SQLite (independent of the in-memory cache, which
may have an empty `messages` list after a restart). This prevents the
regression where titles collapse to the placeholder "对话".
"""
convs = await _conversation_store.list_conversations(limit=limit) convs = await _conversation_store.list_conversations(limit=limit)
return [ result: list[dict] = []
{ for c in convs:
"id": c.id, # Re-derive title from the persisted user message so cache misses
"title": _derive_conversation_title(c), # after a restart don't surface the default placeholder.
"created_at": c.created_at.isoformat(), first_user = await _conversation_store.get_first_user_message(c.id)
"updated_at": c.updated_at.isoformat(), title = _derive_conversation_title_from_content(
"message_count": len(c.messages), first_user.content if first_user else None
} )
for c in convs result.append(
] {
"id": c.id,
"title": title,
"created_at": c.created_at.isoformat(),
"updated_at": c.updated_at.isoformat(),
"message_count": len(c.messages),
}
)
return result
def _derive_conversation_title(conv: Conversation) -> str: def _derive_conversation_title(conv: Conversation) -> str:
"""Derive a human-readable title from the first user message.""" """Derive a human-readable title from the first user message in the conversation object."""
for msg in conv.messages: for msg in conv.messages:
if msg.role == "user" and msg.content: if msg.role == "user" and msg.content:
return msg.content[:20] + ("..." if len(msg.content) > 20 else "") return msg.content[:20] + ("..." if len(msg.content) > 20 else "")
return "对话" return "对话"
def _derive_conversation_title_from_content(content: str | None) -> str:
"""Derive title from a string content (used when conv.messages is empty)."""
if content:
return content[:20] + ("..." if len(content) > 20 else "")
return "对话"
@router.get("/portal/conversations/{conversation_id}") @router.get("/portal/conversations/{conversation_id}")
async def get_conversation( async def get_conversation(
conversation_id: str, limit: int = 50, _auth: None = Depends(_verify_api_key) conversation_id: str, limit: int = 50, _auth: None = Depends(_verify_api_key)
): ):
"""Get conversation history from SQLite-backed store.""" """Get conversation history from SQLite-backed store.
Title is derived from the first user message in `history` (the
authoritative source) rather than the in-memory cache, which may have
an empty `messages` list after a server restart. This prevents the
regression where selecting a conversation collapses the title to "对话".
"""
history = await _conversation_store.get_history(conversation_id, limit=limit) history = await _conversation_store.get_history(conversation_id, limit=limit)
if not history: if not history:
raise HTTPException(status_code=404, detail=f"Conversation '{conversation_id}' not found") raise HTTPException(status_code=404, detail=f"Conversation '{conversation_id}' not found")
conv = await _conversation_store.get_or_create(conversation_id) conv = await _conversation_store.get_or_create(conversation_id)
first_user_content = next(
(m.content for m in history if m.role == "user" and m.content),
None,
)
return { return {
"id": conv.id, "id": conv.id,
"title": _derive_conversation_title(conv), "title": _derive_conversation_title_from_content(first_user_content),
"messages": [ "messages": [
{ {
"id": f"{conv.id}-{i}", "id": f"{conv.id}-{i}",

View File

@ -6,10 +6,7 @@ import shutil
import time import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter
from agentkit.server.auth.dependencies import require_permission
from agentkit.server.auth.permissions import Permission
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -32,15 +29,21 @@ def _read_meminfo() -> dict[str, int]:
return values return values
@router.get( @router.get("/system/resources")
"/system/resources",
dependencies=[Depends(require_permission(Permission.SYSTEM_CONFIG))],
)
async def get_system_resources() -> dict[str, Any]: async def get_system_resources() -> dict[str, Any]:
"""Return lightweight system resource usage. """Return lightweight system resource usage.
Uses only stdlib modules so it works without psutil. Values are best-effort Uses only stdlib modules so it works without psutil. Values are best-effort
and may be partial on platforms without /proc/meminfo. and may be partial on platforms without /proc/meminfo.
Note: This endpoint is intentionally NOT gated by SYSTEM_CONFIG. It is
read-only monitoring data (CPU/memory/disk stats) that should be visible
to any authenticated or dev-mode user. Gating it under SYSTEM_CONFIG
caused 401 responses in dev mode, which the frontend's API client
treated as session expiration and triggered an unwanted redirect to
the login page. If stricter access control is required in the future,
add a dedicated ``SYSTEM_VIEW`` permission rather than reusing
``SYSTEM_CONFIG``.
""" """
now = time.time() now = time.time()