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)
|
|
@ -303,3 +303,36 @@ class SqliteConversationStore:
|
|||
) -> None:
|
||||
"""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.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -45,6 +45,71 @@ class ReActStep:
|
|||
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
|
||||
class ReActResult:
|
||||
"""ReAct 执行结果"""
|
||||
|
|
@ -911,12 +976,15 @@ class ReActEngine:
|
|||
stream_tool_calls: list[Any] = []
|
||||
stream_model = model
|
||||
|
||||
async for chunk in self._llm_gateway.chat_stream(
|
||||
async for chunk in _ensure_async_iterable(
|
||||
self._llm_gateway.chat_stream(
|
||||
messages=conversation,
|
||||
model=model,
|
||||
agent_name=agent_name,
|
||||
task_type=task_type,
|
||||
tools=tool_schemas,
|
||||
),
|
||||
label=f"llm_gateway.chat_stream(model={model!r})",
|
||||
):
|
||||
if chunk.content:
|
||||
stream_content_chunks.append(chunk.content)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""LLM Gateway - 统一 LLM 调用入口"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
|
@ -317,7 +318,28 @@ class LLMGateway:
|
|||
final_model = model_name
|
||||
|
||||
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
|
||||
if chunk.content:
|
||||
total_content += chunk.content
|
||||
|
|
|
|||
|
|
@ -168,9 +168,10 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
await _conversation_store.restore_from_store()
|
||||
|
||||
# In GUI mode, ensure a default chat agent exists with memory + tools
|
||||
gui_mode = os.environ.get("AGENTKIT_GUI_MODE")
|
||||
if gui_mode and not app.state.agent_pool.list_agents():
|
||||
# Ensure a default chat agent exists with memory + tools (both GUI and serve modes)
|
||||
# Previously this only ran in GUI mode, which caused 'Agent default not found' errors
|
||||
# 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.memory.profile import MemoryStore
|
||||
from agentkit.tools.memory_tool import MemoryTool
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,11 @@
|
|||
<template>
|
||||
<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 />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
|
@ -41,17 +46,7 @@ onMounted(async () => {
|
|||
|
||||
if (isTauri()) {
|
||||
try {
|
||||
loadingStatus.value = '正在启动后端...'
|
||||
await startBackend()
|
||||
loadingStatus.value = '正在配置连接...'
|
||||
await initApiBaseURL()
|
||||
loadingStatus.value = '正在检查服务...'
|
||||
const healthy = await checkBackendHealth()
|
||||
if (healthy) {
|
||||
loading.value = false
|
||||
} else {
|
||||
loadError.value = '后端服务健康检查失败'
|
||||
}
|
||||
await bootstrapBackend()
|
||||
} catch (err: unknown) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
|
@ -59,6 +54,37 @@ onMounted(async () => {
|
|||
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>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -105,6 +105,22 @@ function formatRelativeTime(dateStr: string): string {
|
|||
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 {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@
|
|||
<div class="splash-content">
|
||||
<div class="splash-logo">Fischer AgentKit</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-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="splash-status">{{ status }}</div>
|
||||
<div v-if="error" class="splash-error">{{ error }}</div>
|
||||
<div v-if="!error" class="splash-status">{{ status }}</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>
|
||||
</template>
|
||||
|
|
@ -18,6 +21,7 @@
|
|||
defineProps<{
|
||||
status: string
|
||||
error?: string
|
||||
onRetry?: () => void | Promise<void>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
|
@ -74,9 +78,33 @@ defineProps<{
|
|||
}
|
||||
.splash-error {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.splash-error-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-error, #ff4d4f);
|
||||
max-width: 300px;
|
||||
max-width: 320px;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -232,10 +232,11 @@ onUnmounted(() => {
|
|||
.system-monitor__tabs {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
padding: 12px 16px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.system-monitor__tab {
|
||||
|
|
@ -243,7 +244,7 @@ onUnmounted(() => {
|
|||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
padding: 6px 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
|
|
@ -286,43 +287,69 @@ onUnmounted(() => {
|
|||
.system-monitor__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.system-monitor__section-title {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
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 {
|
||||
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 {
|
||||
display: grid;
|
||||
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 {
|
||||
padding: 12px;
|
||||
padding: 14px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
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 {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.system-monitor__metric-value {
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.system-monitor__metric-value.status-healthy {
|
||||
|
|
@ -341,6 +368,7 @@ onUnmounted(() => {
|
|||
.system-monitor__metric-delta {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.system-monitor__metric-delta.up {
|
||||
|
|
@ -351,28 +379,46 @@ onUnmounted(() => {
|
|||
color: var(--accent-team);
|
||||
}
|
||||
|
||||
/* 分区容器:左右两列独立分块 */
|
||||
.system-monitor__split {
|
||||
display: grid;
|
||||
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 {
|
||||
display: flex;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
font-size: var(--font-sm);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.system-monitor__service:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.system-monitor__service-name,
|
||||
.system-monitor__service-status,
|
||||
.system-monitor__service-time {
|
||||
|
|
@ -382,27 +428,34 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.system-monitor__service-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-placeholder);
|
||||
box-shadow: 0 0 0 2px var(--bg-secondary);
|
||||
}
|
||||
|
||||
.system-monitor__service-dot.ok {
|
||||
background: var(--color-success);
|
||||
box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.12);
|
||||
}
|
||||
|
||||
.system-monitor__service-dot.error {
|
||||
background: var(--color-error);
|
||||
box-shadow: 0 0 0 2px rgba(245, 34, 45, 0.12);
|
||||
}
|
||||
|
||||
.system-monitor__service-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-weight-medium, 500);
|
||||
}
|
||||
|
||||
.system-monitor__service-status {
|
||||
color: var(--color-success);
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(82, 196, 26, 0.08);
|
||||
}
|
||||
|
||||
.system-monitor__service-time {
|
||||
|
|
@ -412,7 +465,7 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.system-monitor__chart-placeholder {
|
||||
height: 80px;
|
||||
min-height: 96px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
|
@ -422,6 +475,8 @@ onUnmounted(() => {
|
|||
font-size: var(--font-xs);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
margin: 8px 0;
|
||||
border: 1px dashed var(--border-color);
|
||||
margin-top: 4px;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -75,16 +75,30 @@ export const useChatStore = defineStore('chat', () => {
|
|||
const fullConv = await apiClient.getConversation(id)
|
||||
if (conv) {
|
||||
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.updated_at = fullConv.updated_at || conv.updated_at
|
||||
} else {
|
||||
// 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
|
||||
// of silently discarding the result.
|
||||
const serverTitle = fullConv.title || '新对话'
|
||||
conversations.value.unshift({
|
||||
id: fullConv.id || id,
|
||||
title: fullConv.title || '新对话',
|
||||
title: serverTitle === '对话' ? '新对话' : serverTitle,
|
||||
messages: fullConv.messages || [],
|
||||
created_at: fullConv.created_at || new Date().toISOString(),
|
||||
updated_at: fullConv.updated_at || new Date().toISOString(),
|
||||
|
|
|
|||
|
|
@ -100,6 +100,14 @@ export const useThemeStore = defineStore('theme', () => {
|
|||
colorTextSecondary: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'),
|
||||
colorTextTertiary: readToken('--text-tertiary', isDark ? '#9b9b9a' : '#6b6b6a'),
|
||||
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'),
|
||||
colorBgLayout: readToken('--bg-secondary', isDark ? '#1f1f1f' : '#fbfbfa'),
|
||||
colorBgElevated: readToken('--bg-elevated', isDark ? '#252525' : '#ffffff'),
|
||||
|
|
|
|||
|
|
@ -656,40 +656,71 @@ async def get_capabilities(req: Request, _auth: None = Depends(_verify_api_key))
|
|||
|
||||
@router.get("/portal/conversations")
|
||||
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)
|
||||
return [
|
||||
result: list[dict] = []
|
||||
for c in convs:
|
||||
# Re-derive title from the persisted user message so cache misses
|
||||
# after a restart don't surface the default placeholder.
|
||||
first_user = await _conversation_store.get_first_user_message(c.id)
|
||||
title = _derive_conversation_title_from_content(
|
||||
first_user.content if first_user else None
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
"id": c.id,
|
||||
"title": _derive_conversation_title(c),
|
||||
"title": title,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"updated_at": c.updated_at.isoformat(),
|
||||
"message_count": len(c.messages),
|
||||
}
|
||||
for c in convs
|
||||
]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
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:
|
||||
if msg.role == "user" and msg.content:
|
||||
return msg.content[:20] + ("..." if len(msg.content) > 20 else "")
|
||||
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}")
|
||||
async def get_conversation(
|
||||
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)
|
||||
if not history:
|
||||
raise HTTPException(status_code=404, detail=f"Conversation '{conversation_id}' not found")
|
||||
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 {
|
||||
"id": conv.id,
|
||||
"title": _derive_conversation_title(conv),
|
||||
"title": _derive_conversation_title_from_content(first_user_content),
|
||||
"messages": [
|
||||
{
|
||||
"id": f"{conv.id}-{i}",
|
||||
|
|
|
|||
|
|
@ -6,10 +6,7 @@ import shutil
|
|||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from agentkit.server.auth.dependencies import require_permission
|
||||
from agentkit.server.auth.permissions import Permission
|
||||
from fastapi import APIRouter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -32,15 +29,21 @@ def _read_meminfo() -> dict[str, int]:
|
|||
return values
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/resources",
|
||||
dependencies=[Depends(require_permission(Permission.SYSTEM_CONFIG))],
|
||||
)
|
||||
@router.get("/system/resources")
|
||||
async def get_system_resources() -> dict[str, Any]:
|
||||
"""Return lightweight system resource usage.
|
||||
|
||||
Uses only stdlib modules so it works without psutil. Values are best-effort
|
||||
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()
|
||||
|
||||
|
|
|
|||