mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
feat(proxy): implement streaming timeout control with validation
- Add first byte timeout (0 or 1-180s) for streaming requests - Add idle timeout (0 or 60-600s) for streaming data gaps - Add non-streaming timeout (0 or 60-1800s) for total request - Implement timeout logic in response processor - Add 1800s global timeout fallback when disabled - Add database schema migration for timeout fields - Add i18n translations for timeout settings
This commit is contained in:
@@ -17,7 +17,8 @@ impl Database {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.query_row(
|
||||
"SELECT listen_address, listen_port, max_retries,
|
||||
request_timeout, enable_logging, live_takeover_active
|
||||
request_timeout, enable_logging, live_takeover_active,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
|
||||
FROM proxy_config WHERE id = 1",
|
||||
[],
|
||||
|row| {
|
||||
@@ -28,6 +29,9 @@ impl Database {
|
||||
request_timeout: row.get::<_, i32>(3)? as u64,
|
||||
enable_logging: row.get::<_, i32>(4)? != 0,
|
||||
live_takeover_active: row.get::<_, i32>(5).unwrap_or(0) != 0,
|
||||
streaming_first_byte_timeout: row.get::<_, i32>(6).unwrap_or(30) as u64,
|
||||
streaming_idle_timeout: row.get::<_, i32>(7).unwrap_or(60) as u64,
|
||||
non_streaming_timeout: row.get::<_, i32>(8).unwrap_or(300) as u64,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -51,8 +55,11 @@ impl Database {
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_config
|
||||
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
||||
(id, enabled, listen_address, listen_port, max_retries, request_timeout,
|
||||
enable_logging, live_takeover_active, target_app,
|
||||
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||
created_at, updated_at)
|
||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11,
|
||||
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
|
||||
datetime('now'))",
|
||||
rusqlite::params![
|
||||
@@ -64,6 +71,9 @@ impl Database {
|
||||
if config.enable_logging { 1 } else { 0 },
|
||||
if config.live_takeover_active { 1 } else { 0 },
|
||||
"claude", // 兼容旧字段,写入默认值
|
||||
config.streaming_first_byte_timeout as i32,
|
||||
config.streaming_idle_timeout as i32,
|
||||
config.non_streaming_timeout as i32,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -135,6 +135,9 @@ impl Database {
|
||||
request_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||
target_app TEXT NOT NULL DEFAULT 'claude',
|
||||
streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
|
||||
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
@@ -313,6 +316,20 @@ impl Database {
|
||||
[],
|
||||
);
|
||||
|
||||
// 尝试添加超时配置列到 proxy_config 表(v3 新增)
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
|
||||
[],
|
||||
);
|
||||
|
||||
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
@@ -475,6 +492,28 @@ impl Database {
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// 添加代理超时配置字段(在 v2 迁移中直接添加)
|
||||
if Self::table_exists(conn, "proxy_config")? {
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"streaming_first_byte_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 30",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"streaming_idle_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 60",
|
||||
)?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"proxy_config",
|
||||
"non_streaming_timeout",
|
||||
"INTEGER NOT NULL DEFAULT 300",
|
||||
)?;
|
||||
}
|
||||
|
||||
// 删除旧的 failover_queue 表(如果存在)
|
||||
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
|
||||
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
|
||||
|
||||
@@ -39,7 +39,7 @@ pub struct RequestForwarder {
|
||||
failover_manager: Arc<FailoverSwitchManager>,
|
||||
/// AppHandle,用于发射事件和更新托盘
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
/// 请求开始时的“当前供应商 ID”(用于判断是否需要同步 UI/托盘)
|
||||
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||
current_provider_id_at_start: String,
|
||||
}
|
||||
|
||||
@@ -47,17 +47,27 @@ impl RequestForwarder {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
router: Arc<ProviderRouter>,
|
||||
timeout_secs: u64,
|
||||
non_streaming_timeout: u64,
|
||||
max_retries: u8,
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||
failover_manager: Arc<FailoverSwitchManager>,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
current_provider_id_at_start: String,
|
||||
_streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
) -> Self {
|
||||
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
|
||||
// 参考 Claude Code Hub 的 undici 全局超时设计
|
||||
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
|
||||
|
||||
let mut client_builder = Client::builder();
|
||||
if timeout_secs > 0 {
|
||||
client_builder = client_builder.timeout(Duration::from_secs(timeout_secs));
|
||||
if non_streaming_timeout > 0 {
|
||||
// 使用配置的非流式超时
|
||||
client_builder = client_builder.timeout(Duration::from_secs(non_streaming_timeout));
|
||||
} else {
|
||||
// 禁用超时时使用全局超时作为保底
|
||||
client_builder = client_builder.timeout(Duration::from_secs(GLOBAL_TIMEOUT_SECS));
|
||||
}
|
||||
|
||||
let client = client_builder
|
||||
|
||||
@@ -9,6 +9,15 @@ use crate::proxy::{
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
/// 流式超时配置
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct StreamingTimeoutConfig {
|
||||
/// 首字节超时(秒),0 表示禁用
|
||||
pub first_byte_timeout: u64,
|
||||
/// 静默期超时(秒),0 表示禁用
|
||||
pub idle_timeout: u64,
|
||||
}
|
||||
|
||||
/// 请求上下文
|
||||
///
|
||||
/// 贯穿整个请求生命周期,包含:
|
||||
@@ -135,13 +144,15 @@ impl RequestContext {
|
||||
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {
|
||||
RequestForwarder::new(
|
||||
state.provider_router.clone(),
|
||||
self.config.request_timeout,
|
||||
self.config.non_streaming_timeout,
|
||||
self.config.max_retries,
|
||||
state.status.clone(),
|
||||
state.current_providers.clone(),
|
||||
state.failover_manager.clone(),
|
||||
state.app_handle.clone(),
|
||||
self.current_provider_id.clone(),
|
||||
self.config.streaming_first_byte_timeout,
|
||||
self.config.streaming_idle_timeout,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,4 +168,13 @@ impl RequestContext {
|
||||
pub fn latency_ms(&self) -> u64 {
|
||||
self.start_time.elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
/// 获取流式超时配置
|
||||
#[inline]
|
||||
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
|
||||
StreamingTimeoutConfig {
|
||||
first_byte_timeout: self.config.streaming_first_byte_timeout,
|
||||
idle_timeout: self.config.streaming_idle_timeout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,10 +170,14 @@ async fn handle_claude_transform(
|
||||
})
|
||||
};
|
||||
|
||||
// 获取流式超时配置
|
||||
let timeout_config = ctx.streaming_timeout_config();
|
||||
|
||||
let logged_stream = create_logged_passthrough_stream(
|
||||
sse_stream,
|
||||
"Claude/OpenRouter",
|
||||
Some(usage_collector),
|
||||
timeout_config,
|
||||
);
|
||||
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
//! 统一处理流式和非流式 API 响应
|
||||
|
||||
use super::{
|
||||
handler_config::UsageParserConfig, handler_context::RequestContext, server::ProxyState,
|
||||
usage::parser::TokenUsage, ProxyError,
|
||||
handler_config::UsageParserConfig,
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
server::ProxyState,
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
use axum::response::Response;
|
||||
use bytes::Bytes;
|
||||
@@ -17,6 +20,7 @@ use std::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -60,8 +64,12 @@ pub async fn handle_streaming(
|
||||
// 创建使用量收集器
|
||||
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
|
||||
|
||||
// 创建带日志的透传流
|
||||
let logged_stream = create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector));
|
||||
// 获取流式超时配置
|
||||
let timeout_config = ctx.streaming_timeout_config();
|
||||
|
||||
// 创建带日志和超时的透传流
|
||||
let logged_stream =
|
||||
create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);
|
||||
|
||||
let body = axum::body::Body::from_stream(logged_stream);
|
||||
builder.body(body).unwrap()
|
||||
@@ -348,21 +356,60 @@ async fn log_usage_internal(
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带日志记录的透传流
|
||||
/// 创建带日志记录和超时控制的透传流
|
||||
pub fn create_logged_passthrough_stream(
|
||||
stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
|
||||
tag: &'static str,
|
||||
usage_collector: Option<SseUsageCollector>,
|
||||
timeout_config: StreamingTimeoutConfig,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut collector = usage_collector;
|
||||
let mut is_first_chunk = true;
|
||||
|
||||
// 超时配置
|
||||
let first_byte_timeout = if timeout_config.first_byte_timeout > 0 {
|
||||
Some(Duration::from_secs(timeout_config.first_byte_timeout))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let idle_timeout = if timeout_config.idle_timeout > 0 {
|
||||
Some(Duration::from_secs(timeout_config.idle_timeout))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
loop {
|
||||
// 选择超时时间:首字节超时或静默期超时
|
||||
let timeout_duration = if is_first_chunk {
|
||||
first_byte_timeout
|
||||
} else {
|
||||
idle_timeout
|
||||
};
|
||||
|
||||
let chunk_result = match timeout_duration {
|
||||
Some(duration) => {
|
||||
match tokio::time::timeout(duration, stream.next()).await {
|
||||
Ok(Some(chunk)) => Some(chunk),
|
||||
Ok(None) => None, // 流结束
|
||||
Err(_) => {
|
||||
// 超时
|
||||
let timeout_type = if is_first_chunk { "首字节" } else { "静默期" };
|
||||
log::error!("[{tag}] 流式响应{}超时 ({}秒)", timeout_type, duration.as_secs());
|
||||
yield Err(std::io::Error::other(format!("流式响应{timeout_type}超时")));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => stream.next().await, // 无超时限制
|
||||
};
|
||||
|
||||
match chunk_result {
|
||||
Some(Ok(bytes)) => {
|
||||
is_first_chunk = false;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
buffer.push_str(&text);
|
||||
|
||||
@@ -398,11 +445,15 @@ pub fn create_logged_passthrough_stream(
|
||||
|
||||
yield Ok(bytes);
|
||||
}
|
||||
Err(e) => {
|
||||
Some(Err(e)) => {
|
||||
log::error!("[{tag}] 流错误: {e}");
|
||||
yield Err(std::io::Error::other(e.to_string()));
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
// 流正常结束
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,34 @@ pub struct ProxyConfig {
|
||||
pub listen_port: u16,
|
||||
/// 最大重试次数
|
||||
pub max_retries: u8,
|
||||
/// 请求超时时间(秒)
|
||||
/// 请求超时时间(秒)- 已废弃,保留兼容
|
||||
pub request_timeout: u64,
|
||||
/// 是否启用日志
|
||||
pub enable_logging: bool,
|
||||
/// 是否正在接管 Live 配置
|
||||
#[serde(default)]
|
||||
pub live_takeover_active: bool,
|
||||
/// 流式首字超时(秒)- 等待首个数据块的最大时间
|
||||
#[serde(default = "default_streaming_first_byte_timeout")]
|
||||
pub streaming_first_byte_timeout: u64,
|
||||
/// 流式静默超时(秒)- 两个数据块之间的最大间隔
|
||||
#[serde(default = "default_streaming_idle_timeout")]
|
||||
pub streaming_idle_timeout: u64,
|
||||
/// 非流式总超时(秒)- 非流式请求的总超时时间
|
||||
#[serde(default = "default_non_streaming_timeout")]
|
||||
pub non_streaming_timeout: u64,
|
||||
}
|
||||
|
||||
fn default_streaming_first_byte_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_streaming_idle_timeout() -> u64 {
|
||||
60
|
||||
}
|
||||
|
||||
fn default_non_streaming_timeout() -> u64 {
|
||||
600
|
||||
}
|
||||
|
||||
impl Default for ProxyConfig {
|
||||
@@ -27,6 +48,9 @@ impl Default for ProxyConfig {
|
||||
request_timeout: 300,
|
||||
enable_logging: true,
|
||||
live_takeover_active: false,
|
||||
streaming_first_byte_timeout: 30,
|
||||
streaming_idle_timeout: 60,
|
||||
non_streaming_timeout: 600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useCircuitBreakerConfig,
|
||||
useUpdateCircuitBreakerConfig,
|
||||
} from "@/lib/query/failover";
|
||||
import { useProxyConfig } from "@/hooks/useProxyConfig";
|
||||
|
||||
export interface AutoFailoverConfigPanelProps {
|
||||
enabled?: boolean;
|
||||
@@ -26,6 +27,12 @@ export function AutoFailoverConfigPanel({
|
||||
const { t } = useTranslation();
|
||||
const { data: config, isLoading, error } = useCircuitBreakerConfig();
|
||||
const updateConfig = useUpdateCircuitBreakerConfig();
|
||||
const {
|
||||
config: proxyConfig,
|
||||
isLoading: isProxyLoading,
|
||||
updateConfig: updateProxyConfig,
|
||||
isUpdating: isProxyUpdating,
|
||||
} = useProxyConfig();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
failureThreshold: 5,
|
||||
@@ -34,6 +41,11 @@ export function AutoFailoverConfigPanel({
|
||||
errorRateThreshold: 0.5,
|
||||
minRequests: 10,
|
||||
});
|
||||
const [timeoutConfig, setTimeoutConfig] = useState({
|
||||
streaming_first_byte_timeout: 30,
|
||||
streaming_idle_timeout: 60,
|
||||
non_streaming_timeout: 600,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
@@ -43,6 +55,17 @@ export function AutoFailoverConfigPanel({
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (proxyConfig) {
|
||||
setTimeoutConfig({
|
||||
streaming_first_byte_timeout:
|
||||
proxyConfig.streaming_first_byte_timeout ?? 30,
|
||||
streaming_idle_timeout: proxyConfig.streaming_idle_timeout ?? 60,
|
||||
non_streaming_timeout: proxyConfig.non_streaming_timeout ?? 300,
|
||||
});
|
||||
}
|
||||
}, [proxyConfig]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateConfig.mutateAsync({
|
||||
@@ -52,6 +75,15 @@ export function AutoFailoverConfigPanel({
|
||||
errorRateThreshold: formData.errorRateThreshold,
|
||||
minRequests: formData.minRequests,
|
||||
});
|
||||
if (proxyConfig) {
|
||||
await updateProxyConfig({
|
||||
...proxyConfig,
|
||||
streaming_first_byte_timeout:
|
||||
timeoutConfig.streaming_first_byte_timeout,
|
||||
streaming_idle_timeout: timeoutConfig.streaming_idle_timeout,
|
||||
non_streaming_timeout: timeoutConfig.non_streaming_timeout,
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
|
||||
{ closeButton: true },
|
||||
@@ -69,6 +101,14 @@ export function AutoFailoverConfigPanel({
|
||||
...config,
|
||||
});
|
||||
}
|
||||
if (proxyConfig) {
|
||||
setTimeoutConfig({
|
||||
streaming_first_byte_timeout:
|
||||
proxyConfig.streaming_first_byte_timeout ?? 30,
|
||||
streaming_idle_timeout: proxyConfig.streaming_idle_timeout ?? 60,
|
||||
non_streaming_timeout: proxyConfig.non_streaming_timeout ?? 300,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
@@ -255,20 +295,134 @@ export function AutoFailoverConfigPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 代理请求超时配置 */}
|
||||
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.settings.timeout.title", {
|
||||
defaultValue: "超时设置",
|
||||
})}
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="streamingFirstByteTimeout">
|
||||
{t("proxy.settings.fields.streamingFirstByteTimeout.label", {
|
||||
defaultValue: "流式首字超时(秒)",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="streamingFirstByteTimeout"
|
||||
type="number"
|
||||
min="0"
|
||||
max="180"
|
||||
value={timeoutConfig.streaming_first_byte_timeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
if (val === 0 || (val >= 1 && val <= 180)) {
|
||||
setTimeoutConfig({
|
||||
...timeoutConfig,
|
||||
streaming_first_byte_timeout: val,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!enabled || isProxyLoading || isProxyUpdating}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"proxy.settings.fields.streamingFirstByteTimeout.description",
|
||||
"等待首个数据块的最大时间(0 禁用,范围 1-180 秒)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="streamingIdleTimeout">
|
||||
{t("proxy.settings.fields.streamingIdleTimeout.label", {
|
||||
defaultValue: "流式静默超时(秒)",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="streamingIdleTimeout"
|
||||
type="number"
|
||||
min="0"
|
||||
max="600"
|
||||
value={timeoutConfig.streaming_idle_timeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
if (val === 0 || (val >= 60 && val <= 600)) {
|
||||
setTimeoutConfig({
|
||||
...timeoutConfig,
|
||||
streaming_idle_timeout: val,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!enabled || isProxyLoading || isProxyUpdating}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"proxy.settings.fields.streamingIdleTimeout.description",
|
||||
"数据块之间的最大间隔(0 禁用,范围 60-600 秒)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nonStreamingTimeout">
|
||||
{t("proxy.settings.fields.nonStreamingTimeout.label", {
|
||||
defaultValue: "非流式超时(秒)",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="nonStreamingTimeout"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1800"
|
||||
value={timeoutConfig.non_streaming_timeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
if (val === 0 || (val >= 60 && val <= 1800)) {
|
||||
setTimeoutConfig({
|
||||
...timeoutConfig,
|
||||
non_streaming_timeout: val,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!enabled || isProxyLoading || isProxyUpdating}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"proxy.settings.fields.nonStreamingTimeout.description",
|
||||
"非流式请求的总超时时间(0 禁用,范围 60-1800 秒)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={updateConfig.isPending || !enabled}
|
||||
disabled={
|
||||
updateConfig.isPending ||
|
||||
isProxyLoading ||
|
||||
isProxyUpdating ||
|
||||
!enabled
|
||||
}
|
||||
>
|
||||
{t("common.reset", "重置")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateConfig.isPending || !enabled}
|
||||
disabled={
|
||||
updateConfig.isPending ||
|
||||
isProxyLoading ||
|
||||
isProxyUpdating ||
|
||||
!enabled
|
||||
}
|
||||
>
|
||||
{updateConfig.isPending ? (
|
||||
{updateConfig.isPending || isProxyUpdating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("common.saving", "保存中...")}
|
||||
|
||||
@@ -34,29 +34,45 @@ type ProxyConfigForm = Pick<
|
||||
| "max_retries"
|
||||
| "request_timeout"
|
||||
| "enable_logging"
|
||||
| "streaming_first_byte_timeout"
|
||||
| "streaming_idle_timeout"
|
||||
| "non_streaming_timeout"
|
||||
>;
|
||||
|
||||
const createProxyConfigSchema = (t: TFunction) => {
|
||||
const requestTimeoutSchema = z
|
||||
// 流式首字节超时:0 或 1-180 秒
|
||||
const streamingFirstByteSchema = z
|
||||
.number()
|
||||
.min(
|
||||
0,
|
||||
t("proxy.settings.validation.timeoutNonNegative", {
|
||||
defaultValue: "超时时间不能为负数",
|
||||
}),
|
||||
)
|
||||
.max(
|
||||
600,
|
||||
t("proxy.settings.validation.timeoutMax", {
|
||||
defaultValue: "超时时间最多600秒",
|
||||
}),
|
||||
)
|
||||
.refine((value) => value === 0 || value >= 10, {
|
||||
message: t("proxy.settings.validation.timeoutRange", {
|
||||
defaultValue: "请输入 0 或 10-600 之间的数值",
|
||||
.min(0)
|
||||
.refine((val) => val === 0 || (val >= 1 && val <= 180), {
|
||||
message: t("proxy.settings.validation.streamingFirstByteRange", {
|
||||
defaultValue: "请输入 0 或 1-180 之间的数值",
|
||||
}),
|
||||
});
|
||||
|
||||
// 流式静默期超时:0 或 60-600 秒
|
||||
const streamingIdleSchema = z
|
||||
.number()
|
||||
.min(0)
|
||||
.refine((val) => val === 0 || (val >= 60 && val <= 600), {
|
||||
message: t("proxy.settings.validation.streamingIdleRange", {
|
||||
defaultValue: "请输入 0 或 60-600 之间的数值",
|
||||
}),
|
||||
});
|
||||
|
||||
// 非流式总超时:0 或 60-1800 秒
|
||||
const nonStreamingSchema = z
|
||||
.number()
|
||||
.min(0)
|
||||
.refine((val) => val === 0 || (val >= 60 && val <= 1800), {
|
||||
message: t("proxy.settings.validation.nonStreamingRange", {
|
||||
defaultValue: "请输入 0 或 60-1800 之间的数值",
|
||||
}),
|
||||
});
|
||||
|
||||
// 旧版请求超时(兼容)
|
||||
const requestTimeoutSchema = z.number().min(0);
|
||||
|
||||
return z.object({
|
||||
listen_address: z.string().regex(
|
||||
/^(\d{1,3}\.){3}\d{1,3}$/,
|
||||
@@ -94,6 +110,9 @@ const createProxyConfigSchema = (t: TFunction) => {
|
||||
),
|
||||
request_timeout: requestTimeoutSchema,
|
||||
enable_logging: z.boolean(),
|
||||
streaming_first_byte_timeout: streamingFirstByteSchema,
|
||||
streaming_idle_timeout: streamingIdleSchema,
|
||||
non_streaming_timeout: nonStreamingSchema,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -120,6 +139,9 @@ export function ProxySettingsDialog({
|
||||
max_retries: 3,
|
||||
request_timeout: 300,
|
||||
enable_logging: true,
|
||||
streaming_first_byte_timeout: 30,
|
||||
streaming_idle_timeout: 60,
|
||||
non_streaming_timeout: 600,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -132,6 +154,9 @@ export function ProxySettingsDialog({
|
||||
max_retries: config.max_retries,
|
||||
request_timeout: config.request_timeout,
|
||||
enable_logging: config.enable_logging,
|
||||
streaming_first_byte_timeout: config.streaming_first_byte_timeout ?? 30,
|
||||
streaming_idle_timeout: config.streaming_idle_timeout ?? 60,
|
||||
non_streaming_timeout: config.non_streaming_timeout ?? 300,
|
||||
});
|
||||
}
|
||||
}, [config, form]);
|
||||
@@ -395,6 +420,131 @@ export function ProxySettingsDialog({
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t("proxy.settings.timeout.title", {
|
||||
defaultValue: "超时设置",
|
||||
})}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("proxy.settings.timeout.description", {
|
||||
defaultValue: "配置流式和非流式请求的超时时间。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="streaming_first_byte_timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"proxy.settings.fields.streamingFirstByteTimeout.label",
|
||||
{
|
||||
defaultValue: "流式首字超时(秒)",
|
||||
},
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
placeholder="30"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"proxy.settings.fields.streamingFirstByteTimeout.description",
|
||||
{
|
||||
defaultValue: "等待首个数据块的最大时间",
|
||||
},
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="streaming_idle_timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("proxy.settings.fields.streamingIdleTimeout.label", {
|
||||
defaultValue: "流式静默超时(秒)",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
placeholder="60"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"proxy.settings.fields.streamingIdleTimeout.description",
|
||||
{
|
||||
defaultValue: "数据块之间的最大间隔",
|
||||
},
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="non_streaming_timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("proxy.settings.fields.nonStreamingTimeout.label", {
|
||||
defaultValue: "非流式超时(秒)",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
placeholder="300"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"proxy.settings.fields.nonStreamingTimeout.description",
|
||||
{
|
||||
defaultValue: "非流式请求的总超时时间",
|
||||
},
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -981,6 +981,76 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Proxy Service Settings",
|
||||
"description": "Configure local proxy server listening address, port and runtime parameters. Changes take effect immediately after saving.",
|
||||
"alert": {
|
||||
"autoApply": "Changes will be automatically synced to the running proxy service without manual restart."
|
||||
},
|
||||
"basic": {
|
||||
"title": "Basic Settings",
|
||||
"description": "Configure proxy service listening address and port."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced Parameters",
|
||||
"description": "Control request stability and logging."
|
||||
},
|
||||
"timeout": {
|
||||
"title": "Timeout Settings",
|
||||
"description": "Configure timeout for streaming and non-streaming requests."
|
||||
},
|
||||
"fields": {
|
||||
"listenAddress": {
|
||||
"label": "Listen Address",
|
||||
"placeholder": "127.0.0.1",
|
||||
"description": "IP address the proxy server listens on (recommended: 127.0.0.1)"
|
||||
},
|
||||
"listenPort": {
|
||||
"label": "Listen Port",
|
||||
"placeholder": "5000",
|
||||
"description": "Port number the proxy server listens on (1024 ~ 65535)"
|
||||
},
|
||||
"maxRetries": {
|
||||
"label": "Max Retries",
|
||||
"placeholder": "3",
|
||||
"description": "Number of retries on request failure (0 ~ 10)"
|
||||
},
|
||||
"requestTimeout": {
|
||||
"label": "Request Timeout (sec)",
|
||||
"placeholder": "0 (unlimited) or 300",
|
||||
"description": "Maximum wait time for a single request (0 = unlimited, or 10 ~ 600 seconds)"
|
||||
},
|
||||
"enableLogging": {
|
||||
"label": "Enable Logging",
|
||||
"description": "Log all proxy requests for troubleshooting"
|
||||
},
|
||||
"streamingFirstByteTimeout": {
|
||||
"label": "Streaming First Byte Timeout (sec)",
|
||||
"description": "Maximum time to wait for the first data chunk"
|
||||
},
|
||||
"streamingIdleTimeout": {
|
||||
"label": "Streaming Idle Timeout (sec)",
|
||||
"description": "Maximum interval between data chunks"
|
||||
},
|
||||
"nonStreamingTimeout": {
|
||||
"label": "Non-Streaming Timeout (sec)",
|
||||
"description": "Total timeout for non-streaming requests"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"addressInvalid": "Please enter a valid IP address",
|
||||
"portMin": "Port must be greater than 1024",
|
||||
"portMax": "Port must be less than 65535",
|
||||
"retryMin": "Retry count cannot be negative",
|
||||
"retryMax": "Retry count cannot exceed 10",
|
||||
"timeoutNonNegative": "Timeout cannot be negative",
|
||||
"timeoutMax": "Timeout cannot exceed 600 seconds",
|
||||
"timeoutRange": "Please enter 0 or a value between 10-600",
|
||||
"streamingTimeoutMin": "Timeout must be at least 5 seconds",
|
||||
"streamingTimeoutMax": "Timeout cannot exceed 300 seconds"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save Configuration"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "Proxy configuration saved",
|
||||
"saveFailed": "Save failed: {{error}}"
|
||||
|
||||
@@ -981,6 +981,76 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "プロキシサービス設定",
|
||||
"description": "ローカルプロキシサーバーのリッスンアドレス、ポート、実行パラメータを設定します。保存後すぐに反映されます。",
|
||||
"alert": {
|
||||
"autoApply": "変更は実行中のプロキシサービスに自動的に同期され、手動での再起動は不要です。"
|
||||
},
|
||||
"basic": {
|
||||
"title": "基本設定",
|
||||
"description": "プロキシサービスのリッスンアドレスとポートを設定します。"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "詳細パラメータ",
|
||||
"description": "リクエストの安定性とログ記録を制御します。"
|
||||
},
|
||||
"timeout": {
|
||||
"title": "タイムアウト設定",
|
||||
"description": "ストリーミングと非ストリーミングリクエストのタイムアウトを設定します。"
|
||||
},
|
||||
"fields": {
|
||||
"listenAddress": {
|
||||
"label": "リッスンアドレス",
|
||||
"placeholder": "127.0.0.1",
|
||||
"description": "プロキシサーバーがリッスンするIPアドレス(推奨: 127.0.0.1)"
|
||||
},
|
||||
"listenPort": {
|
||||
"label": "リッスンポート",
|
||||
"placeholder": "5000",
|
||||
"description": "プロキシサーバーがリッスンするポート番号(1024 ~ 65535)"
|
||||
},
|
||||
"maxRetries": {
|
||||
"label": "最大リトライ回数",
|
||||
"placeholder": "3",
|
||||
"description": "リクエスト失敗時のリトライ回数(0 ~ 10)"
|
||||
},
|
||||
"requestTimeout": {
|
||||
"label": "リクエストタイムアウト(秒)",
|
||||
"placeholder": "0(無制限)または 300",
|
||||
"description": "単一リクエストの最大待機時間(0 = 無制限、または 10 ~ 600 秒)"
|
||||
},
|
||||
"enableLogging": {
|
||||
"label": "ログ記録を有効化",
|
||||
"description": "トラブルシューティングのためにすべてのプロキシリクエストを記録"
|
||||
},
|
||||
"streamingFirstByteTimeout": {
|
||||
"label": "ストリーミング初回バイトタイムアウト(秒)",
|
||||
"description": "最初のデータチャンクを待つ最大時間"
|
||||
},
|
||||
"streamingIdleTimeout": {
|
||||
"label": "ストリーミングアイドルタイムアウト(秒)",
|
||||
"description": "データチャンク間の最大間隔"
|
||||
},
|
||||
"nonStreamingTimeout": {
|
||||
"label": "非ストリーミングタイムアウト(秒)",
|
||||
"description": "非ストリーミングリクエストの総タイムアウト"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"addressInvalid": "有効なIPアドレスを入力してください",
|
||||
"portMin": "ポートは1024より大きい必要があります",
|
||||
"portMax": "ポートは65535より小さい必要があります",
|
||||
"retryMin": "リトライ回数は負の値にできません",
|
||||
"retryMax": "リトライ回数は10を超えることはできません",
|
||||
"timeoutNonNegative": "タイムアウトは負の値にできません",
|
||||
"timeoutMax": "タイムアウトは600秒を超えることはできません",
|
||||
"timeoutRange": "0または10-600の間の値を入力してください",
|
||||
"streamingTimeoutMin": "タイムアウトは少なくとも5秒必要です",
|
||||
"streamingTimeoutMax": "タイムアウトは300秒を超えることはできません"
|
||||
},
|
||||
"actions": {
|
||||
"save": "設定を保存"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "プロキシ設定を保存しました",
|
||||
"saveFailed": "保存に失敗しました: {{error}}"
|
||||
|
||||
@@ -981,6 +981,76 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "代理服务设置",
|
||||
"description": "配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
|
||||
"alert": {
|
||||
"autoApply": "保存后将自动同步到正在运行的代理服务,无需手动重启。"
|
||||
},
|
||||
"basic": {
|
||||
"title": "基础设置",
|
||||
"description": "配置代理服务监听的地址与端口。"
|
||||
},
|
||||
"advanced": {
|
||||
"title": "高级参数",
|
||||
"description": "控制请求的稳定性和日志记录。"
|
||||
},
|
||||
"timeout": {
|
||||
"title": "超时设置",
|
||||
"description": "配置流式和非流式请求的超时时间。"
|
||||
},
|
||||
"fields": {
|
||||
"listenAddress": {
|
||||
"label": "监听地址",
|
||||
"placeholder": "127.0.0.1",
|
||||
"description": "代理服务器监听的 IP 地址(推荐 127.0.0.1)"
|
||||
},
|
||||
"listenPort": {
|
||||
"label": "监听端口",
|
||||
"placeholder": "5000",
|
||||
"description": "代理服务器监听的端口号(1024 ~ 65535)"
|
||||
},
|
||||
"maxRetries": {
|
||||
"label": "最大重试次数",
|
||||
"placeholder": "3",
|
||||
"description": "请求失败时的重试次数(0 ~ 10)"
|
||||
},
|
||||
"requestTimeout": {
|
||||
"label": "请求超时(秒)",
|
||||
"placeholder": "0(不限)或 300",
|
||||
"description": "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)"
|
||||
},
|
||||
"enableLogging": {
|
||||
"label": "启用日志记录",
|
||||
"description": "记录所有代理请求,便于排查问题"
|
||||
},
|
||||
"streamingFirstByteTimeout": {
|
||||
"label": "流式首字超时(秒)",
|
||||
"description": "等待首个数据块的最大时间"
|
||||
},
|
||||
"streamingIdleTimeout": {
|
||||
"label": "流式静默超时(秒)",
|
||||
"description": "数据块之间的最大间隔"
|
||||
},
|
||||
"nonStreamingTimeout": {
|
||||
"label": "非流式超时(秒)",
|
||||
"description": "非流式请求的总超时时间"
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"addressInvalid": "请输入有效的IP地址",
|
||||
"portMin": "端口必须大于1024",
|
||||
"portMax": "端口必须小于65535",
|
||||
"retryMin": "重试次数不能为负",
|
||||
"retryMax": "重试次数不能超过10",
|
||||
"timeoutNonNegative": "超时时间不能为负数",
|
||||
"timeoutMax": "超时时间最多600秒",
|
||||
"timeoutRange": "请输入 0 或 10-600 之间的数值",
|
||||
"streamingTimeoutMin": "超时时间至少5秒",
|
||||
"streamingTimeoutMax": "超时时间最多300秒"
|
||||
},
|
||||
"actions": {
|
||||
"save": "保存配置"
|
||||
},
|
||||
"toast": {
|
||||
"saved": "代理配置已保存",
|
||||
"saveFailed": "保存失败: {{error}}"
|
||||
|
||||
@@ -5,6 +5,10 @@ export interface ProxyConfig {
|
||||
request_timeout: number;
|
||||
enable_logging: boolean;
|
||||
live_takeover_active?: boolean;
|
||||
// 超时配置
|
||||
streaming_first_byte_timeout: number;
|
||||
streaming_idle_timeout: number;
|
||||
non_streaming_timeout: number;
|
||||
}
|
||||
|
||||
export interface ProxyStatus {
|
||||
|
||||
Reference in New Issue
Block a user