fix: 修复 Flow Monitor 响应内容显示和提供商显示问题 (#75)

- 修复凭证池模式下非流式响应的 content 和 body 字段未保存问题
- 从响应 JSON 中提取 content、tool_calls 和 usage 信息
- 添加 provider_id 字段用于显示实际的提供商名称
- 优化清理完成弹窗样式,使用自定义 Modal 组件
- 美化排序下拉框,使用 Radix UI Select 组件
- 添加详细的调试日志用于追踪 Flow 显示问题
- 修复所有测试代码中缺少 provider_id 字段的问题
This commit is contained in:
Chiron
2026-01-10 22:27:06 +08:00
parent 85bfc56037
commit 3ca4bfe7f6
13 changed files with 431 additions and 68 deletions
@@ -791,6 +791,7 @@ pub async fn create_test_flows(
// 创建测试元数据
let metadata = FlowMetadata {
provider: ProviderType::OpenAI,
provider_id: Some("openai".to_string()),
credential_id: Some(format!("test-cred-{}", i)),
credential_name: Some(format!("测试凭证 {}", i)),
retry_count: 0,
+1
View File
@@ -1269,6 +1269,7 @@ mod property_tests {
fn arb_flow_metadata() -> impl Strategy<Value = FlowMetadata> {
arb_provider_type().prop_map(|provider| FlowMetadata {
provider,
provider_id: None,
credential_id: None,
credential_name: None,
retry_count: 0,
+2
View File
@@ -1618,6 +1618,7 @@ mod property_tests {
fn arb_flow_metadata() -> impl Strategy<Value = FlowMetadata> {
arb_provider_type().prop_map(|provider| FlowMetadata {
provider,
provider_id: None,
credential_id: None,
credential_name: None,
retry_count: 0,
@@ -1969,6 +1970,7 @@ mod redaction_property_tests {
let metadata = FlowMetadata {
provider,
provider_id: None,
credential_id: None,
credential_name: None,
retry_count: 0,
+10 -1
View File
@@ -394,19 +394,28 @@ impl FlowMemoryStore {
pub fn add(&mut self, flow: LLMFlow) {
let id = flow.id.clone();
eprintln!(
"[MEMORY_STORE] 添加 Flow: id={}, model={}, state={:?}",
id, flow.request.model, flow.state
);
// 如果已存在,先移除旧的
if self.flows.contains_key(&id) {
self.ordered_ids.retain(|i| i != &id);
eprintln!("[MEMORY_STORE] 移除旧的 Flow: id={}", id);
}
// 检查是否需要驱逐
while self.flows.len() >= self.max_size {
eprintln!("[MEMORY_STORE] 缓存已满,驱逐最旧的 Flow");
self.evict_oldest();
}
// 添加新 Flow
self.flows.insert(id.clone(), Arc::new(RwLock::new(flow)));
self.ordered_ids.push_back(id);
self.ordered_ids.push_back(id.clone());
eprintln!("[MEMORY_STORE] Flow 已添加,当前数量: {}", self.flows.len());
}
/// 获取 Flow
+5
View File
@@ -539,6 +539,9 @@ pub struct ToolCallDelta {
pub struct FlowMetadata {
/// 提供商类型
pub provider: ProviderType,
/// 提供商 ID(实际的 provider ID,如 "deepseek", "moonshot" 等)
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_id: Option<String>,
/// 凭证 ID
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_id: Option<String>,
@@ -564,6 +567,7 @@ impl Default for FlowMetadata {
fn default() -> Self {
Self {
provider: ProviderType::Kiro,
provider_id: None,
credential_id: None,
credential_name: None,
retry_count: 0,
@@ -1063,6 +1067,7 @@ mod property_tests {
)
.prop_map(|(provider, credential_id, credential_name)| FlowMetadata {
provider,
provider_id: None,
credential_id,
credential_name,
retry_count: 0,
+36 -1
View File
@@ -1052,6 +1052,10 @@ impl FlowMonitor {
// 检查是否应该监控
if !config.should_monitor(&request.model, &request.path) {
eprintln!(
"[FLOW_MONITOR] 跳过监控: model={}, path={}",
request.model, request.path
);
return None;
}
@@ -1064,6 +1068,11 @@ impl FlowMonitor {
// 生成唯一 ID
let flow_id = Uuid::new_v4().to_string();
eprintln!(
"[FLOW_MONITOR] 创建新 Flow: id={}, model={}, provider={:?}",
flow_id, request.model, metadata.provider
);
// 确定 Flow 类型
let flow_type = Self::determine_flow_type(&request.path);
@@ -1081,6 +1090,7 @@ impl FlowMonitor {
{
let mut active = self.active_flows.write().await;
active.insert(flow_id.clone(), active_flow);
eprintln!("[FLOW_MONITOR] 活跃 Flow 数量: {}", active.len());
}
// 发送事件
@@ -1172,6 +1182,12 @@ impl FlowMonitor {
/// - `flow_id`: Flow ID
/// - `response`: LLM 响应(如果是非流式响应)
pub async fn complete_flow(&self, flow_id: &str, response: Option<LLMResponse>) {
eprintln!(
"[FLOW_MONITOR] 准备完成 Flow: id={}, has_response={}",
flow_id,
response.is_some()
);
let mut active = self.active_flows.write().await;
if let Some(mut active_flow) = active.remove(flow_id) {
@@ -1185,12 +1201,17 @@ impl FlowMonitor {
};
// 更新 Flow
active_flow.flow.response = final_response;
active_flow.flow.response = final_response.clone();
active_flow.flow.state = FlowState::Completed;
active_flow.flow.timestamps.response_end = Some(now);
active_flow.flow.timestamps.calculate_duration();
active_flow.flow.timestamps.calculate_ttfb();
eprintln!(
"[FLOW_MONITOR] Flow 状态更新: id={}, state={:?}, duration_ms={}",
flow_id, active_flow.flow.state, active_flow.flow.timestamps.duration_ms
);
// 检查阈值
let threshold_result = self.check_threshold(&active_flow.flow).await;
@@ -1198,13 +1219,23 @@ impl FlowMonitor {
{
let mut store = self.memory_store.write().await;
store.add(active_flow.flow.clone());
eprintln!(
"[FLOW_MONITOR] 已保存到内存存储: id={}, 内存中 Flow 数量={}",
flow_id,
store.len()
);
}
// 保存到文件存储
if let Some(ref file_store) = self.file_store {
if let Err(e) = file_store.write(&active_flow.flow) {
tracing::error!("保存 Flow 到文件失败: {}", e);
eprintln!("[FLOW_MONITOR] 保存到文件失败: id={}, error={}", flow_id, e);
} else {
eprintln!("[FLOW_MONITOR] 已保存到文件存储: id={}", flow_id);
}
} else {
eprintln!("[FLOW_MONITOR] 文件存储未启用");
}
// 发送完成事件
@@ -1225,6 +1256,10 @@ impl FlowMonitor {
self.check_threshold_notifications(&active_flow.flow, &threshold_result)
.await;
}
eprintln!("[FLOW_MONITOR] Flow 完成处理完毕: id={}", flow_id);
} else {
eprintln!("[FLOW_MONITOR] 警告: 未找到活跃 Flow: id={}", flow_id);
}
}
+16 -1
View File
@@ -212,10 +212,17 @@ impl FlowQueryService {
page: usize,
page_size: usize,
) -> Result<FlowQueryResult, FileStoreError> {
eprintln!(
"[QUERY_SERVICE] 开始查询: filter={:?}, sort_by={:?}, page={}, page_size={}",
filter, sort_by, page, page_size
);
// 先从内存获取
let memory_flows = {
let store = self.memory_store.read().await;
store.query(&filter)
let flows = store.query(&filter);
eprintln!("[QUERY_SERVICE] 内存中查询到 {} 条记录", flows.len());
flows
};
// 再从文件获取(如果需要更多数据)
@@ -228,8 +235,14 @@ impl FlowQueryService {
let needed = page * page_size;
if memory_count < needed {
eprintln!(
"[QUERY_SERVICE] 内存数据不足,从文件补充: memory_count={}, needed={}",
memory_count, needed
);
// 从文件存储获取更多数据
let file_flows = self.file_store.query(&filter, needed * 2, 0)?;
eprintln!("[QUERY_SERVICE] 文件中查询到 {} 条记录", file_flows.len());
// 合并并去重(以 ID 为准)
let memory_ids: std::collections::HashSet<_> =
@@ -242,6 +255,8 @@ impl FlowQueryService {
}
}
eprintln!("[QUERY_SERVICE] 合并后总记录数: {}", all_flows.len());
// 排序
Self::sort_flows(&mut all_flows, sort_by, sort_desc);
+186 -48
View File
@@ -268,6 +268,7 @@ fn build_llm_request_from_anthropic(
/// 构建 FlowMetadata
fn build_flow_metadata(
provider: ProviderType,
provider_id: Option<&str>,
credential_id: Option<&str>,
credential_name: Option<&str>,
headers: &HeaderMap,
@@ -287,6 +288,7 @@ fn build_flow_metadata(
FlowMetadata {
provider,
provider_id: provider_id.map(|s| s.to_string()),
credential_id: credential_id.map(|s| s.to_string()),
credential_name: credential_name.map(|s| s.to_string()),
retry_count: 0,
@@ -972,13 +974,25 @@ pub async fn chat_completions(
let llm_request = build_llm_request_from_openai(&request, "/v1/chat/completions", &headers);
// 尝试将 selected_provider 解析为 ProviderType
// 如果是自定义 provider ID,则使用 OpenAI 作为默认值(因为大多数自定义 provider 使用 OpenAI 协议)
// 构建 Flow Metadata,同时保存 provider_type 和实际的 provider_id
let provider_type = selected_provider
.parse::<ProviderType>()
.unwrap_or(ProviderType::OpenAI);
// 从凭证名称中提取 Provider 显示名称
// 凭证名称格式:Some("[降级] DeepSeek") 或 Some("DeepSeek")
let provider_display_name = cred.name.as_ref().and_then(|name| {
// 去掉 "[降级] " 前缀
if name.starts_with("[降级] ") {
Some(&name[9..]) // "[降级] " 是 9 个字节
} else {
Some(name.as_str())
}
});
let flow_metadata = build_flow_metadata(
provider_type,
provider_display_name, // 使用 Provider 显示名称(如 "DeepSeek")
Some(&cred.uuid),
cred.name.as_deref(),
&headers,
@@ -1026,6 +1040,7 @@ pub async fn chat_completions(
// 记录请求统计
let is_success = response.status().is_success();
let status_code = response.status().as_u16();
let status = if is_success {
crate::telemetry::RequestStatus::Success
} else {
@@ -1033,38 +1048,99 @@ pub async fn chat_completions(
};
record_request_telemetry(&state, &ctx, status, None);
// 如果成功,记录估算的 Token 使用量
let estimated_input_tokens = request
.messages
.iter()
.map(|m| {
let content_len = match &m.content {
Some(c) => message_content_len(c),
None => 0,
};
content_len / 4
})
.sum::<usize>() as u32;
let estimated_output_tokens = if is_success { 100u32 } else { 0u32 };
// 如果成功且需要 Flow 捕获,提取响应体内容和响应头
// 注意:非流式响应需要读取 body,所以必须在这里处理
if is_success && flow_id.is_some() && !request.stream {
// 将 Response 转换为 bytes
let (parts, body) = response.into_parts();
if is_success {
record_token_usage(
&state,
&ctx,
Some(estimated_input_tokens),
Some(estimated_output_tokens),
);
}
// 提取响应头
let mut response_headers = HashMap::new();
for (name, value) in parts.headers.iter() {
if let Ok(v) = value.to_str() {
response_headers.insert(name.as_str().to_string(), v.to_string());
}
}
// 完成 Flow 捕获并检查响应拦截
// **Validates: Requirements 2.1, 2.5**
if let Some(fid) = flow_id {
if is_success {
let llm_response = build_llm_response(
200,
"", // 内容在 provider_calls 中处理
Some((estimated_input_tokens, estimated_output_tokens)),
);
let body_bytes = match axum::body::to_bytes(body, usize::MAX).await {
Ok(bytes) => bytes,
Err(e) => {
eprintln!("[CHAT_COMPLETIONS] 读取响应体失败: {}", e);
// 如果读取失败,返回错误
if let Some(fid) = flow_id {
let error = FlowError::new(FlowErrorType::Network, &e.to_string());
state.flow_monitor.fail_flow(&fid, error).await;
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": {"message": format!("Failed to read response body: {}", e)}})),
)
.into_response();
}
};
// 解析响应体
let response_json: serde_json::Value = match serde_json::from_slice(&body_bytes) {
Ok(json) => json,
Err(e) => {
eprintln!("[CHAT_COMPLETIONS] 解析响应体失败: {}", e);
// 如果解析失败,仍然返回原始响应
if let Some(fid) = flow_id {
let error = FlowError::new(
FlowErrorType::Other,
&format!("Failed to parse response: {}", e),
);
state.flow_monitor.fail_flow(&fid, error).await;
}
// 重新构建响应
let response = Response::from_parts(parts, Body::from(body_bytes));
return response;
}
};
// 提取内容和 token 使用量
// 优先从 content 字段提取,如果为空则尝试从 tool_calls 提取
let mut content = response_json["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("")
.to_string();
// 如果 content 为空,检查是否有 tool_calls
if content.is_empty() {
if let Some(tool_calls) =
response_json["choices"][0]["message"]["tool_calls"].as_array()
{
if !tool_calls.is_empty() {
// 从第一个 tool_call 的 arguments 中提取内容
if let Some(arguments) = tool_calls[0]["function"]["arguments"].as_str() {
content = arguments.to_string();
eprintln!("[CHAT_COMPLETIONS] 从 tool_calls 中提取内容");
}
}
}
}
let input_tokens = response_json["usage"]["prompt_tokens"]
.as_u64()
.unwrap_or(0) as u32;
let output_tokens = response_json["usage"]["completion_tokens"]
.as_u64()
.unwrap_or(0) as u32;
eprintln!("[CHAT_COMPLETIONS] 提取响应内容: content_len={}, input_tokens={}, output_tokens={}",
content.len(), input_tokens, output_tokens);
// 记录 Token 使用量
record_token_usage(&state, &ctx, Some(input_tokens), Some(output_tokens));
// 完成 Flow 捕获并检查响应拦截
// **Validates: Requirements 2.1, 2.5**
if let Some(fid) = flow_id {
// 构建 LLMResponse,包含完整的响应体和响应头
let mut llm_response =
build_llm_response(200, &content, Some((input_tokens, output_tokens)));
llm_response.body = response_json.clone();
llm_response.headers = response_headers; // 设置响应头
// 检查是否需要拦截响应
if let Some(modified_response) = check_response_intercept(
@@ -1090,7 +1166,6 @@ pub async fn chat_completions(
.await;
// 构建修改后的 HTTP 响应
// 注意:这里简化处理,实际应该根据修改后的内容重新构建完整响应
return (
StatusCode::OK,
Json(serde_json::json!({
@@ -1119,21 +1194,59 @@ pub async fn chat_completions(
.into_response();
}
eprintln!("[FLOW_DEBUG] 准备完成 Flow: flow_id={}, content_len={}, input_tokens={}, output_tokens={}",
fid, llm_response.content.len(), llm_response.usage.input_tokens, llm_response.usage.output_tokens);
state
.flow_monitor
.complete_flow(&fid, Some(llm_response))
.await;
} else {
let error = FlowError::new(
FlowErrorType::from_status_code(response.status().as_u16()),
"Request failed",
)
.with_status_code(response.status().as_u16());
state.flow_monitor.fail_flow(&fid, error).await;
}
}
return response;
eprintln!("[FLOW_DEBUG] Flow 已完成: flow_id={}", fid);
}
// 重新构建响应返回给客户端
let response = Response::from_parts(parts, Body::from(body_bytes));
return response;
} else {
// 流式响应或没有 Flow 捕获,直接返回
// 估算 Token 使用量(用于统计)
let estimated_input_tokens = request
.messages
.iter()
.map(|m| {
let content_len = match &m.content {
Some(c) => message_content_len(c),
None => 0,
};
content_len / 4
})
.sum::<usize>() as u32;
let estimated_output_tokens = if is_success { 100u32 } else { 0u32 };
if is_success {
record_token_usage(
&state,
&ctx,
Some(estimated_input_tokens),
Some(estimated_output_tokens),
);
}
// 如果失败,标记 Flow 失败
if let Some(fid) = flow_id {
if !is_success {
let error = FlowError::new(
FlowErrorType::from_status_code(status_code),
"Request failed",
)
.with_status_code(status_code);
state.flow_monitor.fail_flow(&fid, error).await;
}
}
return response;
}
}
// 回退到旧的单凭证模式(仅当选择的 Provider 是 Kiro 时)
@@ -1171,13 +1284,19 @@ pub async fn chat_completions(
// 启动 Flow 捕获(legacy mode)
let llm_request = build_llm_request_from_openai(&request, "/v1/chat/completions", &headers);
// 尝试将 selected_provider 解析为 ProviderType
// 如果是自定义 provider ID,则使用 OpenAI 作为默认值
// 使用实际的 provider ID 构建 Flow Metadata
let provider_type = selected_provider
.parse::<ProviderType>()
.unwrap_or(ProviderType::OpenAI);
let flow_metadata = build_flow_metadata(provider_type, None, None, &headers, &ctx.request_id);
let flow_metadata = build_flow_metadata(
provider_type,
Some(&selected_provider),
None,
None,
&headers,
&ctx.request_id,
);
let flow_id = state
.flow_monitor
.start_flow(llm_request.clone(), flow_metadata.clone())
@@ -1934,12 +2053,25 @@ pub async fn anthropic_messages(
// 尝试将 selected_provider 解析为 ProviderType
// 如果是自定义 provider ID,则使用 OpenAI 作为默认值
// 使用实际的 provider ID 构建 Flow Metadata
let provider_type = selected_provider
.parse::<ProviderType>()
.unwrap_or(ProviderType::OpenAI);
// 从凭证名称中提取 Provider 显示名称
// 凭证名称格式:Some("[降级] DeepSeek") 或 Some("DeepSeek")
let provider_display_name = cred.name.as_ref().and_then(|name| {
// 去掉 "[降级] " 前缀
if name.starts_with("[降级] ") {
Some(&name[9..]) // "[降级] " 是 9 个字节
} else {
Some(name.as_str())
}
});
let flow_metadata = build_flow_metadata(
provider_type,
provider_display_name, // 使用 Provider 显示名称(如 "DeepSeek")
Some(&cred.uuid),
cred.name.as_deref(),
&headers,
@@ -2129,13 +2261,19 @@ pub async fn anthropic_messages(
// 启动 Flow 捕获(legacy mode)
let llm_request = build_llm_request_from_anthropic(&request, "/v1/messages", &headers);
// 尝试将 selected_provider 解析为 ProviderType
// 如果是自定义 provider ID,则使用 OpenAI 作为默认值
// 使用实际的 provider ID 构建 Flow Metadata
let provider_type = selected_provider
.parse::<ProviderType>()
.unwrap_or(ProviderType::OpenAI);
let flow_metadata = build_flow_metadata(provider_type, None, None, &headers, &ctx.request_id);
let flow_metadata = build_flow_metadata(
provider_type,
Some(&selected_provider),
None,
None,
&headers,
&ctx.request_id,
);
let flow_id = state
.flow_monitor
.start_flow(llm_request.clone(), flow_metadata.clone())
+1
View File
@@ -128,6 +128,7 @@ impl E2ETestContext {
error: None,
metadata: FlowMetadata {
provider: provider,
provider_id: None,
credential_name: Some("test-cred".to_string()),
credential_id: Some("test-cred-id".to_string()),
retry_count: 0,
+115 -8
View File
@@ -35,6 +35,12 @@ export function CleanupDialog({
const [cleanupType, setCleanupType] = useState<CleanupType>("ByTime");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showSuccess, setShowSuccess] = useState(false);
const [cleanupResult, setCleanupResult] = useState<{
cleaned_count: number;
cleaned_files: number;
freed_bytes: number;
} | null>(null);
// 时间清理选项
const [retentionDays, setRetentionDays] = useState(7);
@@ -105,14 +111,9 @@ export function CleanupDialog({
const result = await flowMonitorApi.cleanupFlows(request);
// 显示清理结果
const sizeText = formatBytes(result.freed_bytes);
alert(
`清理完成!\n删除了 ${result.cleaned_count} 条记录\n清理了 ${result.cleaned_files} 个文件\n释放了 ${sizeText} 空间`,
);
onSuccess();
onClose();
// 保存清理结果并显示成功提示
setCleanupResult(result);
setShowSuccess(true);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
@@ -120,6 +121,13 @@ export function CleanupDialog({
}
};
const handleSuccessClose = () => {
setShowSuccess(false);
setCleanupResult(null);
onSuccess();
onClose();
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
@@ -149,6 +157,105 @@ export function CleanupDialog({
"IFlow",
];
// 如果显示成功提示,渲染成功对话框
if (showSuccess && cleanupResult) {
return (
<Modal isOpen={isOpen} onClose={handleSuccessClose} maxWidth="max-w-md">
<div className="px-6 py-8">
{/* 成功图标 */}
<div className="flex justify-center mb-6">
<div className="relative">
<div className="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center">
<svg
className="w-10 h-10 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
{/* 动画圆环 */}
<div className="absolute inset-0 w-20 h-20 border-4 border-green-200 dark:border-green-800 rounded-full animate-ping opacity-75"></div>
</div>
</div>
{/* 标题 */}
<h3 className="text-2xl font-bold text-center mb-2">清理完成!</h3>
<p className="text-center text-muted-foreground mb-6">
已成功清理日志数据
</p>
{/* 清理统计 */}
<div className="space-y-3 mb-6">
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<Hash className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<span className="text-sm text-muted-foreground">删除记录</span>
</div>
<span className="text-lg font-semibold">
{cleanupResult.cleaned_count} 条
</span>
</div>
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<svg
className="w-5 h-5 text-purple-600 dark:text-purple-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<span className="text-sm text-muted-foreground">清理文件</span>
</div>
<span className="text-lg font-semibold">
{cleanupResult.cleaned_files} 个
</span>
</div>
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center">
<HardDrive className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<span className="text-sm font-medium text-green-900 dark:text-green-100">
释放空间
</span>
</div>
<span className="text-lg font-bold text-green-600 dark:text-green-400">
{formatBytes(cleanupResult.freed_bytes)}
</span>
</div>
</div>
{/* 确认按钮 */}
<button
onClick={handleSuccessClose}
className="w-full py-3 bg-primary hover:bg-primary/90 text-white font-medium rounded-lg transition-colors"
>
完成
</button>
</div>
</Modal>
);
}
return (
<Modal isOpen={isOpen} onClose={onClose} maxWidth="max-w-2xl">
{/* 标题 */}
+4 -1
View File
@@ -383,7 +383,10 @@ function FlowDetailHeader({
<div className="rounded-lg border bg-card p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<InfoItem label="模型" value={flow.request.model} />
<InfoItem label="提供商" value={flow.metadata.provider} />
<InfoItem
label="提供商"
value={flow.metadata.provider_id || flow.metadata.provider}
/>
<InfoItem label="类型" value={formatFlowType(flow.flow_type)} />
<InfoItem
label="创建时间"
+53 -8
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from "react";
import * as Select from "@radix-ui/react-select";
import {
CheckCircle2,
XCircle,
@@ -21,6 +22,7 @@ import {
Activity,
Bell,
BellOff,
Check,
} from "lucide-react";
import {
flowMonitorApi,
@@ -570,16 +572,59 @@ export function FlowList({
)}
</button>
)}
<select
className="rounded border bg-background px-2 py-1 text-sm"
<Select.Root
value={sortBy}
onChange={(e) => setSortBy(e.target.value as FlowSortBy)}
onValueChange={(value) => setSortBy(value as FlowSortBy)}
>
<option value="created_at">按时间</option>
<option value="duration">按耗时</option>
<option value="total_tokens">按 Token</option>
<option value="model">按模型</option>
</select>
<Select.Trigger className="inline-flex min-w-[120px] 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">
<Select.Value />
<Select.Icon>
<ChevronDown className="h-4 w-4 opacity-50" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content className="relative z-50 min-w-[120px] overflow-hidden rounded-md border border-border bg-white dark:bg-gray-900 text-foreground shadow-lg animate-in fade-in-80 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2">
<Select.Viewport className="p-1 bg-white dark:bg-gray-900">
<Select.Item
value="created_at"
className="relative flex cursor-pointer select-none items-center rounded-sm px-8 py-2 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>按时间</Select.ItemText>
</Select.Item>
<Select.Item
value="duration"
className="relative flex cursor-pointer select-none items-center rounded-sm px-8 py-2 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>按耗时</Select.ItemText>
</Select.Item>
<Select.Item
value="total_tokens"
className="relative flex cursor-pointer select-none items-center rounded-sm px-8 py-2 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>按 Token</Select.ItemText>
</Select.Item>
<Select.Item
value="model"
className="relative flex cursor-pointer select-none items-center rounded-sm px-8 py-2 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>按模型</Select.ItemText>
</Select.Item>
</Select.Viewport>
</Select.Content>
</Select.Portal>
</Select.Root>
<button
onClick={() => setSortDesc(!sortDesc)}
className="rounded border px-2 py-1 text-sm hover:bg-muted"
+1
View File
@@ -333,6 +333,7 @@ export interface RoutingInfo {
*/
export interface FlowMetadata {
provider: ProviderType;
provider_id?: string; // 实际的 provider ID(如 "deepseek", "moonshot" 等)
credential_id?: string;
credential_name?: string;
retry_count: number;