feat: show conversation content in usage logs

This commit is contained in:
boos
2026-06-06 19:36:42 +08:00
parent ef0f91e683
commit cd843465b2
10 changed files with 566 additions and 70 deletions
+3
View File
@@ -405,6 +405,9 @@ CREATE TABLE IF NOT EXISTS proxy_logs (
client_app_name TEXT, client_app_name TEXT,
client_confidence TEXT, client_confidence TEXT,
billing_details TEXT, billing_details TEXT,
content_summary TEXT,
request_content TEXT,
response_content TEXT,
created_at TEXT DEFAULT (datetime('now')) created_at TEXT DEFAULT (datetime('now'))
); );
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN response_content TEXT;
+3
View File
@@ -405,6 +405,9 @@ CREATE TABLE IF NOT EXISTS proxy_logs (
client_app_name TEXT, client_app_name TEXT,
client_confidence TEXT, client_confidence TEXT,
billing_details TEXT, billing_details TEXT,
content_summary TEXT,
request_content TEXT,
response_content TEXT,
created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT) created_at TEXT DEFAULT (CURRENT_TIMESTAMP::TEXT)
); );
@@ -0,0 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN IF NOT EXISTS response_content TEXT;
+317 -29
View File
@@ -45,6 +45,13 @@ pub enum DownstreamSource {
Global, Global,
} }
#[derive(Clone, Debug, Default)]
struct ProxyLogContent {
summary: Option<String>,
request_content: Option<String>,
response_content: Option<String>,
}
// ── Route definitions ───────────────────────────────────────────────── // ── Route definitions ─────────────────────────────────────────────────
/// Build the /v1/ proxy router /// Build the /v1/ proxy router
@@ -101,17 +108,20 @@ async fn messages_handler(
let canonical = crate::transformers::anthropic::messages::parse_messages_request(&body) let canonical = crate::transformers::anthropic::messages::parse_messages_request(&body)
.map_err(AppError::BadRequest)?; .map_err(AppError::BadRequest)?;
let model = canonical.model.clone(); let model = canonical.model.clone();
let log_content = proxy_log_content_from_request(&body);
if canonical.stream { if canonical.stream {
let chunks = let chunks =
execute_messages_stream_with_conductor(state, key_id, model, canonical).await?; execute_messages_stream_with_conductor(state, key_id, model, canonical, log_content)
.await?;
let sse = let sse =
Sse::new(stream::iter(chunks).map(|chunk| { Sse::new(stream::iter(chunks).map(|chunk| {
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk)) Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
})); }));
Ok(sse.into_response()) Ok(sse.into_response())
} else { } else {
let result = execute_messages_with_conductor(state, key_id, model, canonical).await?; let result =
execute_messages_with_conductor(state, key_id, model, canonical, log_content).await?;
Ok(Json(result).into_response()) Ok(Json(result).into_response())
} }
} }
@@ -372,6 +382,7 @@ async fn images_generations(
let selected = TokenRouter::new(state.db.clone()) let selected = TokenRouter::new(state.db.clone())
.resolve_selected(&model) .resolve_selected(&model)
.await?; .await?;
let request_log_body = body.clone();
let body = body_with_selected_model(body, &selected); let body = body_with_selected_model(body, &selected);
let result = executors::execute_image_generation( let result = executors::execute_image_generation(
state.clone(), state.clone(),
@@ -394,6 +405,7 @@ async fn images_generations(
cost, cost,
1, 1,
false, false,
proxy_log_content_from_request_response(&request_log_body, Some(&payload)),
) )
.await; .await;
record_billing(&state, key_id, &model, 0, 0, cost).await; record_billing(&state, key_id, &model, 0, 0, cost).await;
@@ -410,6 +422,7 @@ async fn images_generations(
Some(&err.to_string()), Some(&err.to_string()),
1, 1,
false, false,
proxy_log_content_from_request(&request_log_body),
) )
.await; .await;
Err(err) Err(err)
@@ -437,6 +450,7 @@ async fn images_edits(
let mut fields: Vec<executors::MultipartField> = Vec::new(); let mut fields: Vec<executors::MultipartField> = Vec::new();
let mut model: Option<String> = None; let mut model: Option<String> = None;
let mut log_fields = serde_json::Map::new();
while let Some(field) = multipart while let Some(field) = multipart
.next_field() .next_field()
@@ -481,6 +495,7 @@ async fn images_edits(
if name == "model" { if name == "model" {
model = Some(text.clone()); model = Some(text.clone());
} }
log_fields.insert(name.clone(), Value::String(text.clone()));
fields.push(executors::MultipartField { fields.push(executors::MultipartField {
name, name,
bytes: None, bytes: None,
@@ -492,6 +507,7 @@ async fn images_edits(
} }
let model = model.ok_or_else(|| AppError::BadRequest("model is required".into()))?; let model = model.ok_or_else(|| AppError::BadRequest("model is required".into()))?;
let request_log_body = Value::Object(log_fields);
let started_at = Instant::now(); let started_at = Instant::now();
let selected = TokenRouter::new(state.db.clone()) let selected = TokenRouter::new(state.db.clone())
.resolve_selected(&model) .resolve_selected(&model)
@@ -528,6 +544,7 @@ async fn images_edits(
cost, cost,
1, 1,
false, false,
proxy_log_content_from_request_response(&request_log_body, Some(&payload)),
) )
.await; .await;
record_billing(&state, key_id, &model, 0, 0, cost).await; record_billing(&state, key_id, &model, 0, 0, cost).await;
@@ -544,6 +561,7 @@ async fn images_edits(
Some(&err.to_string()), Some(&err.to_string()),
1, 1,
false, false,
proxy_log_content_from_request(&request_log_body),
) )
.await; .await;
Err(err) Err(err)
@@ -665,6 +683,168 @@ fn estimate_request_cost(
} }
} }
fn push_text_part(parts: &mut Vec<String>, text: &str) {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
if !normalized.is_empty() {
parts.push(normalized);
}
}
fn collect_text_from_value(value: &Value, parts: &mut Vec<String>) {
match value {
Value::String(text) => push_text_part(parts, text),
Value::Array(items) => {
for item in items {
collect_text_from_value(item, parts);
}
}
Value::Object(object) => {
if let Some(text) = object
.get("text")
.or_else(|| object.get("content"))
.or_else(|| object.get("prompt"))
.or_else(|| object.get("input"))
.and_then(Value::as_str)
{
push_text_part(parts, text);
}
if let Some(parts_value) = object
.get("parts")
.or_else(|| object.get("content"))
.or_else(|| object.get("input"))
{
if !parts_value.is_string() {
collect_text_from_value(parts_value, parts);
}
}
}
_ => {}
}
}
fn trim_chars(value: &str, max_chars: usize) -> String {
let mut output = String::new();
for (index, ch) in value.chars().enumerate() {
if index >= max_chars {
output.push('…');
return output;
}
output.push(ch);
}
output
}
fn join_content_parts(parts: Vec<String>, max_chars: usize) -> Option<String> {
let mut deduped = Vec::new();
for part in parts {
let clean = part.trim();
if clean.is_empty() || deduped.iter().any(|item: &String| item == clean) {
continue;
}
deduped.push(clean.to_string());
}
if deduped.is_empty() {
return None;
}
Some(trim_chars(&deduped.join("\n\n"), max_chars))
}
fn extract_request_content(body: &Value) -> Option<String> {
let mut parts = Vec::new();
if let Some(prompt) = body.get("prompt").and_then(Value::as_str) {
push_text_part(&mut parts, prompt);
}
if let Some(input) = body.get("input") {
collect_text_from_value(input, &mut parts);
}
if let Some(messages) = body.get("messages").and_then(Value::as_array) {
for message in messages {
let role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or_default();
if matches!(role, "assistant" | "tool") {
continue;
}
if let Some(content) = message.get("content") {
collect_text_from_value(content, &mut parts);
}
}
}
if let Some(system) = body.get("system") {
collect_text_from_value(system, &mut parts);
}
if let Some(contents) = body.get("contents").and_then(Value::as_array) {
for content in contents {
collect_text_from_value(content, &mut parts);
}
}
join_content_parts(parts, 4000)
}
fn extract_response_content(response: &Value) -> Option<String> {
let mut parts = Vec::new();
if let Some(output) = response.get("output") {
collect_text_from_value(output, &mut parts);
}
if let Some(content) = response.get("content") {
collect_text_from_value(content, &mut parts);
}
if let Some(text) = response.get("text").and_then(Value::as_str) {
push_text_part(&mut parts, text);
}
if let Some(choices) = response.get("choices").and_then(Value::as_array) {
for choice in choices {
if let Some(message) = choice.get("message") {
collect_text_from_value(message.get("content").unwrap_or(message), &mut parts);
}
if let Some(text) = choice.get("text").and_then(Value::as_str) {
push_text_part(&mut parts, text);
}
}
}
join_content_parts(parts, 4000)
}
fn proxy_log_content_from_request_response(
request: &Value,
response: Option<&Value>,
) -> ProxyLogContent {
let request_content = extract_request_content(request);
let response_content = response.and_then(extract_response_content);
let summary = request_content
.as_deref()
.or(response_content.as_deref())
.map(|text| trim_chars(text, 240));
ProxyLogContent {
summary,
request_content,
response_content,
}
}
fn proxy_log_content_from_request(request: &Value) -> ProxyLogContent {
proxy_log_content_from_request_response(request, None)
}
fn proxy_log_content_with_response(
mut content: ProxyLogContent,
response: Option<&Value>,
) -> ProxyLogContent {
content.response_content = response.and_then(extract_response_content);
content.summary = content
.request_content
.as_deref()
.or(content.response_content.as_deref())
.map(|text| trim_chars(text, 240));
content
}
async fn record_proxy_log_success( async fn record_proxy_log_success(
state: &AppState, state: &AppState,
key_id: Option<i64>, key_id: Option<i64>,
@@ -676,14 +856,15 @@ async fn record_proxy_log_success(
cost: f64, cost: f64,
attempts: usize, attempts: usize,
is_stream: bool, is_stream: bool,
content: ProxyLogContent,
) { ) {
if let Err(err) = sqlx::query( if let Err(err) = sqlx::query(
"INSERT INTO proxy_logs ( "INSERT INTO proxy_logs (
route_id, channel_id, account_id, downstream_api_key_id, route_id, channel_id, account_id, downstream_api_key_id,
model_requested, model_actual, status, http_status, model_requested, model_actual, status, http_status,
latency_ms, prompt_tokens, completion_tokens, total_tokens, estimated_cost, latency_ms, prompt_tokens, completion_tokens, total_tokens, estimated_cost,
retry_count, is_stream retry_count, is_stream, content_summary, request_content, response_content
) VALUES (?, ?, ?, ?, ?, ?, 'success', 200, ?, ?, ?, ?, ?, ?, ?)", ) VALUES (?, ?, ?, ?, ?, ?, 'success', 200, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
) )
.bind(selected.route_id) .bind(selected.route_id)
.bind(selected.channel_id) .bind(selected.channel_id)
@@ -698,6 +879,9 @@ async fn record_proxy_log_success(
.bind(cost) .bind(cost)
.bind(attempts.saturating_sub(1) as i64) .bind(attempts.saturating_sub(1) as i64)
.bind(if is_stream { 1 } else { 0 }) .bind(if is_stream { 1 } else { 0 })
.bind(content.summary.as_deref())
.bind(content.request_content.as_deref())
.bind(content.response_content.as_deref())
.execute(&*state.db) .execute(&*state.db)
.await .await
{ {
@@ -720,13 +904,15 @@ async fn record_proxy_log_failure(
error_message: Option<&str>, error_message: Option<&str>,
attempts: usize, attempts: usize,
is_stream: bool, is_stream: bool,
content: ProxyLogContent,
) { ) {
if let Err(err) = sqlx::query( if let Err(err) = sqlx::query(
"INSERT INTO proxy_logs ( "INSERT INTO proxy_logs (
route_id, channel_id, account_id, downstream_api_key_id, route_id, channel_id, account_id, downstream_api_key_id,
model_requested, model_actual, status, http_status, model_requested, model_actual, status, http_status,
latency_ms, error_message, retry_count, is_stream latency_ms, error_message, retry_count, is_stream,
) VALUES (?, ?, ?, ?, ?, ?, 'error', ?, ?, ?, ?, ?)", content_summary, request_content, response_content
) VALUES (?, ?, ?, ?, ?, ?, 'error', ?, ?, ?, ?, ?, ?, ?, ?)",
) )
.bind(selected.and_then(|item| item.route_id)) .bind(selected.and_then(|item| item.route_id))
.bind(selected.map(|item| item.channel_id)) .bind(selected.map(|item| item.channel_id))
@@ -739,6 +925,9 @@ async fn record_proxy_log_failure(
.bind(error_message.unwrap_or("proxy request failed")) .bind(error_message.unwrap_or("proxy request failed"))
.bind(attempts.saturating_sub(1) as i64) .bind(attempts.saturating_sub(1) as i64)
.bind(if is_stream { 1 } else { 0 }) .bind(if is_stream { 1 } else { 0 })
.bind(content.summary.as_deref())
.bind(content.request_content.as_deref())
.bind(content.response_content.as_deref())
.execute(&*state.db) .execute(&*state.db)
.await .await
{ {
@@ -879,6 +1068,7 @@ async fn execute_messages_with_conductor(
key_id: Option<i64>, key_id: Option<i64>,
model: String, model: String,
request: crate::transformers::canonical::CanonicalRequest, request: crate::transformers::canonical::CanonicalRequest,
log_content: ProxyLogContent,
) -> AppResult<Value> { ) -> AppResult<Value> {
let started_at = Instant::now(); let started_at = Instant::now();
let conductor = build_conductor(&state); let conductor = build_conductor(&state);
@@ -935,6 +1125,7 @@ async fn execute_messages_with_conductor(
cost, cost,
attempts, attempts,
false, false,
proxy_log_content_with_response(log_content, Some(&response)),
) )
.await; .await;
record_billing( record_billing(
@@ -965,6 +1156,7 @@ async fn execute_messages_with_conductor(
raw_error_text.as_deref(), raw_error_text.as_deref(),
attempts, attempts,
false, false,
log_content,
) )
.await; .await;
Err(AppError::UpstreamError(format!( Err(AppError::UpstreamError(format!(
@@ -981,6 +1173,7 @@ async fn execute_messages_stream_with_conductor(
key_id: Option<i64>, key_id: Option<i64>,
model: String, model: String,
request: crate::transformers::canonical::CanonicalRequest, request: crate::transformers::canonical::CanonicalRequest,
log_content: ProxyLogContent,
) -> AppResult<Vec<String>> { ) -> AppResult<Vec<String>> {
let started_at = Instant::now(); let started_at = Instant::now();
let conductor = build_conductor(&state); let conductor = build_conductor(&state);
@@ -1055,6 +1248,7 @@ async fn execute_messages_stream_with_conductor(
raw_error_text.as_deref(), raw_error_text.as_deref(),
attempts, attempts,
true, true,
log_content,
) )
.await; .await;
return Err(AppError::UpstreamError(format!( return Err(AppError::UpstreamError(format!(
@@ -1077,6 +1271,7 @@ async fn execute_messages_stream_with_conductor(
cost, cost,
attempts, attempts,
true, true,
log_content,
) )
.await; .await;
record_billing(&billing_state, key_id, &billing_model, 0, 0, cost).await; record_billing(&billing_state, key_id, &billing_model, 0, 0, cost).await;
@@ -1090,6 +1285,7 @@ async fn execute_chat_completions_with_conductor(
body: Value, body: Value,
) -> AppResult<Value> { ) -> AppResult<Value> {
let started_at = Instant::now(); let started_at = Instant::now();
let request_log_body = body.clone();
let conductor = build_conductor(&state); let conductor = build_conductor(&state);
let billing_state = state.clone(); let billing_state = state.clone();
let billing_model = model.clone(); let billing_model = model.clone();
@@ -1145,6 +1341,7 @@ async fn execute_chat_completions_with_conductor(
cost, cost,
attempts, attempts,
false, false,
proxy_log_content_from_request_response(&request_log_body, Some(&response)),
) )
.await; .await;
record_billing( record_billing(
@@ -1175,6 +1372,7 @@ async fn execute_chat_completions_with_conductor(
raw_error_text.as_deref(), raw_error_text.as_deref(),
attempts, attempts,
false, false,
proxy_log_content_from_request(&request_log_body),
) )
.await; .await;
Err(AppError::UpstreamError(format!( Err(AppError::UpstreamError(format!(
@@ -1193,6 +1391,7 @@ async fn execute_chat_completions_stream_with_conductor(
body: Value, body: Value,
) -> AppResult<Vec<String>> { ) -> AppResult<Vec<String>> {
let started_at = Instant::now(); let started_at = Instant::now();
let request_log_body = body.clone();
let conductor = build_conductor(&state); let conductor = build_conductor(&state);
let billing_state = state.clone(); let billing_state = state.clone();
let billing_model = model.clone(); let billing_model = model.clone();
@@ -1273,6 +1472,7 @@ async fn execute_chat_completions_stream_with_conductor(
raw_error_text.as_deref(), raw_error_text.as_deref(),
attempts, attempts,
true, true,
proxy_log_content_from_request(&request_log_body),
) )
.await; .await;
return Err(AppError::UpstreamError(format!( return Err(AppError::UpstreamError(format!(
@@ -1318,6 +1518,7 @@ async fn execute_chat_completions_stream_with_conductor(
cost, cost,
attempts, attempts,
true, true,
proxy_log_content_from_request(&request_log_body),
) )
.await; .await;
record_billing( record_billing(
@@ -1332,29 +1533,14 @@ async fn execute_chat_completions_stream_with_conductor(
Ok(chunks) Ok(chunks)
} }
fn conductor_result_to_app_result(result: ExecuteResult) -> AppResult<Value> {
match result {
ExecuteResult::Success { response, .. } => Ok(response),
ExecuteResult::Failure {
reason,
status,
raw_error_text,
attempts,
..
} => Err(AppError::UpstreamError(format!(
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
status,
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
))),
}
}
async fn execute_responses_with_conductor( async fn execute_responses_with_conductor(
state: AppState, state: AppState,
key_id: Option<i64>, key_id: Option<i64>,
model: String, model: String,
body: Value, body: Value,
) -> AppResult<Value> { ) -> AppResult<Value> {
let started_at = Instant::now();
let request_log_body = body.clone();
let conductor = build_conductor(&state); let conductor = build_conductor(&state);
let billing_state = state.clone(); let billing_state = state.clone();
let billing_model = model.clone(); let billing_model = model.clone();
@@ -1392,15 +1578,62 @@ async fn execute_responses_with_conductor(
}) })
.await; .await;
let response = conductor_result_to_app_result(result)?; let (selected, response, attempts) = match result {
ExecuteResult::Success {
selected,
response,
attempts,
} => (selected, response, attempts),
ExecuteResult::Failure {
reason,
selected,
status,
raw_error_text,
attempts,
} => {
record_proxy_log_failure(
&billing_state,
key_id,
selected.as_ref(),
&billing_model,
status,
Some(elapsed_ms_since(started_at)),
raw_error_text.as_deref(),
attempts,
false,
proxy_log_content_from_request(&request_log_body),
)
.await;
return Err(AppError::UpstreamError(format!(
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
status,
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
)));
}
};
let (prompt, completion) = extract_usage(&response); let (prompt, completion) = extract_usage(&response);
let cost = estimate_request_cost(&selected, prompt, completion);
record_proxy_log_success(
&billing_state,
key_id,
&selected,
&billing_model,
Some(elapsed_ms_since(started_at)),
prompt,
completion,
cost,
attempts,
false,
proxy_log_content_from_request_response(&request_log_body, Some(&response)),
)
.await;
record_billing( record_billing(
&billing_state, &billing_state,
key_id, key_id,
&billing_model, &billing_model,
prompt, prompt,
completion, completion,
0.0, cost,
) )
.await; .await;
Ok(response) Ok(response)
@@ -1412,6 +1645,8 @@ async fn execute_responses_stream_with_conductor(
model: String, model: String,
body: Value, body: Value,
) -> AppResult<Vec<String>> { ) -> AppResult<Vec<String>> {
let started_at = Instant::now();
let request_log_body = body.clone();
let conductor = build_conductor(&state); let conductor = build_conductor(&state);
let billing_state = state.clone(); let billing_state = state.clone();
let billing_model = model.clone(); let billing_model = model.clone();
@@ -1447,20 +1682,58 @@ async fn execute_responses_stream_with_conductor(
}) })
.await; .await;
let chunks = match conductor_result_to_app_result(result)? { let (selected, attempts, chunks) = match result {
Value::Array(chunks) => chunks ExecuteResult::Success {
selected,
response,
attempts,
} => match response {
Value::Array(chunks) => (
selected,
attempts,
chunks
.into_iter() .into_iter()
.map(|chunk| { .map(|chunk| {
chunk.as_str().map(str::to_string).ok_or_else(|| { chunk.as_str().map(str::to_string).ok_or_else(|| {
AppError::UpstreamError("Invalid streaming response from conductor".into()) AppError::UpstreamError(
"Invalid streaming response from conductor".into(),
)
}) })
}) })
.collect::<AppResult<Vec<String>>>()?, .collect::<AppResult<Vec<String>>>()?,
),
_ => { _ => {
return Err(AppError::UpstreamError( return Err(AppError::UpstreamError(
"Invalid streaming response from conductor".into(), "Invalid streaming response from conductor".into(),
)) ))
} }
},
ExecuteResult::Failure {
reason,
selected,
status,
raw_error_text,
attempts,
} => {
record_proxy_log_failure(
&billing_state,
key_id,
selected.as_ref(),
&billing_model,
status,
Some(elapsed_ms_since(started_at)),
raw_error_text.as_deref(),
attempts,
true,
proxy_log_content_from_request(&request_log_body),
)
.await;
return Err(AppError::UpstreamError(format!(
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
status,
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
)));
}
}; };
// Responses API streams `response.completed` events that contain a // Responses API streams `response.completed` events that contain a
@@ -1485,13 +1758,28 @@ async fn execute_responses_stream_with_conductor(
} }
}) })
.unwrap_or((0, 0)); .unwrap_or((0, 0));
let cost = estimate_request_cost(&selected, prompt, completion);
record_proxy_log_success(
&billing_state,
key_id,
&selected,
&billing_model,
Some(elapsed_ms_since(started_at)),
prompt,
completion,
cost,
attempts,
true,
proxy_log_content_from_request(&request_log_body),
)
.await;
record_billing( record_billing(
&billing_state, &billing_state,
key_id, key_id,
&billing_model, &billing_model,
prompt, prompt,
completion, completion,
0.0, cost,
) )
.await; .await;
Ok(chunks) Ok(chunks)
+5
View File
@@ -427,6 +427,7 @@ async fn proxy_logs(
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost, l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream, l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence, l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.content_summary, l.request_content, l.response_content,
l.created_at, l.created_at,
a.site_id AS site_id, a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username, COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
@@ -506,6 +507,9 @@ pub struct ProxyLogRow {
client_app_id: Option<String>, client_app_id: Option<String>,
client_app_name: Option<String>, client_app_name: Option<String>,
client_confidence: Option<String>, client_confidence: Option<String>,
content_summary: Option<String>,
request_content: Option<String>,
response_content: Option<String>,
created_at: Option<String>, created_at: Option<String>,
} }
@@ -522,6 +526,7 @@ async fn proxy_log_detail(
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost, l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream, l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence, l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.content_summary, l.request_content, l.response_content,
l.created_at, l.created_at,
a.site_id AS site_id, a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username, COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
+33 -2
View File
@@ -63,7 +63,19 @@ async fn user_picture_log_items_limited(
"pending" => "pending", "pending" => "pending",
_ => "running", _ => "running",
}; };
json!({ let content_summary = result
.get("prompt")
.and_then(Value::as_str)
.or_else(|| result.get("input").and_then(Value::as_str))
.or_else(|| result.get("message").and_then(Value::as_str))
.map(|text| text.chars().take(240).collect::<String>());
let request_content = result
.get("prompt")
.and_then(Value::as_str)
.or_else(|| result.get("input").and_then(Value::as_str))
.or_else(|| result.get("message").and_then(Value::as_str))
.map(str::to_string);
let mut item = json!({
"id": task.id, "id": task.id,
"route_id": Value::Null, "route_id": Value::Null,
"channel_id": Value::Null, "channel_id": Value::Null,
@@ -103,7 +115,19 @@ async fn user_picture_log_items_limited(
"progress_total": task.progress_total, "progress_total": task.progress_total,
"status_label": status, "status_label": status,
"result": result, "result": result,
}) });
if let Some(object) = item.as_object_mut() {
object.insert(
"content_summary".to_string(),
content_summary.map(Value::String).unwrap_or(Value::Null),
);
object.insert(
"request_content".to_string(),
request_content.map(Value::String).unwrap_or(Value::Null),
);
object.insert("response_content".to_string(), Value::Null);
}
item
}) })
.collect()) .collect())
} }
@@ -181,6 +205,9 @@ async fn user_usage_log_items_limited(
"client_app_id": "picture-agent", "client_app_id": "picture-agent",
"client_app_name": "Picture Agent", "client_app_name": "Picture Agent",
"client_confidence": "high", "client_confidence": "high",
"content_summary": Value::Null,
"request_content": Value::Null,
"response_content": Value::Null,
"created_at": row.try_get::<Option<String>, _>("created_at").ok().flatten(), "created_at": row.try_get::<Option<String>, _>("created_at").ok().flatten(),
"source": "usage-logs", "source": "usage-logs",
"task_type": "usage-log", "task_type": "usage-log",
@@ -202,6 +229,9 @@ fn user_log_text(item: &Value) -> String {
item.get("site_name").and_then(Value::as_str), item.get("site_name").and_then(Value::as_str),
item.get("site_url").and_then(Value::as_str), item.get("site_url").and_then(Value::as_str),
item.get("error_message").and_then(Value::as_str), item.get("error_message").and_then(Value::as_str),
item.get("content_summary").and_then(Value::as_str),
item.get("request_content").and_then(Value::as_str),
item.get("response_content").and_then(Value::as_str),
item.get("message").and_then(Value::as_str), item.get("message").and_then(Value::as_str),
item.get("title").and_then(Value::as_str), item.get("title").and_then(Value::as_str),
item.get("source").and_then(Value::as_str), item.get("source").and_then(Value::as_str),
@@ -315,6 +345,7 @@ async fn load_user_log_items_limited(
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost, l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream, l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence, l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.content_summary, l.request_content, l.response_content,
l.created_at, l.created_at,
a.site_id AS site_id, a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username, COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
+6
View File
@@ -392,6 +392,9 @@ function normalizeProxyLogRow(row: any): any {
clientAppId: row.clientAppId ?? row.client_app_id ?? null, clientAppId: row.clientAppId ?? row.client_app_id ?? null,
clientAppName: row.clientAppName ?? row.client_app_name ?? null, clientAppName: row.clientAppName ?? row.client_app_name ?? null,
clientConfidence: row.clientConfidence ?? row.client_confidence ?? null, clientConfidence: row.clientConfidence ?? row.client_confidence ?? null,
contentSummary: row.contentSummary ?? row.content_summary ?? null,
requestContent: row.requestContent ?? row.request_content ?? null,
responseContent: row.responseContent ?? row.response_content ?? null,
createdAt: row.createdAt ?? row.created_at ?? null, createdAt: row.createdAt ?? row.created_at ?? null,
}; };
} }
@@ -784,6 +787,9 @@ export type ProxyLogListItem = {
promptTokens?: number | null; promptTokens?: number | null;
completionTokens?: number | null; completionTokens?: number | null;
estimatedCost?: number | null; estimatedCost?: number | null;
contentSummary?: string | null;
requestContent?: string | null;
responseContent?: string | null;
}; };
export type ProxyLogDetail = ProxyLogListItem & { export type ProxyLogDetail = ProxyLogListItem & {
+89
View File
@@ -276,6 +276,88 @@ function formatLatency(ms?: number | null) {
return `${ms}ms`; return `${ms}ms`;
} }
function renderContentSummary(log: ProxyLogRenderItem) {
const summary = log.contentSummary?.trim();
if (!summary) return null;
return (
<div
style={{
color: "var(--color-text-muted)",
fontSize: 11,
lineHeight: 1.45,
maxWidth: 360,
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
title={summary}
>
内容:{summary}
</div>
);
}
function renderContentDetailBlock(log: ProxyLogRenderItem) {
const sections = [
{ label: "请求内容", value: log.requestContent?.trim() },
{ label: "响应内容", value: log.responseContent?.trim() },
].filter(
(section): section is { label: string; value: string } =>
Boolean(section.value),
);
if (sections.length === 0) return null;
return (
<div style={{ display: "grid", gap: 10, marginTop: 10 }}>
<div
style={{
fontSize: 12,
fontWeight: 700,
color: "var(--color-text-primary)",
}}
>
对话内容
</div>
{sections.map((section) => (
<div key={section.label} style={{ display: "grid", gap: 6 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--color-text-muted)",
}}
>
{section.label}
</span>
<button
type="button"
className="btn btn-link"
style={{ fontSize: 11, padding: 0 }}
onClick={(event) => {
event.stopPropagation();
void copyTextToClipboard(section.value);
}}
>
复制
</button>
</div>
<pre style={debugCodeBlockStyle}>{section.value}</pre>
</div>
))}
</div>
);
}
function latencyColor(ms?: number | null) { function latencyColor(ms?: number | null) {
if (typeof ms !== "number" || !Number.isFinite(ms)) { if (typeof ms !== "number" || !Number.isFinite(ms)) {
return "var(--color-text-muted)"; return "var(--color-text-muted)";
@@ -2628,6 +2710,7 @@ export default function ProxyLogs() {
const firstByteLabel = formatFirstByteLabel( const firstByteLabel = formatFirstByteLabel(
detailLog.firstByteLatencyMs, detailLog.firstByteLatencyMs,
); );
const contentDetailBlock = renderContentDetailBlock(detailLog);
return ( return (
<MobileCard <MobileCard
@@ -2731,6 +2814,7 @@ export default function ProxyLogs() {
</div> </div>
</div> </div>
</div> </div>
{renderContentSummary(detailLog)}
{isExpanded ? ( {isExpanded ? (
<div className="mobile-card-extra"> <div className="mobile-card-extra">
<MobileField <MobileField
@@ -2811,6 +2895,7 @@ export default function ProxyLogs() {
))} ))}
</div> </div>
)} )}
{contentDetailBlock}
{detail && pathMeta.errorMessage.trim().length > 0 && ( {detail && pathMeta.errorMessage.trim().length > 0 && (
<div style={{ color: "var(--color-danger)" }}> <div style={{ color: "var(--color-danger)" }}>
{pathMeta.errorMessage} {pathMeta.errorMessage}
@@ -2863,6 +2948,7 @@ export default function ProxyLogs() {
const firstByteLabel = formatFirstByteLabel( const firstByteLabel = formatFirstByteLabel(
detailLog.firstByteLatencyMs, detailLog.firstByteLatencyMs,
); );
const contentDetailBlock = renderContentDetailBlock(detailLog);
return ( return (
<React.Fragment key={log.id}> <React.Fragment key={log.id}>
@@ -2968,6 +3054,7 @@ export default function ProxyLogs() {
) : null} ) : null}
</div> </div>
) : null} ) : null}
{renderContentSummary(detailLog)}
</div> </div>
</td> </td>
<td <td
@@ -3426,6 +3513,8 @@ export default function ProxyLogs() {
)} )}
</div> </div>
{contentDetailBlock}
<div <div
style={{ style={{
display: "flex", display: "flex",
+74 -9
View File
@@ -3,7 +3,7 @@ import { userApi } from '../api.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
interface LogItem { interface LogItem {
id: number; id: number | string;
model_requested: string | null; model_requested: string | null;
model_actual: string | null; model_actual: string | null;
status: string | null; status: string | null;
@@ -16,6 +16,9 @@ interface LogItem {
estimated_cost: number | null; estimated_cost: number | null;
error_message: string | null; error_message: string | null;
is_stream: boolean | null; is_stream: boolean | null;
content_summary?: string | null;
request_content?: string | null;
response_content?: string | null;
created_at: string | null; created_at: string | null;
} }
@@ -60,6 +63,63 @@ function latencyTextColor(log: LogItem): string {
return latencyColor(log.latency_ms); return latencyColor(log.latency_ms);
} }
function renderContentPreview(log: LogItem) {
const summary = log.content_summary?.trim();
if (!summary) return null;
return (
<div
style={{
marginTop: 4,
maxWidth: 420,
color: 'var(--color-text-muted)',
fontSize: 11,
lineHeight: 1.45,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
title={summary}
>
内容:{summary}
</div>
);
}
function renderContentDetail(log: LogItem) {
const sections = [
{ label: '请求内容', value: log.request_content?.trim() },
{ label: '响应内容', value: log.response_content?.trim() },
].filter((section): section is { label: string; value: string } => Boolean(section.value));
if (sections.length === 0) return null;
return (
<div style={{ gridColumn: '1 / -1', display: 'grid', gap: 10 }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 600 }}>对话内容</div>
{sections.map((section) => (
<div key={section.label} style={{ display: 'grid', gap: 4 }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{section.label}</div>
<pre style={{
margin: 0,
padding: 10,
maxHeight: 220,
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
border: '1px solid var(--color-border)',
borderRadius: 6,
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-mono)',
fontSize: 12,
lineHeight: 1.5,
}}>{section.value}</pre>
</div>
))}
</div>
);
}
export default function UsageRecords() { export default function UsageRecords() {
const [logs, setLogs] = useState<LogItem[]>([]); const [logs, setLogs] = useState<LogItem[]>([]);
const [summary, setSummary] = useState<Summary | null>(null); const [summary, setSummary] = useState<Summary | null>(null);
@@ -70,7 +130,7 @@ export default function UsageRecords() {
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [dateFrom, setDateFrom] = useState(''); const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState(''); const [dateTo, setDateTo] = useState('');
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const toast = useToast(); const toast = useToast();
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -162,7 +222,7 @@ export default function UsageRecords() {
</svg> </svg>
<input <input
style={{ width: '100%', paddingLeft: 30, paddingRight: 10, height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, boxSizing: 'border-box' }} style={{ width: '100%', paddingLeft: 30, paddingRight: 10, height: 34, border: '1px solid var(--color-border)', borderRadius: 6, background: 'var(--color-bg)', color: 'var(--color-text-primary)', fontSize: 13, boxSizing: 'border-box' }}
placeholder="搜索模型名称..." placeholder="搜索模型、提示词或错误..."
value={search} value={search}
onChange={(e) => handleSearchChange(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
/> />
@@ -233,11 +293,13 @@ export default function UsageRecords() {
</td> </td>
</tr> </tr>
) : ( ) : (
logs.map((log) => ( logs.map((log) => {
<React.Fragment key={log.id}> const logKey = String(log.id);
return (
<React.Fragment key={logKey}>
<tr <tr
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)} onClick={() => setExpandedId(expandedId === logKey ? null : logKey)}
> >
<td style={{ fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}> <td style={{ fontSize: 12, color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{formatDate(log.created_at)} {formatDate(log.created_at)}
@@ -247,6 +309,7 @@ export default function UsageRecords() {
{log.model_actual && log.model_actual !== log.model_requested && ( {log.model_actual && log.model_actual !== log.model_requested && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div> <div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div>
)} )}
{renderContentPreview(log)}
</td> </td>
<td> <td>
<span className={`badge badge-${log.status === 'success' ? 'success' : 'error'}`} style={{ fontSize: 11 }}> <span className={`badge badge-${log.status === 'success' ? 'success' : 'error'}`} style={{ fontSize: 11 }}>
@@ -268,14 +331,14 @@ export default function UsageRecords() {
</td> </td>
<td> <td>
<svg <svg
style={{ display: 'block', color: 'var(--color-text-muted)', transition: 'transform 0.15s', transform: expandedId === log.id ? 'rotate(180deg)' : 'none' }} style={{ display: 'block', color: 'var(--color-text-muted)', transition: 'transform 0.15s', transform: expandedId === logKey ? 'rotate(180deg)' : 'none' }}
width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
</td> </td>
</tr> </tr>
{expandedId === log.id && ( {expandedId === logKey && (
<tr> <tr>
<td colSpan={7} style={{ padding: 0, background: 'var(--color-bg-alt)' }}> <td colSpan={7} style={{ padding: 0, background: 'var(--color-bg-alt)' }}>
<div style={{ padding: '14px 20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, borderBottom: '1px solid var(--color-border)' }}> <div style={{ padding: '14px 20px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, borderBottom: '1px solid var(--color-border)' }}>
@@ -296,12 +359,14 @@ export default function UsageRecords() {
<div style={{ fontSize: 12, color: 'var(--color-danger)', wordBreak: 'break-all' }}>{log.error_message}</div> <div style={{ fontSize: 12, color: 'var(--color-danger)', wordBreak: 'break-all' }}>{log.error_message}</div>
</div> </div>
)} )}
{renderContentDetail(log)}
</div> </div>
</td> </td>
</tr> </tr>
)} )}
</React.Fragment> </React.Fragment>
)) );
})
)} )}
</tbody> </tbody>
</table> </table>