Merge pull request #120 from Chiron-Brahm/fix/issue-110-lan-listen-address

fix(server): 修复监听地址配置不生效的问题 (#110)
This commit is contained in:
Chiron
2026-01-13 20:04:41 +08:00
committed by GitHub
11 changed files with 359 additions and 198 deletions
+2 -9
View File
@@ -87,21 +87,14 @@ pub fn load_and_validate_config() -> Result<Config, ConfigError> {
return Err(ConfigError::InvalidHost);
}
let is_non_local = is_non_local_bind(&config.server.host);
// 如果是本地绑定且使用默认 API key,自动生成新密钥
if !is_non_local && config.server.api_key == config::DEFAULT_API_KEY {
// 如果使用默认 API key,自动生成新密钥
if config.server.api_key == config::DEFAULT_API_KEY {
let new_key = generate_api_key();
config.server.api_key = new_key;
config::save_config(&config).map_err(|e| ConfigError::SaveFailed(e.to_string()))?;
tracing::info!("检测到默认 API key,已自动生成并保存新密钥");
}
// 如果是非本地绑定,必须使用非默认 API key
if is_non_local && config.server.api_key == config::DEFAULT_API_KEY {
return Err(ConfigError::DefaultApiKeyWithNonLocalBind);
}
// 检查 TLS 配置
if config.server.tls.enable {
return Err(ConfigError::TlsNotSupported);
+16 -9
View File
@@ -25,28 +25,35 @@ pub async fn save_config(
) -> Result<(), String> {
let host = config.server.host.to_lowercase();
tracing::info!("[CONFIG] 保存配置请求: host={}, port={}", host, config.server.port);
// 验证绑定地址
if !is_valid_bind_host(&host) {
tracing::warn!("[CONFIG] 无效的监听地址: {}", host);
return Err(
"无效的监听地址。允许的地址:127.0.0.1、localhost、::1、0.0.0.0、::".to_string(),
);
}
// 如果监听所有接口,要求使用强 API Key
if is_non_local_bind(&host) && config.server.api_key == DEFAULT_API_KEY {
return Err(
"安全限制:监听所有网络接口 (0.0.0.0 或 ::) 时,必须设置非默认的 API Key".to_string(),
"无效的监听地址。允许的地址:127.0.0.1、localhost、::1、0.0.0.0、:: 或局域网 IP".to_string(),
);
}
// 禁止开启远程管理
if config.remote_management.allow_remote {
tracing::warn!("[CONFIG] 安全限制:不允许开启远程管理功能");
return Err("安全限制:不允许开启远程管理功能".to_string());
}
let mut s = state.write().await;
s.config = config.clone();
config::save_config(&config).map_err(|e| e.to_string())
match config::save_config(&config) {
Ok(()) => {
tracing::info!("[CONFIG] 配置保存成功: host={}", config.server.host);
Ok(())
}
Err(e) => {
tracing::error!("[CONFIG] 配置保存失败: {}", e);
Err(e.to_string())
}
}
}
/// 获取默认 Provider
+14 -3
View File
@@ -98,17 +98,28 @@ mod tests {
// 监听所有接口
assert!(is_valid_bind_host("0.0.0.0"));
assert!(is_valid_bind_host("::"));
// 其他地址不允许
assert!(!is_valid_bind_host("192.168.1.1"));
assert!(!is_valid_bind_host("10.0.0.1"));
// 私有网络地址(局域网)- 应该允许
assert!(is_valid_bind_host("192.168.1.1"));
assert!(is_valid_bind_host("10.0.0.1"));
assert!(is_valid_bind_host("172.16.0.1"));
assert!(is_valid_bind_host("172.31.255.255"));
// 公网地址不允许
assert!(!is_valid_bind_host("8.8.8.8"));
assert!(!is_valid_bind_host("1.1.1.1"));
}
#[test]
fn test_is_non_local_bind() {
// 监听所有接口
assert!(is_non_local_bind("0.0.0.0"));
assert!(is_non_local_bind("::"));
// 回环地址不是非本地绑定
assert!(!is_non_local_bind("127.0.0.1"));
assert!(!is_non_local_bind("localhost"));
// 私有网络地址是非本地绑定(需要强 API Key)
assert!(is_non_local_bind("192.168.1.1"));
assert!(is_non_local_bind("10.0.0.1"));
assert!(is_non_local_bind("172.16.0.1"));
}
#[test]
+12 -7
View File
@@ -9,6 +9,7 @@ use crate::agent::tools::{
use crate::agent::{
AgentMessage, AgentSession, ImageData, NativeAgentState, NativeChatRequest, ProviderType,
};
use crate::commands::network_cmd::get_local_url;
use crate::database::dao::agent::AgentDao;
use crate::database::DbConnection;
use crate::AppState;
@@ -42,9 +43,10 @@ pub async fn agent_start_process(
) -> Result<AgentProcessStatus, String> {
tracing::info!("[Agent] 初始化原生 Agent");
let (port, api_key, running, default_provider) = {
let (host, port, api_key, running, default_provider) = {
let state = app_state.read().await;
(
state.config.server.host.clone(),
state.config.server.port,
state.running_api_key.clone(),
state.running,
@@ -57,7 +59,7 @@ pub async fn agent_start_process(
}
let api_key = api_key.ok_or_else(|| "ProxyCast API Server 未配置 API Key".to_string())?;
let base_url = format!("http://127.0.0.1:{}", port);
let base_url = get_local_url(&host, port);
let provider_type = ProviderType::from_str(&default_provider);
agent_state.init(
@@ -92,9 +94,10 @@ pub async fn agent_get_process_status(
if initialized {
let state = app_state.read().await;
let base_url = get_local_url(&state.config.server.host, state.config.server.port);
Ok(AgentProcessStatus {
running: true,
base_url: Some(format!("http://127.0.0.1:{}", state.config.server.port)),
base_url: Some(base_url),
port: Some(state.config.server.port),
})
} else {
@@ -134,9 +137,10 @@ pub async fn agent_create_session(
// 如果未初始化,自动初始化
if !agent_state.is_initialized() {
let (port, api_key, running, default_provider) = {
let (host, port, api_key, running, default_provider) = {
let state = app_state.read().await;
(
state.config.server.host.clone(),
state.config.server.port,
state.running_api_key.clone(),
state.running,
@@ -149,7 +153,7 @@ pub async fn agent_create_session(
}
let api_key = api_key.ok_or_else(|| "未配置 API Key".to_string())?;
let base_url = format!("http://127.0.0.1:{}", port);
let base_url = get_local_url(&host, port);
let provider_type = ProviderType::from_str(&default_provider);
agent_state.init(base_url, api_key, provider_type, Some(default_provider))?;
}
@@ -258,9 +262,10 @@ pub async fn agent_send_message(
// 如果未初始化,自动初始化
if !agent_state.is_initialized() {
let (port, api_key, running, default_provider) = {
let (host, port, api_key, running, default_provider) = {
let state = app_state.read().await;
(
state.config.server.host.clone(),
state.config.server.port,
state.running_api_key.clone(),
state.running,
@@ -273,7 +278,7 @@ pub async fn agent_send_message(
}
let api_key = api_key.ok_or_else(|| "未配置 API Key".to_string())?;
let base_url = format!("http://127.0.0.1:{}", port);
let base_url = get_local_url(&host, port);
let provider_type = ProviderType::from_str(&default_provider);
agent_state.init(base_url, api_key, provider_type, Some(default_provider))?;
}
+10 -6
View File
@@ -6,6 +6,7 @@ use crate::agent::{
AgentMessage, AgentSession, ImageData, MessageContent, NativeAgentState, NativeChatRequest,
NativeChatResponse, ProviderType, StreamEvent, ToolLoopEngine,
};
use crate::commands::network_cmd::get_local_url;
use crate::database::dao::agent::AgentDao;
use crate::database::dao::api_key_provider::ApiKeyProviderDao;
use crate::database::DbConnection;
@@ -28,9 +29,10 @@ pub async fn native_agent_init(
) -> Result<NativeAgentStatus, String> {
tracing::info!("[NativeAgent] 初始化 Agent");
let (port, api_key, running, default_provider, agent_config) = {
let (host, port, api_key, running, default_provider, agent_config) = {
let state = app_state.read().await;
(
state.config.server.host.clone(),
state.config.server.port,
state.running_api_key.clone(),
state.running,
@@ -45,7 +47,7 @@ pub async fn native_agent_init(
let api_key = api_key.ok_or_else(|| "ProxyCast API Server 未配置 API Key".to_string())?;
let base_url = format!("http://127.0.0.1:{}", port);
let base_url = get_local_url(&host, port);
let provider_type = ProviderType::from_str(&default_provider);
tracing::info!(
@@ -111,9 +113,10 @@ pub async fn native_agent_chat(
// 如果 Agent 未初始化,自动初始化
if !agent_state.is_initialized() {
let (port, api_key, running, default_provider) = {
let (host, port, api_key, running, default_provider) = {
let state = app_state.read().await;
(
state.config.server.host.clone(),
state.config.server.port,
state.running_api_key.clone(),
state.running,
@@ -126,7 +129,7 @@ pub async fn native_agent_chat(
}
let api_key = api_key.ok_or_else(|| "未配置 API Key".to_string())?;
let base_url = format!("http://127.0.0.1:{}", port);
let base_url = get_local_url(&host, port);
let provider_type = ProviderType::from_str(&default_provider);
agent_state.init(base_url, api_key, provider_type, Some(default_provider))?;
}
@@ -176,9 +179,10 @@ pub async fn native_agent_chat_stream(
);
// 获取配置信息
let (port, api_key, running, default_provider) = {
let (host, port, api_key, running, default_provider) = {
let state = app_state.read().await;
(
state.config.server.host.clone(),
state.config.server.port,
state.running_api_key.clone(),
state.running,
@@ -241,7 +245,7 @@ pub async fn native_agent_chat_stream(
};
if need_reinit {
let base_url = format!("http://127.0.0.1:{}", port);
let base_url = get_local_url(&host, port);
agent_state.init(base_url, api_key, provider_type, Some(provider_str.clone()))?;
}
+121 -1
View File
@@ -34,13 +34,34 @@ pub fn get_network_info() -> Result<NetworkInfo, String> {
/// 获取本机内网 IP 地址
///
/// 通过创建 UDP socket 连接外部地址来获取本机的内网 IP
/// 如果获取到的是 VPN 地址,则从 all_ips 中选择一个合适的
fn get_local_ip() -> Option<String> {
// 创建一个 UDP socket 并连接到外部地址(不会真正发送数据)
// 这样可以获取到本机用于出站连接的 IP 地址
let socket = UdpSocket::bind("0.0.0.0:0").ok()?;
socket.connect("8.8.8.8:80").ok()?;
let local_addr = socket.local_addr().ok()?;
Some(local_addr.ip().to_string())
let ip_str = local_addr.ip().to_string();
// 检查是否是 VPN 地址 (198.18.x.x)
if let IpAddr::V4(ipv4) = local_addr.ip() {
if ipv4.octets()[0] == 198 && (ipv4.octets()[1] == 18 || ipv4.octets()[1] == 19) {
// 是 VPN 地址,尝试从 all_ips 中获取真实的局域网 IP
let all_ips = get_all_local_ips();
// 优先选择 192.168.x.x
if let Some(ip) = all_ips.iter().find(|ip| ip.starts_with("192.168.")) {
return Some(ip.clone());
}
// 其次选择任意私有 IP
if let Some(ip) = all_ips.first() {
return Some(ip.clone());
}
// 如果没有私有 IP,返回 127.0.0.1
return Some("127.0.0.1".to_string());
}
}
Some(ip_str)
}
/// 获取所有本地网络接口的 IP 地址
@@ -86,3 +107,102 @@ fn get_all_local_ips() -> Vec<String> {
ips
}
/// 根据监听地址生成可访问的 URL
///
/// 用于生成客户端配置中的 API URL。
///
/// # 参数
/// - `listen_host`: 服务器监听地址
/// - `port`: 服务器端口
///
/// # 返回
/// - 如果监听地址为 `0.0.0.0`,返回局域网 IP 或 `127.0.0.1`
/// - 如果监听地址为 `127.0.0.1` 或 `localhost`,返回 `127.0.0.1`
/// - 其他情况返回原始地址
pub fn get_accessible_host(listen_host: &str) -> String {
match listen_host {
"0.0.0.0" => {
// 获取局域网 IP,如果没有则使用 127.0.0.1
get_network_info()
.ok()
.and_then(|info| info.lan_ip)
.unwrap_or_else(|| "127.0.0.1".to_string())
}
"localhost" => "127.0.0.1".to_string(),
_ => listen_host.to_string(),
}
}
/// 根据监听地址生成可访问的 URL
///
/// # 参数
/// - `listen_host`: 服务器监听地址
/// - `port`: 服务器端口
///
/// # 返回
/// 格式为 `http://{host}:{port}` 的 URL
pub fn get_accessible_url(listen_host: &str, port: u16) -> String {
let host = get_accessible_host(listen_host);
format!("http://{}:{}", host, port)
}
/// 根据监听地址生成本地访问的 URL
///
/// 用于 Agent 等本地组件访问服务器。
/// 对于 `0.0.0.0`,返回 `127.0.0.1`(本地访问)。
///
/// # 参数
/// - `listen_host`: 服务器监听地址
/// - `port`: 服务器端口
///
/// # 返回
/// 格式为 `http://{host}:{port}` 的 URL
pub fn get_local_url(listen_host: &str, port: u16) -> String {
let host = match listen_host {
"0.0.0.0" | "localhost" => "127.0.0.1".to_string(),
_ => listen_host.to_string(),
};
format!("http://{}:{}", host, port)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_accessible_host_localhost() {
assert_eq!(get_accessible_host("127.0.0.1"), "127.0.0.1");
assert_eq!(get_accessible_host("localhost"), "127.0.0.1");
}
#[test]
fn test_get_accessible_host_specific_ip() {
assert_eq!(get_accessible_host("192.168.1.100"), "192.168.1.100");
assert_eq!(get_accessible_host("10.0.0.1"), "10.0.0.1");
}
#[test]
fn test_get_local_url() {
assert_eq!(get_local_url("0.0.0.0", 8999), "http://127.0.0.1:8999");
assert_eq!(get_local_url("127.0.0.1", 8999), "http://127.0.0.1:8999");
assert_eq!(get_local_url("localhost", 8999), "http://127.0.0.1:8999");
assert_eq!(
get_local_url("192.168.1.100", 8999),
"http://192.168.1.100:8999"
);
}
#[test]
fn test_get_accessible_url_specific_ip() {
assert_eq!(
get_accessible_url("192.168.1.100", 8999),
"http://192.168.1.100:8999"
);
assert_eq!(
get_accessible_url("127.0.0.1", 8999),
"http://127.0.0.1:8999"
);
}
}
+6 -40
View File
@@ -1,51 +1,17 @@
//! 路由相关 Tauri 命令
use crate::commands::network_cmd::get_accessible_url;
use crate::commands::provider_pool_cmd::ProviderPoolServiceState;
use crate::config;
use crate::database::DbConnection;
use crate::models::route_model::{RouteInfo, RouteListResponse};
/// 获取有效的服务器地址
/// 如果配置的 IP 不在当前网卡列表中,自动替换为当前的局域网 IP
/// 获取可访问的服务器地址
///
/// 使用 `get_accessible_url` 函数生成可访问的 URL。
/// 对于 `0.0.0.0`,会转换为局域网 IP 或 `127.0.0.1`。
fn get_valid_base_url(config: &config::Config) -> String {
let configured_host = &config.server.host;
let port = config.server.port;
// 特殊地址不需要检查
if configured_host == "127.0.0.1" || configured_host == "localhost" {
return format!("http://{}:{}", configured_host, port);
}
// 0.0.0.0 或其他 IP 需要检查
if let Ok(network_info) = crate::commands::network_cmd::get_network_info() {
let host = if configured_host == "0.0.0.0" {
// 0.0.0.0 替换为局域网 IP
network_info
.all_ips
.iter()
.find(|ip| ip.starts_with("192.168.") || ip.starts_with("10."))
.or_else(|| network_info.lan_ip.as_ref())
.or_else(|| network_info.all_ips.first())
.cloned()
.unwrap_or_else(|| "localhost".to_string())
} else if network_info.all_ips.contains(configured_host) {
// IP 在当前网卡列表中,使用配置的 IP
configured_host.clone()
} else {
// IP 不在当前网卡列表中,替换为局域网 IP
network_info
.all_ips
.iter()
.find(|ip| ip.starts_with("192.168.") || ip.starts_with("10."))
.or_else(|| network_info.lan_ip.as_ref())
.or_else(|| network_info.all_ips.first())
.cloned()
.unwrap_or_else(|| "localhost".to_string())
};
format!("http://{}:{}", host, port)
} else {
format!("http://{}:{}", configured_host, port)
}
get_accessible_url(&config.server.host, config.server.port)
}
/// 获取所有可用的路由端点
-9
View File
@@ -418,15 +418,6 @@ impl HotReloadManager {
));
}
// 非本地绑定或远程管理时,禁止使用默认 API Key
if (is_non_local || !is_localhost || config.remote_management.allow_remote)
&& is_default_api_key(&config.server.api_key)
{
return Err(HotReloadError::ValidationError(
"监听所有网络接口或开启远程管理时,禁止使用默认 API Key,请设置强口令".to_string(),
));
}
if config.server.tls.enable {
return Err(HotReloadError::ValidationError(
"当前版本暂不支持 TLS,请关闭 TLS 配置".to_string(),
+7 -1
View File
@@ -91,7 +91,13 @@ impl DevBridgeServer {
.with_state(app_state);
let addr = format!("{}:{}", config.host, config.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
let listener = match tokio::net::TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("[DevBridge] 绑定失败: {} (地址: {})", e, addr);
return Err(e.into());
}
};
eprintln!("[DevBridge] 正在监听: http://{}", addr);
+36 -48
View File
@@ -222,6 +222,15 @@ impl ServerState {
self.requests = self.requests.saturating_add(1);
}
/// 解析绑定地址
///
/// 直接返回用户配置的地址,不做任何自动替换。
/// 如果地址无效,绑定时会失败并返回错误。
fn resolve_bind_host(&self, configured_host: &str) -> String {
tracing::info!("[SERVER] 使用配置的监听地址: {}", configured_host);
configured_host.to_string()
}
pub async fn start(
&mut self,
logs: Arc<RwLock<LogStore>>,
@@ -285,52 +294,21 @@ impl ServerState {
let (tx, rx) = oneshot::channel();
self.shutdown_tx = Some(tx);
// 检查配置的 host 是否有效(在当前网卡列表中或是特殊地址)
let host = {
let configured_host = &self.config.server.host;
// 特殊地址不需要检查
if configured_host == "0.0.0.0"
|| configured_host == "127.0.0.1"
|| configured_host == "localhost"
{
configured_host.clone()
} else {
// 检查 IP 是否在当前网卡列表中
match crate::commands::network_cmd::get_network_info() {
Ok(network_info) => {
if network_info.all_ips.contains(configured_host) {
configured_host.clone()
} else {
// IP 不在当前网卡列表中,使用当前的局域网 IP
// 优先选择 192.168.x.x 或 10.x.x.x 开头的 IP(真正的局域网 IP)
let preferred_ip = network_info
.all_ips
.iter()
.find(|ip| ip.starts_with("192.168.") || ip.starts_with("10."));
let new_ip = preferred_ip
.or_else(|| network_info.lan_ip.as_ref())
.or_else(|| network_info.all_ips.first())
.cloned()
.unwrap_or_else(|| "127.0.0.1".to_string());
tracing::warn!(
"[SERVER] 配置的 IP {} 不在当前网卡列表中,自动切换到 {}",
configured_host,
new_ip
);
eprintln!(
"[SERVER] 警告:配置的 IP {} 不在当前网卡列表中,自动切换到 {}",
configured_host, new_ip
);
new_ip
}
}
Err(_) => configured_host.clone(),
}
}
};
// 智能选择监听地址
// - 127.0.0.1, localhost, 0.0.0.0, :: 直接使用
// - 局域网 IP:检查是否在当前网卡列表中,如果不在则自动切换到当前局域网 IP
let configured_host = self.config.server.host.clone();
let host = self.resolve_bind_host(&configured_host);
// 如果地址发生了变化,记录日志
if host != configured_host {
tracing::warn!(
"[SERVER] 配置的监听地址 {} 不可用,自动切换到 {}",
configured_host,
host
);
}
let port = self.config.server.port;
let api_key = self.config.server.api_key.clone();
let api_key_for_state = api_key.clone(); // 用于保存到 running_api_key
@@ -1070,8 +1048,18 @@ async fn run_server(
.layer(DefaultBodyLimit::max(body_limit))
.with_state(state);
let addr: std::net::SocketAddr = format!("{host}:{port}").parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
let addr: std::net::SocketAddr = format!("{host}:{port}")
.parse()
.map_err(|e| format!("无效的监听地址 {}:{} - {}", host, port, e))?;
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| {
format!(
"无法绑定到 {}:{},错误: {}。请检查地址是否有效或端口是否被占用。",
host, port, e
)
})?;
tracing::info!("Server listening on {}", addr);
+135 -65
View File
@@ -202,9 +202,14 @@ export function ApiServerPage() {
const fetchConfig = async () => {
try {
const c = await getConfig();
console.log(
"[DEBUG] fetchConfig - loaded config.server.host:",
c.server.host,
);
setConfig(c);
setEditPort(c.server.port.toString());
setEditHost(c.server.host);
console.log("[DEBUG] fetchConfig - setEditHost to:", c.server.host);
setEditApiKey(c.server.api_key);
} catch (e) {
console.error(e);
@@ -383,10 +388,42 @@ export function ApiServerPage() {
setLoading(false);
};
// 自动保存监听地址
const handleHostChange = async (newHost: string) => {
setEditHost(newHost);
// 如果配置已加载,自动保存
if (config) {
try {
const newConfig = {
...config,
server: {
...config.server,
host: newHost,
},
};
await saveConfig(newConfig);
setConfig(newConfig);
console.log("[DEBUG] handleHostChange - 自动保存地址:", newHost);
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
console.error("[DEBUG] handleHostChange - 保存失败:", errMsg);
setMessage({ type: "error", text: `保存地址失败: ${errMsg}` });
}
}
};
const handleSaveServerConfig = async () => {
if (!config) return;
setLoading(true);
try {
// 调试日志:确认保存时的值
console.log("[DEBUG] handleSaveServerConfig - editHost:", editHost);
console.log(
"[DEBUG] handleSaveServerConfig - config.server.host:",
config.server.host,
);
const newConfig = {
...config,
server: {
@@ -396,11 +433,21 @@ export function ApiServerPage() {
api_key: editApiKey,
},
};
console.log(
"[DEBUG] handleSaveServerConfig - newConfig.server.host:",
newConfig.server.host,
);
await saveConfig(newConfig);
console.log("[DEBUG] handleSaveServerConfig - saveConfig completed");
await fetchConfig();
setMessage({ type: "success", text: "服务器配置已保存" });
} catch (e: unknown) {
const errMsg = e instanceof Error ? e.message : String(e);
console.error("[DEBUG] handleSaveServerConfig - error:", errMsg);
setMessage({ type: "error", text: `保存失败: ${errMsg}` });
}
setLoading(false);
@@ -693,20 +740,63 @@ export function ApiServerPage() {
// - 0.0.0.0: 使用 127.0.0.1(本机访问所有接口)
// - 局域网 IP: 使用该 IP(允许局域网测试)
const getTestUrl = (host: string, port: number) => {
if (host === "0.0.0.0") {
if (host === "0.0.0.0" || !host) {
return `http://127.0.0.1:${port}`;
}
return `http://${host}:${port}`;
};
// 使用 editHost 而不是 status.host,这样可以实时反映用户的选择
const currentHost = status?.running ? status.host : editHost;
// 获取当前有效的监听地址
// 优先使用服务器运行时的实际地址,否则使用编辑中的地址
const currentHost = status?.running ? status.host : editHost || "127.0.0.1";
const currentPort = status?.running
? status.port
: parseInt(editPort) || 8999;
const serverUrl = getTestUrl(currentHost, currentPort);
const apiKey = config?.server.api_key ?? "";
// 检测是否发生了地址自动切换
// 只有当配置的地址是一个不可用的局域网 IP(不在当前网卡列表中)时才显示提示
// 127.0.0.1, 0.0.0.0 等特殊地址不会触发自动切换
const hostMismatch = useMemo(() => {
if (!status?.running || !config?.server.host) return false;
const configuredHost = config.server.host;
const actualHost = status.host;
// 如果地址相同,没有切换
if (configuredHost === actualHost) return false;
// 如果配置的是特殊地址,不显示提示(这些地址不会被自动切换)
if (
["127.0.0.1", "localhost", "0.0.0.0", "::", "::1"].includes(
configuredHost,
)
) {
return false;
}
// 配置的是局域网 IP,但实际运行地址不同,说明发生了自动切换
return true;
}, [status, config]);
// 计算 Select 组件的可用选项
// 包括固定选项 + 网络接口 IP + 当前配置的地址(如果不在列表中)+ 实际运行地址
const hostOptions = useMemo(() => {
const options = new Set<string>(["127.0.0.1", "0.0.0.0"]);
// 添加网络接口 IP
networkInfo?.all_ips.forEach((ip) => options.add(ip));
// 如果当前配置的地址不在列表中,也添加进去
if (editHost && !options.has(editHost)) {
options.add(editHost);
}
// 如果服务器运行中且实际地址不在列表中,也添加进去
if (status?.running && status.host && !options.has(status.host)) {
options.add(status.host);
}
return Array.from(options);
}, [networkInfo, editHost, status]);
// 动态生成测试端点
const testEndpoints = useMemo(() => {
if (!testModel) return [];
@@ -960,8 +1050,10 @@ export function ApiServerPage() {
<div className="flex items-center gap-2">
<span className="text-muted-foreground">监听地址:</span>
<Select.Root
value={editHost}
onValueChange={setEditHost}
value={
status?.running ? status.host : editHost || "127.0.0.1"
}
onValueChange={handleHostChange}
disabled={status?.running}
>
<Select.Trigger className="inline-flex min-w-[200px] items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
@@ -979,59 +1071,36 @@ export function ApiServerPage() {
className="z-50 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-md border border-border bg-white dark:bg-gray-900 text-foreground shadow-lg"
>
<Select.Viewport className="p-1 bg-white dark:bg-gray-900">
<Select.Item
value="127.0.0.1"
className="relative flex cursor-pointer select-none items-center rounded-sm px-8 py-2.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<Select.ItemIndicator className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Check className="h-4 w-4" />
</Select.ItemIndicator>
<Select.ItemText>
<span className="flex items-center gap-2">
<span className="font-mono">127.0.0.1</span>
<span className="text-xs text-muted-foreground">
(仅本机)
</span>
</span>
</Select.ItemText>
</Select.Item>
<Select.Item
value="0.0.0.0"
className="relative flex cursor-pointer select-none items-center rounded-sm px-8 py-2.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<Select.ItemIndicator className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Check className="h-4 w-4" />
</Select.ItemIndicator>
<Select.ItemText>
<span className="flex items-center gap-2">
<span className="font-mono">0.0.0.0</span>
<span className="text-xs text-muted-foreground">
(所有接口)
</span>
</span>
</Select.ItemText>
</Select.Item>
{networkInfo?.all_ips.map((ip) => (
<Select.Item
key={ip}
value={ip}
className="relative flex cursor-pointer select-none items-center rounded-sm px-8 py-2.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<Select.ItemIndicator className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Check className="h-4 w-4" />
</Select.ItemIndicator>
<Select.ItemText>
<span className="flex items-center gap-2">
<span className="font-mono">{ip}</span>
<span className="text-xs text-muted-foreground">
(局域网)
{hostOptions.map((ip) => {
// 确定 IP 类型标签
let label = "(局域网)";
if (ip === "127.0.0.1") {
label = "(仅本机)";
} else if (ip === "0.0.0.0") {
label = "(所有接口)";
} else if (!networkInfo?.all_ips.includes(ip)) {
label = "(已配置)";
}
return (
<Select.Item
key={ip}
value={ip}
className="relative flex cursor-pointer select-none items-center rounded-sm px-8 py-2.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<Select.ItemIndicator className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Check className="h-4 w-4" />
</Select.ItemIndicator>
<Select.ItemText>
<span className="flex items-center gap-2">
<span className="font-mono">{ip}</span>
<span className="text-xs text-muted-foreground">
{label}
</span>
</span>
</span>
</Select.ItemText>
</Select.Item>
))}
</Select.ItemText>
</Select.Item>
);
})}
</Select.Viewport>
</Select.Content>
</Select.Portal>
@@ -1073,14 +1142,15 @@ export function ApiServerPage() {
<span>修改配置需要先停止服务</span>
</div>
)}
{(editHost === "0.0.0.0" ||
(networkInfo?.all_ips.includes(editHost) ?? false)) &&
!status?.running && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-amber-50 dark:bg-amber-950/20 px-3 py-2 text-xs text-amber-700 dark:text-amber-500">
<span>⚠️</span>
<span>局域网访问需要使用非默认 API Key 以确保安全</span>
</div>
)}
{hostMismatch && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-blue-50 dark:bg-blue-950/20 px-3 py-2 text-xs text-blue-700 dark:text-blue-400">
<span>ℹ️</span>
<span>
配置的地址 {config?.server.host} 不可用,已自动切换到{" "}
{status?.host}。 停止服务后可更新配置。
</span>
</div>
)}
</div>
{/* Default Provider - 动态显示有凭证的 Provider */}