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:
|
) -> 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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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}",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||