feat: optimize usage body viewing and provider card layout

This commit is contained in:
elky
2026-09-08 02:49:06 +08:00
parent 7aa0c89244
commit 099b810a2f
36 changed files with 3113 additions and 739 deletions
@@ -1593,6 +1593,19 @@ impl GatewayDataState {
}
}
pub(crate) async fn read_request_usage_body_payload(
&self,
body_ref: &str,
) -> Result<
Option<aether_data_contracts::repository::usage::StoredUsageBodyPayload>,
DataLayerError,
> {
match &self.usage_reader {
Some(repository) => repository.read_body_payload(body_ref).await,
None => Ok(None),
}
}
pub(crate) async fn list_usage_audits(
&self,
query: &UsageAuditListQuery,
@@ -17,7 +17,10 @@ use aether_admin::observability::usage::{
admin_usage_bad_request_response, admin_usage_data_unavailable_response,
admin_usage_provider_key_name, ADMIN_USAGE_DATA_UNAVAILABLE_DETAIL,
};
use aether_data_contracts::repository::usage::{StoredRequestUsageAudit, UsageBodyField};
use aether_data_contracts::repository::usage::{
canonical_usage_body_ref_for, StoredRequestUsageAudit, StoredUsageBodyPayload,
UsageBodyCaptureState, UsageBodyField, MAX_DECOMPRESSED_USAGE_JSON_BYTES,
};
use axum::{
body::Body,
http,
@@ -28,9 +31,59 @@ use serde_json::{json, Value};
use std::collections::BTreeMap;
use tokio::try_join;
#[derive(Default)]
struct AdminUsageDetailBodyValue {
value: Option<Value>,
load_failed: bool,
error_code: Option<&'static str>,
}
impl AdminUsageDetailBodyValue {
fn resolved(
item: &StoredRequestUsageAudit,
field: UsageBodyField,
value: Option<Value>,
) -> Self {
let missing = value.is_none()
&& item
.body_capture_result(field, item.body_value(field))
.available;
Self {
value,
error_code: missing.then_some("missing"),
}
}
}
fn admin_usage_body_load_error_code(error: &GatewayError) -> &'static str {
if let GatewayError::Internal(message) = error {
if message.contains("decompressed usage json exceeds ")
|| message.contains("encoded usage json exceeds ")
{
return "too_large";
}
if message.contains("failed to decompress usage json:")
|| message.contains("failed to parse decompressed usage json:")
{
return "decode_failed";
}
}
"storage_unavailable"
}
async fn resolve_admin_usage_detail_field(
state: &AdminAppState<'_>,
item: &StoredRequestUsageAudit,
field: UsageBodyField,
selected_field: Option<UsageBodyField>,
) -> AdminUsageDetailBodyValue {
if selected_field.is_some_and(|selected| selected != field) {
return AdminUsageDetailBodyValue::default();
}
if field == UsageBodyField::RequestBody {
resolve_admin_usage_detail_request_body(state, item).await
} else {
resolve_admin_usage_detail_body_value(state, item, field).await
}
}
async fn resolve_admin_usage_detail_request_body(
@@ -38,10 +91,7 @@ async fn resolve_admin_usage_detail_request_body(
item: &StoredRequestUsageAudit,
) -> AdminUsageDetailBodyValue {
match admin_usage_resolve_request_capture_body_for_item(state, item, None).await {
Ok(body) => AdminUsageDetailBodyValue {
value: body,
load_failed: false,
},
Ok(body) => AdminUsageDetailBodyValue::resolved(item, UsageBodyField::RequestBody, body),
Err(err) => {
tracing::warn!(
error = ?err,
@@ -52,7 +102,9 @@ async fn resolve_admin_usage_detail_request_body(
);
let value = admin_usage_resolve_request_capture_body(item, None);
AdminUsageDetailBodyValue {
load_failed: value.is_none(),
error_code: value
.is_none()
.then(|| admin_usage_body_load_error_code(&err)),
value,
}
}
@@ -66,10 +118,7 @@ async fn resolve_admin_usage_detail_body_value(
) -> AdminUsageDetailBodyValue {
let inline_body = item.body_value(field);
match admin_usage_resolve_body_value(state, item, inline_body, field).await {
Ok(body) => AdminUsageDetailBodyValue {
value: body,
load_failed: false,
},
Ok(body) => AdminUsageDetailBodyValue::resolved(item, field, body),
Err(err) => {
tracing::warn!(
error = ?err,
@@ -80,13 +129,139 @@ async fn resolve_admin_usage_detail_body_value(
);
let value = inline_body.cloned();
AdminUsageDetailBodyValue {
load_failed: value.is_none(),
error_code: value
.is_none()
.then(|| admin_usage_body_load_error_code(&err)),
value,
}
}
}
}
async fn read_admin_usage_raw_body(
state: &AdminAppState<'_>,
item: &StoredRequestUsageAudit,
field: UsageBodyField,
) -> Result<Option<StoredUsageBodyPayload>, GatewayError> {
if matches!(
item.body_state(field),
Some(
UsageBodyCaptureState::Disabled
| UsageBodyCaptureState::Unavailable
| UsageBodyCaptureState::None
)
) {
return Ok(None);
}
let inline_body = item.body_value(field);
let prefer_inline = matches!(
item.body_state(field),
Some(UsageBodyCaptureState::Inline | UsageBodyCaptureState::Truncated)
) && inline_body.is_some();
if !prefer_inline {
if let Some(body_ref) = item
.body_ref(field)
.and_then(|reference| canonical_usage_body_ref_for(reference, &item.request_id, field))
{
if let Some(payload) = state.read_request_usage_body_payload(&body_ref).await? {
return Ok(Some(payload));
}
}
}
let fallback = inline_body.cloned().or_else(|| {
(field == UsageBodyField::RequestBody)
.then(|| admin_usage_resolve_request_capture_body(item, None))
.flatten()
});
fallback
.map(|value| {
serde_json::to_vec(&value)
.map(StoredUsageBodyPayload::Json)
.map_err(|error| GatewayError::Internal(error.to_string()))
})
.transpose()
}
async fn build_admin_usage_raw_body_response(
state: &AdminAppState<'_>,
item: &StoredRequestUsageAudit,
field: UsageBodyField,
) -> Response<Body> {
let result = read_admin_usage_raw_body(state, item, field).await;
let mut response = match result {
Ok(Some(payload)) => admin_usage_raw_payload_response(payload),
Ok(None) => admin_usage_raw_body_error(http::StatusCode::NOT_FOUND, "missing"),
Err(error) => {
tracing::warn!(error = ?error, usage_id = %item.id, field = field.as_storage_field(), "failed to read admin usage raw body");
let code = admin_usage_body_load_error_code(&error);
admin_usage_raw_body_error(
if code == "too_large" {
http::StatusCode::PAYLOAD_TOO_LARGE
} else {
http::StatusCode::SERVICE_UNAVAILABLE
},
code,
)
}
};
let headers = response.headers_mut();
headers.insert(
http::header::CACHE_CONTROL,
http::HeaderValue::from_static("no-store, no-transform"),
);
headers.insert(
"x-content-type-options",
http::HeaderValue::from_static("nosniff"),
);
headers.insert(
"x-aether-body-field",
http::HeaderValue::from_static(field.as_storage_field()),
);
if let Ok(value) = http::HeaderValue::from_str(&item.id) {
headers.insert("x-aether-usage-id", value);
}
attach_admin_audit_response(
response,
"admin_usage_detail_viewed",
"view_usage_detail",
"usage_record",
&item.id,
)
}
fn admin_usage_raw_payload_response(payload: StoredUsageBodyPayload) -> Response<Body> {
let (encoding, bytes, limit) = match payload {
StoredUsageBodyPayload::Gzip(bytes) => (
"gzip",
bytes,
MAX_DECOMPRESSED_USAGE_JSON_BYTES + 1024 * 1024,
),
StoredUsageBodyPayload::Json(bytes) => ("json", bytes, MAX_DECOMPRESSED_USAGE_JSON_BYTES),
};
if bytes.len() > limit {
admin_usage_raw_body_error(http::StatusCode::PAYLOAD_TOO_LARGE, "too_large")
} else {
(
[
("content-type", "application/octet-stream"),
("content-encoding", "identity"),
("x-aether-body-encoding", encoding),
],
bytes,
)
.into_response()
}
}
fn admin_usage_raw_body_error(status: http::StatusCode, code: &'static str) -> Response<Body> {
(
status,
[("x-aether-body-error", code)],
Json(json!({ "body_load_error_code": code })),
)
.into_response()
}
pub(super) async fn maybe_build_local_admin_usage_detail_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
@@ -212,6 +387,25 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
"include_bodies",
true,
);
let body_field =
match request_context
.request_query_string
.as_deref()
.and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == "body_field")
.map(|(_, value)| value.into_owned())
}) {
Some(value) => {
match UsageBodyField::from_storage_field(value.trim()) {
Some(field) if include_bodies => Some(field),
_ => return Ok(Some(admin_usage_bad_request_response(
"body_field 必须是有效的正文字段,且 include_bodies 必须为 true",
))),
}
}
None => None,
};
let Some(item) = state.find_request_usage_by_id(&usage_id).await? else {
return Ok(Some(
@@ -223,6 +417,27 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
));
};
let body_format = request_context
.request_query_string
.as_deref()
.and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == "body_format")
.map(|(_, value)| value.into_owned())
});
if let Some(format) = body_format {
if format != "raw" || body_field.is_none() {
return Ok(Some(admin_usage_bad_request_response(
"body_format=raw 必须指定 body_field",
)));
}
if let Some(field) = body_field {
return Ok(Some(
build_admin_usage_raw_body_response(state, &item, field).await,
));
}
}
let user_ids = item.user_id.clone().into_iter().collect::<Vec<_>>();
let (users_by_id, provider_key_names, api_key_names): (
BTreeMap<String, aether_data::repository::users::StoredUserSummary>,
@@ -250,23 +465,32 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
}
}
let mut body_load_errors = serde_json::Map::new();
let request_body = if include_bodies {
let mut body_load_error_codes = serde_json::Map::new();
let mut request_body = if include_bodies {
let (request_body, provider_request_body, response_body, client_response_body) = tokio::join!(
resolve_admin_usage_detail_request_body(state, &item),
resolve_admin_usage_detail_body_value(
resolve_admin_usage_detail_field(
state,
&item,
UsageBodyField::RequestBody,
body_field
),
resolve_admin_usage_detail_field(
state,
&item,
UsageBodyField::ProviderRequestBody,
body_field,
),
resolve_admin_usage_detail_body_value(
resolve_admin_usage_detail_field(
state,
&item,
UsageBodyField::ResponseBody,
body_field,
),
resolve_admin_usage_detail_body_value(
resolve_admin_usage_detail_field(
state,
&item,
UsageBodyField::ClientResponseBody,
body_field,
),
);
for (field, resolved) in [
@@ -275,20 +499,25 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
(UsageBodyField::ResponseBody, &response_body),
(UsageBodyField::ClientResponseBody, &client_response_body),
] {
if resolved.load_failed {
if let Some(error_code) = resolved.error_code {
body_load_errors.insert(field.as_storage_field().to_string(), json!(true));
body_load_error_codes
.insert(field.as_storage_field().to_string(), json!(error_code));
}
}
detail_item.provider_request_body = provider_request_body.value;
detail_item.response_body = response_body.value;
detail_item.client_response_body = client_response_body.value;
if body_field.is_none_or(|field| field == UsageBodyField::ProviderRequestBody) {
detail_item.provider_request_body = provider_request_body.value;
}
if body_field.is_none_or(|field| field == UsageBodyField::ResponseBody) {
detail_item.response_body = response_body.value;
}
if body_field.is_none_or(|field| field == UsageBodyField::ClientResponseBody) {
detail_item.client_response_body = client_response_body.value;
}
request_body.value
} else {
None
};
if include_bodies {
// request_body 已通过 request capture 解析;其余 detached body 在上方并行加载。
}
let default_headers = admin_usage_curl_headers();
let mut payload = build_admin_usage_detail_payload(
&detail_item,
@@ -297,15 +526,33 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
state.has_auth_user_data_reader(),
state.has_auth_api_key_data_reader(),
provider_key_name.as_deref(),
include_bodies,
request_body,
include_bodies && body_field.is_none(),
if body_field.is_none() {
request_body.take()
} else {
None
},
&default_headers,
);
if let Some(field) = body_field {
payload[field.as_storage_field()] = match field {
UsageBodyField::RequestBody => request_body,
UsageBodyField::ProviderRequestBody => detail_item.provider_request_body.take(),
UsageBodyField::ResponseBody => detail_item.response_body.take(),
UsageBodyField::ClientResponseBody => detail_item.client_response_body.take(),
}
.unwrap_or(Value::Null);
}
payload["body_load_errors"] = if include_bodies && !body_load_errors.is_empty() {
Value::Object(body_load_errors)
} else {
Value::Null
};
payload["body_load_error_codes"] = if body_load_error_codes.is_empty() {
Value::Null
} else {
Value::Object(body_load_error_codes)
};
return Ok(Some(attach_admin_audit_response(
Json(payload).into_response(),
@@ -320,3 +567,61 @@ pub(super) async fn maybe_build_local_admin_usage_detail_response(
Ok(None)
}
#[cfg(test)]
mod tests {
use super::admin_usage_body_load_error_code;
use crate::GatewayError;
#[tokio::test]
async fn admin_usage_raw_body_does_not_decode_or_reencode_stored_bytes() {
use super::{admin_usage_raw_payload_response, StoredUsageBodyPayload};
for (payload, encoding, expected) in [
(
StoredUsageBodyPayload::Gzip(vec![31, 139, 8, 0, 1]),
"gzip",
vec![31, 139, 8, 0, 1],
),
(
StoredUsageBodyPayload::Json(b"{ \"untouched\" : true }".to_vec()),
"json",
b"{ \"untouched\" : true }".to_vec(),
),
] {
let response = admin_usage_raw_payload_response(payload);
assert_eq!(response.headers()["content-encoding"], "identity");
assert_eq!(response.headers()["x-aether-body-encoding"], encoding);
let bytes = axum::body::to_bytes(response.into_body(), 1024)
.await
.unwrap();
assert_eq!(bytes.as_ref(), expected.as_slice());
}
}
#[test]
fn body_load_errors_expose_safe_codes_instead_of_internal_messages() {
for (message, expected) in [
(
"unexpected database value: decompressed usage json exceeds 67108864 bytes",
"too_large",
),
(
"failed to decompress usage json: invalid gzip header",
"decode_failed",
),
(
"failed to parse decompressed usage json: invalid JSON",
"decode_failed",
),
(
"postgres error: private connection details",
"storage_unavailable",
),
] {
assert_eq!(
admin_usage_body_load_error_code(&GatewayError::Internal(message.to_string())),
expected
);
}
}
}
@@ -234,6 +234,20 @@ impl<'a> AdminAppState<'a> {
.map_err(|err| GatewayError::Internal(err.to_string()))
}
pub(crate) async fn read_request_usage_body_payload(
&self,
body_ref: &str,
) -> Result<
Option<aether_data_contracts::repository::usage::StoredUsageBodyPayload>,
GatewayError,
> {
self.app
.data
.read_request_usage_body_payload(body_ref)
.await
.map_err(|error| GatewayError::Internal(error.to_string()))
}
pub(crate) async fn build_api_format_health_monitor_payload(
&self,
lookback_hours: u64,
@@ -211,9 +211,19 @@ fn apply_sensitive_route_cache_policy(
return;
}
let preserve_no_transform = headers
.get_all(http::header::CACHE_CONTROL)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.any(|directive| directive.trim().eq_ignore_ascii_case("no-transform"));
headers.insert(
http::header::CACHE_CONTROL,
HeaderValue::from_static("no-store"),
HeaderValue::from_static(if preserve_no_transform {
"no-store, no-transform"
} else {
"no-store"
}),
);
headers.insert(http::header::PRAGMA, HeaderValue::from_static("no-cache"));
}
@@ -354,6 +364,29 @@ mod tests {
);
}
#[test]
fn raw_body_no_transform_survives_sensitive_cache_policy() {
let mut headers = HeaderMap::new();
headers.append(
http::header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=3600"),
);
headers.append(
http::header::CACHE_CONTROL,
HeaderValue::from_static(" No-Transform "),
);
apply_sensitive_route_cache_policy(
&mut headers,
"/api/admin/usage/usage-1?body_format=raw",
None,
);
assert_eq!(
headers[http::header::CACHE_CONTROL],
"no-store, no-transform"
);
assert_eq!(headers[http::header::PRAGMA], "no-cache");
}
#[test]
fn authenticated_user_data_responses_are_never_cacheable() {
let mut headers = HeaderMap::new();
@@ -31,7 +31,11 @@ fn apply_frontdoor_cors_headers(
);
headers.insert(
http::header::ACCESS_CONTROL_EXPOSE_HEADERS,
HeaderValue::from_static("*"),
HeaderValue::from_static(if headers.contains_key("x-aether-body-field") {
"*, X-Aether-Body-Encoding, X-Aether-Body-Field, X-Aether-Body-Error, X-Aether-Usage-Id"
} else {
"*"
}),
);
if let Some(value) = requested_headers {
if let Ok(value) = HeaderValue::from_str(value) {
@@ -109,3 +113,36 @@ pub(crate) async fn frontdoor_cors_middleware(
);
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_body_headers_are_explicitly_exposed_for_credentialed_requests() {
let cors =
FrontdoorCorsConfig::new(vec!["https://console.example".to_string()], true).unwrap();
let mut headers = http::HeaderMap::new();
headers.insert(
"x-aether-body-field",
HeaderValue::from_static("request_body"),
);
apply_frontdoor_cors_headers(&mut headers, &cors, "https://console.example", None);
let exposed = headers[http::header::ACCESS_CONTROL_EXPOSE_HEADERS]
.to_str()
.unwrap()
.to_ascii_lowercase();
for name in [
"x-aether-body-encoding",
"x-aether-body-field",
"x-aether-body-error",
"x-aether-usage-id",
] {
assert!(exposed.split(',').any(|header| header.trim() == name));
}
assert_eq!(
headers[http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS],
"true"
);
}
}
@@ -2366,6 +2366,265 @@ async fn gateway_handles_admin_usage_detail_with_ref_backed_bodies() {
upstream_handle.abort();
}
fn sample_selective_body_usage() -> StoredRequestUsageAudit {
let mut usage = sample_usage_row(
"usage-selected-body",
"req-selected-body",
Some("user-1"),
Some("key-1"),
Some("primary"),
"OpenAI",
"gpt-5",
"completed",
120,
30,
0.3,
0.36,
DAY_1_UNIX_SECS,
);
usage.request_body = Some(json!({ "marker": "request_body" }));
usage.provider_request_body = Some(json!({ "marker": "provider_request_body" }));
usage.response_body = Some(json!({ "marker": "response_body" }));
usage.client_response_body = Some(json!({ "marker": "client_response_body" }));
usage
}
#[tokio::test]
async fn gateway_admin_usage_detail_returns_only_the_selected_body() {
let fields = [
"request_body",
"provider_request_body",
"response_body",
"client_response_body",
];
for detached in [false, true] {
let usage = sample_selective_body_usage();
let repository = if detached {
InMemoryUsageReadRepository::seed_with_detached_bodies(vec![usage])
} else {
InMemoryUsageReadRepository::seed(vec![usage])
};
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(Arc::new(
repository,
)));
for selected in fields {
let response = local_admin_usage_response(
&state, http::Method::GET,
&format!("/api/admin/usage/usage-selected-body?include_bodies=true&body_field={selected}"),
None,
).await;
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should collect");
let payload: serde_json::Value =
serde_json::from_slice(&bytes).expect("json should parse");
for field in fields {
assert_eq!(payload[format!("has_{field}")], true);
if field == selected {
assert_eq!(payload[field]["marker"], selected);
} else {
assert!(
payload[field].is_null(),
"unselected {field} must not be returned"
);
}
}
assert!(payload["body_load_errors"].is_null());
assert!(payload["body_load_error_codes"].is_null());
}
}
}
#[tokio::test]
async fn gateway_admin_usage_detail_validates_body_field_selection() {
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(Arc::new(
InMemoryUsageReadRepository::seed(vec![sample_selective_body_usage()]),
)));
for query in [
"body_field=unknown",
"body_field=",
"include_bodies=false&body_field=request_body",
"body_format=raw",
"body_format=unknown&body_field=request_body",
"body_format=&body_field=request_body",
] {
let response = local_admin_usage_response(
&state,
http::Method::GET,
&format!("/api/admin/usage/usage-selected-body?{query}"),
None,
)
.await;
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"invalid query: {query}"
);
}
}
#[tokio::test]
async fn gateway_admin_usage_detail_raw_reads_only_selected_body_and_is_not_cacheable() {
for detached in [false, true] {
let repository = if detached {
InMemoryUsageReadRepository::seed_with_detached_bodies(vec![
sample_selective_body_usage(),
])
} else {
InMemoryUsageReadRepository::seed(vec![sample_selective_body_usage()])
};
let state = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::with_usage_reader_for_tests(Arc::new(repository)),
);
for field in [
"request_body",
"provider_request_body",
"response_body",
"client_response_body",
] {
let response = local_admin_usage_response(
&state,
http::Method::GET,
&format!("/api/admin/usage/usage-selected-body?body_field={field}&body_format=raw"),
None,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers()["cache-control"],
"no-store, no-transform"
);
assert_eq!(
response.headers()["x-aether-usage-id"],
"usage-selected-body"
);
assert_eq!(response.headers()["x-aether-body-field"], field);
assert_eq!(response.headers()["x-aether-body-encoding"], "json");
assert!(response.extensions().get::<AdminAuditEvent>().is_some());
let bytes = to_bytes(response.into_body(), 1024).await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
json!({ "marker": field })
);
}
}
}
#[tokio::test]
async fn gateway_admin_usage_detail_raw_rejects_foreign_refs_and_disabled_capture() {
for body_state in [
UsageBodyCaptureState::Reference,
UsageBodyCaptureState::Disabled,
] {
let mut usage = sample_selective_body_usage();
usage.response_body = None;
usage.response_body_ref = Some("usage://request/foreign-request/response_body".to_string());
usage.response_body_state = Some(body_state);
let mut foreign = sample_selective_body_usage();
foreign.id = "foreign-usage".to_string();
foreign.request_id = "foreign-request".to_string();
foreign.response_body = Some(json!({ "secret": "must not leak" }));
let state = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::with_usage_reader_for_tests(Arc::new(
InMemoryUsageReadRepository::seed_with_detached_bodies(vec![usage, foreign]),
)),
);
let response = local_admin_usage_response(
&state,
http::Method::GET,
"/api/admin/usage/usage-selected-body?body_field=response_body&body_format=raw",
None,
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.headers()["x-aether-body-error"], "missing");
let bytes = to_bytes(response.into_body(), 1024).await.unwrap();
assert!(!String::from_utf8_lossy(&bytes).contains("secret"));
}
}
#[tokio::test]
async fn gateway_admin_usage_detail_raw_preserves_authorization_and_binary_headers() {
let state = AppState::new().unwrap().with_data_state_for_tests(
GatewayDataState::with_usage_reader_for_tests(Arc::new(
InMemoryUsageReadRepository::seed_with_detached_bodies(vec![
sample_selective_body_usage(),
]),
)),
);
let gateway =
build_router_with_state(state).layer(tower_http::compression::CompressionLayer::new());
let (url, server) = start_server(gateway).await;
let client = reqwest::Client::new();
let endpoint = format!(
"{url}/api/admin/usage/usage-selected-body?body_field=response_body&body_format=raw"
);
let unauthorized = client.get(&endpoint).send().await.unwrap();
assert!(matches!(
unauthorized.status(),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
));
let response = admin_request(client.get(&endpoint))
.header("accept-encoding", "gzip")
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["content-encoding"], "identity");
assert_eq!(response.headers()["x-aether-body-encoding"], "json");
assert_eq!(
response.headers()["cache-control"],
"no-store, no-transform"
);
let bytes = response.bytes().await.unwrap();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
json!({ "marker": "response_body" })
);
server.abort();
}
#[tokio::test]
async fn gateway_admin_usage_detail_isolates_missing_body_errors() {
let mut usage = sample_selective_body_usage();
usage.request_body = None;
usage.request_body_ref = Some("usage://request/req-selected-body/request_body".to_string());
usage.request_body_state = Some(UsageBodyCaptureState::Reference);
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_usage_reader_for_tests(Arc::new(
InMemoryUsageReadRepository::seed_with_detached_bodies(vec![usage]),
)));
for selected in ["response_body", "request_body"] {
let response = local_admin_usage_response(
&state,
http::Method::GET,
&format!("/api/admin/usage/usage-selected-body?body_field={selected}"),
None,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should collect");
let payload: serde_json::Value = serde_json::from_slice(&bytes).expect("json should parse");
if selected == "request_body" {
assert_eq!(payload["body_load_errors"]["request_body"], true);
assert_eq!(payload["body_load_error_codes"]["request_body"], "missing");
assert!(payload["request_body"].is_null());
} else {
assert_eq!(payload["response_body"]["marker"], "response_body");
assert!(payload["body_load_errors"].is_null());
assert!(payload["body_load_error_codes"].is_null());
}
}
}
#[tokio::test]
async fn gateway_resolves_admin_usage_detail_when_inline_state_has_body_ref() {
let (_upstream_url, upstream_hits, upstream_handle) =
@@ -18,8 +18,6 @@ futures-util.workspace = true
sqlx = { workspace = true, features = ["postgres", "runtime-tokio-rustls", "chrono", "migrate", "macros"] }
serde_json.workspace = true
sha2.workspace = true
tokio.workspace = true
tracing.workspace = true
uuid.workspace = true
[dev-dependencies]
tokio.workspace = true
@@ -1,9 +1,9 @@
use aether_data_contracts::repository::usage::{
canonical_usage_body_ref_for, parse_usage_body_ref, read_decompressed_usage_json,
usage_body_ref, ApiKeyLastUsedDelta, ManagementTokenCounterDelta, ProxyNodeCounterDelta,
StoredUsageAuditAggregation, StoredUsageAuditSummary, StoredUsageBreakdownSummaryRow,
StoredUsageCacheAffinityHitSummary, StoredUsageCacheAffinityIntervalRow,
StoredUsageCacheHitSummary, StoredUsageCostSavingsSummary,
StoredUsageAuditAggregation, StoredUsageAuditSummary, StoredUsageBodyPayload,
StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary,
StoredUsageCacheAffinityIntervalRow, StoredUsageCacheHitSummary, StoredUsageCostSavingsSummary,
StoredUsageDashboardDailyBreakdownRow, StoredUsageDashboardProviderCount,
StoredUsageDashboardStatsSummary, StoredUsageDashboardSummary, StoredUsageErrorDistributionRow,
StoredUsageLeaderboardSummary, StoredUsagePerformancePercentilesRow,
@@ -63,8 +63,25 @@ pub mod cleanup;
// newly captured bodies always spill to usage_body_blobs and resolve through usage_http_audits.
const MAX_INLINE_USAGE_BODY_BYTES: usize = 0;
const MAX_SUPPORTED_UNIX_SECS: u64 = 253_402_300_799;
const FIND_USAGE_BODY_BLOB_BY_REF_SQL: &str = r#"SELECT payload_gzip FROM usage_body_blobs WHERE body_ref = $1 AND request_id = $2 AND body_field = $3 LIMIT 1"#;
const FIND_USAGE_BODY_BLOB_BY_REF_SQL: &str = r#"SELECT CASE WHEN octet_length(payload_gzip) <= $4 THEN payload_gzip END AS payload_gzip FROM usage_body_blobs WHERE body_ref = $1 AND request_id = $2 AND body_field = $3 LIMIT 1"#;
const DELETE_USAGE_BODY_BLOB_SQL: &str = include_str!("queries/delete_usage_body_blob_sql.sql");
static USAGE_BODY_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
async fn decode_usage_body_in_background(
decode: impl FnOnce() -> Result<Option<Value>, DataLayerError> + Send + 'static,
) -> Result<Option<Value>, DataLayerError> {
let permit = USAGE_BODY_DECODE_SLOTS.acquire().await.map_err(|error| {
DataLayerError::UnexpectedValue(format!("usage body decoder unavailable: {error}"))
})?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
decode()
})
.await
.map_err(|error| {
DataLayerError::UnexpectedValue(format!("usage body decoder failed: {error}"))
})?
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct AggregateRangeSplit {
@@ -2862,36 +2879,82 @@ ORDER BY request_count DESC, "usage".provider_name ASC
Ok(items)
}
pub async fn resolve_body_ref(&self, body_ref: &str) -> Result<Option<Value>, DataLayerError> {
pub async fn read_body_payload(
&self,
body_ref: &str,
) -> Result<Option<StoredUsageBodyPayload>, DataLayerError> {
let json_limit =
aether_data_contracts::repository::usage::MAX_DECOMPRESSED_USAGE_JSON_BYTES as i64;
let encoded_limit = json_limit + 1024 * 1024;
let Some((request_id, field)) = parse_usage_body_ref(body_ref) else {
return Ok(None);
};
let canonical_ref = usage_body_ref(&request_id, field);
let blob_row = sqlx::query(FIND_USAGE_BODY_BLOB_BY_REF_SQL)
let row = sqlx::query(FIND_USAGE_BODY_BLOB_BY_REF_SQL)
.bind(&canonical_ref)
.bind(&request_id)
.bind(field.as_storage_field())
.bind(encoded_limit)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
if let Some(row) = blob_row.as_ref() {
let payload_gzip = row
.try_get::<Vec<u8>, _>("payload_gzip")
.map_postgres_err()?;
return inflate_usage_json_value(&payload_gzip).map(Some);
if let Some(row) = row {
return row
.try_get::<Option<Vec<u8>>, _>("payload_gzip")
.map_postgres_err()?
.map(|bytes| Some(StoredUsageBodyPayload::Gzip(bytes)))
.ok_or_else(|| {
DataLayerError::UnexpectedValue(format!(
"encoded usage json exceeds {encoded_limit} bytes"
))
});
}
let (inline_column, compressed_column) = usage_body_sql_columns(field);
let row = sqlx::query(&format!(
"SELECT {inline_column} AS inline_body, {compressed_column} AS compressed_body FROM \"usage\" WHERE request_id = $1 LIMIT 1"
"SELECT CASE WHEN octet_length({inline_column}::text) <= $2 THEN {inline_column}::text END AS inline_body, CASE WHEN octet_length({compressed_column}) <= $3 THEN {compressed_column} END AS compressed_body, (COALESCE(octet_length({inline_column}::text) > $2, false) OR ({inline_column} IS NULL AND COALESCE(octet_length({compressed_column}) > $3, false))) AS too_large FROM \"usage\" WHERE request_id = $1 LIMIT 1"
))
.bind(request_id)
.bind(json_limit)
.bind(encoded_limit)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
row.as_ref()
.map(|row| usage_json_column(row, "inline_body", "compressed_body", true))
.transpose()
.map(|value| value.and_then(|column| column.value))
let Some(row) = row else {
return Ok(None);
};
if row.try_get::<bool, _>("too_large").map_postgres_err()? {
return Err(DataLayerError::UnexpectedValue(
"encoded usage json exceeds preview limit".to_string(),
));
}
if let Some(body) = row
.try_get::<Option<String>, _>("inline_body")
.map_postgres_err()?
{
return Ok(Some(StoredUsageBodyPayload::Json(body.into_bytes())));
}
Ok(row
.try_get::<Option<Vec<u8>>, _>("compressed_body")
.map_postgres_err()?
.map(StoredUsageBodyPayload::Gzip))
}
pub async fn resolve_body_ref(&self, body_ref: &str) -> Result<Option<Value>, DataLayerError> {
let Some(payload) = self.read_body_payload(body_ref).await? else {
return Ok(None);
};
decode_usage_body_in_background(move || match payload {
StoredUsageBodyPayload::Gzip(bytes) => inflate_usage_json_value(&bytes).map(Some),
StoredUsageBodyPayload::Json(bytes) => {
let bytes = read_decompressed_usage_json(std::io::Cursor::new(bytes))?;
serde_json::from_slice(&bytes).map(Some).map_err(|error| {
DataLayerError::UnexpectedValue(format!(
"failed to parse decompressed usage json: {error}"
))
})
}
})
.await
}
async fn hydrate_usage_body_refs(
@@ -10306,6 +10369,13 @@ impl UsageReadRepository for SqlxUsageReadRepository {
Self::resolve_body_ref(self, body_ref).await
}
async fn read_body_payload(
&self,
body_ref: &str,
) -> Result<Option<StoredUsageBodyPayload>, DataLayerError> {
Self::read_body_payload(self, body_ref).await
}
async fn list_usage_audits(
&self,
query: &UsageAuditListQuery,
@@ -4274,6 +4274,38 @@ fn prepare_usage_body_storage_detaches_small_payloads_into_blob_storage() {
);
}
#[tokio::test(flavor = "current_thread")]
async fn usage_body_decode_does_not_block_the_async_runtime_thread() {
let runtime_thread = std::thread::current().id();
let payload = json!({"message": "background decoding"});
let compressed = prepare_usage_body_storage(Some(&payload))
.expect("body should compress")
.detached_blob_bytes
.expect("body should be detached");
let decoded = super::decode_usage_body_in_background(move || {
assert_ne!(std::thread::current().id(), runtime_thread);
inflate_usage_json_value(&compressed).map(Some)
})
.await
.expect("body should decode");
assert_eq!(decoded, Some(payload));
}
#[tokio::test]
async fn usage_body_decode_preserves_storage_decode_errors() {
let error = super::decode_usage_body_in_background(|| {
inflate_usage_json_value(b"invalid gzip").map(Some)
})
.await
.expect_err("corrupt bodies should fail");
assert!(error
.to_string()
.contains("failed to decompress usage json:"));
}
#[test]
fn prepare_usage_body_storage_compresses_large_payloads() {
let payload = json!({
@@ -16,7 +16,7 @@ pub use types::{
ProxyNodeCounterDelta, StoredProviderApiKeyUsageSummary,
StoredProviderApiKeyWindowUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow,
StoredRequestUsageAudit, StoredUsageAuditAggregation, StoredUsageAuditSummary,
StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary,
StoredUsageBodyPayload, StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary,
StoredUsageCacheAffinityIntervalRow, StoredUsageCacheHitSummary, StoredUsageCostSavingsSummary,
StoredUsageDailySummary, StoredUsageDashboardDailyBreakdownRow,
StoredUsageDashboardProviderCount, StoredUsageDashboardStatsSummary,
@@ -1732,6 +1732,12 @@ pub fn canonical_usage_body_ref_for(
.map(|(request_id, field)| usage_body_ref(&request_id, field))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoredUsageBodyPayload {
Gzip(Vec<u8>),
Json(Vec<u8>),
}
#[async_trait]
pub trait UsageReadRepository: Send + Sync {
async fn find_by_id(
@@ -1761,6 +1767,20 @@ pub trait UsageReadRepository: Send + Sync {
body_ref: &str,
) -> Result<Option<Value>, crate::DataLayerError>;
async fn read_body_payload(
&self,
body_ref: &str,
) -> Result<Option<StoredUsageBodyPayload>, crate::DataLayerError> {
self.resolve_body_ref(body_ref)
.await?
.map(|value| {
serde_json::to_vec(&value)
.map(StoredUsageBodyPayload::Json)
.map_err(|error| crate::DataLayerError::UnexpectedValue(error.to_string()))
})
.transpose()
}
async fn list_usage_audits(
&self,
query: &UsageAuditListQuery,
+65
View File
@@ -0,0 +1,65 @@
# 请求正文查看与性能边界
## 按需读取
请求详情的轻量读取仍使用 `GET /api/admin/usage/{id}?include_bodies=false`,返回正文可用性与记录概要,不解析正文。
查看正文时,管理界面使用 `GET /api/admin/usage/{id}?include_bodies=true&body_field={field}&body_format=raw``body_field` 仅允许以下值:
- `request_body`:客户端请求体。
- `provider_request_body`:提供商请求体。
- `response_body`:提供商响应体。
- `client_response_body`:客户端响应体。
网页正文读取不再经过服务器 JSON 解码链路。数据库中的 `payload_gzip` 原样返回,历史压缩列同样直传;历史内联 JSON 返回 JSON 字节。接口仍经过管理权限校验、正文捕获状态检查、引用归属校验及审计,不加载用户名称和提供商名称等无关详情。
二进制响应的 `X-Aether-Body-Encoding``gzip``json`,另含 `X-Aether-Usage-Id``X-Aether-Body-Field`,前端验证记录与字段匹配。使用 `application/octet-stream``Content-Encoding: identity`,避免 HTTP 中间件重复压缩或浏览器提前解压;`Cache-Control: no-store, no-transform` 避免缓存敏感正文及代理改写。跨域允许凭证时显式暴露上述协议头。错误通过 HTTP 状态及 `X-Aether-Body-Error` 返回,不要求主线程解析二进制错误响应。
未指定 `body_format=raw` 的服务端 JSON 接口保持原有行为,但网页不再调用它加载正文;前端详情 API 默认也只取概要。非法字段、空字段、raw 模式缺少字段或同时设置 `include_bodies=false` 返回 HTTP 400。
前端按请求和正文字段保留 Worker 句柄,最多缓存两份已解析正文,并按解压字节总量 64 MiB 预算淘汰最久未访问的 Worker。该预算不是浏览器进程内存上限:解析对象、字符串及加载中的数据仍有额外开销。切换正文来源或标签会取消未完成的下载与解压;关闭抽屉、切换记录或组件卸载会终止全部 Worker。迟到结果不显示、不入缓存。请求从进行中变为完成或失败时,正文缓存失效,并重新读取当前选中的正文。
## 渲染与资源控制
- 仅挂载当前标签的内容,不再后台渲染隐藏标签。
- JSON 与对话均连续虚拟滚动,不需要点击上一页或下一页。接近底部时自动读取后续内容,向上滚动可重新查看先前内容。
- 折叠节点不遍历其子节点;点击括号展开节点时完整展开该子树,无需逐层点击。JSON 内部按 50 个显示片段批量读取,视口与预读区域最多挂载 4 批(200 个片段)。离屏内容用高度占位,缓存仅保留视口附近最多 6 批,不随滚动积累 DOM 或正文副本。
- JSON 不再有“显示更多”或“继续显示”按钮:长字符串和长键名在 Worker 中分段转义,随滚动自动显示全部字符。纯文本及非 JSON 响应同样自动衔接完整内容。分段保持 Unicode 字符完整,续段不重复显示 JSON 行号。
- 虚拟显示范围不改变正文长度;复制按钮仍复制完整内容。JSON 顺序读取复用遍历游标,避免每次滚动都从正文起点重新遍历。
- 压缩数据以 transferable ArrayBuffer 交给 Worker;解压、UTF-8 解码、JSON 解析、JSON 遍历及对话解析均在 Worker 中进行,完整对象不返回页面主线程。
- 对话内部按每批最多 10 个顶层块预览并自动衔接,限制嵌套块和文本传输量,长内容明确提示并支持增加预览。完整复制在用户点击后由 Worker 生成,不受虚拟显示范围影响。
- Worker 解压时逐块检查 64 MiB 上限,损坏 gzip、无效 UTF-8 和无效 JSON 给出明确错误。后台任务 30 秒超时会终止 Worker;不支持 Worker 或原生 gzip 解压的浏览器提示升级,不回退到主线程解析。
- 服务端最多 4 个后台解码任务的保护仍用于复制 cURL、请求重放等实际内部调用,不是网页解压的兼容回退。存储读取实现复用,不维护两套数据库读取逻辑。
- 数据库读取先按存储字节过滤:压缩正文最大 65 MiB,未压缩正文最大 64 MiB,超限不将完整载荷读入网关;浏览器还会独立校验解压后的大小。
## 错误定位
二进制接口的 `X-Aether-Body-Error` 和 JSON 接口的 `body_load_error_codes` 使用以下存储错误码;浏览器解压失败也映射为相同的明确提示:
| 错误码 | 含义 |
| --- | --- |
| `too_large` | 解压后正文超过 64 MiB 的安全读取上限。 |
| `decode_failed` | 正文解压或 JSON 解析失败。 |
| `missing` | 记录标记正文可用,但未能解析到实际存储内容。 |
| `storage_unavailable` | 其他存储读取错误;内部连接信息不会返回给页面。 |
前端分别显示请求超时、网络失败、HTTP 错误及上述存储错误。`too_large``decode_failed` 不提供无意义的重复重试;其他错误可手动重试。正文请求仍沿用现有 API 超时配置。
完整记录模式与已有记录不做静默截断;64 MiB 解压安全上限没有放宽。因此,超出上限的历史正文仍不能在线预览,但页面会明确说明限制,不再仅显示笼统的加载失败。
前后端需要一同更新。前端收到缺少二进制协议头、记录不匹配或字段不匹配的响应时会拒绝显示,并提示检查前后端版本;不会自动退回耗资源的完整 JSON 正文接口。构建前端时必须同时发布生成的 Worker JavaScript 资源。
## 针对性验证
```sh
cd frontend
npm run test:run -- src/features/usage src/api/__tests__/dashboard-body-loading.spec.ts
npm run type-check
```
```sh
cargo test -p aether-data-postgres usage_body_decode --lib --offline
cargo test -p aether-gateway admin_usage_detail --lib --offline
cargo test -p aether-gateway raw_body --lib --offline
cargo test -p aether-gateway body_load_errors_expose_safe_codes --lib --offline
```
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock } = vi.hoisted(() => ({ getMock: vi.fn() }))
vi.mock('@/api/client', () => ({ default: { get: getMock } }))
import { dashboardApi } from '@/api/dashboard'
import { cache } from '@/utils/cache'
beforeEach(() => {
cache.clear()
getMock.mockReset()
getMock.mockResolvedValue({ data: { id: 'usage-1' } })
})
describe('dashboard body loading', () => {
it('requests opaque body bytes, outside the JSON detail cache', async () => {
const bytes = new ArrayBuffer(20)
const controller = new AbortController()
getMock.mockResolvedValue({ data: bytes, headers: { 'x-aether-body-encoding': 'gzip', 'x-aether-usage-id': 'usage-1', 'x-aether-body-field': 'request_body' } })
await expect(dashboardApi.getRequestBody('usage-1', 'request_body', controller.signal)).resolves.toEqual({ bytes, encoding: 'gzip' })
expect(getMock).toHaveBeenCalledWith('/api/admin/usage/usage-1', { params: { include_bodies: true, body_field: 'request_body', body_format: 'raw' }, responseType: 'arraybuffer', signal: controller.signal })
})
it.each([
{ 'x-aether-body-encoding': 'br', 'x-aether-usage-id': 'usage-1', 'x-aether-body-field': 'request_body' },
{ 'x-aether-body-encoding': 'gzip', 'x-aether-usage-id': 'usage-other', 'x-aether-body-field': 'request_body' },
{ 'x-aether-body-encoding': 'gzip', 'x-aether-usage-id': 'usage-1', 'x-aether-body-field': 'response_body' },
{},
])('rejects mismatched or invalid body response headers', async headers => {
getMock.mockResolvedValue({ data: new ArrayBuffer(10), headers })
await expect(dashboardApi.getRequestBody('usage-1', 'request_body')).rejects.toThrow('Invalid body response')
})
it('defaults to shallow details and isolates cached records', async () => {
await dashboardApi.getRequestDetail('usage-1')
expect(getMock).toHaveBeenLastCalledWith('/api/admin/usage/usage-1', { params: { include_bodies: false } })
await dashboardApi.getRequestDetail('usage-1', { cacheTtlMs: 5000 })
await dashboardApi.getRequestDetail('usage-2', { cacheTtlMs: 5000 })
await dashboardApi.getRequestDetail('usage-1', { cacheTtlMs: 5000 })
expect(getMock).toHaveBeenCalledTimes(3)
expect(getMock).toHaveBeenLastCalledWith('/api/admin/usage/usage-2', { params: { include_bodies: false } })
})
it('does not share abortable requests with another caller or a cancelled request', async () => {
const first = new AbortController()
const second = new AbortController()
const firstRequest = dashboardApi.getRequestDetail('usage-1', { signal: first.signal })
first.abort()
const secondRequest = dashboardApi.getRequestDetail('usage-1', { signal: second.signal })
await Promise.all([firstRequest, secondRequest])
expect(getMock).toHaveBeenCalledTimes(2)
expect(getMock).toHaveBeenLastCalledWith('/api/admin/usage/usage-1', {
params: { include_bodies: false }, signal: second.signal,
})
})
})
+34 -12
View File
@@ -190,6 +190,16 @@ export interface RequestSettlementSnapshot {
[key: string]: unknown
}
export type RequestBodyField = 'request_body' | 'provider_request_body' | 'response_body' | 'client_response_body'
export type RequestBodyLoadErrorCode = 'too_large' | 'decode_failed' | 'missing' | 'storage_unavailable'
export class RequestBodyProtocolError extends Error {
constructor() {
super('Invalid body response')
this.name = 'RequestBodyProtocolError'
}
}
export interface RequestDetail {
id: string // UUID
request_id: string
@@ -296,6 +306,7 @@ export interface RequestDetail {
response_body?: boolean
client_response_body?: boolean
} | null
body_load_error_codes?: Partial<Record<RequestBodyField, RequestBodyLoadErrorCode>> | null
metadata?: Record<string, unknown>
routing?: Record<string, unknown>
body_capture?: Record<string, unknown>
@@ -455,21 +466,32 @@ export const dashboardApi = {
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
async getRequestDetail(
requestId: string,
options: { includeBodies?: boolean, cacheTtlMs?: number } = {}
options: { includeBodies?: boolean, cacheTtlMs?: number, signal?: AbortSignal } = {}
): Promise<RequestDetail> {
const includeBodies = options.includeBodies ?? true
const includeBodies = options.includeBodies ?? false
const cacheTtlMs = options.cacheTtlMs ?? 0
const cacheKey = buildCacheKey('dashboard:request-detail', { requestId, includeBodies })
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: includeBodies },
})
return response.data
},
cacheTtlMs
)
const fetchDetail = async () => {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: includeBodies },
...(options.signal ? { signal: options.signal } : {}),
})
return response.data
}
return options.signal ? fetchDetail() : cachedRequest(cacheKey, fetchDetail, cacheTtlMs)
},
async getRequestBody(requestId: string, field: RequestBodyField, signal?: AbortSignal) {
const response = await apiClient.get<ArrayBuffer>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: true, body_field: field, body_format: 'raw' },
responseType: 'arraybuffer',
signal,
})
const encoding = response.headers['x-aether-body-encoding']
if ((encoding !== 'gzip' && encoding !== 'json') || response.headers['x-aether-usage-id'] !== requestId || response.headers['x-aether-body-field'] !== field) {
throw new RequestBodyProtocolError()
}
return { bytes: response.data, encoding: encoding as 'gzip' | 'json' }
},
async prefetchRequestDetail(requestId: string): Promise<void> {
@@ -1,11 +1,11 @@
<template>
<Card
variant="interactive"
class="flex min-w-0 flex-col cursor-pointer overflow-hidden"
class="flex max-h-96 w-full min-w-0 flex-col cursor-pointer overflow-hidden"
@mousedown="$emit('mousedown', $event)"
@click="$emit('rowClick', $event, provider.id)"
>
<div class="flex items-start gap-2 p-4 pb-3">
<div class="flex shrink-0 items-start gap-2 p-4 pb-3">
<slot name="drag-handle" />
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-base font-semibold"
@@ -88,7 +88,7 @@
</Badge>
</div>
<div class="flex flex-1 flex-col gap-4 px-4 pb-4">
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain break-words px-4 pb-4">
<div class="space-y-2 rounded-xl border border-border/40 bg-muted/20 p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-xs text-muted-foreground">{{ legacyT('余额监控') }}</span>
@@ -176,7 +176,7 @@
</div>
<div
class="flex items-center justify-between gap-2 border-t border-border/40 bg-muted/10 px-3 py-2"
class="flex shrink-0 items-center justify-between gap-2 border-t border-border/40 bg-muted/10 px-3 py-2"
@click.stop
>
<Button
@@ -638,7 +638,7 @@
:copied="Boolean(copiedStates[activeTab])"
:custom-copy="true"
:expand-disabled="viewMode === 'compare' || (supportsConversationView && contentViewMode === 'conversation')"
:copy-disabled="viewMode === 'compare'"
:copy-disabled="viewMode === 'compare' || bodyCopying || (supportsConversationView && !hasValidConversation)"
max-height="500px"
@update:expand-depth="currentExpandDepth = $event"
@copy="copyContent(activeTab)"
@@ -723,7 +723,10 @@
<!-- 区域3常驻按钮展开/收缩复制 -->
<div class="w-px h-3.5 bg-border mx-0.5" />
</template>
<TabsContent value="request-headers">
<TabsContent
v-if="activeTab === 'request-headers'"
value="request-headers"
>
<RequestHeadersContent
:detail="detail"
:view-mode="viewMode"
@@ -736,7 +739,10 @@
/>
</TabsContent>
<TabsContent value="request-body">
<TabsContent
v-if="activeTab === 'request-body'"
value="request-body"
>
<div
v-if="isRequestBodyLoading"
class="p-4"
@@ -752,6 +758,7 @@
<span>{{ bodyLoadErrorMessage }}</span>
</div>
<Button
v-if="canRetryBodyContentLoad"
variant="outline"
size="sm"
class="mt-3 h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
@@ -761,22 +768,30 @@
重试
</Button>
</div>
<ConversationView
<BodyConversationContent
v-else-if="contentViewMode === 'conversation'"
:render-result="requestRenderResult"
:body-document="currentRequestBody"
kind="request"
:api-format="currentRequestBodyApiFormat"
empty-message="无请求体信息"
@load-error="handleBodyDocumentError"
/>
<JsonContent
v-else
:data="currentRequestBody"
:data="null"
:body-document="currentRequestBody"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无请求体信息"
@load-error="handleBodyDocumentError"
/>
</TabsContent>
<TabsContent value="response-headers">
<TabsContent
v-if="activeTab === 'response-headers'"
value="response-headers"
>
<RequestHeadersContent
v-if="viewMode === 'compare'"
:detail="detail"
@@ -803,7 +818,10 @@
/>
</TabsContent>
<TabsContent value="response-body">
<TabsContent
v-if="activeTab === 'response-body'"
value="response-body"
>
<div
v-if="isResponseBodyLoading"
class="p-4"
@@ -819,6 +837,7 @@
<span>{{ bodyLoadErrorMessage }}</span>
</div>
<Button
v-if="canRetryBodyContentLoad"
variant="outline"
size="sm"
class="mt-3 h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
@@ -828,22 +847,30 @@
重试
</Button>
</div>
<ConversationView
<BodyConversationContent
v-else-if="contentViewMode === 'conversation'"
:render-result="responseRenderResult"
:body-document="currentResponseBody"
kind="response"
:api-format="currentResponseBodyApiFormat"
empty-message="无响应体信息"
@load-error="handleBodyDocumentError"
/>
<JsonContent
v-else
:data="currentResponseBody"
:data="null"
:body-document="currentResponseBody"
:view-mode="viewMode"
:expand-depth="currentExpandDepth"
:is-dark="isDark"
empty-message="无响应体信息"
@load-error="handleBodyDocumentError"
/>
</TabsContent>
<TabsContent value="metadata">
<TabsContent
v-if="activeTab === 'metadata'"
value="metadata"
>
<JsonContent
:data="metadataPanelData"
:view-mode="viewMode"
@@ -874,7 +901,8 @@
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, shallowRef, watch, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
import axios from 'axios'
import Button from '@/components/ui/button.vue'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useClipboard } from '@/composables/useClipboard'
@@ -886,7 +914,8 @@ import Skeleton from '@/components/ui/skeleton.vue'
import Tabs from '@/components/ui/tabs.vue'
import TabsContent from '@/components/ui/tabs-content.vue'
import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
import { dashboardApi, type RequestBodyField, type RequestDetail } from '@/api/dashboard'
import { formatRequestBodyLoadError, formatStoredBodyLoadError } from '../utils/body-load-error'
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
import type { UsageRecord } from '../types'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
@@ -928,7 +957,10 @@ import {
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
import JsonContent from './RequestDetailDrawer/JsonContent.vue'
import JsonContentPanel from './JsonContentPanel.vue'
import ConversationView from './RequestDetailDrawer/ConversationView.vue'
import BodyConversationContent from './RequestDetailDrawer/BodyConversationContent.vue'
import { BodyDocument } from '../utils/body-document'
import { BodyDocumentError, MAX_BODY_BYTES } from '../utils/body-document-protocol'
import { useToast } from '@/composables/useToast'
import HorizontalRequestTimeline from './HorizontalRequestTimeline.vue'
import ReplayDialog from './ReplayDialog.vue'
import ServiceTierFacts from './ServiceTierFacts.vue'
@@ -939,14 +971,6 @@ import {
resolveServiceTierFacts,
} from '../utils/service-tier'
//
import {
renderRequest,
renderResponse,
type RenderResult,
type RenderBlock,
} from '../conversation'
type RequestStateStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
const props = defineProps<{
@@ -1008,7 +1032,7 @@ const REQUEST_STATE_STATUSES = new Set<RequestStateStatus>([
const loading = ref(false)
const error = ref<string | null>(null)
const detail = ref<RequestDetail | null>(null)
const detail = shallowRef<RequestDetail | null>(null)
const detailUsageAvailable = computed(() => detail.value?.usage_available !== false)
const detailPricingAvailable = computed(() => (
detailUsageAvailable.value && detail.value?.usage_pricing_available !== false
@@ -1026,6 +1050,7 @@ const currentExpandDepth = ref(0)
const dataSource = ref<'client' | 'provider'>('provider')
const contentViewMode = ref<'json' | 'conversation'>('json')
const { copyToClipboard } = useClipboard()
const { error: showBodyError } = useToast()
const historicalPricing = ref<{
input_price: string
output_price: string
@@ -1405,7 +1430,10 @@ const curlCopied = ref(false)
const replayDialogOpen = ref(false)
const bodyLoading = ref(false)
const bodyLoadError = ref<string | null>(null)
const bodiesLoadedForRequestId = ref<string | null>(null)
const bodyDocuments = shallowRef(new Map<RequestBodyField, BodyDocument>())
const bodyCopying = ref(false)
const bodyLoadFailures = new Map<RequestBodyField, string>()
let bodyLoadController: AbortController | null = null
const showTimeline = ref(false)
const AUTO_REFRESH_INTERVAL_MS = 1000
const TIMELINE_MOUNT_DELAY_MS = 120
@@ -1457,10 +1485,6 @@ watch(activeTab, (newTab) => {
contentViewMode.value = 'json'
}
dataSource.value = getDefaultDataSourceForTab(newTab)
if (['request-body', 'response-body'].includes(newTab)) {
void ensureBodyContentLoaded()
}
})
const { isDark } = useDarkMode()
@@ -1666,6 +1690,33 @@ const hasResponseBodyAvailable = computed(() => {
|| hasBodyContent(detail.value?.has_client_response_body, detail.value?.client_response_body)
})
const requestBodyField = computed<RequestBodyField>(() => {
if (dataSource.value === 'provider' && hasBodyContent(detail.value?.has_provider_request_body, detail.value?.provider_request_body)) {
return 'provider_request_body'
}
return hasBodyContent(detail.value?.has_request_body, detail.value?.request_body)
? 'request_body' : 'provider_request_body'
})
const responseBodyField = computed<RequestBodyField>(() => {
if (dataSource.value === 'client' && hasBodyContent(detail.value?.has_client_response_body, detail.value?.client_response_body)) {
return 'client_response_body'
}
return hasBodyContent(detail.value?.has_response_body, detail.value?.response_body)
? 'response_body' : 'client_response_body'
})
const activeBodyField = computed(() => {
if (activeTab.value === 'request-body') return requestBodyField.value
if (activeTab.value === 'response-body') return responseBodyField.value
return null
})
watch([activeBodyField, () => props.isOpen, () => detail.value?.id], () => {
cancelBodyContentLoad()
void ensureBodyContentLoaded()
})
const isRequestBodyLoading = computed(() => {
return bodyLoading.value && activeTab.value === 'request-body' && !currentRequestBody.value
})
@@ -1676,16 +1727,23 @@ const isResponseBodyLoading = computed(() => {
const requestBodyLoadFailed = computed(() => {
const errors = detail.value?.body_load_errors
return Boolean(errors?.request_body || errors?.provider_request_body)
return Boolean(errors?.[requestBodyField.value])
})
const responseBodyLoadFailed = computed(() => {
const errors = detail.value?.body_load_errors
return Boolean(errors?.response_body || errors?.client_response_body)
return Boolean(errors?.[responseBodyField.value])
})
const bodyLoadErrorMessage = computed(() => {
return bodyLoadError.value || '正文内容加载失败,请重试'
return bodyLoadError.value || formatStoredBodyLoadError(
activeBodyField.value ? detail.value?.body_load_error_codes?.[activeBodyField.value] : undefined,
)
})
const canRetryBodyContentLoad = computed(() => {
const code = activeBodyField.value ? detail.value?.body_load_error_codes?.[activeBodyField.value] : undefined
return code !== 'too_large' && code !== 'decode_failed'
})
const requestBodyLoadErrorVisible = computed(() => {
@@ -1802,20 +1860,12 @@ function setDataSource(source: 'client' | 'provider') {
//
const currentRequestBody = computed(() => {
if (!detail.value) return null
if (dataSource.value === 'provider' && detail.value.provider_request_body) {
return detail.value.provider_request_body
}
return detail.value.request_body
return bodyDocuments.value.get(requestBodyField.value) ?? null
})
//
const currentResponseBody = computed(() => {
if (!detail.value) return null
if (dataSource.value === 'client' && detail.value.client_response_body) {
return detail.value.client_response_body
}
return detail.value.response_body
return bodyDocuments.value.get(responseBodyField.value) ?? null
})
const currentRequestBodyApiFormat = computed(() => {
@@ -1855,11 +1905,11 @@ const activeJsonPanelData = computed(() => {
case 'request-headers':
return currentHeaderData.value
case 'request-body':
return currentRequestBody.value
return null
case 'response-headers':
return currentResponseHeaderData.value
case 'response-body':
return currentResponseBody.value
return null
case 'metadata':
return metadataPanelData.value
default:
@@ -1873,30 +1923,6 @@ const activeJsonPanelTitle = computed(() => {
return 'JSON'
})
//
const requestRenderResult = computed<RenderResult>(() => {
const body = currentRequestBody.value
if (!body) {
return { blocks: [], isStream: false }
}
if (activeTab.value !== 'request-body' || contentViewMode.value !== 'conversation') {
return { blocks: [], isStream: false }
}
return renderRequest(body, currentResponseBody.value, currentRequestBodyApiFormat.value)
})
//
const responseRenderResult = computed<RenderResult>(() => {
const body = currentResponseBody.value
if (!body) {
return { blocks: [], isStream: false }
}
if (activeTab.value !== 'response-body' || contentViewMode.value !== 'conversation') {
return { blocks: [], isStream: false }
}
return renderResponse(body, currentRequestBody.value, currentResponseBodyApiFormat.value)
})
// Tab
const supportsConversationView = computed(() => {
return ['request-body', 'response-body'].includes(activeTab.value)
@@ -2376,15 +2402,6 @@ function hasContent(data: unknown): boolean {
return true
}
function hasBodyLoadErrors(errors: RequestDetail['body_load_errors'] | null | undefined): boolean {
return Boolean(
errors?.request_body ||
errors?.provider_request_body ||
errors?.response_body ||
errors?.client_response_body
)
}
function toFiniteNumber(value: unknown): number | null {
const num = Number(value)
return Number.isFinite(num) ? num : null
@@ -2581,9 +2598,11 @@ watch([() => props.isOpen, () => props.requestId], async ([isOpen, requestId]) =
stopAutoRefresh()
showTimeline.value = false
clearTimelineMountTimer()
bodyLoading.value = false
bodyLoadError.value = null
bodiesLoadedForRequestId.value = null
resetBodyContentState()
detail.value = null
activeTab.value = 'request-headers'
++loadDetailRequestId
loadDetailInFlight = false
}
})
@@ -2596,56 +2615,99 @@ function detailMatchesRequestId(
}
async function ensureBodyContentLoaded() {
if (!props.requestId || !detail.value) return
const cacheKey = detail.value.request_id || detail.value.id
if (bodiesLoadedForRequestId.value === cacheKey || bodyLoading.value) return
if (!hasRequestBodyAvailable.value && !hasResponseBodyAvailable.value) return
const field = activeBodyField.value
const usageId = props.requestId
if (!props.isOpen || !usageId || !field || !detailMatchesRequestId(detail.value, usageId)) return
if (!detail.value || bodyLoading.value) return
const cached = bodyDocuments.value.get(field)
if (cached) {
const documents = new Map(bodyDocuments.value)
documents.delete(field)
documents.set(field, cached)
bodyDocuments.value = documents
return
}
if (!hasBodyContent(detail.value[`has_${field}`], detail.value[field])) return
const previousFailure = bodyLoadFailures.get(field)
if (previousFailure) {
bodyLoadError.value = previousFailure
return
}
const requestId = ++bodyLoadRequestId
const controller = new AbortController()
bodyLoadController = controller
bodyLoading.value = true
bodyLoadError.value = null
try {
const response = await dashboardApi.getRequestDetail(props.requestId, { includeBodies: true })
if (requestId !== bodyLoadRequestId || !detail.value) return
detail.value = {
...detail.value,
request_body: response.request_body,
provider_request_body: response.provider_request_body,
response_body: response.response_body,
client_response_body: response.client_response_body,
has_request_body: response.has_request_body,
has_provider_request_body: response.has_provider_request_body,
has_response_body: response.has_response_body,
has_client_response_body: response.has_client_response_body,
body_load_errors: response.body_load_errors,
request_error: response.request_error,
upstream_error: response.upstream_error,
client_error: response.client_error,
failure_summary: response.failure_summary,
errors: response.errors,
error_flow: response.error_flow,
scheduling_failure: response.scheduling_failure,
const response = await dashboardApi.getRequestBody(detail.value.id, field, controller.signal)
const document = await BodyDocument.load(response.bytes, response.encoding, controller.signal)
if (requestId !== bodyLoadRequestId || controller.signal.aborted || !detail.value ||
!props.isOpen || !detailMatchesRequestId(detail.value, usageId)) {
document.dispose()
return
}
if (!hasBodyLoadErrors(response.body_load_errors)) {
bodiesLoadedForRequestId.value = cacheKey
const documents = new Map(bodyDocuments.value)
let cachedBytes = document.byteLength
for (const existing of documents.values()) cachedBytes += existing.byteLength
for (const [cachedField, existing] of documents) {
if (documents.size < 2 && cachedBytes <= MAX_BODY_BYTES) break
existing.dispose()
documents.delete(cachedField)
cachedBytes -= existing.byteLength
}
documents.set(field, document)
bodyDocuments.value = documents
detail.value = { ...detail.value, body_load_errors: { ...detail.value.body_load_errors, [field]: false }, body_load_error_codes: { ...detail.value.body_load_error_codes, [field]: undefined } }
} catch (err) {
if (requestId !== bodyLoadRequestId) return
if (requestId !== bodyLoadRequestId || controller.signal.aborted || axios.isCancel(err)) return
log.error('Failed to load request bodies:', err)
bodyLoadError.value = '正文内容加载失败,请重试'
bodyLoadError.value = formatRequestBodyLoadError(err)
bodyLoadFailures.set(field, bodyLoadError.value)
const code = err instanceof BodyDocumentError ? err.code : axios.isAxiosError(err) ? err.response?.headers?.['x-aether-body-error'] : undefined
if ((code === 'too_large' || code === 'decode_failed') && detail.value) {
detail.value = { ...detail.value, body_load_error_codes: { ...detail.value.body_load_error_codes, [field]: code } }
}
} finally {
if (requestId === bodyLoadRequestId) {
bodyLoading.value = false
bodyLoadController = null
}
}
}
function handleBodyDocumentError(error: unknown) {
const field = activeBodyField.value
if (!field) return
const documents = new Map(bodyDocuments.value)
documents.get(field)?.dispose()
documents.delete(field)
bodyDocuments.value = documents
bodyLoadError.value = formatRequestBodyLoadError(error)
bodyLoadFailures.set(field, bodyLoadError.value)
}
function retryBodyContentLoad() {
if (activeBodyField.value) bodyLoadFailures.delete(activeBodyField.value)
bodyLoadError.value = null
void ensureBodyContentLoaded()
}
function cancelBodyContentLoad() {
++bodyLoadRequestId
bodyLoadController?.abort()
bodyLoadController = null
bodyLoading.value = false
bodyLoadError.value = null
}
function resetBodyContentState() {
cancelBodyContentLoad()
for (const document of bodyDocuments.value.values()) document.dispose()
bodyDocuments.value = new Map()
bodyLoadFailures.clear()
}
async function loadDetail(id: string, silent = false) {
if (silent && loadDetailInFlight) {
return
@@ -2662,9 +2724,7 @@ async function loadDetail(id: string, silent = false) {
timelineHasTrace.value = false
showTimeline.value = false
clearTimelineMountTimer()
++bodyLoadRequestId
bodyLoading.value = false
bodyLoadError.value = null
resetBodyContentState()
}
error.value = null
try {
@@ -2678,13 +2738,18 @@ async function loadDetail(id: string, silent = false) {
const prevKey = previousDetail?.request_id || previousDetail?.id
const currKey = response.request_id || response.id
const sameRequest = !!prevKey && prevKey === currKey
const bodySnapshotChanged = sameRequest && previousDetail != null &&
['pending', 'streaming'].includes(previousDetail.status ?? '') && previousDetail.status !== response.status
const preserveBodies = sameRequest && !bodySnapshotChanged
const nextDetail: RequestDetail = {
...response,
status: resolveRequestStateStatusFromDetail(response) ?? response.status,
request_body: sameRequest ? previousDetail?.request_body : undefined,
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
response_body: sameRequest ? previousDetail?.response_body : undefined,
client_response_body: sameRequest ? previousDetail?.client_response_body : undefined,
request_body: preserveBodies ? previousDetail?.request_body : undefined,
provider_request_body: preserveBodies ? previousDetail?.provider_request_body : undefined,
response_body: preserveBodies ? previousDetail?.response_body : undefined,
client_response_body: preserveBodies ? previousDetail?.client_response_body : undefined,
body_load_errors: preserveBodies ? previousDetail?.body_load_errors : response.body_load_errors,
body_load_error_codes: preserveBodies ? previousDetail?.body_load_error_codes : response.body_load_error_codes,
request_error: response.request_error,
upstream_error: response.upstream_error,
client_error: response.client_error,
@@ -2695,7 +2760,7 @@ async function loadDetail(id: string, silent = false) {
}
detailModelRevision.value = ++modelSnapshotRevision
detail.value = nextDetail
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
if (!sameRequest || bodySnapshotChanged) resetBodyContentState()
emitDetailRequestState(nextDetail)
// tab body
@@ -2718,7 +2783,8 @@ async function loadDetail(id: string, silent = false) {
}
// Tab
dataSource.value = getDefaultDataSourceForTab(activeTab.value)
if (!silent || !sameRequest) dataSource.value = getDefaultDataSourceForTab(activeTab.value)
if (bodySnapshotChanged) void nextTick().then(ensureBodyContentLoaded)
// 使
if (detail.value.input_price_per_1m || detail.value.output_price_per_1m || detail.value.price_per_request) {
@@ -2844,6 +2910,7 @@ onBeforeUnmount(() => {
clearTimelineMountTimer()
loadDetailRequestId += 1
loadDetailInFlight = false
resetBodyContentState()
})
function formatDateTime(dateStr: string | null | undefined): string {
@@ -2936,108 +3003,37 @@ function getTierRangeText(tier: { up_to?: number | null }, index: number, tiers:
return `> ${formatNumber(start)} tokens`
}
/** 将 RenderResult 格式化为可复制的文本 */
function formatRenderResultAsText(result: RenderResult): string {
if (result.error) {
return `[Error] ${result.error}`
}
const parts: string[] = []
for (const block of result.blocks) {
const text = formatBlockAsText(block)
if (text) {
parts.push(text)
async function copyContent(tabName: string) {
if (!detail.value || viewMode.value === 'compare' || bodyCopying.value) return
const usageId = detail.value.id
const document = tabName === 'request-body' ? currentRequestBody.value
: tabName === 'response-body' ? currentResponseBody.value : null
bodyCopying.value = true
try {
let text: string
if (document) {
text = await document.copy(contentViewMode.value === 'conversation' ? {
kind: tabName === 'request-body' ? 'request' : 'response',
apiFormat: tabName === 'request-body' ? currentRequestBodyApiFormat.value : currentResponseBodyApiFormat.value,
} : undefined)
} else {
const data = tabName === 'request-headers' ? (dataSource.value === 'provider' ? detail.value.provider_request_headers : detail.value.request_headers)
: tabName === 'response-headers' ? currentResponseHeaderData.value
: tabName === 'metadata' ? metadataPanelData.value : null
if (data == null) return
text = JSON.stringify(data, null, 2)
}
}
return parts.join('\n\n---\n\n')
}
/** 将单个 RenderBlock 格式化为文本 */
function formatBlockAsText(block: RenderBlock): string {
switch (block.type) {
case 'text':
return block.content
case 'code':
return block.language
? `\`\`\`${block.language}\n${block.code}\n\`\`\``
: `\`\`\`\n${block.code}\n\`\`\``
case 'collapsible':
return `[${block.title}]\n${block.content.map(formatBlockAsText).filter(Boolean).join('\n')}`
case 'error':
return `[Error${block.code ? `: ${block.code}` : ''}] ${block.message}`
case 'image':
return `[Image: ${block.mimeType || block.alt || 'unknown'}]`
case 'tool_use':
return `[Tool: ${block.toolName}]\n${block.input}`
case 'tool_result':
return `[Tool Result${block.isError ? ' (Error)' : ''}]\n${block.content}`
case 'message': {
const roleLabel = block.roleLabel || block.role
const contentText = block.content.map(formatBlockAsText).filter(Boolean).join('\n\n')
return `[${roleLabel}]\n${contentText}`
if (!props.isOpen || detail.value?.id !== usageId) return
if (await copyToClipboard(text, false)) {
copiedStates.value[tabName] = true
setTimeout(() => { copiedStates.value[tabName] = false }, 2000)
} else {
showBodyError('复制失败,请检查剪贴板权限后重试。')
}
case 'container':
return block.children.map(formatBlockAsText).filter(Boolean).join('\n')
case 'label':
return `${block.label}: ${block.value}`
case 'divider':
return '---'
case 'badge':
return ''
default:
return ''
}
}
// JSON
function copyContent(tabName: string) {
if (!detail.value) return
if (viewMode.value === 'compare') return
let textToCopy = ''
//
if (contentViewMode.value === 'conversation') {
if (tabName === 'request-body') {
textToCopy = formatRenderResultAsText(requestRenderResult.value)
} else if (tabName === 'response-body') {
textToCopy = formatRenderResultAsText(responseRenderResult.value)
}
} else {
// JSON JSON
let data: unknown = null
switch (tabName) {
case 'request-headers':
data = dataSource.value === 'provider'
? detail.value.provider_request_headers
: detail.value.request_headers
break
case 'request-body':
data = currentRequestBody.value
break
case 'response-headers':
data = currentResponseHeaderData.value
break
case 'response-body':
data = currentResponseBody.value
break
case 'metadata':
data = metadataPanelData.value
break
}
if (data) {
textToCopy = JSON.stringify(data, null, 2)
}
}
if (textToCopy) {
copyToClipboard(textToCopy, false)
copiedStates.value[tabName] = true
setTimeout(() => {
copiedStates.value[tabName] = false
}, 2000)
} catch (error) {
if (props.isOpen && detail.value?.id === usageId) showBodyError(formatRequestBodyLoadError(error))
} finally {
bodyCopying.value = false
}
}
@@ -0,0 +1,75 @@
<template>
<div>
<div
v-if="!bodyDocument"
class="text-sm text-muted-foreground"
>
{{ emptyMessage }}
</div>
<VirtualBodyContent
v-else
:key="viewRevision"
ref="viewer"
:load-chunk="loadChunk"
:estimated-height="1000"
@load-error="emit('load-error', $event)"
>
<template #default="{ chunk, index }">
<ConversationView
:render-result="chunk.result"
:empty-message="emptyMessage"
embedded
/>
<div
v-if="chunk.truncated"
class="px-3 py-2 text-xs text-muted-foreground"
>
长内容仅显示预览复制仍保留完整内容
<button
v-if="(previewLimits[index] ?? 64_000) < 1_024_000"
type="button"
class="text-primary hover:underline"
@click="showMore(index)"
>
显示更多
</button>
</div>
</template>
</VirtualBodyContent>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import ConversationView from './ConversationView.vue'
import VirtualBodyContent from './VirtualBodyContent.vue'
import type { BodyDocument } from '../../utils/body-document'
import type { BodyConversationPage } from '../../utils/body-document-protocol'
const props = defineProps<{
bodyDocument: BodyDocument | null
kind: 'request' | 'response'
apiFormat?: string
emptyMessage: string
}>()
const emit = defineEmits<{ 'load-error': [error: unknown] }>()
const viewer = ref<{ refresh: (index: number) => void } | null>(null)
const viewRevision = ref(0)
const previewLimits = ref<Record<number, number>>({})
function loadChunk(index: number): Promise<BodyConversationPage> {
const document = props.bodyDocument
if (!document) return Promise.resolve({ result: { blocks: [] }, hasNext: false, truncated: false })
return document.conversation({ kind: props.kind, apiFormat: props.apiFormat, page: index, previewLimit: previewLimits.value[index] ?? 64_000 })
}
function showMore(index: number) {
previewLimits.value = { ...previewLimits.value, [index]: (previewLimits.value[index] ?? 64_000) + 64_000 }
viewer.value?.refresh(index)
}
watch([() => props.bodyDocument, () => props.kind, () => props.apiFormat], () => {
viewRevision.value += 1
previewLimits.value = {}
})
</script>
@@ -1,5 +1,5 @@
<template>
<div class="max-h-[500px] overflow-y-auto">
<div :class="{ 'max-h-[500px] overflow-y-auto': !embedded }">
<div class="flex flex-col gap-2 p-2">
<!-- 渲染错误提示 -->
<div
@@ -18,6 +18,17 @@
{{ emptyMessage }}
</div>
<template v-else-if="embedded">
<BlockRenderer :blocks="renderResult.blocks" />
<div
v-if="renderResult.isStream"
class="flex items-center gap-1.5 px-3 py-2 text-xs text-muted-foreground bg-muted/30 rounded-lg w-fit"
>
<Zap class="w-4 h-4" />
<span>流式响应</span>
</div>
</template>
<!-- 轮次视图 -->
<template v-else>
<!-- System Prompt -->
@@ -110,12 +121,14 @@
import { ref, computed, watch, nextTick } from 'vue'
import { AlertCircle, Zap, Settings, ChevronRight, ChevronDown } from 'lucide-vue-next'
import TurnCard from './TurnCard.vue'
import BlockRenderer from './BlockRenderer.vue'
import type { RenderResult } from '../../conversation'
import { groupRenderBlocksIntoTurns } from '../../conversation/grouper'
const props = defineProps<{
renderResult: RenderResult
emptyMessage: string
embedded?: boolean
}>()
//
@@ -1,406 +1,189 @@
<template>
<div>
<div
v-if="!data || (typeof data === 'object' && Object.keys(data).length === 0)"
v-if="!bodyDocument && (data == null || (typeof data === 'object' && (Array.isArray(data) ? data.length === 0 : Object.keys(data).length === 0)))"
class="text-sm text-muted-foreground"
>
{{ emptyMessage }}
</div>
<!-- 纯字符串数据 JSON 对象 -->
<Card
v-else-if="typeof data === 'string'"
class="bg-muted/30 overflow-hidden"
>
<div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
<pre class="text-xs font-mono whitespace-pre-wrap">{{ data }}</pre>
</div>
</Card>
<!-- JSON 响应 HTML 错误页面 -->
<Card
v-else-if="hasParseError"
class="bg-muted/30 overflow-hidden"
>
<div class="p-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800">
<div class="flex items-start gap-2">
<span class="text-amber-600 dark:text-amber-400 text-sm font-medium">Warning: 响应解析失败</span>
<span class="text-xs text-amber-700 dark:text-amber-300">{{ parseErrorMessage }}</span>
</div>
</div>
<div class="p-4 overflow-x-auto max-h-[500px] overflow-y-auto">
<pre class="text-xs font-mono whitespace-pre-wrap text-muted-foreground">{{ rawResponseContent }}</pre>
</div>
</Card>
<Card
v-else
class="bg-muted/30 overflow-hidden"
>
<!-- JSON 查看器 -->
<div
<VirtualBodyContent
:key="viewRevision"
ref="viewer"
class="json-viewer"
:class="{ 'theme-dark': isDark }"
:load-chunk="loadChunk"
@load-error="emit('load-error', $event)"
>
<div class="json-lines">
<template
v-for="line in visibleLines"
:key="line.displayId"
<template #default="{ chunk, index }">
<div
v-if="chunk.parseError && index === 0"
class="p-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800"
>
<div
class="json-line"
:class="{ 'has-fold': line.canFold }"
>
<!-- 行号区域包含折叠按钮 -->
<div class="line-number-area">
<span
v-if="line.canFold"
class="fold-button"
@click="toggleFold(line.blockId)"
>
<ChevronRight
v-if="collapsedBlocks.has(line.blockId)"
class="fold-icon"
/>
<ChevronDown
v-else
class="fold-icon"
/>
</span>
<span class="line-number">{{ line.displayLineNumber }}</span>
</div>
<!-- 内容区域 -->
<div class="line-content-area">
<!-- 缩进 -->
<span
class="indent"
:style="{ width: `${line.indent * 16}px` }"
/>
<!-- 内容 -->
<!-- eslint-disable vue/no-v-html -->
<span
class="line-content"
:class="{ 'clickable-collapsed': line.canFold && collapsedBlocks.has(line.blockId) }"
@click="line.canFold && collapsedBlocks.has(line.blockId) && toggleFold(line.blockId)"
v-html="getDisplayHtml(line)"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
<div class="flex items-start gap-2">
<span class="text-amber-600 dark:text-amber-400 text-sm font-medium">Warning: 响应解析失败</span>
<span class="text-xs text-amber-700 dark:text-amber-300">{{ chunk.parseError }}</span>
</div>
</template>
</div>
</div>
</div>
<div
v-if="chunk.text !== undefined"
class="px-4"
:class="{ 'pt-4': index === 0, 'pb-4': !chunk.hasNext }"
>
<pre class="text-xs font-mono whitespace-pre-wrap break-all">{{ chunk.text }}</pre>
</div>
<div
v-else
class="json-lines"
>
<template
v-for="line in chunk.lines"
:key="line.id"
>
<div
class="json-line"
:class="{ 'has-fold': line.canFold }"
:data-json-line="line.lineNumber"
>
<!-- 行号区域包含折叠按钮 -->
<div class="line-number-area">
<button
v-if="line.canFold"
class="fold-button"
type="button"
:aria-label="line.collapsed ? '展开节点' : '折叠节点'"
:aria-expanded="!line.collapsed"
@click="toggleFold(line, index)"
>
<ChevronRight
v-if="line.collapsed"
class="fold-icon"
/>
<ChevronDown
v-else
class="fold-icon"
/>
</button>
<span class="line-number">{{ line.continuation ? '' : line.lineNumber }}</span>
</div>
<!-- 内容区域 -->
<div class="line-content-area">
<!-- 缩进 -->
<span
class="indent"
:style="{ width: `${line.indent * 16}px` }"
/>
<!-- 内容 -->
<!-- eslint-disable vue/no-v-html -->
<span
class="line-content"
:class="{ 'clickable-collapsed': line.canFold && line.collapsed }"
@click="line.canFold && line.collapsed && toggleFold(line, index)"
v-html="getDisplayHtml(line)"
/>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
</template>
</div>
</template>
</VirtualBodyContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, watch } from 'vue'
import { ChevronRight, ChevronDown } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import VirtualBodyContent from './VirtualBodyContent.vue'
import { getRawTextChunk, JsonPageReader, JSON_SCROLL_CHUNK_SIZE, JSON_TEXT_CHUNK_SIZE, type JsonDisplayLine } from '../../utils/json-viewer'
import type { BodyDocument } from '../../utils/body-document'
import type { BodyJsonPage } from '../../utils/body-document-protocol'
interface JsonLine {
id: number
lineNumber: number
indent: number
html: string
canFold: boolean
blockId: string
blockEnd?: number
collapsedInfo?: string
closingBracket?: string
trailingComma?: string
}
interface DisplayLine extends JsonLine {
displayId: string
displayLineNumber: number
}
/** JSON data can be any serializable value: object, array, string, number, boolean, null */
const props = defineProps<{
data: unknown
bodyDocument?: BodyDocument | null
viewMode: 'formatted' | 'raw' | 'compare'
expandDepth: number
isDark: boolean
emptyMessage: string
}>()
/** Safely cast data to an object for property access in templates */
const dataAsObject = computed(() => {
if (props.data && typeof props.data === 'object' && !Array.isArray(props.data)) {
return props.data as Record<string, unknown>
}
return null
})
const emit = defineEmits<{ 'load-error': [error: unknown] }>()
const viewer = ref<{ refresh: (index: number, resetTail?: boolean) => void } | null>(null)
const viewRevision = ref(0)
const foldOverrides = ref(new Map<string, boolean>())
let localReader: JsonPageReader | undefined
/** Whether the data contains a raw_response with a parse error (non-JSON response) */
const hasParseError = computed(() => {
const obj = dataAsObject.value
if (!obj) return false
const metadata = obj.metadata as Record<string, unknown> | undefined
return Boolean(obj.raw_response && metadata?.parse_error)
})
/** Parse error message */
const parseErrorMessage = computed(() => {
const obj = dataAsObject.value
if (!obj) return ''
const metadata = obj.metadata as Record<string, unknown> | undefined
return String(metadata?.parse_error || '')
})
/** Raw response content */
const rawResponseContent = computed(() => {
const obj = dataAsObject.value
return obj ? String(obj.raw_response || '') : ''
})
const collapsedBlocks = ref<Set<string>>(new Set())
const lines = ref<JsonLine[]>([])
const getTokenHtml = (value: string, type: 'key' | 'string' | 'number' | 'boolean' | 'null' | 'bracket' | 'punctuation' | 'ellipsis'): string => {
const classMap = {
key: 'token-key',
string: 'token-string',
number: 'token-number',
boolean: 'token-boolean',
null: 'token-null',
bracket: 'token-bracket',
punctuation: 'token-punctuation',
ellipsis: 'token-ellipsis',
}
return `<span class="${classMap[type]}">${escapeHtml(value)}</span>`
function loadChunk(index: number): BodyJsonPage | Promise<BodyJsonPage> {
if (props.bodyDocument) return props.bodyDocument.json({
page: index,
pageSize: JSON_SCROLL_CHUNK_SIZE,
expandDepth: props.expandDepth,
foldOverrides: new Map(foldOverrides.value),
})
const record = props.data && typeof props.data === 'object' ? props.data as Record<string, unknown> : null
const metadata = record?.metadata as Record<string, unknown> | undefined
const parseError = record?.raw_response && metadata?.parse_error ? String(metadata.parse_error) : undefined
const text = typeof props.data === 'string' ? props.data : parseError ? String(record?.raw_response) : undefined
if (text !== undefined) return { lines: [], ...getRawTextChunk(text, index), parseError }
localReader ??= new JsonPageReader(props.data, { pageSize: JSON_SCROLL_CHUNK_SIZE, expandDepth: props.expandDepth, foldOverrides: new Map(foldOverrides.value), stringChunkSize: JSON_TEXT_CHUNK_SIZE })
return localReader.read(index)
}
const escapeHtml = (str: string): string => {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
function escapeHtml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
const jsonStringLiteral = (value: string): string => JSON.stringify(value)
function token(value: string, type: string): string {
return `<span class="token-${type}">${escapeHtml(value)}</span>`
}
const parseJsonToLines = (data: unknown): JsonLine[] => {
const result: JsonLine[] = []
let lineNumber = 1
let blockIdCounter = 0
function getDisplayHtml(line: JsonDisplayLine): string {
if (line.tokens) return line.tokens.map(part => part.type === 'info'
? `<span class="collapsed-info">${escapeHtml(part.text)}</span>` : token(part.text, part.type)).join('')
const key = line.key === undefined ? ''
: token(JSON.stringify(line.key), 'key') + token(': ', 'punctuation')
if (line.bracket) {
let content = key + token(line.bracket, 'bracket')
if (line.collapsed) {
if (line.childCount) content += token('...', 'ellipsis')
content += token(line.closingBracket || '', 'bracket') + line.comma
if (line.childCount) content += `<span class="collapsed-info">${line.childCount} ${line.isArray ? 'items' : 'keys'}</span>`
} else if (!line.canFold) {
content += line.comma
}
return content
}
if (line.value === null) return key + token('null', 'null') + line.comma
if (typeof line.value === 'string') {
return key + token(JSON.stringify(line.value), 'string') + line.comma
}
const valueType = typeof line.value
return key + token(String(line.value), valueType === 'number' || valueType === 'boolean' ? valueType : 'string') + line.comma
}
const getBlockId = () => `block-${blockIdCounter++}`
const processValue = (value: unknown, indent: number, isLast: boolean, keyPrefix: string = ''): void => {
const comma = isLast ? '' : ','
if (value === null) {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('null', 'null') + comma,
canFold: false,
blockId: '',
})
} else if (typeof value === 'boolean') {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(String(value), 'boolean') + comma,
canFold: false,
blockId: '',
})
} else if (typeof value === 'number') {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(String(value), 'number') + comma,
canFold: false,
blockId: '',
})
} else if (typeof value === 'string') {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(jsonStringLiteral(value), 'string') + comma,
canFold: false,
blockId: '',
})
} else if (Array.isArray(value)) {
if (value.length === 0) {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('[]', 'bracket') + comma,
canFold: false,
blockId: '',
})
} else {
const blockId = getBlockId()
const startLine = result.length
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('[', 'bracket'),
canFold: true,
blockId,
collapsedInfo: `${value.length} items`,
closingBracket: ']',
trailingComma: comma,
})
value.forEach((item, i) => {
processValue(item, indent + 1, i === value.length - 1)
})
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: getTokenHtml(']', 'bracket') + comma,
canFold: false,
blockId: '',
})
result[startLine].blockEnd = result.length - 1
}
} else if (typeof value === 'object') {
const obj = value as Record<string, unknown>
const keys = Object.keys(obj)
if (keys.length === 0) {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('{}', 'bracket') + comma,
canFold: false,
blockId: '',
})
} else {
const blockId = getBlockId()
const startLine = result.length
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml('{', 'bracket'),
canFold: true,
blockId,
collapsedInfo: `${keys.length} keys`,
closingBracket: '}',
trailingComma: comma,
})
keys.forEach((key, i) => {
const keyHtml = getTokenHtml(jsonStringLiteral(key), 'key') + getTokenHtml(': ', 'punctuation')
processValue(obj[key], indent + 1, i === keys.length - 1, keyHtml)
})
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: getTokenHtml('}', 'bracket') + comma,
canFold: false,
blockId: '',
})
result[startLine].blockEnd = result.length - 1
}
} else {
result.push({
id: result.length,
lineNumber: lineNumber++,
indent,
html: keyPrefix + getTokenHtml(String(value), 'string') + comma,
canFold: false,
blockId: '',
})
function toggleFold(line: JsonDisplayLine, index: number) {
const overrides = new Map(foldOverrides.value)
if (line.collapsed) {
for (const path of overrides.keys()) {
if (path.startsWith(`${line.id}/`)) overrides.delete(path)
}
}
processValue(data, 0, true)
return result
overrides.set(line.id, !line.collapsed)
foldOverrides.value = overrides
localReader = undefined
viewer.value?.refresh(index, true)
}
const visibleLines = computed((): DisplayLine[] => {
const result: DisplayLine[] = []
const hiddenRanges: Array<{ start: number; end: number }> = []
for (const line of lines.value) {
if (line.canFold && collapsedBlocks.value.has(line.blockId) && line.blockEnd !== undefined) {
hiddenRanges.push({ start: line.id + 1, end: line.blockEnd })
}
}
const isHidden = (id: number): boolean => {
return hiddenRanges.some(range => id >= range.start && id <= range.end)
}
let displayLineNumber = 1
for (const line of lines.value) {
if (!isHidden(line.id)) {
result.push({
...line,
displayId: `display-${line.id}`,
displayLineNumber: displayLineNumber++,
})
}
}
return result
})
const getDisplayHtml = (line: DisplayLine): string => {
if (line.canFold && collapsedBlocks.value.has(line.blockId)) {
const closingBracket = getTokenHtml(line.closingBracket || '}', 'bracket')
const ellipsis = getTokenHtml('...', 'ellipsis')
const comma = line.trailingComma || ''
return `${line.html}${ellipsis}${closingBracket}${comma}<span class="collapsed-info">${line.collapsedInfo}</span>`
}
return line.html
}
const toggleFold = (blockId: string) => {
const newSet = new Set(collapsedBlocks.value)
if (newSet.has(blockId)) {
newSet.delete(blockId)
} else {
newSet.add(blockId)
}
collapsedBlocks.value = newSet
}
const initCollapsedState = () => {
const newSet = new Set<string>()
// indent = 0indent >= 1
// expandDepth = 999
const depth = props.expandDepth === 0 ? 1 : props.expandDepth
if (depth < 999) {
for (const line of lines.value) {
if (line.canFold && line.indent >= depth) {
newSet.add(line.blockId)
}
}
}
collapsedBlocks.value = newSet
}
watch(() => props.data, () => {
if (props.data) {
lines.value = parseJsonToLines(props.data)
initCollapsedState()
} else {
lines.value = []
}
}, { immediate: true })
watch(() => props.expandDepth, () => {
initCollapsedState()
watch([() => props.data, () => props.bodyDocument, () => props.expandDepth], () => {
viewRevision.value += 1
localReader = undefined
foldOverrides.value = new Map()
})
</script>
@@ -413,10 +196,6 @@ watch(() => props.expandDepth, () => {
line-height: 20px;
}
.json-lines {
padding: 4px 0;
}
.json-line {
display: flex;
min-height: 20px;
@@ -0,0 +1,210 @@
<template>
<div
ref="viewport"
class="virtual-body-scroll"
tabindex="0"
:aria-busy="loading"
@scroll.passive="readScrollPosition"
>
<div
aria-hidden="true"
:style="{ height: `${offsets[visibleStart]}px` }"
/>
<div
v-for="index in visibleIndexes"
:key="index"
:ref="element => setChunkElement(index, element)"
:data-body-chunk="index"
:style="{ minHeight: chunks.has(index) ? (chunks.get(index)!.hasNext ? '250px' : undefined) : `${heights[index]}px` }"
>
<slot
v-if="chunks.has(index)"
:chunk="chunks.get(index)!"
:index="index"
/>
<div
v-else
class="p-4 text-sm text-muted-foreground"
role="status"
>
{{ failed ? '正文视图加载失败' : '正在后台准备正文视图…' }}
</div>
</div>
<div
aria-hidden="true"
:style="{ height: `${offsets[heights.length] - offsets[visibleEnd]}px` }"
/>
</div>
</template>
<script setup lang="ts" generic="Chunk extends { hasNext: boolean }">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch, type ComponentPublicInstance } from 'vue'
const props = withDefaults(defineProps<{
loadChunk: (index: number) => Chunk | Promise<Chunk>
estimatedHeight?: number
}>(), { estimatedHeight: 1000 })
const emit = defineEmits<{ 'load-error': [error: unknown] }>()
const viewport = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(500)
const heights = shallowRef([props.estimatedHeight])
const chunks = shallowRef(new Map<number, Chunk>())
const loading = ref(false)
const failed = ref(false)
const elements = new Map<number, HTMLElement>()
let observer: ResizeObserver | undefined
let generation = 0
let disposed = false
const offsets = computed(() => {
const positions = [0]
for (const height of heights.value) positions.push(positions[positions.length - 1] + height)
return positions
})
function indexAt(position: number) {
let lower = 0
let upper = heights.value.length
while (lower < upper) {
const middle = Math.floor((lower + upper) / 2)
if (offsets.value[middle + 1] <= position) lower = middle + 1
else upper = middle
}
return Math.min(lower, heights.value.length - 1)
}
const visibleStart = computed(() => indexAt(Math.max(0, scrollTop.value - 250)))
const visibleEnd = computed(() => Math.min(
heights.value.length,
visibleStart.value + 4,
indexAt(scrollTop.value + viewportHeight.value + 250) + 1,
))
const visibleIndexes = computed(() => Array.from(
{ length: visibleEnd.value - visibleStart.value },
(_value, offset) => visibleStart.value + offset,
))
function readScrollPosition() {
if (!viewport.value) return
scrollTop.value = viewport.value.scrollTop
viewportHeight.value = viewport.value.clientHeight || 500
}
function measureChunks() {
if (!viewport.value || disposed) return
const updated = [...heights.value]
let adjustment = 0
let changed = false
for (const [index, element] of elements) {
if (!chunks.value.has(index) || index >= updated.length) continue
const height = element.getBoundingClientRect().height
if (height <= 0 || Math.abs(height - updated[index]) < 0.5) continue
if (offsets.value[index + 1] <= viewport.value.scrollTop) adjustment += height - updated[index]
updated[index] = height
changed = true
}
if (changed) {
heights.value = updated
if (adjustment) viewport.value.scrollTop += adjustment
}
readScrollPosition()
}
function setChunkElement(index: number, element: Element | ComponentPublicInstance | null) {
const previous = elements.get(index)
if (previous) observer?.unobserve(previous)
if (element instanceof HTMLElement) {
elements.set(index, element)
observer?.observe(element)
} else {
elements.delete(index)
}
}
function evictDistantChunks() {
const retained = new Map(chunks.value)
for (const index of retained.keys()) {
if (index < visibleStart.value - 1 || index > visibleEnd.value) retained.delete(index)
}
if (retained.size !== chunks.value.size) chunks.value = retained
}
async function loadVisibleChunks() {
if (loading.value || disposed || failed.value) return
loading.value = true
try {
while (!disposed && !failed.value) {
const index = visibleIndexes.value.find(candidate => !chunks.value.has(candidate))
if (index === undefined) break
const currentGeneration = generation
try {
const result = await props.loadChunk(index)
if (disposed) return
if (currentGeneration !== generation) continue
const updated = [...heights.value]
if (result.hasNext && index === updated.length - 1) updated.push(props.estimatedHeight)
if (!result.hasNext) updated.length = index + 1
heights.value = updated
chunks.value = new Map(chunks.value).set(index, result)
evictDistantChunks()
await nextTick()
measureChunks()
} catch (error) {
if (disposed) return
if (currentGeneration !== generation) continue
failed.value = true
emit('load-error', error)
}
}
} finally {
loading.value = false
}
}
function refresh(index: number, resetTail = false) {
generation += 1
failed.value = false
const retained = new Map(chunks.value)
for (const candidate of retained.keys()) {
if (candidate === index || (resetTail && candidate > index)) retained.delete(candidate)
}
chunks.value = retained
if (resetTail) heights.value = heights.value.slice(0, index + 1)
void loadVisibleChunks()
}
watch([visibleStart, visibleEnd], () => {
evictDistantChunks()
void loadVisibleChunks()
}, { immediate: true })
onMounted(() => {
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(measureChunks)
if (viewport.value) observer.observe(viewport.value)
for (const element of elements.values()) observer.observe(element)
}
window.addEventListener('resize', measureChunks)
measureChunks()
})
onBeforeUnmount(() => {
disposed = true
generation += 1
observer?.disconnect()
elements.clear()
window.removeEventListener('resize', measureChunks)
})
defineExpose({ refresh })
</script>
<style scoped>
.virtual-body-scroll {
max-height: 500px;
overflow: auto;
overflow-anchor: none;
}
</style>
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, shallowRef, type App } from 'vue'
import JsonContent from '../RequestDetailDrawer/JsonContent.vue'
import BodyConversationContent from '../RequestDetailDrawer/BodyConversationContent.vue'
import { BodyDocumentEngine } from '../../utils/body-document-engine'
import type { BodyDocument } from '../../utils/body-document'
import type { BodyJsonOptions } from '../../utils/body-document-protocol'
const apps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => { for (const { app, root } of apps.splice(0)) { app.unmount(); root.remove() } })
function mountBody(value: unknown) {
const engine = new BodyDocumentEngine(value)
const json = vi.fn(async (options: BodyJsonOptions) => engine.json(options))
const bodyDocument = shallowRef({ json } as unknown as BodyDocument)
const errors: unknown[] = []
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({ setup: () => () => h(JsonContent, {
data: null, bodyDocument: bodyDocument.value, expandDepth: 999, viewMode: 'formatted', isDark: false, emptyMessage: '无数据',
onLoadError: (error: unknown) => errors.push(error),
}) }))
app.mount(root)
apps.push({ app, root })
return { root, json, bodyDocument, engine, errors }
}
function button(root: HTMLElement, label: string) { return [...root.querySelectorAll('button')].find(button => button.textContent?.includes(label))! }
describe('worker-backed body views', () => {
it('automatically reads worker chunks while scrolling and preserves the complete copy', async () => {
const values = Array.from({ length: 1000 }, (_value, index) => `value-${index}`)
const { root, json, engine } = mountBody(values)
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(50))
expect(root.textContent).not.toMatch(/上一页|下一页|第 1 页/)
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 20; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector(`[data-body-chunk="${index}"] .json-line`)).not.toBeNull())
expect(root.querySelectorAll('.json-line').length).toBeLessThanOrEqual(200)
}
expect(root.textContent).toContain('value-999')
expect(json).toHaveBeenLastCalledWith(expect.objectContaining({ page: 20, pageSize: 50 }))
expect(JSON.parse(engine.copy())).toEqual(values)
viewport.scrollTop = 0
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector('.line-number')?.textContent).toBe('1'))
})
it('streams every part of a long string without a show-more button', async () => {
const text = `${'x'.repeat(300_000) }STRING-END`
const { root, json, engine } = mountBody({ text })
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(50))
expect(root.textContent).not.toMatch(/显示更多|继续显示|STRING-END/)
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 3; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector(`[data-body-chunk="${index}"] .json-line`)).not.toBeNull())
expect(root.querySelectorAll('.json-line').length).toBeLessThanOrEqual(200)
}
expect(root.textContent).toContain('STRING-END')
expect(json).toHaveBeenLastCalledWith(expect.objectContaining({ page: 3, pageSize: 50 }))
expect(JSON.parse(engine.copy())).toEqual({ text })
})
it('folds nodes further down without resetting scroll position or dropping later content', async () => {
const values = Array.from({ length: 300 }, (_value, index) => ({ content: `message-${index}` }))
const { root, json, engine } = mountBody(values)
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(50))
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 3; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector(`[data-body-chunk="${index}"] .json-line`)).not.toBeNull())
}
root.querySelector<HTMLButtonElement>('[data-body-chunk="3"] button[aria-label="折叠节点"]')!.click()
await vi.waitFor(() => expect(root.querySelector('[data-body-chunk="3"] button[aria-label="展开节点"]')).not.toBeNull())
expect(viewport.scrollTop).toBe(3000)
expect(json).toHaveBeenLastCalledWith(expect.objectContaining({ page: 3, foldOverrides: expect.any(Map) }))
viewport.scrollTop = 4000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector('[data-body-chunk="4"] .json-line')).not.toBeNull())
expect(JSON.parse(engine.copy())).toEqual(values)
})
it('ignores obsolete worker replies and propagates active worker failures', async () => {
const { root, bodyDocument, errors } = mountBody({ initial: true })
await vi.waitFor(() => expect(root.textContent).toContain('initial'))
let complete!: (value: ReturnType<BodyDocumentEngine['json']>) => void
bodyDocument.value = { json: () => new Promise(resolve => { complete = resolve }) } as unknown as BodyDocument
await nextTick()
bodyDocument.value = { json: async () => new BodyDocumentEngine({ replacement: true }).json() } as unknown as BodyDocument
await vi.waitFor(() => expect(root.textContent).toContain('replacement'))
complete(new BodyDocumentEngine({ stale: true }).json())
await nextTick()
expect(root.textContent).not.toContain('stale')
const error = new Error('worker failed')
bodyDocument.value = { json: async () => { throw error } } as unknown as BodyDocument
await vi.waitFor(() => expect(errors).toEqual([error]))
})
it('scrolls through bounded conversation previews without page controls or nested scrollbars', async () => {
const conversation = vi.fn(async ({ page }: { page: number }) => ({ result: { blocks: [{ type: 'text', content: `message ${page}` }] }, hasNext: page < 8, truncated: true }))
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({ setup: () => () => h(BodyConversationContent, {
bodyDocument: { conversation } as unknown as BodyDocument, kind: 'request', apiFormat: 'openai:chat', emptyMessage: '无数据',
}) }))
app.mount(root)
apps.push({ app, root })
await vi.waitFor(() => expect(root.textContent).toContain('message 0'))
expect(root.textContent).toContain('复制仍保留完整内容')
expect(root.textContent).not.toMatch(/上一页|下一页|对话第/)
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 8; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.textContent).toContain(`message ${index}`))
expect(root.querySelectorAll('[data-body-chunk]').length).toBeLessThanOrEqual(4)
}
expect(root.textContent).not.toContain('message 0')
expect(root.querySelectorAll('.overflow-y-auto')).toHaveLength(0)
viewport.scrollTop = 0
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.textContent).toContain('message 0'))
button(root, '显示更多').click()
await vi.waitFor(() => expect(conversation).toHaveBeenLastCalledWith(expect.objectContaining({ page: 0, previewLimit: 128_000 })))
})
})
@@ -0,0 +1,96 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, shallowRef, type App } from 'vue'
import JsonContent from '../RequestDetailDrawer/JsonContent.vue'
import { JSON_PAGE_SIZE, JSON_SCROLL_CHUNK_SIZE, JSON_TEXT_CHUNK_SIZE } from '../../utils/json-viewer'
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
function mountJson(initialData: unknown, expandDepth = 999) {
const data = shallowRef(initialData)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup: () => () => h(JsonContent, { data: data.value, expandDepth, isDark: false, viewMode: 'formatted', emptyMessage: '无数据' }),
}))
app.mount(root)
mountedApps.push({ app, root })
return { root, data }
}
describe('JsonContent lazy rendering', () => {
it('scrolls continuously to the last line and back with bounded DOM and complete data', async () => {
const values = Array.from({ length: 1000 }, (_value, index) => `value-${index}`)
const { root, data } = mountJson(values)
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(JSON_SCROLL_CHUNK_SIZE))
expect(root.textContent).not.toMatch(/上一页|下一页|第 1 页/)
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 20; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector(`[data-body-chunk="${index}"] .json-line`)).not.toBeNull())
expect(root.querySelectorAll('.json-line').length).toBeLessThanOrEqual(JSON_PAGE_SIZE)
}
expect(root.textContent).toContain('value-999')
expect(root.textContent).not.toContain('value-0"')
viewport.scrollTop = 0
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector('.line-number')?.textContent).toBe('1'))
expect(root.textContent).toContain('value-0')
expect(data.value).toEqual(values)
data.value = { replacement: true }
await vi.waitFor(() => expect(root.textContent).toContain('replacement'))
expect(root.querySelector<HTMLElement>('.virtual-body-scroll')?.scrollTop).toBe(0)
})
it('opens the complete subtree with one bracket click', async () => {
const { root } = mountJson({ messages: [{ content: 'hidden content' }] }, 0)
await vi.waitFor(() => expect(root.querySelectorAll('.json-line')).toHaveLength(3))
expect(root.textContent).not.toContain('hidden content')
root.querySelector<HTMLButtonElement>('button[aria-label="展开节点"]')!.click()
await vi.waitFor(() => expect(root.textContent).toContain('hidden content'))
expect(root.querySelector('button[aria-label="展开节点"]')).toBeNull()
})
it('shows complete long strings without an extra expansion button', async () => {
const content = `${'x'.repeat(JSON_TEXT_CHUNK_SIZE * 10) }末尾🙂`
const { root, data } = mountJson({ content })
await vi.waitFor(() => expect(root.textContent).toContain('末尾🙂'))
const displayed = [...root.querySelectorAll('.token-string')].map(element => element.textContent).join('')
expect(displayed).toBe(JSON.stringify(content))
expect(root.textContent).not.toMatch(/显示更多|继续显示|剩余.*字符/)
expect(data.value).toEqual({ content })
})
it('automatically streams complete raw text and escapes HTML in JSON strings', async () => {
const text = `${'x'.repeat(100_000) }RAW-END`
const { root } = mountJson(text)
await vi.waitFor(() => expect(root.querySelector('pre')?.textContent).toHaveLength(16_000))
const chunks = [root.querySelector('pre')!.textContent]
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
for (let index = 1; index <= 6; index += 1) {
viewport.scrollTop = index * 1000
viewport.dispatchEvent(new Event('scroll'))
await vi.waitFor(() => expect(root.querySelector(`[data-body-chunk="${index}"] pre`)).not.toBeNull())
chunks.push(root.querySelector(`[data-body-chunk="${index}"] pre`)!.textContent)
}
expect(chunks.join('')).toBe(text)
expect(root.textContent).toContain('RAW-END')
expect(root.querySelector('button')).toBeNull()
const escaped = mountJson({ content: '<img src=x onerror="alert(1)">'.repeat(200) }).root
await vi.waitFor(() => expect(escaped.querySelector('.token-string')).not.toBeNull())
expect(escaped.querySelector('img')).toBeNull()
expect(escaped.textContent).toContain('<img')
})
it.each([false, 0])('renders %s as a value rather than an empty body', async value => {
const { root } = mountJson(value)
await vi.waitFor(() => expect(root.textContent).toContain(String(value)))
expect(root.textContent).not.toContain('无数据')
})
})
@@ -1,130 +1,258 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import type { RequestDetail } from '@/api/dashboard'
import { AxiosError } from 'axios'
import type { RequestBodyField, RequestDetail } from '@/api/dashboard'
import { BodyDocumentError } from '../../utils/body-document-protocol'
import RequestDetailDrawer from '../RequestDetailDrawer.vue'
const apiMocks = vi.hoisted(() => ({ getRequestDetail: vi.fn() }))
vi.mock('@/api/dashboard', async (importOriginal) => {
const mocks = vi.hoisted(() => ({ getRequestDetail: vi.fn(), getRequestBody: vi.fn(), load: vi.fn(), copyToClipboard: vi.fn() }))
vi.mock('@/composables/useClipboard', () => ({ useClipboard: () => ({ copyToClipboard: mocks.copyToClipboard }) }))
vi.mock('@/api/dashboard', async importOriginal => {
const actual = await importOriginal<typeof import('@/api/dashboard')>()
return {
...actual,
dashboardApi: { ...actual.dashboardApi, getRequestDetail: apiMocks.getRequestDetail },
}
return { ...actual, dashboardApi: { ...actual.dashboardApi, getRequestDetail: mocks.getRequestDetail, getRequestBody: mocks.getRequestBody } }
})
vi.mock('../../utils/body-document', () => ({ BodyDocument: { load: mocks.load } }))
vi.mock('../HorizontalRequestTimeline.vue', () => ({ default: { render: () => null } }))
vi.mock('../JsonContentPanel.vue', async () => {
vi.mock('../RequestDetailDrawer/JsonContent.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
props: { data: { type: null, default: null } },
setup(props) {
return () => h('pre', { 'data-testid': 'captured-body' }, JSON.stringify(props.data))
},
}),
}
return { default: defineComponent({
props: { data: { type: null, default: null }, bodyDocument: { type: Object, default: null } },
setup: props => () => h('pre', { 'data-testid': 'captured-body' }, JSON.stringify(props.bodyDocument?.display ?? props.data)),
}) }
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
const documents: Array<{ display: unknown, byteLength: number, dispose: ReturnType<typeof vi.fn>, copy: ReturnType<typeof vi.fn> }> = []
function body(value: unknown) { return { bytes: new TextEncoder().encode(JSON.stringify(value)).buffer, encoding: 'json' as const } }
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
apiMocks.getRequestDetail.mockReset()
beforeEach(() => {
mocks.getRequestDetail.mockImplementation(async id => ({ ...buildDetail(true), id, request_id: `req-${id}` }))
mocks.getRequestBody.mockImplementation(async (_id, field) => body({ text: field }))
mocks.copyToClipboard.mockResolvedValue(true)
mocks.load.mockImplementation(async (bytes: ArrayBuffer, _encoding, signal: AbortSignal) => {
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
const display = JSON.parse(new TextDecoder().decode(bytes))
const document = { display, byteLength: bytes.byteLength, dispose: vi.fn(), copy: vi.fn(async () => JSON.stringify(display, null, 2)) }
documents.push(document)
return document
})
})
afterEach(async () => {
for (const { app, root } of mountedApps.splice(0)) { app.unmount(); root.remove() }
await nextTick()
document.body.replaceChildren()
vi.useRealTimers()
vi.resetAllMocks()
documents.length = 0
})
function buildDetail(captured: boolean): RequestDetail {
return {
id: 'usage-full-capture',
request_id: 'req-full-capture',
id: 'usage-full-capture', request_id: 'req-full-capture',
user: { id: 'user-1', username: 'test-user', email: 'test@example.com' },
api_key: { id: 'key-1', name: 'test-key', display: 'test-key' },
provider: 'test-provider',
api_format: 'openai:chat',
model: 'test-model',
tokens: { input: 10, output: 20, total: 30 },
cost: { input: 0, output: 0, total: 0 },
request_type: 'chat',
is_stream: false,
status: 'completed',
status_code: 200,
response_time_ms: 10,
created_at: '2026-09-07T00:00:00Z',
provider: 'test-provider', api_format: 'openai:chat', model: 'test-model',
tokens: { input: 10, output: 20, total: 30 }, cost: { input: 0, output: 0, total: 0 },
request_type: 'chat', is_stream: false, status: 'completed', status_code: 200,
response_time_ms: 10, created_at: '2026-09-07T00:00:00Z',
request_headers: { 'content-type': 'application/json', authorization: '[redacted]' },
has_request_body: captured,
has_provider_request_body: false,
has_response_body: captured,
has_client_response_body: false,
has_request_body: captured, has_provider_request_body: false,
has_response_body: captured, has_client_response_body: false,
}
}
async function openDrawer() {
const isOpen = ref(false)
const Host = defineComponent({
setup: () => () => h(RequestDetailDrawer, {
isOpen: isOpen.value,
requestId: 'usage-full-capture',
}),
})
const requestId = ref('usage-full-capture')
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(Host)
const app = createApp(defineComponent({ setup: () => () => h(RequestDetailDrawer, { isOpen: isOpen.value, requestId: requestId.value }) }))
app.mount(root)
mountedApps.push({ app, root })
isOpen.value = true
await nextTick()
await vi.waitFor(() => {
expect(document.body.textContent).toContain('请求头')
})
await vi.waitFor(() => expect(findButton('请求头')).toBeDefined())
return { isOpen, requestId }
}
function findTab(label: string) {
return [...document.body.querySelectorAll('button')]
.find(button => button.textContent?.trim() === label)
function findButton(label: string) {
return [...document.body.querySelectorAll('button')].find(button => button.textContent?.trim() === label)
}
async function source(label: '客户端' | '提供商') {
await nextTick()
document.body.querySelector<HTMLButtonElement>(`button[title="${label}"]`)!.click()
await nextTick()
}
async function expectBody(text: string) {
await vi.waitFor(() => expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent).toContain(text))
}
function lastSignal() { return mocks.getRequestBody.mock.calls[mocks.getRequestBody.mock.calls.length - 1][2] as AbortSignal }
describe('RequestDetailDrawer body capture', () => {
it('keeps body tabs from shallow availability and loads full captures on demand', async () => {
const shallow = buildDetail(true)
const full: RequestDetail = {
...shallow,
request_body: { messages: [{ role: 'user', content: 'captured request text' }] },
response_body: { choices: [{ message: { role: 'assistant', content: 'captured response text' } }] },
}
apiMocks.getRequestDetail.mockImplementation(async (_requestId, options) => (
options?.includeBodies ? full : shallow
))
it('uses only shallow details and loads a single binary body on demand', async () => {
await openDrawer()
expect(findTab('请求体')).toBeDefined()
expect(findTab('响应体')).toBeDefined()
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(apiMocks.getRequestDetail).toHaveBeenCalledWith('usage-full-capture', expect.objectContaining({ includeBodies: false }))
findTab('请求体')!.click()
await vi.waitFor(() => {
expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent)
.toContain('captured request text')
})
expect(apiMocks.getRequestDetail).toHaveBeenLastCalledWith('usage-full-capture', { includeBodies: true })
findTab('响应体')!.click()
await nextTick()
expect(document.body.querySelector('[data-testid="captured-body"]')?.textContent)
.toContain('captured response text')
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(2)
expect(mocks.getRequestBody).not.toHaveBeenCalled()
expect(mocks.getRequestDetail).toHaveBeenCalledWith('usage-full-capture', expect.objectContaining({ includeBodies: false }))
findButton('请求体')!.click()
await expectBody('request_body')
expect(mocks.getRequestBody).toHaveBeenLastCalledWith('usage-full-capture', 'request_body', expect.any(AbortSignal))
findButton('响应体')!.click()
await expectBody('response_body')
findButton('请求体')!.click()
await expectBody('request_body')
expect(mocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(mocks.getRequestBody).toHaveBeenCalledTimes(2)
})
it('does not offer body tabs or fetch uncaptured bodies for basic records', async () => {
apiMocks.getRequestDetail.mockResolvedValue(buildDetail(false))
it('does not offer body tabs or fetch uncaptured basic records', async () => {
mocks.getRequestDetail.mockResolvedValue(buildDetail(false))
await openDrawer()
expect(findTab('请求体')).toBeUndefined()
expect(findTab('响应体')).toBeUndefined()
expect(apiMocks.getRequestDetail).toHaveBeenCalledTimes(1)
expect(findButton('请求体')).toBeUndefined()
expect(findButton('响应体')).toBeUndefined()
expect(mocks.getRequestBody).not.toHaveBeenCalled()
})
it('selects all four fields independently and evicts least-recently-used workers', async () => {
mocks.getRequestDetail.mockResolvedValue({ ...buildDetail(true), has_provider_request_body: true, has_client_response_body: true })
await openDrawer()
findButton('请求体')!.click()
await source('客户端')
await expectBody('request_body')
await source('提供商')
await expectBody('provider_request_body')
const client = documents.find(document => (document.display as { text: string }).text === 'request_body')!
findButton('响应体')!.click()
await source('提供商')
await expectBody('response_body')
expect(client.dispose).toHaveBeenCalledOnce()
await source('客户端')
await expectBody('client_response_body')
findButton('请求体')!.click()
await source('客户端')
await expectBody('request_body')
expect(mocks.getRequestBody.mock.calls.map(([, field]) => field)).toContain('provider_request_body')
expect(mocks.getRequestBody.mock.calls.filter(([, field]) => field === 'request_body')).toHaveLength(2)
})
it('cancels stale tab requests without caching their late responses', async () => {
let resolveBody!: (value: ReturnType<typeof body>) => void
mocks.getRequestBody.mockImplementationOnce(() => new Promise(resolve => { resolveBody = resolve }))
await openDrawer()
findButton('请求体')!.click()
await vi.waitFor(() => expect(mocks.getRequestBody).toHaveBeenCalledOnce())
const signal = lastSignal()
findButton('响应体')!.click()
await expectBody('response_body')
expect(signal.aborted).toBe(true)
resolveBody(body({ text: 'stale request' }))
await nextTick()
findButton('请求体')!.click()
await expectBody('request_body')
expect(document.body.textContent).not.toContain('stale request')
expect(mocks.getRequestBody).toHaveBeenCalledTimes(3)
})
it.each(['close', 'record'] as const)('cancels loading on %s and releases completed workers', async action => {
const host = await openDrawer()
findButton('请求体')!.click()
await expectBody('request_body')
mocks.getRequestBody.mockImplementationOnce(() => new Promise(() => undefined))
findButton('响应体')!.click()
await vi.waitFor(() => expect(mocks.getRequestBody).toHaveBeenCalledTimes(2))
const signal = lastSignal()
if (action === 'close') host.isOpen.value = false
else host.requestId.value = 'usage-other'
await vi.waitFor(() => expect(signal.aborted).toBe(true))
await vi.waitFor(() => expect(documents[0].dispose).toHaveBeenCalledOnce())
})
it('shows network timeout details and retries only the active body', async () => {
mocks.getRequestBody.mockRejectedValueOnce(new AxiosError('timeout', 'ECONNABORTED'))
await openDrawer()
findButton('请求体')!.click()
await vi.waitFor(() => expect(document.body.textContent).toContain('正文加载超时'))
findButton('重试')!.click()
await expectBody('request_body')
expect(mocks.getRequestBody.mock.calls.every(([, field]) => field === 'request_body')).toBe(true)
})
it('reopens on a lightweight tab without starting an obsolete body request', async () => {
const host = await openDrawer()
findButton('请求体')!.click()
await expectBody('request_body')
host.isOpen.value = false
await nextTick()
host.isOpen.value = true
await vi.waitFor(() => expect(mocks.getRequestDetail).toHaveBeenCalledTimes(2))
expect(mocks.getRequestBody).toHaveBeenCalledOnce()
expect(documents[0].dispose).toHaveBeenCalledOnce()
})
it.each(['too_large', 'decode_failed'] as const)('isolates non-retryable %s worker failures from other bodies', async code => {
mocks.load.mockRejectedValueOnce(new BodyDocumentError(code))
await openDrawer()
findButton('请求体')!.click()
await vi.waitFor(() => expect(document.body.textContent).toContain(code === 'too_large' ? '64 MiB' : '解析失败'))
expect(findButton('重试')).toBeUndefined()
findButton('响应体')!.click()
await expectBody('response_body')
findButton('请求体')!.click()
await vi.waitFor(() => expect(document.body.textContent).toContain(code === 'too_large' ? '64 MiB' : '解析失败'))
expect(mocks.getRequestBody).toHaveBeenCalledTimes(2)
})
it('reports missing storage from binary response headers without parsing an error body', async () => {
mocks.getRequestBody.mockRejectedValueOnce(new AxiosError('missing', 'ERR_BAD_REQUEST', undefined, undefined, {
status: 404, statusText: 'Not Found', headers: { 'x-aether-body-error': 'missing' }, data: new ArrayBuffer(0), config: { headers: {} } as never,
}))
await openDrawer()
findButton('请求体')!.click()
await vi.waitFor(() => expect(document.body.textContent).toContain('正文暂不可用'))
expect(findButton('重试')).toBeDefined()
expect(mocks.load).not.toHaveBeenCalled()
})
it('does not pass invalid binary responses to the worker', async () => {
mocks.getRequestBody.mockRejectedValueOnce(new Error('Invalid body response'))
await openDrawer()
findButton('请求体')!.click()
await vi.waitFor(() => expect(document.body.textContent).toContain('正文内容加载失败'))
expect(mocks.load).not.toHaveBeenCalled()
})
it('invalidates the worker cache when a streaming request finishes', async () => {
vi.useFakeTimers()
let finished = false
mocks.getRequestDetail.mockImplementation(async () => ({ ...buildDetail(true), status: finished ? 'completed' : 'streaming' }))
mocks.getRequestBody.mockImplementation(async (_id, field: RequestBodyField) => body({ field, text: finished ? 'final response' : 'partial response' }))
await openDrawer()
findButton('响应体')!.click()
await expectBody('partial response')
finished = true
await vi.advanceTimersByTimeAsync(1000)
await expectBody('final response')
expect(documents[0].dispose).toHaveBeenCalledOnce()
expect(mocks.getRequestBody).toHaveBeenCalledTimes(2)
})
it('enforces the decoded-byte cache budget, not just a worker count', async () => {
await openDrawer()
findButton('请求体')!.click()
await expectBody('request_body')
documents[0].byteLength = 64 * 1024 * 1024
findButton('响应体')!.click()
await expectBody('response_body')
expect(documents[0].dispose).toHaveBeenCalledOnce()
})
it('requests full copy from the worker only after an explicit click', async () => {
const value = { text: 'x'.repeat(100_000), last: 'complete content' }
mocks.getRequestBody.mockResolvedValue(body(value))
await openDrawer()
findButton('请求体')!.click()
await expectBody('complete content')
expect(documents[0].copy).not.toHaveBeenCalled()
document.body.querySelector<HTMLButtonElement>('button[title="复制"]')!.click()
await vi.waitFor(() => expect(mocks.copyToClipboard).toHaveBeenCalledWith(JSON.stringify(value, null, 2), false))
expect(documents[0].copy).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
import VirtualBodyContent from '../RequestDetailDrawer/VirtualBodyContent.vue'
type Chunk = { hasNext: boolean, text: string }
const apps: Array<{ app: App, root: HTMLElement }> = []
afterEach(() => {
for (const { app, root } of apps.splice(0)) { app.unmount(); root.remove() }
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function mountChunks(loadChunk: (index: number) => Chunk | Promise<Chunk>) {
const errors: unknown[] = []
const viewer = ref<{ refresh: (index: number, resetTail?: boolean) => void } | null>(null)
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({ setup: () => () => h(VirtualBodyContent<Chunk>, {
ref: viewer,
loadChunk,
onLoadError: (error: unknown) => errors.push(error),
}, { default: ({ chunk }: { chunk: Chunk }) => h('div', { class: 'chunk-text' }, chunk.text) }) }))
app.mount(root)
apps.push({ app, root })
const viewport = root.querySelector<HTMLElement>('.virtual-body-scroll')!
return { app, root, viewport, viewer, errors }
}
function scroll(viewport: HTMLElement, top: number) {
viewport.scrollTop = top
viewport.dispatchEvent(new Event('scroll'))
}
describe('virtual body scrolling', () => {
it('keeps neighboring chunks for smooth boundaries, evicts distant ones and reloads on return', async () => {
const loadChunk = vi.fn((index: number) => ({ text: `chunk-${index}`, hasNext: index < 20 }))
const { root, viewport } = mountChunks(loadChunk)
await vi.waitFor(() => expect(root.textContent).toContain('chunk-0'))
scroll(viewport, 800)
await vi.waitFor(() => expect(root.textContent).toContain('chunk-1'))
expect(root.textContent).toContain('chunk-0')
scroll(viewport, 0)
await nextTick()
expect(loadChunk.mock.calls.filter(([index]) => index === 0)).toHaveLength(1)
for (let index = 1; index <= 12; index += 1) {
scroll(viewport, index * 1000)
await vi.waitFor(() => expect(root.textContent).toContain(`chunk-${index}`))
expect(root.querySelectorAll('[data-body-chunk]').length).toBeLessThanOrEqual(4)
}
expect(root.textContent).not.toContain('chunk-0')
scroll(viewport, 0)
await vi.waitFor(() => expect(root.textContent).toContain('chunk-0'))
expect(loadChunk.mock.calls.filter(([index]) => index === 0)).toHaveLength(2)
})
it('deduplicates rapid scrolling, ignores obsolete refresh results and stops after the end', async () => {
const pending: Array<(value: Chunk) => void> = []
const loadChunk = vi.fn(() => new Promise<Chunk>(resolve => pending.push(resolve)))
const { root, viewport, viewer } = mountChunks(loadChunk)
for (let count = 0; count < 10; count += 1) scroll(viewport, 100)
await nextTick()
expect(loadChunk).toHaveBeenCalledTimes(1)
viewer.value!.refresh(0)
pending[0]({ text: 'obsolete', hasNext: true })
await vi.waitFor(() => expect(loadChunk).toHaveBeenCalledTimes(2))
expect(root.textContent).not.toContain('obsolete')
pending[1]({ text: 'complete', hasNext: false })
await vi.waitFor(() => expect(root.textContent).toContain('complete'))
scroll(viewport, 5000)
await nextTick()
expect(loadChunk).toHaveBeenCalledTimes(2)
})
it('ignores errors after unmount and disconnects its resize observer', async () => {
const disconnect = vi.fn()
vi.stubGlobal('ResizeObserver', class {
observe = vi.fn()
unobserve = vi.fn()
disconnect = disconnect
})
let reject!: (reason: unknown) => void
const { app, errors } = mountChunks(() => new Promise((_resolve, rejectPromise) => { reject = rejectPromise }))
app.unmount()
apps.pop()!.root.remove()
reject(new Error('late failure'))
await nextTick()
expect(errors).toEqual([])
expect(disconnect).toHaveBeenCalledTimes(1)
})
it('updates variable chunk heights while preserving an already-visible scroll anchor', async () => {
let notifyResize!: () => void
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) { notifyResize = callback }
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
})
const measured = new Map([[0, 1000], [1, 1000], [2, 1000]])
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return { height: measured.get(Number(this.dataset.bodyChunk)) ?? 0 } as DOMRect
})
const { root, viewport } = mountChunks(index => ({ text: `chunk-${index}`, hasNext: index < 3 }))
await vi.waitFor(() => expect(root.textContent).toContain('chunk-0'))
scroll(viewport, 1100)
await vi.waitFor(() => expect(root.textContent).toContain('chunk-1'))
measured.set(0, 1600)
notifyResize()
await nextTick()
expect(viewport.scrollTop).toBe(1700)
expect(root.textContent).toContain('chunk-1')
})
it('fills short conversation chunks without leaving an empty visible window', async () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return { height: this.hasAttribute('data-body-chunk') ? 250 : 0 } as DOMRect
})
const { root, viewport } = mountChunks(index => ({ text: `chunk-${index}`, hasNext: index < 20 }))
await vi.waitFor(() => expect(root.textContent).toContain('chunk-2'))
for (let position = 0; position < 2500; position += 100) {
scroll(viewport, position)
const expected = Math.floor(position / 250)
await vi.waitFor(() => expect(root.textContent).toContain(`chunk-${expected}`))
expect(root.querySelectorAll('.chunk-text').length).toBeLessThanOrEqual(4)
}
})
})
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { gzipSync } from 'node:zlib'
import { BodyDocumentEngine, decodeBody } from '../body-document-engine'
import { JSON_PAGE_SIZE, JSON_TEXT_CHUNK_SIZE } from '../json-viewer'
function bytes(value: string) { return new TextEncoder().encode(value).buffer }
function gzip(value: string) { return Uint8Array.from(gzipSync(value)).buffer }
describe('body document decoding', () => {
it.each(['gzip', 'json'] as const)('decodes %s off the UI protocol with a byte count', async encoding => {
const text = JSON.stringify({ text: '你好🙂', count: 0, enabled: false })
const decoded = await decodeBody(encoding === 'gzip' ? gzip(text) : bytes(text), encoding)
expect(decoded.value).toEqual(JSON.parse(text))
expect(decoded.byteLength).toBe(bytes(text).byteLength)
})
it('enforces decompressed size while streaming, including exact boundaries', async () => {
const text = JSON.stringify('x'.repeat(100_000))
await expect(decodeBody(gzip(text), 'gzip', text.length)).resolves.toHaveProperty('byteLength', text.length)
await expect(decodeBody(gzip(text), 'gzip', text.length - 1)).rejects.toHaveProperty('code', 'too_large')
await expect(decodeBody(bytes(text), 'json', text.length - 1)).rejects.toHaveProperty('code', 'too_large')
})
it('rejects corrupt gzip, invalid JSON and invalid UTF-8 with safe codes', async () => {
await expect(decodeBody(bytes('not gzip'), 'gzip')).rejects.toHaveProperty('code', 'decode_failed')
await expect(decodeBody(gzip('not json'), 'gzip')).rejects.toHaveProperty('code', 'decode_failed')
await expect(decodeBody(new Uint8Array([34, 255, 34]).buffer, 'json')).rejects.toHaveProperty('code', 'decode_failed')
})
})
describe('worker-owned body previews', () => {
it('sends bounded rows and strings, while copy preserves every byte of content', () => {
const value = { messages: Array.from({ length: 6000 }, () => ({ content: 'x'.repeat(4000) })) }
const document = new BodyDocumentEngine(value)
const page = document.json({ expandDepth: 999 })
expect(page.lines).toHaveLength(JSON_PAGE_SIZE)
expect(page.hasNext).toBe(true)
expect(page.lines.some(line => line.continuation)).toBe(true)
expect(page.lines.every(line => (line.tokens ?? []).reduce((length, token) => length + token.text.length, 0) <= JSON_TEXT_CHUNK_SIZE)).toBe(true)
const next = document.json({ page: 1, expandDepth: 999 })
expect(new Set([...page.lines, ...next.lines].map(line => line.id)).size).toBe(JSON_PAGE_SIZE * 2)
expect(JSON.parse(document.copy())).toEqual(value)
})
it('keeps collapsed children unread and identifiers small even with giant keys', () => {
const hidden = new Proxy({}, { ownKeys: () => { throw new Error('must remain lazy') } })
const document = new BodyDocumentEngine({ parent: { hidden } })
expect(document.json({ expandDepth: 0 }).lines).toHaveLength(3)
const key = 'k'.repeat(100_000)
const value = 'v'.repeat(10_000)
const giant = new BodyDocumentEngine({ [key]: value })
const lines = giant.json().lines
expect(lines.every(line => line.id.length < 40)).toBe(true)
expect(lines.filter(line => line.lineNumber === 2).flatMap(line => line.tokens ?? []).map(token => token.text).join('')).toBe(`${JSON.stringify(key)}: ${JSON.stringify(value)}`)
expect(JSON.parse(giant.copy())).toEqual({ [key]: value })
})
it('supports smaller scrolling batches without relaxing the worker transfer limit', () => {
const value = Array.from({ length: 1000 }, (_value, index) => `message-${index}`)
const document = new BodyDocumentEngine(value)
expect(document.json({ pageSize: 50 }).lines).toHaveLength(50)
expect(document.json({ pageSize: 50, page: 1 }).lines[0].lineNumber).toBe(51)
expect(document.json({ pageSize: 10_000 }).lines).toHaveLength(JSON_PAGE_SIZE)
expect(document.json({ pageSize: 50, page: 20 }).hasNext).toBe(false)
expect(JSON.parse(document.copy())).toEqual(value)
})
it('streams all raw text and parse-error response chunks without shipping the full string at once', () => {
const text = 'x'.repeat(100_000)
for (const value of [text, { raw_response: text, metadata: { parse_error: 'invalid upstream response' } }]) {
const document = new BodyDocumentEngine(value)
expect(document.json().text).toHaveLength(16_000)
expect(document.json().hasNext).toBe(true)
const chunks = []
for (let page = 0; page < 10; page += 1) {
const chunk = document.json({ page })
chunks.push(chunk.text)
if (!chunk.hasNext) break
}
expect(chunks.join('')).toBe(text)
expect(JSON.parse(document.copy())).toEqual(value)
}
})
it('paginates conversation blocks and bounds long text without truncating copy', () => {
const content = 'message content '.repeat(10_000)
const document = new BodyDocumentEngine({ model: 'test', messages: Array.from({ length: 40 }, () => ({ role: 'user', content })) })
const options = { kind: 'request' as const, apiFormat: 'openai:chat' }
const preview = document.conversationPage(options)
expect(preview.hasNext).toBe(true)
expect(preview.truncated).toBe(true)
expect(JSON.stringify(preview).length).toBeLessThan(70_000)
expect(document.copy(options)).toContain(content)
expect(document.conversationPage({ ...options, page: 1 }).result.blocks.length).toBeGreaterThan(0)
})
it.each([null, false, 0])('keeps the primitive %s as a valid document', value => {
const document = new BodyDocumentEngine(value)
expect(document.json().lines[0].value).toBe(value)
expect(document.copy()).toBe(JSON.stringify(value))
})
})
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BodyDocument } from '../body-document'
import { BODY_WORKER_TIMEOUT, type BodyWorkerResponse } from '../body-document-protocol'
class FakeWorker {
static instances: FakeWorker[] = []
onmessage: ((event: MessageEvent<BodyWorkerResponse>) => void) | null = null
onerror: (() => void) | null = null
onmessageerror: (() => void) | null = null
postMessage = vi.fn()
terminate = vi.fn()
constructor() { FakeWorker.instances.push(this) }
reply(data: BodyWorkerResponse) { this.onmessage?.({ data } as MessageEvent<BodyWorkerResponse>) }
}
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); FakeWorker.instances = [] })
function start() {
vi.stubGlobal('Worker', FakeWorker)
const bytes = new ArrayBuffer(16)
const controller = new AbortController()
const loading = BodyDocument.load(bytes, 'gzip', controller.signal)
const worker = FakeWorker.instances[FakeWorker.instances.length - 1]
return { bytes, controller, loading, worker }
}
describe('body worker lifecycle', () => {
it('transfers compressed bytes and receives only a summary before requesting pages', async () => {
const { bytes, loading, worker } = start()
expect(worker.postMessage).toHaveBeenCalledWith({ id: 1, action: 'load', bytes, encoding: 'gzip' }, [bytes])
worker.reply({ id: 1, ok: true, result: { byteLength: 100_000 } })
const document = await loading
expect(document.byteLength).toBe(100_000)
const page = document.json({ page: 0 })
worker.reply({ id: 2, ok: true, result: { lines: [], hasNext: true } })
await expect(page).resolves.toEqual({ lines: [], hasNext: true })
document.dispose()
expect(worker.terminate).toHaveBeenCalledOnce()
})
it('terminates in-progress decompression on abort and ignores a late response', async () => {
const { loading, controller, worker } = start()
const rejected = expect(loading).rejects.toHaveProperty('name', 'AbortError')
controller.abort()
worker.reply({ id: 1, ok: true, result: { byteLength: 500 } })
await rejected
expect(worker.terminate).toHaveBeenCalledOnce()
})
it('does not return a disposed handle if abort races with the load response', async () => {
const { loading, controller, worker } = start()
const rejected = expect(loading).rejects.toHaveProperty('name', 'AbortError')
worker.reply({ id: 1, ok: true, result: { byteLength: 500 } })
controller.abort()
await rejected
expect(worker.terminate).toHaveBeenCalledOnce()
})
it('terminates workers that fail, cannot deserialize, or time out', async () => {
vi.useFakeTimers()
for (const mode of ['error', 'messageerror', 'timeout']) {
const { loading, worker } = start()
const rejected = expect(loading).rejects.toHaveProperty('code', mode === 'timeout' ? 'timeout' : 'worker_failed')
if (mode === 'error') worker.onerror?.()
else if (mode === 'messageerror') worker.onmessageerror?.()
else await vi.advanceTimersByTimeAsync(BODY_WORKER_TIMEOUT)
await rejected
expect(worker.terminate).toHaveBeenCalledOnce()
}
})
it('does not silently fall back to parsing on the main thread', async () => {
vi.stubGlobal('Worker', undefined)
await expect(BodyDocument.load(new ArrayBuffer(1), 'json')).rejects.toHaveProperty('code', 'unsupported')
})
})
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { AxiosError, type AxiosResponse } from 'axios'
import { formatRequestBodyLoadError, formatStoredBodyLoadError } from '../body-load-error'
import { RequestBodyProtocolError } from '@/api/dashboard'
describe('body load error messages', () => {
it('distinguishes timeouts, network failures, HTTP failures and storage errors', () => {
expect(formatRequestBodyLoadError(new AxiosError('timeout', 'ECONNABORTED'))).toContain('30 秒')
expect(formatRequestBodyLoadError(new AxiosError('offline', 'ERR_NETWORK'))).toContain('无法连接服务器')
for (const status of [403, 404, 500]) {
const error = new AxiosError('failed', undefined, undefined, undefined, { status } as AxiosResponse)
const expected = status === 403 ? '没有权限' : status === 404 ? '不存在' : 'HTTP 500'
expect(formatRequestBodyLoadError(error)).toContain(expected)
}
expect(formatStoredBodyLoadError('too_large')).toContain('64 MiB')
expect(formatStoredBodyLoadError('decode_failed')).toContain('解压或 JSON 解析失败')
expect(formatStoredBodyLoadError('missing')).toContain('正文暂不可用')
expect(formatStoredBodyLoadError('storage_unavailable')).toContain('读取正文存储失败')
expect(formatStoredBodyLoadError()).toContain('读取正文存储失败')
expect(formatRequestBodyLoadError(new RequestBodyProtocolError())).toContain('前后端均已更新')
})
})
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import { getJsonPage, getRawTextChunk, JsonPageReader, JSON_PAGE_SIZE, RAW_TEXT_CHUNK_SIZE } from '../json-viewer'
describe('lazy JSON pages', () => {
it('does not read descendants of collapsed nodes', () => {
const readContent = vi.fn(() => { throw new Error('collapsed content must not be read') })
const message = Object.defineProperty({}, 'largeContent', { enumerable: true, get: readContent })
const result = getJsonPage({ messages: [message] })
expect(result.lines).toHaveLength(3)
expect(result.lines[1]).toMatchObject({ id: '$/messages', collapsed: true, childCount: 1 })
expect(readContent).not.toHaveBeenCalled()
})
it('stops traversal after a page and one lookahead even when everything is expanded', () => {
const values = new Array(100_000).fill('small value')
const readBeyondPage = vi.fn(() => { throw new Error('off-page data must not be read') })
Object.defineProperty(values, JSON_PAGE_SIZE + 1, { get: readBeyondPage })
const result = getJsonPage(values, { expandDepth: 999 })
expect(result.lines).toHaveLength(JSON_PAGE_SIZE)
expect(result.hasNext).toBe(true)
expect(readBeyondPage).not.toHaveBeenCalled()
})
it('keeps complete ordered JSON lines across pages and collision-free node ids', () => {
const data = { nested: [{ value: 1 }, null, false, 0], 'nested:close': 'text', 'a/b': {}, 'a~1b': [] }
const expected = getJsonPage(data, { expandDepth: 999, pageSize: 1000 }).lines
const actual = []
for (let page = 0; page < 20; page += 1) {
const result = getJsonPage(data, { expandDepth: 999, pageSize: 3, page })
actual.push(...result.lines)
expect(result.lines.length).toBeLessThanOrEqual(3)
if (!result.hasNext) break
}
expect(actual).toEqual(expected)
expect(new Set(actual.map(line => line.id)).size).toBe(actual.length)
})
it('expands only explicitly opened descendants and preserves full string data', () => {
const content = 'large body '.repeat(10_000)
const data = { messages: [{ content }] }
const result = getJsonPage(data, { foldOverrides: new Map([['$/messages', false], ['$/messages/0', false]]) })
expect(result.lines.find(line => line.key === 'content')?.value).toBe(content)
expect(result.hasNext).toBe(false)
expect(data.messages[0].content).toBe(content)
})
it('reuses the traversal cursor for consecutive chunks and safely reloads evicted chunks', () => {
const reads = vi.fn()
const data = new Proxy(new Array(1000).fill('value'), {
get(target, key, receiver) {
if (typeof key === 'string' && /^\d+$/.test(key)) reads(key)
return Reflect.get(target, key, receiver)
},
})
const reader = new JsonPageReader(data, { pageSize: 50, expandDepth: 999 })
for (let page = 0; page < 20; page += 1) {
expect(reader.read(page).lines[0].lineNumber).toBe(page * 50 + 1)
}
expect(reads).toHaveBeenCalledTimes(1000)
expect(reader.read(19).lines[0].lineNumber).toBe(951)
expect(reads).toHaveBeenCalledTimes(1000)
expect(reader.read(0).lines[0].lineNumber).toBe(1)
expect(reader.read(20).lines[1]?.lineNumber).toBe(1002)
expect(reader.read(20).hasNext).toBe(false)
expect(reader.read(21).lines).toEqual([])
})
it('expands all descendants of an opened node while honoring an explicitly closed child', () => {
const data = { messages: [{ content: { text: 'complete content' } }] }
const opened = getJsonPage(data, { expandDepth: 1, foldOverrides: new Map([['$/messages', false]]) })
expect(opened.lines.some(line => line.value === 'complete content')).toBe(true)
const folded = getJsonPage(data, { expandDepth: 1, foldOverrides: new Map([['$/messages', false], ['$/messages/0/content', true]]) })
expect(folded.lines.some(line => line.value === 'complete content')).toBe(false)
})
it.each(['中文🙂', '"\\\n\r\t', '\u0000\b\f', '\ud800', '<img src=x>'])('streams complete escaped keys and strings for %j', text => {
const key = `key-${text.repeat(100)}`
const value = text.repeat(1000)
const reader = new JsonPageReader({ [key]: value }, { pageSize: 3, stringChunkSize: 31 })
const lines = []
for (let page = 0; page < 10_000; page += 1) {
const chunk = reader.read(page)
lines.push(...chunk.lines)
if (!chunk.hasNext) break
}
const tokens = lines.filter(line => line.lineNumber === 2).flatMap(line => line.tokens ?? [])
expect(tokens.map(token => token.text).join('')).toBe(`${JSON.stringify(key)}: ${JSON.stringify(value)}`)
expect(new Set(lines.map(line => line.id)).size).toBe(lines.length)
expect(tokens.every(token => token.text.length <= 31 && !/[\ud800-\udbff]$/.test(token.text))).toBe(true)
expect(lines.filter(line => line.lineNumber === 2 && !line.continuation)).toHaveLength(1)
})
it('keeps raw text Unicode pairs complete across automatic chunk boundaries', () => {
const value = `${'x'.repeat(RAW_TEXT_CHUNK_SIZE - 1) }🙂${ '中'.repeat(RAW_TEXT_CHUNK_SIZE) }RAW-END`
const first = getRawTextChunk(value)
const second = getRawTextChunk(value, 1)
const last = getRawTextChunk(value, 2)
expect(first.text.endsWith('🙂')).toBe(true)
expect(first.text + second.text + last.text).toBe(value)
expect(last.hasNext).toBe(false)
})
})
@@ -0,0 +1,144 @@
import { renderRequest, renderResponse, type RenderBlock, type RenderResult } from '../conversation'
import { getRawTextChunk, JsonPageReader, JSON_PAGE_SIZE, JSON_TEXT_CHUNK_SIZE } from './json-viewer'
import {
BodyDocumentError, MAX_BODY_BYTES, MAX_ENCODED_BODY_BYTES,
type BodyConversationOptions, type BodyConversationPage, type BodyEncoding,
type BodyJsonOptions, type BodyJsonPage,
} from './body-document-protocol'
export async function decodeBody(bytes: ArrayBuffer, encoding: BodyEncoding, limit = MAX_BODY_BYTES) {
if (bytes.byteLength > MAX_ENCODED_BODY_BYTES) throw new BodyDocumentError('too_large')
if (encoding === 'gzip' && typeof globalThis.DecompressionStream === 'undefined') {
throw new BodyDocumentError('unsupported')
}
const source = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(bytes))
controller.close()
},
})
const stream = encoding === 'gzip' ? source.pipeThrough(new DecompressionStream('gzip')) : source
const reader = stream.getReader()
const decoder = new TextDecoder('utf-8', { fatal: true })
const parts: string[] = []
let byteLength = 0
try {
while (true) {
const chunk = await reader.read()
if (chunk.done) break
byteLength += chunk.value.byteLength
if (byteLength > limit) throw new BodyDocumentError('too_large')
parts.push(decoder.decode(chunk.value, { stream: true }))
}
parts.push(decoder.decode())
return { value: JSON.parse(parts.join('')) as unknown, byteLength }
} catch (error) {
await reader.cancel().catch(() => undefined)
if (error instanceof BodyDocumentError) throw error
throw new BodyDocumentError('decode_failed')
} finally {
reader.releaseLock()
}
}
export class BodyDocumentEngine {
private conversation?: { key: string, result: RenderResult }
private jsonReader?: { key: string, reader: JsonPageReader }
constructor(private readonly value: unknown) {}
json(options: BodyJsonOptions = {}): BodyJsonPage {
const record = this.value && typeof this.value === 'object' ? this.value as Record<string, unknown> : null
const metadata = record?.metadata as Record<string, unknown> | undefined
const parseError = record?.raw_response && metadata?.parse_error ? String(metadata.parse_error) : undefined
const text = typeof this.value === 'string' ? this.value : parseError ? String(record?.raw_response) : undefined
if (text !== undefined) {
return { lines: [], ...getRawTextChunk(text, options.page), parseError: parseError?.slice(0, 2000) }
}
const pageSize = Math.min(JSON_PAGE_SIZE, Math.max(1, Math.trunc(options.pageSize ?? JSON_PAGE_SIZE)))
const key = JSON.stringify([pageSize, options.expandDepth, [...(options.foldOverrides ?? [])]])
if (this.jsonReader?.key !== key) {
this.jsonReader = { key, reader: new JsonPageReader(this.value, { ...options, pageSize, indexPaths: true, stringChunkSize: JSON_TEXT_CHUNK_SIZE }) }
}
return this.jsonReader.reader.read(options.page)
}
private render(options: BodyConversationOptions): RenderResult {
const key = `${options.kind}:${options.apiFormat ?? ''}`
if (this.conversation?.key !== key) {
this.conversation = { key, result: options.kind === 'request'
? renderRequest(this.value, undefined, options.apiFormat)
: renderResponse(this.value, undefined, options.apiFormat) }
}
return this.conversation.result
}
conversationPage(options: BodyConversationOptions): BodyConversationPage {
const result = this.render(options)
const first = Math.max(0, options.page ?? 0) * 10
let remaining = Math.min(Math.max(options.previewLimit ?? 64_000, 1000), 1_024_000)
let remainingBlocks = 200
let truncated = false
const clipString = (value: string) => {
const preview = value.slice(0, remaining)
remaining -= preview.length
if (preview.length === value.length) return value
truncated = true
return `${preview}…(内容较长,复制可获取完整内容)`
}
const clipBlocks = (blocks: RenderBlock[], depth = 0): RenderBlock[] => {
const previews: RenderBlock[] = []
for (const block of blocks) {
if (remainingBlocks <= 0 || remaining <= 0 || depth > 30) { truncated = true; break }
remainingBlocks -= 1
const preview = { ...block }
for (const key of Object.keys(preview)) {
const record = preview as unknown as Record<string, unknown>
const value = record[key]
if (typeof value === 'string' && key !== 'type' && key !== 'role') {
if (key === 'src' && value.length > remaining) {
record[key] = undefined
record.alt = '图片较大,请复制正文查看完整内容'
truncated = true
} else {
record[key] = clipString(value)
}
} else if (Array.isArray(value)) {
record[key] = clipBlocks(value, depth + 1)
}
}
previews.push(preview)
}
return previews
}
return {
result: { blocks: clipBlocks(result.blocks.slice(first, first + 10)), isStream: result.isStream, error: result.error?.slice(0, 2000) },
hasNext: result.blocks.length > first + 10,
truncated,
}
}
copy(conversation?: BodyConversationOptions): string {
if (!conversation) return JSON.stringify(this.value, null, 2)
const result = this.render(conversation)
if (result.error) return `[Error] ${result.error}`
return result.blocks.map(formatBlockAsText).filter(Boolean).join('\n\n---\n\n')
}
}
function formatBlockAsText(block: RenderBlock): string {
switch (block.type) {
case 'text': return block.content
case 'code': return `\`\`\`${block.language || ''}\n${block.code}\n\`\`\``
case 'collapsible': return `[${block.title}]\n${block.content.map(formatBlockAsText).filter(Boolean).join('\n')}`
case 'error': return `[Error${block.code ? `: ${block.code}` : ''}] ${block.message}`
case 'image': return `[Image: ${block.mimeType || block.alt || 'unknown'}]`
case 'tool_use': return `[Tool: ${block.toolName}]\n${block.input}`
case 'tool_result': return `[Tool Result${block.isError ? ' (Error)' : ''}]\n${block.content}`
case 'message': return `[${block.roleLabel || block.role}]\n${block.content.map(formatBlockAsText).filter(Boolean).join('\n\n')}`
case 'container': return block.children.map(formatBlockAsText).filter(Boolean).join('\n')
case 'label': return `${block.label}: ${block.value}`
case 'divider': return '---'
case 'badge': return ''
}
}
@@ -0,0 +1,54 @@
import type { RenderResult } from '../conversation'
import type { JsonDisplayLine, JsonPageOptions } from './json-viewer'
export const MAX_BODY_BYTES = 64 * 1024 * 1024
export const MAX_ENCODED_BODY_BYTES = MAX_BODY_BYTES + 1024 * 1024
export const BODY_WORKER_TIMEOUT = 30_000
export type BodyEncoding = 'gzip' | 'json'
export type BodyDocumentErrorCode = 'too_large' | 'decode_failed' | 'unsupported' | 'worker_failed' | 'timeout'
export class BodyDocumentError extends Error {
constructor(public readonly code: BodyDocumentErrorCode) {
super(code)
this.name = 'BodyDocumentError'
}
}
export type BodyJsonOptions = JsonPageOptions
export interface BodyJsonPage {
lines: JsonDisplayLine[]
hasNext: boolean
text?: string
parseError?: string
}
export interface BodyConversationOptions {
kind: 'request' | 'response'
apiFormat?: string
page?: number
previewLimit?: number
}
export interface BodyConversationPage {
result: RenderResult
hasNext: boolean
truncated: boolean
}
export interface BodyDocumentSummary {
byteLength: number
}
export type BodyWorkerCommand =
| { action: 'load', bytes: ArrayBuffer, encoding: BodyEncoding }
| { action: 'json', options: BodyJsonOptions }
| { action: 'conversation', options: BodyConversationOptions }
| { action: 'copy', conversation?: BodyConversationOptions }
export type BodyWorkerResult = BodyDocumentSummary | BodyJsonPage | BodyConversationPage | string
export type BodyWorkerRequest = BodyWorkerCommand & { id: number }
export type BodyWorkerResponse = { id: number } & (
| { ok: true, result: BodyWorkerResult }
| { ok: false, code: BodyDocumentErrorCode }
)
@@ -0,0 +1,88 @@
import {
BODY_WORKER_TIMEOUT, BodyDocumentError,
type BodyConversationOptions, type BodyConversationPage, type BodyDocumentSummary,
type BodyEncoding, type BodyJsonOptions, type BodyJsonPage,
type BodyWorkerCommand, type BodyWorkerResponse, type BodyWorkerResult,
} from './body-document-protocol'
export class BodyDocument {
readonly kind = 'usage-body-document'
private sequence = 0
private disposed = false
private pending = new Map<number, { resolve: (result: BodyWorkerResult) => void, reject: (error: Error) => void, timer: ReturnType<typeof setTimeout> }>()
byteLength = 0
private constructor(private readonly worker: Worker) {
worker.onmessage = ({ data }: MessageEvent<BodyWorkerResponse>) => {
const pending = this.pending.get(data.id)
if (!pending) return
clearTimeout(pending.timer)
this.pending.delete(data.id)
if (data.ok) pending.resolve(data.result)
else pending.reject(new BodyDocumentError(data.code))
}
worker.onerror = () => this.dispose(new BodyDocumentError('worker_failed'))
worker.onmessageerror = () => this.dispose(new BodyDocumentError('worker_failed'))
}
static async load(bytes: ArrayBuffer, encoding: BodyEncoding, signal?: AbortSignal): Promise<BodyDocument> {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
if (typeof Worker === 'undefined') throw new BodyDocumentError('unsupported')
let document: BodyDocument
try {
document = new BodyDocument(new Worker(new URL('./body-document.worker.ts', import.meta.url), { type: 'module' }))
} catch {
throw new BodyDocumentError('worker_failed')
}
const abort = () => document.dispose()
signal?.addEventListener('abort', abort, { once: true })
try {
const result = await document.request<BodyDocumentSummary>({ action: 'load', bytes, encoding }, [bytes])
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
document.byteLength = result.byteLength
return document
} catch (error) {
document.dispose()
throw error
} finally {
signal?.removeEventListener('abort', abort)
}
}
private request<Result extends BodyWorkerResult>(command: BodyWorkerCommand, transfer: Transferable[] = []): Promise<Result> {
if (this.disposed) return Promise.reject(new BodyDocumentError('worker_failed'))
const id = ++this.sequence
return new Promise<Result>((resolve, reject) => {
const timer = setTimeout(() => this.dispose(new BodyDocumentError('timeout')), BODY_WORKER_TIMEOUT)
this.pending.set(id, { resolve: value => resolve(value as Result), reject, timer })
try {
this.worker.postMessage({ ...command, id }, transfer)
} catch {
this.dispose(new BodyDocumentError('worker_failed'))
}
})
}
json(options: BodyJsonOptions): Promise<BodyJsonPage> {
return this.request({ action: 'json', options })
}
conversation(options: BodyConversationOptions): Promise<BodyConversationPage> {
return this.request({ action: 'conversation', options })
}
copy(conversation?: BodyConversationOptions): Promise<string> {
return this.request({ action: 'copy', conversation })
}
dispose(error: Error = new DOMException('Aborted', 'AbortError')) {
if (this.disposed) return
this.disposed = true
this.worker.terminate()
for (const pending of this.pending.values()) {
clearTimeout(pending.timer)
pending.reject(error)
}
this.pending.clear()
}
}
@@ -0,0 +1,29 @@
import { BodyDocumentEngine, decodeBody } from './body-document-engine'
import { BodyDocumentError, type BodyWorkerRequest, type BodyWorkerResponse, type BodyWorkerResult } from './body-document-protocol'
const scope = globalThis as unknown as {
onmessage: (event: MessageEvent<BodyWorkerRequest>) => void
postMessage: (message: BodyWorkerResponse) => void
}
let document: BodyDocumentEngine | undefined
scope.onmessage = async ({ data: request }) => {
try {
let result: BodyWorkerResult
if (request.action === 'load') {
const decoded = await decodeBody(request.bytes, request.encoding)
document = new BodyDocumentEngine(decoded.value)
result = { byteLength: decoded.byteLength }
} else {
if (!document) throw new BodyDocumentError('worker_failed')
switch (request.action) {
case 'json': result = document.json(request.options); break
case 'conversation': result = document.conversationPage(request.options); break
case 'copy': result = document.copy(request.conversation); break
}
}
scope.postMessage({ id: request.id, ok: true, result })
} catch (error) {
scope.postMessage({ id: request.id, ok: false, code: error instanceof BodyDocumentError ? error.code : 'worker_failed' })
}
}
@@ -0,0 +1,40 @@
import axios from 'axios'
import { RequestBodyProtocolError, type RequestBodyLoadErrorCode } from '@/api/dashboard'
import { NETWORK_CONFIG } from '@/config/constants'
import { BodyDocumentError } from './body-document-protocol'
export function formatStoredBodyLoadError(code?: RequestBodyLoadErrorCode): string {
switch (code) {
case 'too_large':
return '正文超过 64 MiB 的安全读取上限,无法在线预览;重复重试无法解决此问题。'
case 'decode_failed':
return '正文解压或 JSON 解析失败,请检查该条记录的存储数据。'
case 'missing':
return '正文暂不可用,可能尚未写入或已被清理,请稍后重试。'
default:
return '读取正文存储失败,请稍后重试。'
}
}
export function formatRequestBodyLoadError(error: unknown): string {
if (error instanceof RequestBodyProtocolError) return '正文接口响应不匹配,请确认前后端均已更新后刷新页面。'
if (error instanceof BodyDocumentError) {
if (error.code === 'too_large' || error.code === 'decode_failed') return formatStoredBodyLoadError(error.code)
if (error.code === 'unsupported') return '当前浏览器不支持正文后台解压,请升级浏览器后重试。'
if (error.code === 'timeout') return '浏览器后台处理正文超时,请重试或使用性能更好的设备。'
return '正文后台处理失败,请重新加载正文。'
}
if (axios.isAxiosError(error)) {
const bodyCode = error.response?.headers?.['x-aether-body-error']
if (bodyCode === 'too_large' || bodyCode === 'missing' || bodyCode === 'storage_unavailable') return formatStoredBodyLoadError(bodyCode)
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
const seconds = Math.ceil((error.config?.timeout || NETWORK_CONFIG.API_TIMEOUT) / 1000)
return `正文加载超时(${seconds} 秒),请检查网络后重试。`
}
if (error.response?.status === 403) return '没有权限查看该正文。'
if (error.response?.status === 404) return '请求记录不存在或已被清理。'
if (error.response) return `正文加载失败(HTTP ${error.response.status}),请稍后重试。`
return '正文加载失败,无法连接服务器,请检查网络后重试。'
}
return '正文内容加载失败,请重试。'
}
@@ -0,0 +1,213 @@
export const JSON_PAGE_SIZE = 200
export const JSON_SCROLL_CHUNK_SIZE = 50
export const JSON_TEXT_CHUNK_SIZE = 2000
export const RAW_TEXT_CHUNK_SIZE = 16_000
export interface JsonDisplayToken {
text: string
type: string
}
export interface JsonDisplayLine {
id: string
lineNumber: number
indent: number
key?: string
value?: unknown
bracket?: string
closingBracket?: string
comma: string
canFold: boolean
collapsed: boolean
childCount?: number
isArray?: boolean
tokens?: JsonDisplayToken[]
continuation?: boolean
}
export interface JsonPageOptions {
page?: number
pageSize?: number
expandDepth?: number
foldOverrides?: ReadonlyMap<string, boolean>
indexPaths?: boolean
stringChunkSize?: number
}
type JsonTreeLine = Omit<JsonDisplayLine, 'lineNumber'>
function splitsSurrogatePair(value: string, position: number) {
const previous = value.charCodeAt(position - 1)
const next = value.charCodeAt(position)
return previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff
}
export function getRawTextChunk(value: string, index = 0) {
const offset = Math.max(0, Math.trunc(index)) * RAW_TEXT_CHUNK_SIZE
const boundary = Math.min(offset + RAW_TEXT_CHUNK_SIZE, value.length)
const start = offset + (splitsSurrogatePair(value, offset) ? 1 : 0)
const end = boundary + (splitsSurrogatePair(value, boundary) ? 1 : 0)
return { text: value.slice(start, end), hasNext: end < value.length }
}
function* quotedTokens(value: string, type: string, chunkSize: number): Generator<JsonDisplayToken> {
yield { text: '"', type }
let offset = 0
while (offset < value.length) {
let end = Math.min(offset + chunkSize, value.length)
if (splitsSurrogatePair(value, end)) end -= 1
yield { text: JSON.stringify(value.slice(offset, end)).slice(1, -1), type }
offset = end
}
yield { text: '"', type }
}
function* lineTokens(line: JsonTreeLine, chunkSize: number): Generator<JsonDisplayToken> {
if (line.key !== undefined) {
yield* quotedTokens(line.key, 'key', chunkSize)
yield { text: ': ', type: 'punctuation' }
}
if (line.bracket) {
yield { text: line.bracket, type: 'bracket' }
if (line.collapsed) {
if (line.childCount) yield { text: '...', type: 'ellipsis' }
yield { text: line.closingBracket ?? '', type: 'bracket' }
yield { text: line.comma, type: 'punctuation' }
if (line.childCount) yield { text: `${line.childCount} ${line.isArray ? 'items' : 'keys'}`, type: 'info' }
} else if (!line.canFold) {
yield { text: line.comma, type: 'punctuation' }
}
} else {
if (typeof line.value === 'string') yield* quotedTokens(line.value, 'string', chunkSize)
else yield { text: String(line.value), type: line.value === null ? 'null' : typeof line.value }
yield { text: line.comma, type: 'punctuation' }
}
}
function* splitJsonLine(line: JsonTreeLine, chunkSize: number): Generator<JsonTreeLine> {
if ((line.key?.length ?? 0) + (typeof line.value === 'string' ? line.value.length : 0) + 10 <= chunkSize) {
yield line
return
}
let tokens: JsonDisplayToken[] = []
let length = 0
let part = 0
const fragment = (): JsonTreeLine => ({
...line,
id: part === 0 ? line.id : `fragment:${line.id}:${part}`,
key: undefined,
value: undefined,
tokens,
continuation: part > 0,
canFold: part === 0 && line.canFold,
})
for (const token of lineTokens(line, chunkSize)) {
let offset = 0
while (offset < token.text.length) {
let end = Math.min(offset + chunkSize - length, token.text.length)
if (splitsSurrogatePair(token.text, end)) end -= 1
if (end === offset) {
yield fragment()
tokens = []
length = 0
part += 1
continue
}
tokens.push({ type: token.type, text: token.text.slice(offset, end) })
length += end - offset
offset = end
if (length === chunkSize) {
yield fragment()
tokens = []
length = 0
part += 1
}
}
}
if (tokens.length) yield fragment()
}
function* walkJsonLines(data: unknown, options: JsonPageOptions): Generator<JsonDisplayLine> {
const depth = options.expandDepth === 0 || options.expandDepth == null ? 1 : options.expandDepth
function* walk(value: unknown, path: string, indent: number, comma: string, key?: string, expandedParent = false): Generator<JsonTreeLine> {
if (value == null || typeof value !== 'object') {
yield { id: path, indent, key, value, comma, canFold: false, collapsed: false }
return
}
const isArray = Array.isArray(value)
const keys = isArray ? [] : Object.keys(value)
const childCount = isArray ? value.length : keys.length
const override = options.foldOverrides?.get(path)
const expandedSubtree = expandedParent || override === false
const collapsed = childCount === 0 || (override ?? (!expandedSubtree && indent >= depth))
const bracket = isArray ? '[' : '{'
const closingBracket = isArray ? ']' : '}'
yield { id: path, indent, key, comma, bracket, closingBracket, childCount, isArray, collapsed, canFold: childCount > 0 }
if (collapsed) return
for (let index = 0; index < childCount; index += 1) {
const childKey = isArray ? String(index) : keys[index]
const childPath = `${path}/${options.indexPaths ? index : childKey.replace(/~/g, '~0').replace(/\//g, '~1')}`
const childValue = isArray ? value[index] : (value as Record<string, unknown>)[childKey]
yield* walk(childValue, childPath, indent + 1, index === childCount - 1 ? '' : ',', isArray ? undefined : childKey, expandedSubtree)
}
yield { id: `close:${path}`, indent, bracket: closingBracket, comma, canFold: false, collapsed: false }
}
let lineNumber = 0
for (const line of walk(data, '$', 0, '')) {
lineNumber += 1
if (options.stringChunkSize) {
for (const fragment of splitJsonLine(line, Math.max(2, options.stringChunkSize))) yield { ...fragment, lineNumber }
} else {
yield { ...line, lineNumber }
}
}
}
export class JsonPageReader {
private readonly pageSize: number
private iterator: Generator<JsonDisplayLine>
private nextLine: IteratorResult<JsonDisplayLine>
private nextPage = 0
private readonly cache = new Map<number, { lines: JsonDisplayLine[], hasNext: boolean }>()
constructor(private readonly data: unknown, private readonly options: JsonPageOptions = {}) {
this.pageSize = Math.max(1, Math.trunc(options.pageSize ?? JSON_PAGE_SIZE))
this.iterator = walkJsonLines(data, options)
this.nextLine = this.iterator.next()
}
read(page = 0): { lines: JsonDisplayLine[], hasNext: boolean } {
const requested = Math.max(0, Math.trunc(page))
const cached = this.cache.get(requested)
if (cached) return cached
if (requested < this.nextPage) {
this.iterator = walkJsonLines(this.data, this.options)
this.nextLine = this.iterator.next()
this.nextPage = 0
}
while (this.nextPage <= requested) {
const lines: JsonDisplayLine[] = []
while (lines.length < this.pageSize && !this.nextLine.done) {
lines.push(this.nextLine.value)
this.nextLine = this.iterator.next()
}
const result = { lines, hasNext: !this.nextLine.done }
this.cache.delete(this.nextPage)
this.cache.set(this.nextPage, result)
const oldest = this.cache.keys().next().value
if (this.cache.size > 6 && oldest !== undefined) this.cache.delete(oldest)
this.nextPage += 1
if (this.nextPage > requested) return result
if (!result.hasNext) return { lines: [], hasNext: false }
}
return { lines: [], hasNext: false }
}
}
export function getJsonPage(data: unknown, options: JsonPageOptions = {}) {
return new JsonPageReader(data, options).read(options.page)
}
@@ -63,7 +63,7 @@
<div
v-else-if="cardView"
class="grid grid-cols-1 gap-4 p-4 sm:p-6 md:grid-cols-2 2xl:grid-cols-3"
class="grid grid-cols-[repeat(auto-fill,minmax(min(100%,22rem),1fr))] gap-4 p-4 sm:p-6"
>
<ProviderCard
v-for="provider in displayedProviders"
@@ -190,6 +190,32 @@ describe('ProviderManagement card view', () => {
expect(apiMocks.getProvidersSummary).toHaveBeenCalledTimes(requests)
})
it('fills available width with shared grid columns and keeps bounded card content scrollable', async () => {
apiMocks.getProvidersSummary.mockResolvedValue({
items: Array.from({ length: 5 }, (_, index) => createProvider({ id: `provider-${index + 1}` })),
total: 5,
})
localStorage.setItem('aether-provider-card-view', 'true')
const root = await mountView()
const card = root.querySelector<HTMLElement>('[data-provider-sort-id="provider-1"]')!
const lastCard = root.querySelector<HTMLElement>('[data-provider-sort-id="provider-5"]')!
const [header, content, actions] = Array.from(card.children)
expect(Array.from(card.classList)).toEqual(expect.arrayContaining(['max-h-96', 'w-full']))
expect(card.classList.contains('max-w-sm')).toBe(false)
expect(card.classList.contains('flex-1')).toBe(false)
expect(lastCard.className).toBe(card.className)
expect(lastCard.parentElement).toBe(card.parentElement)
expect(card.parentElement?.classList.contains('grid-cols-[repeat(auto-fill,minmax(min(100%,22rem),1fr))]')).toBe(true)
expect(card.parentElement?.classList.contains('grid')).toBe(true)
expect(card.parentElement?.classList.contains('box-content')).toBe(false)
expect(card.parentElement?.classList.contains('items-start')).toBe(false)
expect(header.classList.contains('shrink-0')).toBe(true)
expect(Array.from(content.classList)).toEqual(expect.arrayContaining(['min-h-0', 'overflow-y-auto']))
expect(actions.classList.contains('shrink-0')).toBe(true)
expect(actions.contains(findButton(root, '查看详情'))).toBe(true)
})
it('remembers the chosen layout across remounts', async () => {
let root = await mountView()
findButton(root, '切换到卡片视图').click()