mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
feat: optimize usage body viewing and provider card layout
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+75
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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 = 0),折叠更深层(indent >= 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('无数据')
|
||||
})
|
||||
})
|
||||
+219
-91
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user