mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 00:09:22 +00:00
fix: ai obs issues (#14114)
Co-authored-by: sai nikhil kethe <nikhil@openobserve.ai> Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
This commit is contained in:
co-authored by
sai nikhil kethe
Shrinath Rao
parent
ec0b08dcee
commit
76bdf957ce
+1
-3
@@ -73,6 +73,4 @@ tests/ui-testing/playwright-tests/utils/auth/user.json
|
||||
/.claude/settings.json
|
||||
/.claude/settings.local.json
|
||||
/.claude/worktrees/
|
||||
|
||||
__pytest__/
|
||||
__pycache__/
|
||||
.screenshots/
|
||||
|
||||
@@ -1232,6 +1232,10 @@ pub fn service_routes() -> Router {
|
||||
put(experiments::set_experiment_baseline)
|
||||
.delete(experiments::clear_experiment_baseline),
|
||||
)
|
||||
.route(
|
||||
"/{org_id}/experiments/{experiment_id}/rows",
|
||||
get(experiments::list_experiment_result_rows),
|
||||
)
|
||||
.route(
|
||||
"/{org_id}/experiments/{experiment_id}/rows/{row_id}",
|
||||
get(experiments::get_experiment_row),
|
||||
|
||||
@@ -535,6 +535,7 @@ pub struct ApiDoc;
|
||||
openobserve_api_management::request::experiments::list_experiments,
|
||||
openobserve_api_management::request::experiments::compare_experiments,
|
||||
openobserve_api_management::request::experiments::get_experiment,
|
||||
openobserve_api_management::request::experiments::list_experiment_result_rows,
|
||||
openobserve_api_management::request::experiments::get_experiment_row,
|
||||
openobserve_api_management::request::experiments::retry_experiment_slot,
|
||||
openobserve_api_management::request::experiments::cancel_experiment,
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
|
||||
//! HTTP boundary models for Experiment comparisons.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use config::meta::self_reporting::llm_experiments::ExperimentExecutionRecord;
|
||||
pub use domain::DEFAULT_COMPARISON_THRESHOLD;
|
||||
use openobserve_core::llm_evaluations::{
|
||||
experiment_comparison as domain, experiment_results::ScoringStatus,
|
||||
@@ -115,6 +118,15 @@ pub struct ExperimentComparisonRowBody {
|
||||
pub candidate_row_id: Option<String>,
|
||||
pub bucket: ExperimentComparisonBucketBody,
|
||||
pub dimensions: Vec<ExperimentComparisonDimensionBody>,
|
||||
pub trials: Vec<ExperimentComparisonTrialBody>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExperimentComparisonTrialBody {
|
||||
pub trial_index: u32,
|
||||
pub baseline_output: Option<Value>,
|
||||
pub candidate_output: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, ToSchema)]
|
||||
@@ -314,12 +326,88 @@ impl From<domain::ExperimentComparison> for ExperimentComparisonResponseBody {
|
||||
candidate_row_id: row.candidate_row_id,
|
||||
bucket: row.bucket.into(),
|
||||
dimensions: row.dimensions.into_iter().map(Into::into).collect(),
|
||||
trials: Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExperimentComparisonResponseBody {
|
||||
pub fn with_trial_outputs(
|
||||
mut self,
|
||||
baseline_executions: &[ExperimentExecutionRecord],
|
||||
candidate_executions: &[ExperimentExecutionRecord],
|
||||
) -> Self {
|
||||
add_trial_outputs(&mut self.rows, baseline_executions, candidate_executions);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn add_trial_outputs(
|
||||
rows: &mut [ExperimentComparisonRowBody],
|
||||
baseline_executions: &[ExperimentExecutionRecord],
|
||||
candidate_executions: &[ExperimentExecutionRecord],
|
||||
) {
|
||||
let baseline_outputs = execution_outputs_by_row(baseline_executions);
|
||||
let candidate_outputs = execution_outputs_by_row(candidate_executions);
|
||||
|
||||
for row in rows {
|
||||
let baseline = row
|
||||
.baseline_row_id
|
||||
.as_deref()
|
||||
.and_then(|row_id| baseline_outputs.get(row_id));
|
||||
let candidate = row
|
||||
.candidate_row_id
|
||||
.as_deref()
|
||||
.and_then(|row_id| candidate_outputs.get(row_id));
|
||||
let trial_indexes = baseline
|
||||
.into_iter()
|
||||
.flat_map(|outputs| outputs.keys())
|
||||
.chain(candidate.into_iter().flat_map(|outputs| outputs.keys()))
|
||||
.copied()
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
row.trials = trial_indexes
|
||||
.into_iter()
|
||||
.map(|trial_index| ExperimentComparisonTrialBody {
|
||||
trial_index,
|
||||
baseline_output: trial_output(baseline, trial_index),
|
||||
candidate_output: trial_output(candidate, trial_index),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
fn execution_outputs_by_row(
|
||||
executions: &[ExperimentExecutionRecord],
|
||||
) -> HashMap<&str, BTreeMap<u32, (i64, Option<&Value>)>> {
|
||||
let mut outputs = HashMap::<_, BTreeMap<_, _>>::new();
|
||||
for execution in executions {
|
||||
let trials = outputs.entry(execution.row_id.as_str()).or_default();
|
||||
let should_replace = trials
|
||||
.get(&execution.trial_index)
|
||||
.is_none_or(|(timestamp, _)| execution._timestamp >= *timestamp);
|
||||
if should_replace {
|
||||
trials.insert(
|
||||
execution.trial_index,
|
||||
(execution._timestamp, execution.output.as_ref()),
|
||||
);
|
||||
}
|
||||
}
|
||||
outputs
|
||||
}
|
||||
|
||||
fn trial_output(
|
||||
outputs: Option<&BTreeMap<u32, (i64, Option<&Value>)>>,
|
||||
trial_index: u32,
|
||||
) -> Option<Value> {
|
||||
outputs
|
||||
.and_then(|outputs| outputs.get(&trial_index))
|
||||
.and_then(|(_, output)| *output)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
@@ -335,6 +423,7 @@ mod tests {
|
||||
candidate_row_id: Some("candidate-row".to_string()),
|
||||
bucket: ExperimentComparisonBucketBody::Unchanged,
|
||||
dimensions: vec![],
|
||||
trials: vec![],
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(body).unwrap();
|
||||
@@ -405,4 +494,64 @@ mod tests {
|
||||
assert_eq!(value["baselineLabel"], serde_json::Value::Null);
|
||||
assert_eq!(value["candidateLabel"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparison_rows_expose_latest_baseline_and_candidate_output_per_trial() {
|
||||
let mut rows = vec![ExperimentComparisonRowBody {
|
||||
logical_id: "row-1".to_string(),
|
||||
input: json!({"question": "What changed?"}),
|
||||
baseline_row_id: Some("baseline-row".to_string()),
|
||||
candidate_row_id: Some("candidate-row".to_string()),
|
||||
bucket: ExperimentComparisonBucketBody::Unchanged,
|
||||
dimensions: vec![],
|
||||
trials: vec![],
|
||||
}];
|
||||
let baseline = vec![
|
||||
execution("baseline-row", 0, 10, json!("old baseline")),
|
||||
execution("baseline-row", 0, 20, json!("baseline")),
|
||||
execution("baseline-row", 1, 30, json!("baseline second")),
|
||||
];
|
||||
let candidate = vec![
|
||||
execution("candidate-row", 0, 20, json!("candidate")),
|
||||
execution("candidate-row", 2, 30, json!("candidate third")),
|
||||
];
|
||||
|
||||
add_trial_outputs(&mut rows, &baseline, &candidate);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&rows[0].trials).unwrap(),
|
||||
json!([
|
||||
{
|
||||
"trialIndex": 0,
|
||||
"baselineOutput": "baseline",
|
||||
"candidateOutput": "candidate"
|
||||
},
|
||||
{
|
||||
"trialIndex": 1,
|
||||
"baselineOutput": "baseline second",
|
||||
"candidateOutput": null
|
||||
},
|
||||
{
|
||||
"trialIndex": 2,
|
||||
"baselineOutput": null,
|
||||
"candidateOutput": "candidate third"
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
fn execution(
|
||||
row_id: &str,
|
||||
trial_index: u32,
|
||||
timestamp: i64,
|
||||
output: Value,
|
||||
) -> ExperimentExecutionRecord {
|
||||
ExperimentExecutionRecord {
|
||||
row_id: row_id.to_string(),
|
||||
trial_index,
|
||||
output: Some(output),
|
||||
_timestamp: timestamp,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ use openobserve_core::llm_evaluations::{
|
||||
experiment_results::{
|
||||
ExperimentAggregateSummary, ExperimentClientScoreSummary, ExperimentProgress,
|
||||
ExperimentResultScore, ExperimentResultScoreStatus, ExperimentResultSlot,
|
||||
ExperimentResultTaskStatus, ExperimentScoreSummary, ExperimentSkipSummary, ScoringStatus,
|
||||
ExperimentResultTaskStatus, ExperimentScoreSummary, ExperimentSkipSummary,
|
||||
ExperimentSlotStatus, ScoringStatus,
|
||||
},
|
||||
experiments::{
|
||||
CloneExperimentOverrides, CreateExperiment, CreateExperimentResult, Experiment,
|
||||
@@ -40,6 +41,59 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExperimentResultRowSortBody {
|
||||
#[default]
|
||||
Dataset,
|
||||
DispersionDesc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
#[into_params(parameter_in = Query)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExperimentResultRowPageQuery {
|
||||
pub page: Option<usize>,
|
||||
pub page_size: Option<usize>,
|
||||
pub sort: Option<ExperimentResultRowSortBody>,
|
||||
pub high_dispersion_only: Option<bool>,
|
||||
}
|
||||
|
||||
/// One pinned Dataset case with every trial reduced to list-surface aggregates.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExperimentResultRowBody {
|
||||
/// Zero-based position in deterministic pinned-snapshot row order.
|
||||
pub row_index: usize,
|
||||
pub row_id: String,
|
||||
pub logical_id: String,
|
||||
pub input: Value,
|
||||
pub expected_output: Option<Value>,
|
||||
pub trial_count: usize,
|
||||
pub status: ExperimentSlotStatusBody,
|
||||
/// Present only for a single-trial row; multi-trial outputs belong in drill-down.
|
||||
pub output: Option<Value>,
|
||||
pub score_summaries: Vec<ExperimentScoreSummaryBody>,
|
||||
pub p50_latency_ms: Option<u64>,
|
||||
pub dispersion: Option<ExperimentRowDispersionBody>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExperimentResultRowPaginationBody {
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
pub total_rows: usize,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExperimentResultRowPageResponseBody {
|
||||
pub rows: Vec<ExperimentResultRowBody>,
|
||||
pub pagination: ExperimentResultRowPaginationBody,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DatasetItemSourceBody {
|
||||
@@ -1083,6 +1137,8 @@ pub struct ExperimentResultSlotBody {
|
||||
pub trial_index: u32,
|
||||
pub input: Value,
|
||||
pub expected_output: Option<Value>,
|
||||
/// The one field a list surface needs; task and score statuses remain for drill-down.
|
||||
pub status: ExperimentSlotStatusBody,
|
||||
pub task_status: ExperimentResultTaskStatusBody,
|
||||
pub execution: Option<Value>,
|
||||
pub scores: Vec<ExperimentResultScoreBody>,
|
||||
@@ -1110,6 +1166,18 @@ pub enum ExperimentResultTaskStatusBody {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExperimentSlotStatusBody {
|
||||
Pending,
|
||||
Running,
|
||||
Scoring,
|
||||
Completed,
|
||||
Skipped,
|
||||
TaskFailed,
|
||||
ScoreFailed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExperimentResultScoreStatusBody {
|
||||
@@ -1182,6 +1250,21 @@ pub struct ExperimentAggregateSummaryBody {
|
||||
pub incomplete: bool,
|
||||
pub incomplete_task_slots: u64,
|
||||
pub incomplete_score_dimensions: u64,
|
||||
pub error_task_slots: u64,
|
||||
}
|
||||
|
||||
impl From<ExperimentSlotStatus> for ExperimentSlotStatusBody {
|
||||
fn from(value: ExperimentSlotStatus) -> Self {
|
||||
match value {
|
||||
ExperimentSlotStatus::Pending => Self::Pending,
|
||||
ExperimentSlotStatus::Running => Self::Running,
|
||||
ExperimentSlotStatus::Scoring => Self::Scoring,
|
||||
ExperimentSlotStatus::Completed => Self::Completed,
|
||||
ExperimentSlotStatus::Skipped => Self::Skipped,
|
||||
ExperimentSlotStatus::TaskFailed => Self::TaskFailed,
|
||||
ExperimentSlotStatus::ScoreFailed => Self::ScoreFailed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExperimentResultTaskStatus> for ExperimentResultTaskStatusBody {
|
||||
@@ -1229,6 +1312,7 @@ impl From<ExperimentResultSlot> for ExperimentResultSlotBody {
|
||||
trial_index: value.trial_index,
|
||||
input: value.input,
|
||||
expected_output: value.expected_output,
|
||||
status: value.status.into(),
|
||||
task_status: value.task_status.into(),
|
||||
execution: value
|
||||
.execution
|
||||
@@ -1305,6 +1389,7 @@ impl From<ExperimentAggregateSummary> for ExperimentAggregateSummaryBody {
|
||||
incomplete: value.incomplete,
|
||||
incomplete_task_slots: value.incomplete_task_slots,
|
||||
incomplete_score_dimensions: value.incomplete_score_dimensions,
|
||||
error_task_slots: value.error_task_slots,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,14 @@ impl From<Snapshot> for PlaygroundSnapshotResponseBody {
|
||||
// Run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PlaygroundToolCallBody {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PlaygroundMessageBody {
|
||||
@@ -65,6 +73,12 @@ pub struct PlaygroundMessageBody {
|
||||
/// tool-result messages survive.
|
||||
#[serde(default)]
|
||||
pub content: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub reasoning_content: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tool_calls: Vec<PlaygroundToolCallBody>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
@@ -25,10 +25,10 @@ use openobserve_core::{
|
||||
auth::{UserEmail, is_ofga_object_visible},
|
||||
llm_evaluations::{
|
||||
datasets, experiment_baseline, experiment_deletion,
|
||||
experiment_dispersion::{self, NormalizationSpans},
|
||||
experiment_dispersion::{self, NormalizationSpans, RowDispersion},
|
||||
experiment_ingest::{self, IngestError},
|
||||
experiment_results,
|
||||
experiments::{self, ExperimentError},
|
||||
experiment_results::{self, ExperimentResultSlot, ExperimentSlotStatus},
|
||||
experiments::{self, ExperimentError, PinnedExperimentScorer},
|
||||
remote_tasks,
|
||||
},
|
||||
};
|
||||
@@ -44,12 +44,14 @@ use crate::{
|
||||
CloneExperimentRequestBody, CreateExperimentRequestBody, CreateExperimentResponseBody,
|
||||
ExperimentBaselineResponseBody, ExperimentDetailQuery, ExperimentDetailResponseBody,
|
||||
ExperimentDispersionSummaryBody, ExperimentPreviewQuery, ExperimentPreviewResponseBody,
|
||||
ExperimentResponseBody, ExperimentResultPaginationBody, ExperimentResultsResponseBody,
|
||||
ExperimentRowDetailResponseBody, ExperimentRowNavigationBody,
|
||||
ExperimentRowSnapshotBody, ExperimentScoreSummaryBody, ExperimentSlotPageQuery,
|
||||
ExperimentSlotPageResponseBody, ExperimentTaskBody, ListExperimentsResponseBody,
|
||||
RetryExperimentSlotRequestBody, SubmitExperimentRecordsRequestBody,
|
||||
SubmitExperimentRecordsResponseBody,
|
||||
ExperimentResponseBody, ExperimentResultPaginationBody, ExperimentResultRowBody,
|
||||
ExperimentResultRowPageQuery, ExperimentResultRowPageResponseBody,
|
||||
ExperimentResultRowPaginationBody, ExperimentResultRowSortBody,
|
||||
ExperimentResultsResponseBody, ExperimentRowDetailResponseBody,
|
||||
ExperimentRowNavigationBody, ExperimentRowSnapshotBody, ExperimentScoreSummaryBody,
|
||||
ExperimentSlotPageQuery, ExperimentSlotPageResponseBody, ExperimentTaskBody,
|
||||
ListExperimentsResponseBody, RetryExperimentSlotRequestBody,
|
||||
SubmitExperimentRecordsRequestBody, SubmitExperimentRecordsResponseBody,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -168,6 +170,151 @@ fn score_summary_bodies(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn experiment_result_row_status(slots: &[ExperimentResultSlot]) -> ExperimentSlotStatus {
|
||||
for status in [
|
||||
ExperimentSlotStatus::TaskFailed,
|
||||
ExperimentSlotStatus::ScoreFailed,
|
||||
ExperimentSlotStatus::Running,
|
||||
ExperimentSlotStatus::Scoring,
|
||||
ExperimentSlotStatus::Pending,
|
||||
ExperimentSlotStatus::Completed,
|
||||
ExperimentSlotStatus::Skipped,
|
||||
] {
|
||||
if slots.iter().any(|slot| slot.status == status) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
ExperimentSlotStatus::Pending
|
||||
}
|
||||
|
||||
fn group_result_slots(slots: Vec<ExperimentResultSlot>) -> Vec<Vec<ExperimentResultSlot>> {
|
||||
let mut rows = Vec::<Vec<ExperimentResultSlot>>::new();
|
||||
for slot in slots {
|
||||
if rows
|
||||
.last()
|
||||
.and_then(|row| row.first())
|
||||
.is_none_or(|first| first.row_id != slot.row_id)
|
||||
{
|
||||
rows.push(Vec::new());
|
||||
}
|
||||
rows.last_mut()
|
||||
.expect("the current result row was initialized")
|
||||
.push(slot);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn experiment_result_rows(
|
||||
slots: Vec<ExperimentResultSlot>,
|
||||
scorers: &[PinnedExperimentScorer],
|
||||
dispersions: Vec<RowDispersion>,
|
||||
scorer_definitions: &[infra::table::scorers::Scorer],
|
||||
score_configs: &[infra::table::score_configs::ScoreConfig],
|
||||
) -> Vec<ExperimentResultRowBody> {
|
||||
let mut dispersions = dispersions
|
||||
.into_iter()
|
||||
.map(|dispersion| (dispersion.row_id.clone(), dispersion))
|
||||
.collect::<HashMap<_, _>>();
|
||||
group_result_slots(slots)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(row_index, trials)| {
|
||||
let first = trials.first()?;
|
||||
let executions = trials
|
||||
.iter()
|
||||
.filter_map(|trial| trial.execution.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let scores = trials
|
||||
.iter()
|
||||
.flat_map(|trial| {
|
||||
trial
|
||||
.scores
|
||||
.iter()
|
||||
.filter_map(|score| score.score.clone())
|
||||
.chain(trial.client_scores.iter().cloned())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let summary =
|
||||
experiment_results::row_result_summary(trials.len(), scorers, &executions, &scores);
|
||||
let aggregate = experiment_results::aggregate_summary(
|
||||
&executions,
|
||||
&summary.task_progress,
|
||||
&summary.scoring_progress,
|
||||
);
|
||||
let output = (trials.len() == 1)
|
||||
.then(|| {
|
||||
first
|
||||
.execution
|
||||
.as_ref()
|
||||
.and_then(|record| record.output.clone())
|
||||
})
|
||||
.flatten();
|
||||
Some(ExperimentResultRowBody {
|
||||
row_index,
|
||||
row_id: first.row_id.clone(),
|
||||
logical_id: first.logical_id.clone(),
|
||||
input: first.input.clone(),
|
||||
expected_output: first.expected_output.clone(),
|
||||
trial_count: trials.len(),
|
||||
status: experiment_result_row_status(&trials).into(),
|
||||
output,
|
||||
score_summaries: score_summary_bodies(
|
||||
summary.score_summaries,
|
||||
scorer_definitions,
|
||||
score_configs,
|
||||
),
|
||||
p50_latency_ms: aggregate.p50_latency_ms,
|
||||
dispersion: dispersions.remove(&first.row_id).map(Into::into),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn paginate_experiment_result_rows(
|
||||
mut rows: Vec<ExperimentResultRowBody>,
|
||||
page: usize,
|
||||
page_size: usize,
|
||||
sort: ExperimentResultRowSortBody,
|
||||
high_dispersion_only: bool,
|
||||
) -> ExperimentResultRowPageResponseBody {
|
||||
if high_dispersion_only {
|
||||
rows.retain(|row| {
|
||||
row.dispersion
|
||||
.as_ref()
|
||||
.is_some_and(|dispersion| dispersion.high)
|
||||
});
|
||||
}
|
||||
if matches!(sort, ExperimentResultRowSortBody::DispersionDesc) {
|
||||
rows.sort_by(|left, right| {
|
||||
let left_value = left
|
||||
.dispersion
|
||||
.as_ref()
|
||||
.and_then(|dispersion| dispersion.max_normalized)
|
||||
.unwrap_or(-1.0);
|
||||
let right_value = right
|
||||
.dispersion
|
||||
.as_ref()
|
||||
.and_then(|dispersion| dispersion.max_normalized)
|
||||
.unwrap_or(-1.0);
|
||||
right_value
|
||||
.total_cmp(&left_value)
|
||||
.then_with(|| left.row_index.cmp(&right.row_index))
|
||||
});
|
||||
}
|
||||
let total_rows = rows.len();
|
||||
let start = page.saturating_sub(1).saturating_mul(page_size);
|
||||
let rows = rows.into_iter().skip(start).take(page_size).collect();
|
||||
ExperimentResultRowPageResponseBody {
|
||||
rows,
|
||||
pagination: ExperimentResultRowPaginationBody {
|
||||
page,
|
||||
page_size,
|
||||
total_rows,
|
||||
has_more: start.saturating_add(page_size) < total_rows,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Scoring Status of one Experiment, derived from the same evidence and the
|
||||
/// same rules the results page uses.
|
||||
async fn derive_scoring_status(
|
||||
@@ -681,29 +828,138 @@ pub async fn compare_experiments(
|
||||
}
|
||||
};
|
||||
let policy = ComparisonPolicy::from_configs(&score_configs);
|
||||
MetaHttpResponse::json(ExperimentComparisonResponseBody::from(compare_experiments(
|
||||
CompareExperimentsInput {
|
||||
baseline_id: baseline.id,
|
||||
candidate_id: candidate.id,
|
||||
dataset_id: baseline.dataset_id,
|
||||
threshold,
|
||||
policy: &policy,
|
||||
scoring: ComparisonScoringState {
|
||||
baseline: baseline_scoring,
|
||||
candidate: candidate_scoring,
|
||||
},
|
||||
baseline: ComparisonEvidence {
|
||||
slots: &baseline_slots,
|
||||
executions: &baseline_results.executions,
|
||||
scores: &baseline_results.scores,
|
||||
},
|
||||
candidate: ComparisonEvidence {
|
||||
slots: &candidate_slots,
|
||||
executions: &candidate_results.executions,
|
||||
scores: &candidate_results.scores,
|
||||
},
|
||||
let comparison = compare_experiments(CompareExperimentsInput {
|
||||
baseline_id: baseline.id,
|
||||
candidate_id: candidate.id,
|
||||
dataset_id: baseline.dataset_id,
|
||||
threshold,
|
||||
policy: &policy,
|
||||
scoring: ComparisonScoringState {
|
||||
baseline: baseline_scoring,
|
||||
candidate: candidate_scoring,
|
||||
},
|
||||
)))
|
||||
baseline: ComparisonEvidence {
|
||||
slots: &baseline_slots,
|
||||
executions: &baseline_results.executions,
|
||||
scores: &baseline_results.scores,
|
||||
},
|
||||
candidate: ComparisonEvidence {
|
||||
slots: &candidate_slots,
|
||||
executions: &candidate_results.executions,
|
||||
scores: &candidate_results.scores,
|
||||
},
|
||||
});
|
||||
let response = ExperimentComparisonResponseBody::from(comparison)
|
||||
.with_trial_outputs(&baseline_results.executions, &candidate_results.executions);
|
||||
MetaHttpResponse::json(response)
|
||||
}
|
||||
|
||||
/// List pinned Dataset cases with trial-level evidence reduced to row aggregates.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/{org_id}/experiments/{experiment_id}/rows",
|
||||
context_path = "/api",
|
||||
tag = "Experiments",
|
||||
operation_id = "ListExperimentResultRows",
|
||||
security(("Authorization" = [])),
|
||||
params(
|
||||
("org_id" = String, Path, description = "Organization name"),
|
||||
("experiment_id" = String, Path, description = "Experiment ID"),
|
||||
ExperimentResultRowPageQuery,
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Pinned result rows", body = ExperimentResultRowPageResponseBody),
|
||||
(status = 400, description = "Invalid pagination"),
|
||||
(status = 403, description = "Experiment is not accessible"),
|
||||
(status = 404, description = "Experiment not found"),
|
||||
)
|
||||
)]
|
||||
pub async fn list_experiment_result_rows(
|
||||
Path((org_id, experiment_id)): Path<(String, String)>,
|
||||
Headers(user): Headers<UserEmail>,
|
||||
Query(query): Query<ExperimentResultRowPageQuery>,
|
||||
) -> Response {
|
||||
if let Err(response) =
|
||||
require_experiment_visibility(&org_id, &experiment_id, &user.user_id, "GET").await
|
||||
{
|
||||
return response;
|
||||
}
|
||||
let page = query.page.unwrap_or(1);
|
||||
let page_size = query
|
||||
.page_size
|
||||
.unwrap_or(openobserve_core::llm_evaluations::experiment_runner::DEFAULT_RESULT_PAGE_SIZE);
|
||||
if page == 0
|
||||
|| page_size == 0
|
||||
|| page_size > openobserve_core::llm_evaluations::experiment_runner::MAX_RESULT_PAGE_SIZE
|
||||
{
|
||||
return MetaHttpResponse::bad_request("Invalid Experiment row pagination");
|
||||
}
|
||||
let experiment = match experiments::get(&org_id, &experiment_id).await {
|
||||
Ok(experiment) => experiment,
|
||||
Err(error) => return experiment_error_response(error),
|
||||
};
|
||||
if let Err(error) =
|
||||
openobserve_core::self_reporting::llm_scores_schema::ensure_llm_scores_stream_initialized(
|
||||
&org_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("[Experiment] failed to initialize score stream for {experiment_id}: {error}");
|
||||
return MetaHttpResponse::internal_error("Failed to load Experiment rows");
|
||||
}
|
||||
let slots = match experiments::slot_set_existing(&org_id, &experiment).await {
|
||||
Ok(slots) => slots,
|
||||
Err(error) => return experiment_error_response(error),
|
||||
};
|
||||
let evidence = match openobserve_core::llm_evaluations::experiment_runner::results(&experiment)
|
||||
.await
|
||||
{
|
||||
Ok(evidence) => evidence,
|
||||
Err(error) => {
|
||||
log::error!("[Experiment] failed to load row evidence for {experiment_id}: {error}");
|
||||
return MetaHttpResponse::internal_error("Failed to load Experiment rows");
|
||||
}
|
||||
};
|
||||
let score_configs = match infra::table::score_configs::get_all_by_org(&org_id).await {
|
||||
Ok(configs) => configs,
|
||||
Err(error) => {
|
||||
log::error!("[Experiment] failed to load Score Configs for {experiment_id}: {error}");
|
||||
return MetaHttpResponse::internal_error("Failed to load Experiment rows");
|
||||
}
|
||||
};
|
||||
let scorer_definitions = match experiments::scorer_definitions(&org_id, &experiment).await {
|
||||
Ok(definitions) => definitions,
|
||||
Err(error) => {
|
||||
log::error!(
|
||||
"[Experiment] failed to load Scorer definitions for {experiment_id}: {error}"
|
||||
);
|
||||
return MetaHttpResponse::internal_error("Failed to load Experiment rows");
|
||||
}
|
||||
};
|
||||
let scorers = experiment.scorers.clone();
|
||||
let dispersions = experiment_dispersion::row_dispersions(
|
||||
&experiments::row_keys(&slots),
|
||||
&evidence.scores,
|
||||
&scorers,
|
||||
&NormalizationSpans::from_configs(&score_configs),
|
||||
);
|
||||
let result_slots =
|
||||
experiment_results::result_slots(slots, &evidence.executions, &evidence.scores, &scorers);
|
||||
let rows = experiment_result_rows(
|
||||
result_slots,
|
||||
&scorers,
|
||||
dispersions,
|
||||
&scorer_definitions,
|
||||
&score_configs,
|
||||
);
|
||||
let response = paginate_experiment_result_rows(
|
||||
rows,
|
||||
page,
|
||||
page_size,
|
||||
query.sort.unwrap_or_default(),
|
||||
query.high_dispersion_only.unwrap_or(false),
|
||||
);
|
||||
MetaHttpResponse::json(response)
|
||||
}
|
||||
|
||||
/// Load one pinned dataset row and every trial's current execution and score evidence.
|
||||
@@ -1235,6 +1491,91 @@ pub async fn finalize_experiment(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn result_slot(
|
||||
row_id: &str,
|
||||
trial_index: u32,
|
||||
status: ExperimentSlotStatus,
|
||||
) -> ExperimentResultSlot {
|
||||
ExperimentResultSlot {
|
||||
row_id: row_id.to_string(),
|
||||
logical_id: format!("case-{row_id}"),
|
||||
trial_index,
|
||||
input: serde_json::json!("question"),
|
||||
expected_output: None,
|
||||
status,
|
||||
task_status:
|
||||
openobserve_core::llm_evaluations::experiment_results::ExperimentResultTaskStatus::Pending,
|
||||
execution: None,
|
||||
scores: Vec::new(),
|
||||
client_scores: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn result_row(row_index: usize, max_normalized: f64, high: bool) -> ExperimentResultRowBody {
|
||||
let row_id = format!("row-{row_index}");
|
||||
ExperimentResultRowBody {
|
||||
row_index,
|
||||
row_id: row_id.clone(),
|
||||
logical_id: format!("case-{row_index}"),
|
||||
input: serde_json::json!("question"),
|
||||
expected_output: None,
|
||||
trial_count: 2,
|
||||
status: crate::models::experiments::ExperimentSlotStatusBody::Completed,
|
||||
output: None,
|
||||
score_summaries: Vec::new(),
|
||||
p50_latency_ms: None,
|
||||
dispersion: Some(crate::models::experiments::ExperimentRowDispersionBody {
|
||||
row_id,
|
||||
logical_id: format!("case-{row_index}"),
|
||||
dimensions: Vec::new(),
|
||||
max_normalized: Some(max_normalized),
|
||||
high,
|
||||
outlier_trial_index: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_rows_group_trials_and_surface_the_most_actionable_status() {
|
||||
let rows = group_result_slots(vec![
|
||||
result_slot("one", 0, ExperimentSlotStatus::Completed),
|
||||
result_slot("one", 1, ExperimentSlotStatus::TaskFailed),
|
||||
result_slot("two", 0, ExperimentSlotStatus::Pending),
|
||||
]);
|
||||
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].len(), 2);
|
||||
assert!(matches!(
|
||||
experiment_result_row_status(&rows[0]),
|
||||
ExperimentSlotStatus::TaskFailed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_row_pagination_orders_and_filters_by_dispersion() {
|
||||
let rows = vec![result_row(0, 0.1, false), result_row(1, 0.8, true)];
|
||||
let sorted = paginate_experiment_result_rows(
|
||||
rows,
|
||||
1,
|
||||
100,
|
||||
ExperimentResultRowSortBody::DispersionDesc,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(sorted.rows[0].row_index, 1);
|
||||
assert_eq!(sorted.pagination.total_rows, 2);
|
||||
|
||||
let filtered = paginate_experiment_result_rows(
|
||||
sorted.rows,
|
||||
1,
|
||||
100,
|
||||
ExperimentResultRowSortBody::Dataset,
|
||||
true,
|
||||
);
|
||||
assert_eq!(filtered.rows.len(), 1);
|
||||
assert_eq!(filtered.rows[0].row_index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_summary_uses_the_pinned_score_config_name_before_scores_exist() {
|
||||
let summaries = vec![experiment_results::ExperimentScoreSummary {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
//! help a person improve. Sharing a snapshot is the only endpoint here that
|
||||
//! stores anything.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, rejection::JsonRejection},
|
||||
@@ -29,8 +31,8 @@ use openobserve_core::{
|
||||
experiment_runner::render_prompt,
|
||||
playground::{self, PlaygroundError},
|
||||
provider::{
|
||||
PreparedProvider, ProviderChatMessage, ProviderChatParams, ProviderStreamStartError,
|
||||
RawProviderConfig,
|
||||
PreparedProvider, ProviderChatMessage, ProviderChatParams, ProviderChatToolCall,
|
||||
ProviderStreamStartError, RawProviderConfig,
|
||||
},
|
||||
provider_stream::ProviderChatDelta,
|
||||
sync_scoring::{self, SyncScoringError},
|
||||
@@ -314,7 +316,7 @@ fn render_messages(
|
||||
column: &PlaygroundColumnBody,
|
||||
input: &Value,
|
||||
) -> Result<Vec<ProviderChatMessage>, Response> {
|
||||
column
|
||||
let messages = column
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| {
|
||||
@@ -333,9 +335,85 @@ fn render_messages(
|
||||
Ok(ProviderChatMessage {
|
||||
role: message.role.clone(),
|
||||
content,
|
||||
reasoning_content: message.reasoning_content.clone(),
|
||||
tool_call_id: message.tool_call_id.clone(),
|
||||
tool_calls: message
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|call| ProviderChatToolCall {
|
||||
id: call.id.clone(),
|
||||
name: call.name.clone(),
|
||||
arguments: call.arguments.clone(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect::<Result<Vec<_>, Response>>()?;
|
||||
validate_tool_messages(&messages)?;
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
fn validate_tool_messages(messages: &[ProviderChatMessage]) -> Result<(), Response> {
|
||||
let mut call_ids = HashSet::new();
|
||||
let mut result_ids = HashSet::new();
|
||||
for message in messages {
|
||||
if message.reasoning_content.is_some() && message.role != "assistant" {
|
||||
return Err(MetaHttpResponse::bad_request(
|
||||
"Only assistant messages may contain reasoningContent",
|
||||
));
|
||||
}
|
||||
if !message.tool_calls.is_empty() {
|
||||
if message.role != "assistant" {
|
||||
return Err(MetaHttpResponse::bad_request(
|
||||
"Only assistant messages may contain tool calls",
|
||||
));
|
||||
}
|
||||
for call in &message.tool_calls {
|
||||
if call.id.trim().is_empty() || call.name.trim().is_empty() {
|
||||
return Err(MetaHttpResponse::bad_request(
|
||||
"Tool calls require a non-empty id and name",
|
||||
));
|
||||
}
|
||||
if !serde_json::from_str::<Value>(&call.arguments)
|
||||
.is_ok_and(|value| value.is_object())
|
||||
{
|
||||
return Err(MetaHttpResponse::bad_request(format!(
|
||||
"Tool call '{}' arguments must be a JSON object",
|
||||
call.id
|
||||
)));
|
||||
}
|
||||
if !call_ids.insert(call.id.as_str()) {
|
||||
return Err(MetaHttpResponse::bad_request(format!(
|
||||
"Tool call id '{}' is duplicated",
|
||||
call.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if message.role == "tool" {
|
||||
let Some(call_id) = message.tool_call_id.as_deref() else {
|
||||
return Err(MetaHttpResponse::bad_request(
|
||||
"Tool result messages require toolCallId",
|
||||
));
|
||||
};
|
||||
if !call_ids.contains(call_id) {
|
||||
return Err(MetaHttpResponse::bad_request(format!(
|
||||
"Tool result references unknown call id '{call_id}'"
|
||||
)));
|
||||
}
|
||||
if !result_ids.insert(call_id) {
|
||||
return Err(MetaHttpResponse::bad_request(format!(
|
||||
"Tool call id '{call_id}' has more than one result"
|
||||
)));
|
||||
}
|
||||
} else if message.tool_call_id.is_some() {
|
||||
return Err(MetaHttpResponse::bad_request(
|
||||
"Only tool result messages may contain toolCallId",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split the column's params into the fields the provider names explicitly and
|
||||
@@ -461,7 +539,15 @@ pub async fn run_playground_cell(
|
||||
"type": "rendered",
|
||||
"messages": messages
|
||||
.iter()
|
||||
.map(|message| json!({"role": message.role, "content": message.content}))
|
||||
.map(|message| {
|
||||
json!({
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
"reasoningContent": message.reasoning_content,
|
||||
"toolCallId": message.tool_call_id,
|
||||
"toolCalls": message.tool_calls,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
});
|
||||
|
||||
@@ -484,6 +570,7 @@ pub async fn run_playground_cell(
|
||||
"type": "done",
|
||||
"model": result.model_used,
|
||||
"latencyMs": result.latency_ms,
|
||||
"reasoningContent": result.reasoning_content,
|
||||
"usage": {
|
||||
"promptTokens": result.prompt_tokens,
|
||||
"completionTokens": result.completion_tokens,
|
||||
@@ -571,7 +658,7 @@ pub async fn score_playground_cell(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::playground::PlaygroundMessageBody;
|
||||
use crate::models::playground::{PlaygroundMessageBody, PlaygroundToolCallBody};
|
||||
|
||||
fn column(params: Value) -> PlaygroundColumnBody {
|
||||
PlaygroundColumnBody {
|
||||
@@ -612,10 +699,16 @@ mod tests {
|
||||
PlaygroundMessageBody {
|
||||
role: "user".to_string(),
|
||||
content: Some(json!("Hello {{name}}")),
|
||||
reasoning_content: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
PlaygroundMessageBody {
|
||||
role: "user".to_string(),
|
||||
content: Some(structured.clone()),
|
||||
reasoning_content: None,
|
||||
tool_call_id: None,
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -623,4 +716,38 @@ mod tests {
|
||||
assert_eq!(rendered[0].content, json!("Hello Ada"));
|
||||
assert_eq!(rendered[1].content, structured);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_metadata_survives_message_rendering() {
|
||||
let mut column = column(json!({}));
|
||||
column.messages = vec![
|
||||
PlaygroundMessageBody {
|
||||
role: "assistant".to_string(),
|
||||
content: Some(json!("")),
|
||||
reasoning_content: Some("I should look up this order.".to_string()),
|
||||
tool_call_id: None,
|
||||
tool_calls: vec![PlaygroundToolCallBody {
|
||||
id: "call_1".to_string(),
|
||||
name: "lookup_order".to_string(),
|
||||
arguments: r#"{"order_id":"123"}"#.to_string(),
|
||||
}],
|
||||
},
|
||||
PlaygroundMessageBody {
|
||||
role: "tool".to_string(),
|
||||
content: Some(json!({"status": "shipped"})),
|
||||
reasoning_content: None,
|
||||
tool_call_id: Some("call_1".to_string()),
|
||||
tool_calls: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
let rendered = render_messages(&column, &Value::Null).unwrap();
|
||||
assert_eq!(rendered[0].tool_calls[0].id, "call_1");
|
||||
assert_eq!(rendered[0].tool_calls[0].name, "lookup_order");
|
||||
assert_eq!(
|
||||
rendered[0].reasoning_content.as_deref(),
|
||||
Some("I should look up this order.")
|
||||
);
|
||||
assert_eq!(rendered[1].tool_call_id.as_deref(), Some("call_1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
//!
|
||||
//! - **New (`gen_ai_*`)** — emitted since PR #11626 ("refactor llm attribute trace"). Tokens, cost,
|
||||
//! model, user, session id all use the OTEL Gen-AI spec names: `gen_ai_usage_input_tokens`,
|
||||
//! `gen_ai_response_model`, `user.id` (queried as `user.id`), `gen_ai_conversation_id`, etc.
|
||||
//! `gen_ai_response_model`, `user.id`, `gen_ai_conversation_id`, etc. Ingestion flattens every
|
||||
//! attribute key with `flatten::format_key` (dot → underscore), so the spec's `user.id` is stored
|
||||
//! — and must be queried — as the `user_id` column.
|
||||
//! - **Legacy (`_o2_llm`)** — pre-PR #11626. Tokens live under
|
||||
//! `llm_usage_tokens_{input,output,total}`, cost under `llm_usage_cost_total`, model under
|
||||
//! `llm_model_name`, and user/session ids under `llm_user_id`/`llm_session_id`.
|
||||
@@ -39,7 +41,6 @@ use config::meta::{
|
||||
REQUIRED_GEN_AI_FIELDS, REQUIRED_LLM_FIELDS,
|
||||
},
|
||||
};
|
||||
use openobserve_core::traces::otel::attributes::OtelAttributes;
|
||||
|
||||
/// Column names that vary between the new and legacy LLM schemas.
|
||||
///
|
||||
@@ -55,10 +56,14 @@ pub(super) struct LlmColumns {
|
||||
|
||||
impl LlmColumns {
|
||||
/// Column layout for the current (`gen_ai_*`) schema.
|
||||
///
|
||||
/// These are stored column names, not raw OTEL attribute keys: ingestion
|
||||
/// flattens `user.id` to `user_id` (see `GEN_AI_SCHEMA_FIELDS` in
|
||||
/// `db/schema.rs`, which provisions exactly these columns).
|
||||
pub(super) const fn current() -> Self {
|
||||
Self {
|
||||
session_id: "gen_ai_conversation_id",
|
||||
user_id: OtelAttributes::USER_ID,
|
||||
user_id: "user_id",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +246,7 @@ pub(super) fn validate_llm_schema(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use arrow_schema::{DataType, Field, Schema};
|
||||
use openobserve_core::traces::otel::attributes::OtelAttributes;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -287,10 +293,10 @@ mod tests {
|
||||
fn current_layout_uses_gen_ai_columns() {
|
||||
let cols = LlmColumns::current();
|
||||
assert_eq!(cols.session_id, "gen_ai_conversation_id");
|
||||
// user.id is the OTEL attribute name — the search layer resolves it
|
||||
// to the flattened column.
|
||||
assert_eq!(cols.user_id, OtelAttributes::USER_ID);
|
||||
assert_eq!(cols.user_id, "user.id");
|
||||
// The stored column is the Gen-AI spec's `user.id` attribute after the
|
||||
// dot → underscore flattening ingestion applies to every key.
|
||||
assert_eq!(cols.user_id, "user_id");
|
||||
assert_eq!(cols.user_id, OtelAttributes::USER_ID.replace('.', "_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -934,6 +934,7 @@ fn build_session_trace_details_sql(
|
||||
service_key_expr: &str,
|
||||
trace_id_predicate: &str,
|
||||
) -> String {
|
||||
let user_id_col = validated.columns.user_id;
|
||||
let (root_service_name_expr, root_operation_name_expr) = if has_ref_parent_id {
|
||||
(
|
||||
"max(CASE WHEN reference_parent_span_id IS NULL OR reference_parent_span_id = '' THEN service_name END)",
|
||||
@@ -950,7 +951,9 @@ fn build_session_trace_details_sql(
|
||||
{root_service_name_expr} AS root_service_name, \
|
||||
{root_operation_name_expr} AS root_operation_name, \
|
||||
first_value(service_name ORDER BY {TIMESTAMP_COL_NAME} ASC) AS first_service_name, \
|
||||
first_value(operation_name ORDER BY {TIMESTAMP_COL_NAME} ASC) AS first_operation_name"
|
||||
first_value(operation_name ORDER BY {TIMESTAMP_COL_NAME} ASC) AS first_operation_name, \
|
||||
array_agg(DISTINCT {user_id_col}) \
|
||||
FILTER (WHERE {user_id_col} IS NOT NULL AND {user_id_col} != '') AS user_ids"
|
||||
);
|
||||
|
||||
if validated.has_gen_ai {
|
||||
@@ -1054,6 +1057,20 @@ fn build_session_trace_response_item(
|
||||
item: &json::Value,
|
||||
) -> Option<(String, i64, SessionTraceResponseItem)> {
|
||||
let tid = item.get("trace_id")?.as_str()?.to_string();
|
||||
let mut user_ids = item
|
||||
.get("user_ids")
|
||||
.and_then(json::Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(String::from)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
user_ids.sort();
|
||||
user_ids.dedup();
|
||||
let trace_start_time = json::get_int_value(item.get("trace_start_time").unwrap_or_default());
|
||||
let trace_end_time = json::get_int_value(item.get("trace_end_time").unwrap_or_default());
|
||||
let span_count = json::get_int_value(item.get("span_count").unwrap_or_default());
|
||||
@@ -1110,6 +1127,7 @@ fn build_session_trace_response_item(
|
||||
|
||||
let hit = SessionTraceResponseItem {
|
||||
trace_id: tid.clone(),
|
||||
user_ids,
|
||||
start_time: trace_start_time,
|
||||
end_time: trace_end_time,
|
||||
duration: computed_duration,
|
||||
@@ -1253,13 +1271,7 @@ fn build_latest_sessions_sql(
|
||||
validated: &super::schema_compat::ValidatedLlmSchema,
|
||||
) -> String {
|
||||
let session_id_col = validated.columns.session_id;
|
||||
// Trace ingestion normalizes OTEL `user.id` to the stored `user_id`
|
||||
// column. Keep the legacy schema's explicit `llm_user_id` name.
|
||||
let user_id_col = if validated.has_gen_ai {
|
||||
"user_id"
|
||||
} else {
|
||||
validated.columns.user_id
|
||||
};
|
||||
let user_id_col = validated.columns.user_id;
|
||||
let session_ids_sql = session_ids
|
||||
.iter()
|
||||
.map(|session_id| format!("'{}'", session_id.replace('\'', "''")))
|
||||
@@ -1419,6 +1431,7 @@ fn normalize_latest_session_hits(
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SessionTraceResponseItem {
|
||||
trace_id: String,
|
||||
user_ids: Vec<String>,
|
||||
start_time: i64,
|
||||
end_time: i64,
|
||||
duration: i64,
|
||||
@@ -1581,6 +1594,7 @@ mod tests {
|
||||
assert!(
|
||||
sql.contains("sum(CASE WHEN span_status = 'ERROR' THEN 1 ELSE 0 END) AS error_count")
|
||||
);
|
||||
assert!(sql.contains("array_agg(DISTINCT user_id)"));
|
||||
assert!(!sql.contains("gen_ai_conversation_id"));
|
||||
}
|
||||
|
||||
@@ -1598,6 +1612,7 @@ mod tests {
|
||||
"root_operation_name": "gen_ai.chat.completions",
|
||||
"first_service_name": "o2-ai",
|
||||
"first_operation_name": "fallback-op",
|
||||
"user_ids": ["user-b", "user-a", "user-b", ""],
|
||||
"gen_ai_usage_details_input": 10,
|
||||
"gen_ai_usage_details_output": 20,
|
||||
"gen_ai_usage_details_total": 30,
|
||||
@@ -1610,10 +1625,52 @@ mod tests {
|
||||
|
||||
assert_eq!(service_count, 1);
|
||||
assert_eq!(hit.spans, [3, 1]);
|
||||
assert_eq!(
|
||||
hit.user_ids,
|
||||
vec!["user-a".to_string(), "user-b".to_string()]
|
||||
);
|
||||
assert_eq!(hit.service_name[0].service_name, "o2-ai");
|
||||
assert_eq!(hit.models, vec!["claude-sonnet-4-6".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gen_ai_session_sql_uses_flattened_user_id_column() {
|
||||
// Ingestion flattens the Gen-AI `user.id` attribute to a `user_id`
|
||||
// column, so the SQL must never emit the dotted attribute name —
|
||||
// DataFusion would read `user.id` as `<table>.<column>`.
|
||||
let validated = super::super::schema_compat::ValidatedLlmSchema::fallback(true);
|
||||
let details_sql = build_session_trace_details_sql(
|
||||
"default",
|
||||
&validated,
|
||||
false,
|
||||
"service_name",
|
||||
"\"trace_id\" IN ('trace-1')",
|
||||
);
|
||||
let sessions_sql =
|
||||
build_latest_sessions_sql("default", &["session-1".to_string()], &validated);
|
||||
|
||||
for sql in [&details_sql, &sessions_sql] {
|
||||
assert!(sql.contains("array_agg(DISTINCT user_id)"));
|
||||
assert!(sql.contains("user_id IS NOT NULL AND user_id != ''"));
|
||||
assert!(!sql.contains("user.id"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_session_trace_details_sql_uses_legacy_user_id_column() {
|
||||
let validated = super::super::schema_compat::ValidatedLlmSchema::fallback(false);
|
||||
let sql = build_session_trace_details_sql(
|
||||
"default",
|
||||
&validated,
|
||||
false,
|
||||
"service_name",
|
||||
"\"trace_id\" IN ('trace-1')",
|
||||
);
|
||||
|
||||
assert!(sql.contains("array_agg(DISTINCT llm_user_id)"));
|
||||
assert!(!sql.contains("array_agg(DISTINCT user_id)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_trace_ids_from_hits_preserves_exact_ids_and_deduplicates() {
|
||||
let hits = vec![
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { defineComponent, nextTick } from "vue";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { useFullscreenBox } from "./useFullscreenBox";
|
||||
|
||||
// jsdom implements neither the Fullscreen API nor a settable
|
||||
// `document.fullscreenElement` — both are stood up manually so `toggle()`'s
|
||||
// real call into `Element.requestFullscreen()` has something to hit, and so a
|
||||
// test can simulate the browser firing `fullscreenchange` (e.g. on Escape).
|
||||
let currentFullscreenElement: Element | null = null;
|
||||
|
||||
function fireFullscreenChange(target: Element | null) {
|
||||
currentFullscreenElement = target;
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
currentFullscreenElement = null;
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
get: () => currentFullscreenElement,
|
||||
});
|
||||
Element.prototype.requestFullscreen = vi.fn(function (this: Element) {
|
||||
fireFullscreenChange(this);
|
||||
return Promise.resolve();
|
||||
});
|
||||
document.exitFullscreen = vi.fn(() => {
|
||||
fireFullscreenChange(null);
|
||||
return Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mountHarness() {
|
||||
let exposed: ReturnType<typeof useFullscreenBox>;
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
exposed = useFullscreenBox();
|
||||
return () => null;
|
||||
},
|
||||
}),
|
||||
);
|
||||
return {
|
||||
wrapper,
|
||||
get exposed() {
|
||||
return exposed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useFullscreenBox", () => {
|
||||
it("starts with nothing fullscreen", () => {
|
||||
const { exposed } = mountHarness();
|
||||
expect(exposed.fullscreenEl.value).toBeNull();
|
||||
});
|
||||
|
||||
it("requests fullscreen on the given element and reflects it once the browser confirms", async () => {
|
||||
const { exposed } = mountHarness();
|
||||
const box = document.createElement("div");
|
||||
|
||||
exposed.toggle(box);
|
||||
await nextTick();
|
||||
|
||||
expect(box.requestFullscreen).toHaveBeenCalled();
|
||||
expect(exposed.fullscreenEl.value).toBe(box);
|
||||
});
|
||||
|
||||
it("a second box's toggle replaces the first — only one element is ever fullscreen", async () => {
|
||||
const { exposed } = mountHarness();
|
||||
const boxA = document.createElement("div");
|
||||
const boxB = document.createElement("div");
|
||||
|
||||
exposed.toggle(boxA);
|
||||
await nextTick();
|
||||
expect(exposed.fullscreenEl.value).toBe(boxA);
|
||||
|
||||
// The real browser call is exitFullscreen() then requestFullscreen() on
|
||||
// the new target, not a direct swap — simulate that sequence.
|
||||
await document.exitFullscreen();
|
||||
exposed.toggle(boxB);
|
||||
await nextTick();
|
||||
|
||||
expect(exposed.fullscreenEl.value).toBe(boxB);
|
||||
});
|
||||
|
||||
it("clears when the browser exits fullscreen outside any click — e.g. Escape", async () => {
|
||||
const { exposed } = mountHarness();
|
||||
const box = document.createElement("div");
|
||||
exposed.toggle(box);
|
||||
await nextTick();
|
||||
expect(exposed.fullscreenEl.value).toBe(box);
|
||||
|
||||
fireFullscreenChange(null);
|
||||
await nextTick();
|
||||
|
||||
expect(exposed.fullscreenEl.value).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing for a null element rather than throwing", () => {
|
||||
const { exposed } = mountHarness();
|
||||
expect(() => exposed.toggle(null)).not.toThrow();
|
||||
expect(exposed.fullscreenEl.value).toBeNull();
|
||||
});
|
||||
|
||||
it("stops listening once the owning component unmounts", async () => {
|
||||
const { wrapper, exposed } = mountHarness();
|
||||
wrapper.unmount();
|
||||
|
||||
fireFullscreenChange(document.createElement("div"));
|
||||
await nextTick();
|
||||
|
||||
// No error from a listener touching a torn-down component, and the last
|
||||
// value it saw stays put — there is nothing left to update it.
|
||||
expect(exposed.fullscreenEl.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { toggleFullscreen } from "@/utils/dom";
|
||||
|
||||
/**
|
||||
* Fullscreen state for a page/drawer with several independently-expandable
|
||||
* content boxes, sharing ONE `fullscreenEl` ref rather than one boolean per
|
||||
* box — the Fullscreen API only ever has a single fullscreen element at a
|
||||
* time, so a per-box boolean could desync from reality (two boxes reading
|
||||
* "fullscreen" at once, or none, after an Escape-key exit browsers fire
|
||||
* outside any click handler).
|
||||
*
|
||||
* const { fullscreenEl, toggle } = useFullscreenBox();
|
||||
* <div ref="inputBox" :class="fullscreenEl === inputBox && '...'">
|
||||
* <button @click="toggle(inputBox)">
|
||||
*/
|
||||
export function useFullscreenBox() {
|
||||
const fullscreenEl = ref<Element | null>(null);
|
||||
|
||||
function sync() {
|
||||
fullscreenEl.value = document.fullscreenElement;
|
||||
}
|
||||
|
||||
function toggle(element: Element | null) {
|
||||
if (!element) return;
|
||||
void toggleFullscreen(element as HTMLElement).catch(() => {
|
||||
// Fullscreen can be denied by the browser (permissions policy, an
|
||||
// exhausted user-gesture requirement) — sync() below already covers the
|
||||
// failure by leaving fullscreenEl at whatever the browser actually did.
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("fullscreenchange", sync));
|
||||
onBeforeUnmount(() => document.removeEventListener("fullscreenchange", sync));
|
||||
|
||||
return { fullscreenEl, toggle };
|
||||
}
|
||||
@@ -228,13 +228,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
v-for="(version, index) in versions"
|
||||
:key="version.rowId"
|
||||
class="border-border-default bg-card-bg rounded-default flex flex-col gap-2 border px-3.5 py-3"
|
||||
:data-test="`ai-dataset-item-detail-version-${version.version}`"
|
||||
:data-test="`ai-dataset-item-detail-version-${itemLocalVersion(index)}`"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-text-heading text-compact font-bold">
|
||||
{{
|
||||
t("aiObservability.datasets.detail.itemDetail.versionLabel", {
|
||||
version: version.version,
|
||||
version: itemLocalVersion(index),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
@@ -331,11 +331,20 @@ const tabs = computed<{ id: TabId; label: I18nText; count: number | null }[]>(()
|
||||
const versionPillLabel = computed(() =>
|
||||
t(
|
||||
"aiObservability.datasets.detail.itemDetail.versionPill",
|
||||
{ version: props.item.version, count: versionCount.value },
|
||||
{ count: versionCount.value },
|
||||
versionCount.value,
|
||||
),
|
||||
);
|
||||
|
||||
/** `version.version` is the dataset-wide MVCC counter (shared across every item
|
||||
* in the dataset), not this item's own edit count — displaying it as "this
|
||||
* item's version number" reads as inconsistent with the version count next to
|
||||
* it. `versions` is sorted newest-first, so the item-local sequence number is
|
||||
* just its distance from the end. */
|
||||
function itemLocalVersion(index: number): number {
|
||||
return versionCount.value - index;
|
||||
}
|
||||
|
||||
const sourceVariant = computed<BadgeVariant>(() =>
|
||||
props.item.source === "trace"
|
||||
? "blue-soft"
|
||||
|
||||
@@ -308,6 +308,58 @@ describe("ExperimentBrowser", () => {
|
||||
expect(wrapper.find("button input, button button").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the baseline when the pin is clicked again", async () => {
|
||||
const wrapper = mount(ExperimentBrowser, {
|
||||
props: {
|
||||
orgId: "acme",
|
||||
experiments: [experiment("new", "dataset-a", 2), experiment("old", "dataset-a", 1)],
|
||||
datasets: [{ id: "dataset-a", name: "Dataset A" }] as any,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const pin = () => wrapper.get('[data-test="ai-experiment-baseline-old"]');
|
||||
await pin().trigger("click");
|
||||
expect(JSON.parse(localStorage.getItem("o2_experiment_baselines_acme") ?? "{}")).toEqual({
|
||||
"dataset-a": "old",
|
||||
});
|
||||
|
||||
// The pin renders filled once set, so it reads as a toggle — before this it
|
||||
// could only ever re-pin the row to itself, stranding the baseline forever.
|
||||
await pin().trigger("click");
|
||||
expect(JSON.parse(localStorage.getItem("o2_experiment_baselines_acme") ?? "{}")).toEqual({});
|
||||
|
||||
// The dataset key is DELETED, not blanked: an empty string left behind still
|
||||
// reads as a configured baseline everywhere the id is looked up.
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
JSON.parse(localStorage.getItem("o2_experiment_baselines_acme") ?? "{}"),
|
||||
"dataset-a",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves another dataset's baseline alone when one is cleared", async () => {
|
||||
localStorage.setItem(
|
||||
"o2_experiment_baselines_acme",
|
||||
JSON.stringify({ "dataset-a": "old", "dataset-b": "other" }),
|
||||
);
|
||||
const wrapper = mount(ExperimentBrowser, {
|
||||
props: {
|
||||
orgId: "acme",
|
||||
experiments: [experiment("old", "dataset-a", 1)],
|
||||
datasets: [{ id: "dataset-a", name: "Dataset A" }] as any,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
await wrapper.get('[data-test="ai-experiment-baseline-old"]').trigger("click");
|
||||
|
||||
expect(JSON.parse(localStorage.getItem("o2_experiment_baselines_acme") ?? "{}")).toEqual({
|
||||
"dataset-b": "other",
|
||||
});
|
||||
});
|
||||
|
||||
it("persists the selected baseline and orders it first", async () => {
|
||||
const wrapper = mount(ExperimentBrowser, {
|
||||
props: {
|
||||
|
||||
@@ -215,16 +215,16 @@
|
||||
:icon-left="isBaseline(row) ? 'keep' : 'keep-outline'"
|
||||
:title="
|
||||
isBaseline(row)
|
||||
? t('aiObservability.experiments.baseline')
|
||||
? t('aiObservability.experiments.clearBaseline')
|
||||
: t('aiObservability.experiments.setBaseline')
|
||||
"
|
||||
:aria-label="
|
||||
isBaseline(row)
|
||||
? t('aiObservability.experiments.baseline')
|
||||
? t('aiObservability.experiments.clearBaseline')
|
||||
: t('aiObservability.experiments.setBaseline')
|
||||
"
|
||||
:data-test="`ai-experiment-baseline-${row.id}`"
|
||||
@click.stop="setBaseline(row)"
|
||||
@click.stop="toggleBaseline(row)"
|
||||
/>
|
||||
<OButton
|
||||
size="icon-sm"
|
||||
@@ -562,9 +562,21 @@ async function cloneExperiment(experiment: LlmExperiment) {
|
||||
}
|
||||
}
|
||||
|
||||
function setBaseline(experiment: LlmExperiment) {
|
||||
baselineByDataset.value = { ...baselineByDataset.value, [experiment.datasetId]: experiment.id };
|
||||
writeExperimentBaselines(props.orgId, baselineByDataset.value);
|
||||
function toggleBaseline(experiment: LlmExperiment) {
|
||||
const next = { ...baselineByDataset.value };
|
||||
if (next[experiment.datasetId] === experiment.id) {
|
||||
// One baseline per dataset, so clearing is a delete rather than a write of
|
||||
// some empty value — a "" left behind would still match a stale id lookup.
|
||||
delete next[experiment.datasetId];
|
||||
baselineByDataset.value = next;
|
||||
writeExperimentBaselines(props.orgId, next);
|
||||
return;
|
||||
}
|
||||
next[experiment.datasetId] = experiment.id;
|
||||
baselineByDataset.value = next;
|
||||
writeExperimentBaselines(props.orgId, next);
|
||||
// Pinning starts a comparison from the new baseline. Clearing does not touch
|
||||
// the selection: dropping the pin is not a statement about what to compare.
|
||||
selectedIds.value = [experiment.id];
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
:page-size="20"
|
||||
:default-columns="false"
|
||||
:show-global-filter="false"
|
||||
:enable-column-resize="true"
|
||||
:persist-columns="true"
|
||||
table-id="ai-experiment-comparison-rows"
|
||||
data-test="ai-experiment-comparison-rows"
|
||||
@row-click="(row: ExperimentComparisonRow) => $emit('inspect', row, visibleRows)"
|
||||
>
|
||||
@@ -55,7 +58,8 @@
|
||||
|
||||
<template #cell-input="{ row }">
|
||||
<span
|
||||
class="text-text-body max-w-[32rem] text-xs break-words whitespace-pre-wrap"
|
||||
class="text-text-body block truncate text-xs"
|
||||
:title="raw(displayRowInput(row.input))"
|
||||
data-test="ai-experiment-comparison-row-input"
|
||||
>
|
||||
{{ displayRowInput(row.input) }}
|
||||
@@ -251,6 +255,11 @@ const comparisonColumns = computed<OTableColumnDef<ExperimentComparisonRow>[]>((
|
||||
header: t("aiObservability.experiments.comparePage.panel.input"),
|
||||
accessorFn: (row) => displayRowInput(row.input),
|
||||
sortable: true,
|
||||
// Starting width the previous max-w-[32rem] cap implied — the resize
|
||||
// handle (enable-column-resize on the table, below) now owns going wider
|
||||
// or narrower than this.
|
||||
size: 512,
|
||||
minSize: 160,
|
||||
},
|
||||
{
|
||||
id: "bucket",
|
||||
|
||||
@@ -16,35 +16,25 @@
|
||||
<div class="min-h-0 flex-1 space-y-5 overflow-auto px-5 py-4">
|
||||
<!-- Input and Expected Output come from the dataset row, so they are the
|
||||
same on both sides — shown once instead of duplicated per column. -->
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<div class="flex min-h-8 items-center gap-2">
|
||||
<h4 class="text-compact text-text-heading m-0 font-semibold">
|
||||
{{ t("aiObservability.experiments.comparePage.rowDrawer.input") }}
|
||||
</h4>
|
||||
<OTag
|
||||
size="sm"
|
||||
icon=""
|
||||
variant="default-soft"
|
||||
:label="t('aiObservability.experiments.comparePage.rowDrawer.fromDataset')"
|
||||
/>
|
||||
</div>
|
||||
<ContentBox :value="sharedInput" data-test="ai-experiment-comparison-input" />
|
||||
</section>
|
||||
<ContentBox
|
||||
ref="inputBoxRef"
|
||||
:label="t('aiObservability.experiments.comparePage.rowDrawer.input')"
|
||||
:tag-label="t('aiObservability.experiments.comparePage.rowDrawer.fromDataset')"
|
||||
:value="sharedInput"
|
||||
:fullscreen="fullscreenEl === inputBoxRef?.rootEl"
|
||||
data-test="ai-experiment-comparison-input"
|
||||
@toggle-fullscreen="toggle"
|
||||
/>
|
||||
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<div class="flex min-h-8 items-center gap-2">
|
||||
<h4 class="text-compact text-text-heading m-0 font-semibold">
|
||||
{{ t("aiObservability.experiments.comparePage.rowDrawer.expectedOutput") }}
|
||||
</h4>
|
||||
<OTag
|
||||
size="sm"
|
||||
icon=""
|
||||
variant="default-soft"
|
||||
:label="t('aiObservability.experiments.comparePage.rowDrawer.fromDataset')"
|
||||
/>
|
||||
</div>
|
||||
<ContentBox :value="sharedExpected" data-test="ai-experiment-comparison-expected" />
|
||||
</section>
|
||||
<ContentBox
|
||||
ref="expectedBoxRef"
|
||||
:label="t('aiObservability.experiments.comparePage.rowDrawer.expectedOutput')"
|
||||
:tag-label="t('aiObservability.experiments.comparePage.rowDrawer.fromDataset')"
|
||||
:value="sharedExpected"
|
||||
:fullscreen="fullscreenEl === expectedBoxRef?.rootEl"
|
||||
data-test="ai-experiment-comparison-expected"
|
||||
@toggle-fullscreen="toggle"
|
||||
/>
|
||||
|
||||
<section class="flex flex-col gap-1.5">
|
||||
<div class="flex min-h-8 flex-wrap items-center justify-between gap-2">
|
||||
@@ -69,19 +59,17 @@
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-2">
|
||||
<div
|
||||
<ContentBox
|
||||
v-for="side in sides"
|
||||
:key="side.key"
|
||||
class="flex min-w-0 flex-col gap-1.5"
|
||||
:data-test="`ai-experiment-comparison-${side.key}`"
|
||||
>
|
||||
<span class="text-2xs text-text-tertiary font-semibold">{{ side.label }}</span>
|
||||
<ContentBox
|
||||
:value="side.output"
|
||||
:absent="!side.detail"
|
||||
:data-test="`ai-experiment-comparison-${side.key}-output`"
|
||||
/>
|
||||
</div>
|
||||
:ref="(el) => setOutputBoxRef(side.key, el)"
|
||||
:label="side.label"
|
||||
:value="side.output"
|
||||
:absent="!side.detail"
|
||||
:fullscreen="fullscreenEl === outputBoxRefs[side.key]?.rootEl"
|
||||
:data-test="`ai-experiment-comparison-${side.key}-output`"
|
||||
@toggle-fullscreen="toggle"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -146,6 +134,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { useFullscreenBox } from "@/composables/useFullscreenBox";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
|
||||
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
|
||||
@@ -190,6 +179,19 @@ const { t } = useI18nTyped();
|
||||
|
||||
const activeTrialIndex = ref(0);
|
||||
|
||||
// One fullscreen slot shared by all four boxes (Input, Expected Output,
|
||||
// Baseline, Candidate) — the Fullscreen API only ever has a single fullscreen
|
||||
// element, so each box compares itself against the SAME ref rather than
|
||||
// carrying its own boolean.
|
||||
const { fullscreenEl, toggle } = useFullscreenBox();
|
||||
const inputBoxRef = ref<InstanceType<typeof ContentBox> | null>(null);
|
||||
const expectedBoxRef = ref<InstanceType<typeof ContentBox> | null>(null);
|
||||
const outputBoxRefs = ref<Record<string, InstanceType<typeof ContentBox> | null>>({});
|
||||
|
||||
function setOutputBoxRef(key: string, el: unknown) {
|
||||
outputBoxRefs.value[key] = el as InstanceType<typeof ContentBox> | null;
|
||||
}
|
||||
|
||||
const bucketLabel = computed(() => raw(props.row?.bucket));
|
||||
|
||||
/** Both sides read the same dataset row, so either side can supply it. */
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ExperimentRowContentBox from "./ExperimentRowContentBox.vue";
|
||||
|
||||
const stubs = {
|
||||
global: {
|
||||
stubs: {
|
||||
OTag: { props: ["label"], template: "<span>{{ label }}</span>" },
|
||||
OButton: {
|
||||
props: ["iconLeft", "title"],
|
||||
emits: ["click"],
|
||||
template:
|
||||
'<button :title="title" :data-icon="iconLeft" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
LLMContentRenderer: { props: ["content"], template: "<div>{{ content }}</div>" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function mountBox(props: Record<string, unknown> = {}) {
|
||||
return mount(ExperimentRowContentBox, {
|
||||
props: { label: "Input", value: "hello", dataTest: "box", ...props } as any,
|
||||
...stubs,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ExperimentRowContentBox", () => {
|
||||
it("renders its own label and, when given one, the source tag", () => {
|
||||
const wrapper = mountBox({ tagLabel: "From Dataset" });
|
||||
expect(wrapper.text()).toContain("Input");
|
||||
expect(wrapper.text()).toContain("From Dataset");
|
||||
});
|
||||
|
||||
it("omits the tag when the box has no source to name", () => {
|
||||
const wrapper = mountBox();
|
||||
expect(wrapper.find("span").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("emits its own root element when the fullscreen button is clicked", async () => {
|
||||
const wrapper = mountBox();
|
||||
await wrapper.get('[data-test="box-fullscreen"]').trigger("click");
|
||||
|
||||
const emitted = wrapper.emitted("toggle-fullscreen");
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(emitted![0][0]).toBe(wrapper.element);
|
||||
});
|
||||
|
||||
it("shows Enter Fullscreen until the parent reports this box IS the fullscreen element", () => {
|
||||
const collapsed = mountBox({ fullscreen: false });
|
||||
expect(collapsed.get('[data-test="box-fullscreen"]').attributes("title")).toBe(
|
||||
"Enter Fullscreen",
|
||||
);
|
||||
expect(collapsed.get('[data-test="box-fullscreen"]').attributes("data-icon")).toBe(
|
||||
"fullscreen",
|
||||
);
|
||||
|
||||
const expanded = mountBox({ fullscreen: true });
|
||||
expect(expanded.get('[data-test="box-fullscreen"]').attributes("title")).toBe(
|
||||
"Exit Fullscreen",
|
||||
);
|
||||
expect(expanded.get('[data-test="box-fullscreen"]').attributes("data-icon")).toBe(
|
||||
"fullscreen-exit",
|
||||
);
|
||||
});
|
||||
|
||||
it("says the side is absent, not empty, when the run never produced it", () => {
|
||||
const wrapper = mountBox({ absent: true, value: null });
|
||||
expect(wrapper.text()).toContain("Not present in this run");
|
||||
expect(wrapper.text()).not.toContain("Not provided");
|
||||
});
|
||||
|
||||
it("says content is missing, distinct from absent, when the side exists but has none", () => {
|
||||
const wrapper = mountBox({ value: null });
|
||||
expect(wrapper.text()).toContain("Not provided");
|
||||
});
|
||||
});
|
||||
@@ -4,41 +4,95 @@
|
||||
The scrollable content box used for a row's input / expected output / output.
|
||||
Same chrome as DatasetItemDetail, so an item reads identically in the dataset,
|
||||
in an experiment row, and in a comparison.
|
||||
|
||||
Owns its own label + fullscreen button (mirrors ReviewContentBox in the Queue
|
||||
Workbench) rather than splitting the header into the parent: the header has to
|
||||
travel INTO fullscreen with the box, or the label and the exit control both
|
||||
disappear the moment the box fills the screen.
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
class="border-border-default bg-code-bg rounded-default text-text-body h-40 overflow-auto border px-3 py-2 text-xs wrap-break-word whitespace-pre-wrap"
|
||||
class="flex min-w-0 flex-col gap-1.5"
|
||||
:class="fullscreen ? '[&:fullscreen]:bg-surface-base [&:fullscreen]:p-4' : ''"
|
||||
ref="rootEl"
|
||||
>
|
||||
<div v-if="absent" class="text-text-secondary p-8 text-center text-sm italic">
|
||||
{{ t("aiObservability.experiments.comparePage.rowDrawer.absentSide") }}
|
||||
<div class="flex min-h-8 shrink-0 items-center gap-2">
|
||||
<h4 class="text-compact text-text-heading m-0 font-semibold">{{ label }}</h4>
|
||||
<OTag v-if="tagLabel" size="sm" icon="" variant="default-soft" :label="tagLabel" />
|
||||
<div class="grow" />
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
:icon-left="fullscreen ? 'fullscreen-exit' : 'fullscreen'"
|
||||
:title="
|
||||
fullscreen
|
||||
? t('aiObservability.experiments.comparePage.rowDrawer.exitFullscreen')
|
||||
: t('aiObservability.experiments.comparePage.rowDrawer.enterFullscreen')
|
||||
"
|
||||
:data-test="`${dataTest}-fullscreen`"
|
||||
@click="handleToggle"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!hasContent(value)" class="text-text-secondary p-8 text-center text-sm italic">
|
||||
{{ t("aiObservability.experiments.comparePage.rowDrawer.noContent") }}
|
||||
<div
|
||||
class="border-border-default bg-code-bg rounded-default text-text-body overflow-auto border px-3 py-2 text-xs wrap-break-word whitespace-pre-wrap"
|
||||
:class="fullscreen ? 'min-h-0 flex-1' : 'h-40'"
|
||||
:data-test="dataTest"
|
||||
>
|
||||
<div v-if="absent" class="text-text-secondary p-8 text-center text-sm italic">
|
||||
{{ t("aiObservability.experiments.comparePage.rowDrawer.absentSide") }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!hasContent(value)"
|
||||
class="text-text-secondary p-8 text-center text-sm italic"
|
||||
>
|
||||
{{ t("aiObservability.experiments.comparePage.rowDrawer.noContent") }}
|
||||
</div>
|
||||
<!-- LLMContentRenderer only renders plain strings; anything else (an object,
|
||||
or a JSON string) falls back to pretty-printed JSON. -->
|
||||
<LLMContentRenderer
|
||||
v-else-if="isPlainText(value)"
|
||||
:content="value as string"
|
||||
content-type="output"
|
||||
view-mode="formatted"
|
||||
/>
|
||||
<pre v-else class="m-0 font-mono text-xs break-words whitespace-pre-wrap">{{
|
||||
pretty(value)
|
||||
}}</pre>
|
||||
</div>
|
||||
<!-- LLMContentRenderer only renders plain strings; anything else (an object,
|
||||
or a JSON string) falls back to pretty-printed JSON. -->
|
||||
<LLMContentRenderer
|
||||
v-else-if="isPlainText(value)"
|
||||
:content="value as string"
|
||||
content-type="output"
|
||||
view-mode="formatted"
|
||||
/>
|
||||
<pre v-else class="m-0 font-mono text-xs break-words whitespace-pre-wrap">{{
|
||||
pretty(value)
|
||||
}}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import { ref } from "vue";
|
||||
import { useI18nTyped, type I18nText } from "@/types/i18n";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import LLMContentRenderer from "@/plugins/traces/LLMContentRenderer.vue";
|
||||
import { hasContent, isPlainText, pretty } from "./experimentRowContent";
|
||||
|
||||
defineProps<{
|
||||
label: I18nText;
|
||||
/** "From Dataset" — only Input and Expected Output carry it. */
|
||||
tagLabel?: I18nText;
|
||||
value: unknown;
|
||||
/** The whole side is missing from this run, which is not the same as empty. */
|
||||
absent?: boolean;
|
||||
/** Whether THIS box is the current document.fullscreenElement. */
|
||||
fullscreen?: boolean;
|
||||
dataTest: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ "toggle-fullscreen": [element: HTMLElement | null] }>();
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
const rootEl = ref<HTMLElement | null>(null);
|
||||
|
||||
function handleToggle() {
|
||||
emit("toggle-fullscreen", rootEl.value);
|
||||
}
|
||||
|
||||
// Lets the parent compare `fullscreenEl.value === thisBox.rootEl` without the
|
||||
// parent needing its own ref on an element this component owns.
|
||||
defineExpose({ rootEl });
|
||||
</script>
|
||||
|
||||
@@ -88,6 +88,29 @@ describe("PlaygroundExpectedBar", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Sampling can land while the user is typing anywhere on the page — a
|
||||
// passive visual cue is welcome, but yanking keyboard focus away mid-type
|
||||
// is not. This is why flash() and focus() are two separate seams.
|
||||
it("flashes without moving keyboard focus", async () => {
|
||||
vi.useFakeTimers();
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
const wrapper = mountBar({ required: true });
|
||||
const field = () => wrapper.get('[data-test="ai-playground-expected-field"]');
|
||||
|
||||
(wrapper.vm as unknown as { flash: () => void }).flash();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(field().attributes("data-flashing")).toBe("true");
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
wrapper.unmount();
|
||||
input.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Asking twice must re-arm the flash rather than let the first timer end it
|
||||
// half a second into the second one.
|
||||
it("restarts the flash when asked again", async () => {
|
||||
|
||||
@@ -95,17 +95,26 @@ const flashing = ref(false);
|
||||
const FLASH_MS = 1200;
|
||||
let flashTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Called by the Score panel's warning, so the notice and its fix are one gesture. */
|
||||
function focus() {
|
||||
void nextTick(() => fieldRef.value?.querySelector("textarea")?.focus());
|
||||
/** Visual cue only — no keyboard focus move. Called when a sample lands: the
|
||||
* field earns a glance, but the user's actual focus can be anywhere else on
|
||||
* the page at that moment and must stay there. */
|
||||
function flash() {
|
||||
flashing.value = true;
|
||||
if (flashTimer) clearTimeout(flashTimer);
|
||||
flashTimer = setTimeout(() => (flashing.value = false), FLASH_MS);
|
||||
}
|
||||
|
||||
/** Called by the Score panel's warning, so the notice and its fix are one
|
||||
* gesture — that link is a deliberate "take me there" click, unlike a
|
||||
* sample landing passively, so this is the one seam allowed to move focus. */
|
||||
function focus() {
|
||||
flash();
|
||||
void nextTick(() => fieldRef.value?.querySelector("textarea")?.focus());
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (flashTimer) clearTimeout(flashTimer);
|
||||
});
|
||||
|
||||
defineExpose({ focus });
|
||||
defineExpose({ focus, flash });
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
// @vitest-environment jsdom
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { emptyVariant } from "@/enterprise/views/AIObservability/playgroundDraft";
|
||||
import PlaygroundMessageList from "./PlaygroundMessageList.vue";
|
||||
|
||||
vi.mock("@/types/i18n", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/types/i18n")>("@/types/i18n");
|
||||
return {
|
||||
...actual,
|
||||
useI18nTyped: () => ({ t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
const OTextarea = {
|
||||
name: "OTextarea",
|
||||
props: ["modelValue", "width"],
|
||||
template: '<textarea :data-width="width" />',
|
||||
};
|
||||
|
||||
describe("PlaygroundMessageList", () => {
|
||||
it("places the tool result below the tool metadata", () => {
|
||||
const variant = emptyVariant("provider-1", "model-1");
|
||||
variant.tools = [
|
||||
{
|
||||
name: "lookup_order",
|
||||
description: "Find an order",
|
||||
parameters: '{"type":"object"}',
|
||||
},
|
||||
];
|
||||
variant.messages.push({
|
||||
id: "tool-result-1",
|
||||
role: "tool",
|
||||
content: "asdf",
|
||||
toolName: "lookup_order",
|
||||
toolCallId: "call-1",
|
||||
toolArguments: '{"order_id":"123"}',
|
||||
});
|
||||
|
||||
const wrapper = mount(PlaygroundMessageList, {
|
||||
props: { variant, varNames: [] },
|
||||
global: {
|
||||
stubs: {
|
||||
OTextarea,
|
||||
OButton: true,
|
||||
ODropdown: true,
|
||||
ODropdownItem: true,
|
||||
OIcon: true,
|
||||
OSelect: true,
|
||||
OTag: true,
|
||||
OTooltip: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const editor = wrapper.get("[data-test='ai-playground-message-tool-editor-tool-result-1']");
|
||||
const metadata = editor.get("[data-test='ai-playground-message-tool-metadata-tool-result-1']");
|
||||
const argumentsField = editor.get(
|
||||
"[data-test='ai-playground-message-tool-arguments-tool-result-1']",
|
||||
);
|
||||
const resultField = editor.get("[data-test='ai-playground-message-input-tool-result-1']");
|
||||
|
||||
expect(argumentsField.attributes("data-width")).toBe("full");
|
||||
expect(metadata.element.contains(argumentsField.element)).toBe(true);
|
||||
expect(metadata.element.contains(resultField.element)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -82,58 +82,97 @@
|
||||
>
|
||||
{{ message.content }}
|
||||
</div>
|
||||
<!-- Inline, beside the field. It shifts this row's textarea right of the
|
||||
other rows' — accepted deliberately: the pairing of tool to response
|
||||
reads better than a straight column of inputs. -->
|
||||
<OSelect
|
||||
v-if="message.role === 'tool' && !message.readonly"
|
||||
class="mt-0.5"
|
||||
:model-value="message.toolName ?? ''"
|
||||
:options="toolOptions"
|
||||
:placeholder="t('aiObservability.playground.messageToolPlaceholder')"
|
||||
size="md"
|
||||
width="sm"
|
||||
searchable
|
||||
:data-test="`ai-playground-message-tool-${message.id}`"
|
||||
@update:model-value="(value: unknown) => emit('set-tool', message.id, String(value ?? ''))"
|
||||
/>
|
||||
|
||||
<div v-if="!message.readonly" class="relative min-w-0 flex-1">
|
||||
<OTextarea
|
||||
:model-value="message.content"
|
||||
:placeholder="
|
||||
t('aiObservability.playground.messagePlaceholder', { role: roleLabel(message.role) })
|
||||
"
|
||||
:rows="1"
|
||||
:max-rows="5"
|
||||
size="sm"
|
||||
autogrow
|
||||
:data-test="`ai-playground-message-input-${message.id}`"
|
||||
@update:model-value="(value: string) => onInput(message.id, value)"
|
||||
@focus="onFocus(message.id, $event)"
|
||||
@blur="closeSuggest"
|
||||
@keydown="onKeydown(message.id, $event)"
|
||||
/>
|
||||
|
||||
<!-- Typing `{{` is the moment someone is reaching for a variable, so
|
||||
that is where the list belongs — not behind a menu they would have
|
||||
to know exists. -->
|
||||
<div
|
||||
v-if="!message.readonly"
|
||||
class="min-w-0 flex-1"
|
||||
:class="
|
||||
message.role === 'tool'
|
||||
? 'grid grid-cols-1 gap-1.5 @min-[28rem]/variant:grid-cols-[minmax(7.5rem,12.5rem)_minmax(0,1fr)]'
|
||||
: ''
|
||||
"
|
||||
:data-test="
|
||||
message.role === 'tool' ? `ai-playground-message-tool-editor-${message.id}` : undefined
|
||||
"
|
||||
>
|
||||
<!-- Tool metadata shares a compact header. The result gets the full
|
||||
content width below it instead of collapsing into a third column. -->
|
||||
<div
|
||||
v-if="suggest?.messageId === message.id && matches.length"
|
||||
class="bg-dropdown-bg border-dropdown-border rounded-default absolute top-full left-0 z-10 mt-1 w-56 border p-1 shadow-md"
|
||||
:data-test="`ai-playground-var-suggest-${message.id}`"
|
||||
v-if="message.role === 'tool'"
|
||||
class="contents"
|
||||
:data-test="`ai-playground-message-tool-metadata-${message.id}`"
|
||||
>
|
||||
<button
|
||||
v-for="(name, index) in matches"
|
||||
:key="name"
|
||||
type="button"
|
||||
class="rounded-default text-dropdown-item-text flex w-full cursor-pointer items-center px-2 py-1 text-left font-mono text-xs"
|
||||
:class="index === activeIndex ? 'bg-dropdown-item-hover-bg' : ''"
|
||||
:data-test="`ai-playground-var-suggest-item-${name}`"
|
||||
@mousedown.prevent="accept(message.id, name)"
|
||||
<OSelect
|
||||
class="min-w-0"
|
||||
:model-value="message.toolName ?? ''"
|
||||
:options="toolOptions"
|
||||
:placeholder="t('aiObservability.playground.messageToolPlaceholder')"
|
||||
size="md"
|
||||
width="full"
|
||||
searchable
|
||||
:data-test="`ai-playground-message-tool-${message.id}`"
|
||||
@update:model-value="
|
||||
(value: unknown) => emit('set-tool', message.id, String(value ?? ''))
|
||||
"
|
||||
/>
|
||||
|
||||
<OTextarea
|
||||
class="min-w-0"
|
||||
:model-value="message.toolArguments ?? '{}'"
|
||||
:placeholder="t('aiObservability.playground.toolArgumentsPlaceholder')"
|
||||
:rows="1"
|
||||
:max-rows="5"
|
||||
size="sm"
|
||||
width="full"
|
||||
autogrow
|
||||
:data-test="`ai-playground-message-tool-arguments-${message.id}`"
|
||||
@update:model-value="(value: string) => emit('set-tool-arguments', message.id, value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative min-w-0"
|
||||
:class="message.role === 'tool' ? '@min-[28rem]/variant:col-span-2' : ''"
|
||||
>
|
||||
<OTextarea
|
||||
:model-value="message.content"
|
||||
:placeholder="
|
||||
message.role === 'tool'
|
||||
? t('aiObservability.playground.toolResultPlaceholder')
|
||||
: t('aiObservability.playground.messagePlaceholder', {
|
||||
role: roleLabel(message.role),
|
||||
})
|
||||
"
|
||||
:rows="1"
|
||||
:max-rows="5"
|
||||
size="sm"
|
||||
autogrow
|
||||
:data-test="`ai-playground-message-input-${message.id}`"
|
||||
@update:model-value="(value: string) => onInput(message.id, value)"
|
||||
@focus="onFocus(message.id, $event)"
|
||||
@blur="closeSuggest"
|
||||
@keydown="onKeydown(message.id, $event)"
|
||||
/>
|
||||
|
||||
<!-- Typing `{{` is the moment someone is reaching for a variable, so
|
||||
that is where the list belongs — not behind a menu they would have
|
||||
to know exists. -->
|
||||
<div
|
||||
v-if="suggest?.messageId === message.id && matches.length"
|
||||
class="bg-dropdown-bg border-dropdown-border rounded-default absolute top-full left-0 z-10 mt-1 w-56 border p-1 shadow-md"
|
||||
:data-test="`ai-playground-var-suggest-${message.id}`"
|
||||
>
|
||||
{{ tokenFor(name) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="(name, index) in matches"
|
||||
:key="name"
|
||||
type="button"
|
||||
class="rounded-default text-dropdown-item-text flex w-full cursor-pointer items-center px-2 py-1 text-left font-mono text-xs"
|
||||
:class="index === activeIndex ? 'bg-dropdown-item-hover-bg' : ''"
|
||||
:data-test="`ai-playground-var-suggest-item-${name}`"
|
||||
@mousedown.prevent="accept(message.id, name)"
|
||||
>
|
||||
{{ tokenFor(name) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -228,6 +267,8 @@ const emit = defineEmits<{
|
||||
add: [role: PlaygroundRole];
|
||||
/** Which tool a `tool`-role message answers. */
|
||||
"set-tool": [messageId: string, toolName: string];
|
||||
/** JSON arguments sent to that tool. */
|
||||
"set-tool-arguments": [messageId: string, toolArguments: string];
|
||||
/** Retype a message in place, keeping whatever was written in it. */
|
||||
"set-role": [messageId: string, role: PlaygroundRole];
|
||||
move: [from: number, to: number];
|
||||
|
||||
@@ -18,10 +18,6 @@ vi.mock("@/types/i18n", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
const OBanner = {
|
||||
props: ["variant", "dense", "content", "icon", "inlineActions"],
|
||||
template: '<div class="o-banner" :data-variant="variant" :data-content="content"><slot /></div>',
|
||||
};
|
||||
const OButton = {
|
||||
props: ["variant", "size", "iconLeft", "title"],
|
||||
emits: ["click"],
|
||||
@@ -31,11 +27,15 @@ const OTag = {
|
||||
props: ["variant", "size", "label"],
|
||||
template: '<span class="o-tag" :data-label="label" />',
|
||||
};
|
||||
const OBanner = {
|
||||
props: ["variant", "dense", "preserveWhitespace"],
|
||||
template: '<div v-bind="$attrs"><slot /></div>',
|
||||
};
|
||||
|
||||
function mountCell(cell: PlaygroundCell | undefined, props: Record<string, unknown> = {}) {
|
||||
return mount(PlaygroundOutputCell, {
|
||||
props: { cell, ...props },
|
||||
global: { stubs: { OBanner, OButton, OTag } },
|
||||
global: { stubs: { OButton, OTag, OBanner } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,16 +134,17 @@ describe("PlaygroundOutputCell", () => {
|
||||
error: { message: "provider 429", retryable: true },
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test="ai-playground-output-error"]').attributes("data-content"),
|
||||
).toBe("provider 429");
|
||||
expect(wrapper.find('[data-test="ai-playground-output-error"]').text()).toBe("provider 429");
|
||||
await wrapper.find('[data-test="ai-playground-output-retry"]').trigger("click");
|
||||
expect(wrapper.emitted("retry")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders a tool call as the output, with no answer text", () => {
|
||||
const wrapper = mountCell(
|
||||
doneCell({ text: "", toolCall: { name: "lookup_order", arguments: '{ "id": 1 }' } }),
|
||||
doneCell({
|
||||
text: "",
|
||||
toolCall: { id: "call_1", name: "lookup_order", arguments: '{ "id": 1 }' },
|
||||
}),
|
||||
);
|
||||
const call = wrapper.find('[data-test="ai-playground-output-tool-call"]');
|
||||
|
||||
|
||||
@@ -25,12 +25,9 @@
|
||||
|
||||
<!-- error -->
|
||||
<template v-else-if="cell.status === 'error'">
|
||||
<OBanner
|
||||
variant="error"
|
||||
dense
|
||||
:content="raw(cell.error?.message)"
|
||||
data-test="ai-playground-output-error"
|
||||
/>
|
||||
<OBanner variant="error" dense preserve-whitespace data-test="ai-playground-output-error">
|
||||
{{ raw(cell.error?.message) }}
|
||||
</OBanner>
|
||||
<div>
|
||||
<OButton
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
|
||||
vi.mock("@/types/i18n", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/types/i18n")>("@/types/i18n");
|
||||
return { ...actual, useI18nTyped: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import PlaygroundSchemaDialog from "./PlaygroundSchemaDialog.vue";
|
||||
|
||||
const ODialog = {
|
||||
props: [
|
||||
"open",
|
||||
"size",
|
||||
"title",
|
||||
"primaryButtonLabel",
|
||||
"secondaryButtonLabel",
|
||||
"primaryButtonDisabled",
|
||||
],
|
||||
emits: ["update:open", "click:primary", "click:secondary"],
|
||||
template:
|
||||
'<div class="o-dialog" :data-primary-disabled="primaryButtonDisabled">' +
|
||||
'<button class="primary" @click="$emit(\'click:primary\')" /><slot /></div>',
|
||||
};
|
||||
const OCheckbox = {
|
||||
props: ["modelValue", "label"],
|
||||
emits: ["update:modelValue"],
|
||||
template:
|
||||
'<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)" />',
|
||||
};
|
||||
const OTextarea = {
|
||||
props: ["modelValue", "rows", "size", "fill", "disabled", "error", "errorMessage", "helpText"],
|
||||
emits: ["update:modelValue"],
|
||||
template:
|
||||
'<textarea :data-disabled="disabled" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
||||
};
|
||||
const OBanner = {
|
||||
props: ["variant", "dataTest"],
|
||||
template: '<div :data-test="dataTest"><slot /></div>',
|
||||
};
|
||||
|
||||
function mountDialog(props: { schema?: string | null; dropped?: boolean } = {}) {
|
||||
return mount(PlaygroundSchemaDialog, {
|
||||
props: { open: true, schema: null, ...props },
|
||||
global: { stubs: { ODialog, OCheckbox, OTextarea, OBanner } },
|
||||
});
|
||||
}
|
||||
|
||||
const WARNING = '[data-test="ai-playground-schema-unsupported"]';
|
||||
|
||||
describe("PlaygroundSchemaDialog", () => {
|
||||
it("starts off with the default schema in place, ready to be turned on", () => {
|
||||
const wrapper = mountDialog();
|
||||
expect(wrapper.find("input[type=checkbox]").attributes("checked")).toBeUndefined();
|
||||
expect(wrapper.find("textarea").attributes("data-disabled")).toBe("true");
|
||||
expect((wrapper.find("textarea").element as HTMLTextAreaElement).value).toContain("grounded");
|
||||
});
|
||||
|
||||
it("emits null when the toggle is off, keeping the text for the next open", async () => {
|
||||
const wrapper = mountDialog({ schema: '{"type":"object"}' });
|
||||
await wrapper.find("input[type=checkbox]").setValue(false);
|
||||
await wrapper.find(".primary").trigger("click");
|
||||
expect(wrapper.emitted("apply")?.[0]?.[0]).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks Apply on unparseable JSON", async () => {
|
||||
const wrapper = mountDialog({ schema: "{}" });
|
||||
await wrapper.find("textarea").setValue("{ not json");
|
||||
expect(wrapper.find(".o-dialog").attributes("data-primary-disabled")).toBe("true");
|
||||
});
|
||||
|
||||
// Said before the schema is written rather than after an answer arrives as
|
||||
// prose: the request drops the schema silently, so the dialog is the only
|
||||
// place the drop can be read.
|
||||
it("warns when the provider carries no schema", () => {
|
||||
expect(mountDialog({ dropped: true }).find(WARNING).exists()).toBe(true);
|
||||
expect(mountDialog({ dropped: false }).find(WARNING).exists()).toBe(false);
|
||||
});
|
||||
|
||||
// The variant keeps the schema whatever the provider is, and swapping the
|
||||
// model is what makes it live — so the fields stay usable behind the warning.
|
||||
it("still lets a schema be written for such a provider", async () => {
|
||||
const wrapper = mountDialog({ dropped: true });
|
||||
await wrapper.find("input[type=checkbox]").setValue(true);
|
||||
await wrapper.find("textarea").setValue('{"type":"object"}');
|
||||
await wrapper.find(".primary").trigger("click");
|
||||
expect(wrapper.emitted("apply")?.[0]?.[0]).toBe('{"type":"object"}');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,10 @@
|
||||
Structured output for one variant. The toggle and the schema are one decision:
|
||||
turning it off keeps the text you wrote, so flipping back does not cost you
|
||||
the schema.
|
||||
|
||||
A provider that cannot carry a schema still gets the full dialog rather than a
|
||||
disabled one — the variant keeps the schema either way, and swapping the model
|
||||
is what makes it live. The banner says so instead.
|
||||
-->
|
||||
<template>
|
||||
<ODialog
|
||||
@@ -18,6 +22,11 @@
|
||||
@click:secondary="emit('update:open', false)"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Read before the schema is written rather than after an answer comes
|
||||
back as prose: the request drops it with nothing else to say so. -->
|
||||
<OBanner v-if="dropped" variant="warning" data-test="ai-playground-schema-unsupported">
|
||||
{{ t("aiObservability.playground.schemaUnsupported") }}
|
||||
</OBanner>
|
||||
<OCheckbox
|
||||
v-model="enabled"
|
||||
:label="t('aiObservability.playground.schemaToggle')"
|
||||
@@ -44,8 +53,15 @@ import { useI18nTyped } from "@/types/i18n";
|
||||
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
|
||||
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
|
||||
import OTextarea from "@/lib/forms/Input/OTextarea.vue";
|
||||
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
|
||||
|
||||
const props = defineProps<{ open: boolean; schema: string | null }>();
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
schema: string | null;
|
||||
/** The selected provider has no field to carry a schema, so one set here is
|
||||
* kept on the variant and left out of the request. */
|
||||
dropped?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [value: boolean];
|
||||
|
||||
@@ -88,3 +88,35 @@ describe("PlaygroundVariableBar — tools dropdown", () => {
|
||||
expect(wrapper.emitted("set-tools")?.[0]).toEqual([[{ name: "fetch" }]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlaygroundVariableBar — sample flash", () => {
|
||||
// A sample lands via PlaygroundPage, which only holds a ref on the bar —
|
||||
// this seam is what lets it reach the Variables button without knowing the
|
||||
// menu inside the bar exists.
|
||||
it("forwards flash() to the Variables menu inside it", () => {
|
||||
const flashSpy = vi.fn();
|
||||
const wrapper = mount(PlaygroundVariableBar, {
|
||||
props: {
|
||||
varNames: [],
|
||||
vars: {},
|
||||
usedVarNames: [],
|
||||
tools: [],
|
||||
provenance: null,
|
||||
sample: null,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
...stubs,
|
||||
PlaygroundVariablesMenu: {
|
||||
template: "<div />",
|
||||
methods: { flash: flashSpy },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
(wrapper.vm as unknown as { flash: () => void }).flash();
|
||||
|
||||
expect(flashSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
</div>
|
||||
|
||||
<PlaygroundVariablesMenu
|
||||
ref="variablesMenuRef"
|
||||
class="self-center"
|
||||
:var-names="varNames"
|
||||
:vars="vars"
|
||||
@@ -239,4 +240,13 @@ const { t } = useI18nTyped();
|
||||
const expectedToken = `{{${EXPECTED_OUTPUT_TOKEN}}}`;
|
||||
|
||||
const usesExpectedToken = computed(() => props.varNames.includes(EXPECTED_OUTPUT_TOKEN));
|
||||
|
||||
/** Forwarded to the Variables menu inside — PlaygroundPage only holds a ref on
|
||||
* the bar, not on everything nested inside it. */
|
||||
const variablesMenuRef = ref<{ flash: () => void } | null>(null);
|
||||
function flash() {
|
||||
variablesMenuRef.value?.flash();
|
||||
}
|
||||
|
||||
defineExpose({ flash });
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import PlaygroundVariablesMenu from "./PlaygroundVariablesMenu.vue";
|
||||
|
||||
vi.mock("@/types/i18n", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/types/i18n")>("@/types/i18n");
|
||||
return { ...actual, useI18nTyped: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
const stubs = {
|
||||
OButton: {
|
||||
emits: ["click"],
|
||||
template: `<button v-bind="$attrs" @click="$emit('click')"><slot /></button>`,
|
||||
},
|
||||
OTag: { props: ["variant", "size"], template: `<span><slot /></span>` },
|
||||
ODropdown: {
|
||||
props: ["open"],
|
||||
emits: ["update:open"],
|
||||
// A real toggle, not a static stub: the "seen" behaviour hinges on the
|
||||
// dropdown's own open transition, so the test has to be able to drive it.
|
||||
template: `<div><span data-test="trigger-slot" @click="$emit('update:open', !open)"><slot name="trigger" /></span><slot v-if="open" /></div>`,
|
||||
},
|
||||
};
|
||||
|
||||
function mountMenu(props: Record<string, unknown> = {}) {
|
||||
return mount(PlaygroundVariablesMenu, {
|
||||
props: { varNames: [], vars: {}, used: [], ...props },
|
||||
global: { stubs },
|
||||
});
|
||||
}
|
||||
|
||||
const newTag = '[data-test="ai-playground-variables-new-tag"]';
|
||||
const trigger = '[data-test="ai-playground-variables-trigger"]';
|
||||
|
||||
async function openAndCloseDropdown(wrapper: ReturnType<typeof mountMenu>) {
|
||||
await wrapper.get('[data-test="trigger-slot"]').trigger("click");
|
||||
await wrapper.get('[data-test="trigger-slot"]').trigger("click");
|
||||
}
|
||||
|
||||
describe("PlaygroundVariablesMenu — New tag", () => {
|
||||
it("shows New for a variable with a value that has never been opened", () => {
|
||||
const wrapper = mountMenu({ varNames: ["input"], vars: { input: "hi" } });
|
||||
expect(wrapper.find(newTag).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows nothing for a declared variable with no value yet", () => {
|
||||
const wrapper = mountMenu({ varNames: ["input"], vars: { input: "" } });
|
||||
expect(wrapper.find(newTag).exists()).toBe(false);
|
||||
});
|
||||
|
||||
// The whole point: once the dropdown has been opened, the value has been
|
||||
// seen, so the badge that exists to earn that first glance has nothing
|
||||
// left to do.
|
||||
it("hides New once the dropdown has been opened", async () => {
|
||||
const wrapper = mountMenu({ varNames: ["input"], vars: { input: "hi" } });
|
||||
await openAndCloseDropdown(wrapper);
|
||||
expect(wrapper.find(newTag).exists()).toBe(false);
|
||||
});
|
||||
|
||||
// A variable already referenced in a message can still receive a fresh
|
||||
// sampled value — "used" and "seen" are independent, so New tracks only
|
||||
// whether THIS value has been looked at.
|
||||
it("shows New for an already-used variable whose value just changed", async () => {
|
||||
const wrapper = mountMenu({ varNames: ["input"], vars: { input: "hi" }, used: ["input"] });
|
||||
await openAndCloseDropdown(wrapper);
|
||||
expect(wrapper.find(newTag).exists()).toBe(false);
|
||||
|
||||
await wrapper.setProps({ vars: { input: "a different question" } });
|
||||
expect(wrapper.find(newTag).exists()).toBe(true);
|
||||
});
|
||||
|
||||
// Sampling can land while the user is looking anywhere on the page — the
|
||||
// flash has to be a passive visual cue, never a focus grab that yanks the
|
||||
// cursor out from under something they're mid-typing.
|
||||
it("flashes the trigger, then settles", async () => {
|
||||
vi.useFakeTimers();
|
||||
const wrapper = mountMenu();
|
||||
const triggerEl = () => wrapper.get(trigger);
|
||||
|
||||
expect(triggerEl().attributes("data-flashing")).toBeUndefined();
|
||||
|
||||
(wrapper.vm as unknown as { flash: () => void }).flash();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(triggerEl().attributes("data-flashing")).toBe("true");
|
||||
expect(triggerEl().classes()).toContain("ring-accent");
|
||||
|
||||
vi.advanceTimersByTime(1200);
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(triggerEl().attributes("data-flashing")).toBeUndefined();
|
||||
expect(triggerEl().classes()).toContain("ring-transparent");
|
||||
|
||||
wrapper.unmount();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -8,26 +8,59 @@
|
||||
they never were.
|
||||
-->
|
||||
<template>
|
||||
<ODropdown align="start" side="bottom">
|
||||
<ODropdown v-model:open="dropdownOpen" align="start" side="bottom">
|
||||
<template #trigger>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon-left="data-array"
|
||||
data-test="ai-playground-variables-btn"
|
||||
<!-- A value can land here (e.g. just sampled) with no message
|
||||
referencing it yet — the toast on sample says so once; New is what
|
||||
still says so on the next glance, and the ring is what a
|
||||
just-arrived value flashes on to earn that first glance. Opening
|
||||
the dropdown is what marks a value seen — New clears then, and
|
||||
reappears only once the value itself changes again (a fresh
|
||||
sample), independently of whether any message references it. The
|
||||
ring is a passive visual cue only — never steals keyboard focus,
|
||||
since a sample can land while the user is mid-typing somewhere
|
||||
else. -->
|
||||
<div
|
||||
class="rounded-default ring-2 transition-colors duration-300 motion-reduce:transition-none"
|
||||
:class="flashing ? 'ring-accent' : 'ring-transparent'"
|
||||
:data-flashing="flashing || undefined"
|
||||
data-test="ai-playground-variables-trigger"
|
||||
>
|
||||
{{
|
||||
varNames.length
|
||||
? t("aiObservability.playground.variablesCount", { count: varNames.length })
|
||||
: t("aiObservability.playground.variables")
|
||||
}}
|
||||
</OButton>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon-left="data-array"
|
||||
data-test="ai-playground-variables-btn"
|
||||
>
|
||||
{{
|
||||
varNames.length
|
||||
? t("aiObservability.playground.variablesCount", { count: varNames.length })
|
||||
: t("aiObservability.playground.variables")
|
||||
}}
|
||||
<OTag
|
||||
v-if="unseenVarNames.length"
|
||||
variant="primary-soft"
|
||||
size="xs"
|
||||
class="ml-1"
|
||||
data-test="ai-playground-variables-new-tag"
|
||||
>
|
||||
{{ t("aiObservability.playground.newValueTag") }}
|
||||
</OTag>
|
||||
</OButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex w-96 flex-col gap-2 p-2">
|
||||
<p v-if="!varNames.length" class="text-text-secondary m-0 text-xs leading-relaxed">
|
||||
{{ t("aiObservability.playground.noVariables", { token: variableToken }) }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="unusedWithValue.length"
|
||||
class="text-accent bg-accent/8 rounded-default m-0 px-2 py-1.5 text-xs leading-relaxed"
|
||||
data-test="ai-playground-var-unused-hint"
|
||||
>
|
||||
{{ t("aiObservability.playground.unusedSampledValue", { tokens: unusedTokensText }) }}
|
||||
</p>
|
||||
|
||||
<div v-for="name in varNames" :key="name" class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -123,11 +156,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OInput from "@/lib/forms/Input/OInput.vue";
|
||||
import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import OTextarea from "@/lib/forms/Input/OTextarea.vue";
|
||||
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
|
||||
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
|
||||
@@ -156,6 +190,39 @@ const expectedToken = raw(`{{${EXPECTED_OUTPUT_TOKEN}}}`);
|
||||
|
||||
const used = computed(() => props.used);
|
||||
|
||||
/** Declared, has a real value, but no message references it yet — the state
|
||||
* a fresh sample leaves behind. Distinct from "declared, no value" (a
|
||||
* manually-added variable someone hasn't filled in), which has nothing
|
||||
* urgent to surface. */
|
||||
const unusedWithValue = computed(() =>
|
||||
props.varNames.filter((name) => !used.value.includes(name) && (props.vars[name] ?? "").trim()),
|
||||
);
|
||||
const unusedTokensText = computed(() =>
|
||||
unusedWithValue.value.map((name) => tokenFor(name)).join(", "),
|
||||
);
|
||||
|
||||
const dropdownOpen = ref(false);
|
||||
|
||||
/** Last value acknowledged per variable, snapshotted the moment the dropdown
|
||||
* opens — that is the one deliberate "I looked" gesture available here. A
|
||||
* value differing from what was last acknowledged (never opened, or changed
|
||||
* since — a fresh sample landing on a name the dropdown was already opened
|
||||
* for once) is what New tracks. Independent of `used`: a variable already
|
||||
* referenced in a message can still receive a value nobody has seen yet. */
|
||||
const acknowledgedValues = ref<Record<string, string>>({});
|
||||
|
||||
watch(dropdownOpen, (open) => {
|
||||
if (!open) return;
|
||||
for (const name of props.varNames) acknowledgedValues.value[name] = props.vars[name] ?? "";
|
||||
});
|
||||
|
||||
const unseenVarNames = computed(() =>
|
||||
props.varNames.filter((name) => {
|
||||
const value = (props.vars[name] ?? "").trim();
|
||||
return value && value !== (acknowledgedValues.value[name] ?? "").trim();
|
||||
}),
|
||||
);
|
||||
|
||||
/** A token is `{{name}}`, so anything that would not survive the braces is not
|
||||
* a name. Trimmed to the identifier characters rather than rejected, so a
|
||||
* pasted `{{foo}}` still resolves to `foo`. */
|
||||
@@ -180,4 +247,25 @@ function submitNew() {
|
||||
newName.value = "";
|
||||
newValue.value = "";
|
||||
}
|
||||
|
||||
const flashing = ref(false);
|
||||
|
||||
/** Long enough to be caught out of the corner of the eye, short enough not to
|
||||
* linger as if it were a permanent error state. */
|
||||
const FLASH_MS = 1200;
|
||||
let flashTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Called when a sample lands, so the button that now holds it earns a first
|
||||
* glance without the dropdown having to be opened to find out. */
|
||||
function flash() {
|
||||
flashing.value = true;
|
||||
if (flashTimer) clearTimeout(flashTimer);
|
||||
flashTimer = setTimeout(() => (flashing.value = false), FLASH_MS);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (flashTimer) clearTimeout(flashTimer);
|
||||
});
|
||||
|
||||
defineExpose({ flash });
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
// @vitest-environment jsdom
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
emptyVariant,
|
||||
idleCell,
|
||||
type PlaygroundVariant,
|
||||
} from "@/enterprise/views/AIObservability/playgroundDraft";
|
||||
import PlaygroundVariantColumn from "./PlaygroundVariantColumn.vue";
|
||||
|
||||
vi.mock("@/types/i18n", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/types/i18n")>("@/types/i18n");
|
||||
return {
|
||||
...actual,
|
||||
useI18nTyped: () => ({ t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
const OButton = {
|
||||
props: ["disabled", "title", "size", "iconLeft"],
|
||||
emits: ["click"],
|
||||
template:
|
||||
'<button :disabled="disabled" :title="title" @click="$emit(\'click\')"><slot /></button>',
|
||||
};
|
||||
|
||||
const OSplitter = {
|
||||
template: '<div><slot name="before" /><slot name="after" /></div>',
|
||||
};
|
||||
|
||||
function mountColumn(
|
||||
variant: PlaygroundVariant,
|
||||
runDisabled = false,
|
||||
props: Record<string, unknown> = {},
|
||||
) {
|
||||
return mount(PlaygroundVariantColumn, {
|
||||
props: {
|
||||
variant,
|
||||
label: "A",
|
||||
cell: {
|
||||
...idleCell(),
|
||||
status: "done",
|
||||
toolCall: {
|
||||
id: "call_1",
|
||||
name: "lookup_order",
|
||||
arguments: '{"order_id":"123"}',
|
||||
},
|
||||
},
|
||||
providers: [],
|
||||
varNames: [],
|
||||
vars: {},
|
||||
runDisabled,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
OButton,
|
||||
OSplitter,
|
||||
PlaygroundVariantHeader: true,
|
||||
PlaygroundVariantConfig: true,
|
||||
PlaygroundOutputCell: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("PlaygroundVariantColumn", () => {
|
||||
it("shows a named action for adding a returned tool call to messages", async () => {
|
||||
const wrapper = mountColumn(emptyVariant("provider-1", "model-1"));
|
||||
const add = wrapper.get('[data-test="ai-playground-output-add-to-messages-A"]');
|
||||
|
||||
expect(add.text()).toBe("aiObservability.playground.toolCallAddToMessages");
|
||||
await add.trigger("click");
|
||||
expect(wrapper.emitted("add-to-messages")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("explains that Run needs the tool result", () => {
|
||||
const variant = emptyVariant("provider-1", "model-1");
|
||||
variant.messages.push({
|
||||
id: "tool-result-1",
|
||||
role: "tool",
|
||||
content: "",
|
||||
toolName: "lookup_order",
|
||||
toolCallId: "call_1",
|
||||
toolArguments: '{"order_id":"123"}',
|
||||
});
|
||||
|
||||
const wrapper = mountColumn(variant, true);
|
||||
|
||||
expect(wrapper.get('[data-test="ai-playground-run-disabled-reason-A"]').text()).toBe(
|
||||
"aiObservability.playground.toolResultRequired",
|
||||
);
|
||||
expect(
|
||||
wrapper.get('[data-test="ai-playground-variant-submit-A"]').attributes("disabled"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlaygroundVariantColumn — width", () => {
|
||||
// Every column always grows to fill whatever space the bench strip has,
|
||||
// down to a comfortable floor — the same rule at any column count, so
|
||||
// existing columns are free to narrow as more are added rather than
|
||||
// stranding leftover space behind an artificial cap. The strip's own
|
||||
// overflow-x-auto (PlaygroundPage.vue) is what takes over once the floor
|
||||
// is hit by enough columns.
|
||||
it("always grows to fill the row, with a floor and no ceiling", () => {
|
||||
// A stray columnCount prop, if the component still read one, would be
|
||||
// exactly what used to trigger the fixed/frozen width — passing it here
|
||||
// proves sizing no longer depends on sibling count at all.
|
||||
const wrapper = mountColumn(emptyVariant("provider-1", "model-1"), false, { columnCount: 4 });
|
||||
const classes = wrapper.get('[data-test="ai-playground-variant-A"]').classes();
|
||||
expect(classes).toContain("flex-1");
|
||||
expect(classes).toContain("min-w-93.5");
|
||||
expect(classes).not.toContain("shrink-0");
|
||||
expect(classes.some((c) => c.startsWith("max-w-"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,7 @@
|
||||
-->
|
||||
<template>
|
||||
<section
|
||||
class="border-border-default bg-surface-base rounded-surface flex min-h-0 min-w-85 flex-1 snap-start flex-col overflow-hidden border"
|
||||
:class="solo ? '' : 'max-w-140'"
|
||||
class="border-border-default bg-surface-base rounded-surface @container/variant flex min-h-0 min-w-93.5 flex-1 snap-start flex-col overflow-hidden border"
|
||||
:data-test="`ai-playground-variant-${label}`"
|
||||
>
|
||||
<div class="border-border-default shrink-0 border-b px-2.5 py-1.5">
|
||||
@@ -26,6 +25,7 @@
|
||||
:can-duplicate="canDuplicate"
|
||||
@change="(next) => emit('change', next)"
|
||||
@duplicate="emit('duplicate')"
|
||||
@reset="emit('reset')"
|
||||
@remove="emit('remove')"
|
||||
@create-experiment="emit('create-experiment')"
|
||||
/>
|
||||
@@ -74,14 +74,21 @@
|
||||
@click="emit('copy')"
|
||||
/>
|
||||
<OButton
|
||||
v-if="!cell.toolCall"
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
:size="cell.toolCall ? 'xs' : 'icon-xs'"
|
||||
icon-left="chat"
|
||||
:title="t('aiObservability.playground.addToMessagesTooltip')"
|
||||
:title="
|
||||
cell.toolCall
|
||||
? t('aiObservability.playground.toolCallContinueTooltip')
|
||||
: t('aiObservability.playground.addToMessagesTooltip')
|
||||
"
|
||||
:data-test="`ai-playground-output-add-to-messages-${label}`"
|
||||
@click="emit('add-to-messages')"
|
||||
/>
|
||||
>
|
||||
<span v-if="cell.toolCall">
|
||||
{{ t("aiObservability.playground.toolCallAddToMessages") }}
|
||||
</span>
|
||||
</OButton>
|
||||
<OButton
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
@@ -108,11 +115,28 @@
|
||||
<!-- Outside the splitter: dragging the panes must never be able to push Run
|
||||
off the bottom of the column. -->
|
||||
<div class="border-border-default shrink-0 border-t px-2.5 py-2.5">
|
||||
<p
|
||||
v-if="runDisabledReason"
|
||||
class="text-text-secondary mt-0 mb-1.5 text-center text-xs"
|
||||
:data-test="`ai-playground-run-disabled-reason-${label}`"
|
||||
>
|
||||
{{ runDisabledReason }}
|
||||
</p>
|
||||
<OButton
|
||||
v-if="running"
|
||||
variant="cancel-query"
|
||||
size="sm-action"
|
||||
class="w-full"
|
||||
:data-test="`ai-playground-variant-cancel-${label}`"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ t("common.cancel") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
v-else
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
class="w-full"
|
||||
:loading="running"
|
||||
:disabled="runDisabled"
|
||||
:data-test="`ai-playground-variant-submit-${label}`"
|
||||
@click="emit('run')"
|
||||
@@ -124,7 +148,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import PlaygroundOutputCell from "./PlaygroundOutputCell.vue";
|
||||
@@ -137,7 +161,7 @@ import type {
|
||||
} from "@/enterprise/views/AIObservability/playgroundDraft";
|
||||
import type { Provider } from "@/services/online-evals.service";
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant: PlaygroundVariant;
|
||||
label: string;
|
||||
@@ -149,9 +173,6 @@ withDefaults(
|
||||
runDisabled?: boolean;
|
||||
canRemove?: boolean;
|
||||
canDuplicate?: boolean;
|
||||
/** The only visible column, so it takes the full width instead of leaving
|
||||
* dead space beside a comparison that has no second participant. */
|
||||
solo?: boolean;
|
||||
/** Baseline output text, when this column should render as a change. */
|
||||
}>(),
|
||||
{
|
||||
@@ -159,12 +180,25 @@ withDefaults(
|
||||
runDisabled: false,
|
||||
canRemove: true,
|
||||
canDuplicate: true,
|
||||
solo: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
|
||||
const runDisabledReason = computed(() => {
|
||||
if (!props.runDisabled) return "";
|
||||
const incompleteTool = props.variant.messages.find(
|
||||
(message) =>
|
||||
message.role === "tool" &&
|
||||
(!message.toolName?.trim() || !message.toolArguments?.trim() || !message.content.trim()),
|
||||
);
|
||||
if (!incompleteTool) return "";
|
||||
if (!incompleteTool.toolName?.trim() || !incompleteTool.toolArguments?.trim()) {
|
||||
return t("aiObservability.playground.toolSetupRequired");
|
||||
}
|
||||
return t("aiObservability.playground.toolResultRequired");
|
||||
});
|
||||
|
||||
/** Percent of the column given to the config. Per column and not persisted —
|
||||
* it is a reading preference for the moment, not part of the draft. */
|
||||
const split = ref(60);
|
||||
@@ -172,7 +206,9 @@ const split = ref(60);
|
||||
const emit = defineEmits<{
|
||||
change: [variant: PlaygroundVariant];
|
||||
run: [];
|
||||
cancel: [];
|
||||
duplicate: [];
|
||||
reset: [];
|
||||
remove: [];
|
||||
copy: [];
|
||||
"add-to-messages": [];
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
@remove="onMessageRemove"
|
||||
@add="onMessageAdd"
|
||||
@set-tool="onMessageToolChange"
|
||||
@set-tool-arguments="onMessageToolArgumentsChange"
|
||||
@set-role="onMessageRoleChange"
|
||||
@move="onMessageMove"
|
||||
/>
|
||||
@@ -27,6 +28,7 @@ import PlaygroundMessageList from "./PlaygroundMessageList.vue";
|
||||
import {
|
||||
moveMessage,
|
||||
playgroundId,
|
||||
toolCallId,
|
||||
withRole,
|
||||
type PlaygroundRole,
|
||||
type PlaygroundVariant,
|
||||
@@ -49,7 +51,22 @@ function patch(changes: Partial<PlaygroundVariant>) {
|
||||
function onMessageToolChange(messageId: string, toolName: string) {
|
||||
patch({
|
||||
messages: props.variant.messages.map((message) =>
|
||||
message.id === messageId ? { ...message, toolName } : message,
|
||||
message.id === messageId
|
||||
? {
|
||||
...message,
|
||||
toolName,
|
||||
toolCallId: toolName ? message.toolCallId || toolCallId(message.id) : undefined,
|
||||
toolArguments: toolName ? message.toolArguments || "{}" : undefined,
|
||||
}
|
||||
: message,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function onMessageToolArgumentsChange(messageId: string, toolArguments: string) {
|
||||
patch({
|
||||
messages: props.variant.messages.map((message) =>
|
||||
message.id === messageId ? { ...message, toolArguments } : message,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -190,16 +190,19 @@ describe("PlaygroundVariantHeader — schema", () => {
|
||||
PlaygroundSchemaDialog: true,
|
||||
};
|
||||
|
||||
function schemaBtn(responseSchema: string | null) {
|
||||
const wrapper = mount(PlaygroundVariantHeader, {
|
||||
function header(responseSchema: string | null, providerId = "openai") {
|
||||
return mount(PlaygroundVariantHeader, {
|
||||
props: {
|
||||
variant: { ...emptyVariant("openai", "gpt-4o-mini"), responseSchema },
|
||||
variant: { ...emptyVariant(providerId, "gpt-4o-mini"), responseSchema },
|
||||
providers,
|
||||
label: "A",
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
return wrapper.get('[data-test^="ai-playground-schema-btn-"]');
|
||||
}
|
||||
|
||||
function schemaBtn(responseSchema: string | null, providerId = "openai") {
|
||||
return header(responseSchema, providerId).get('[data-test^="ai-playground-schema-btn-"]');
|
||||
}
|
||||
|
||||
it("reads as inactive with no schema set", () => {
|
||||
@@ -215,4 +218,29 @@ describe("PlaygroundVariantHeader — schema", () => {
|
||||
expect(button.classes()).toContain("bg-accent/12!");
|
||||
expect(button.attributes("title")).toBe("aiObservability.playground.schemaOn");
|
||||
});
|
||||
|
||||
// The bug this guards: the accent tint read as "in force" on a provider whose
|
||||
// request never carries the schema, and the answer came back as prose with
|
||||
// nothing anywhere to explain why.
|
||||
it("warns instead of reading as on when the provider carries no schema", () => {
|
||||
const button = schemaBtn('{"type":"object"}', "anthropic");
|
||||
expect(button.classes()).toContain("bg-banner-warning-bg!");
|
||||
expect(button.classes()).not.toContain("bg-accent/12!");
|
||||
expect(button.attributes("title")).toBe("aiObservability.playground.schemaIgnored");
|
||||
});
|
||||
|
||||
// Nothing is being dropped until a schema exists to drop.
|
||||
it("stays inactive on such a provider while no schema is set", () => {
|
||||
const button = schemaBtn(null, "anthropic");
|
||||
expect(button.classes()).not.toContain("bg-banner-warning-bg!");
|
||||
expect(button.attributes("title")).toBe("aiObservability.playground.schema");
|
||||
});
|
||||
|
||||
it("tells the dialog which providers drop the schema", () => {
|
||||
const dialog = (providerId: string) =>
|
||||
header('{"type":"object"}', providerId).getComponent({ name: "PlaygroundSchemaDialog" });
|
||||
|
||||
expect(dialog("anthropic").props("dropped")).toBe(true);
|
||||
expect(dialog("openai").props("dropped")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,16 +80,12 @@
|
||||
property resolve by stylesheet order, so an unmarked background loses
|
||||
to it silently. -->
|
||||
<OButton
|
||||
:variant="variant.responseSchema ? 'ghost-primary' : 'ghost-muted'"
|
||||
:variant="schemaButton.variant"
|
||||
size="icon-xs"
|
||||
icon-left="data-object"
|
||||
class="shrink-0"
|
||||
:class="variant.responseSchema ? 'bg-accent/12!' : ''"
|
||||
:title="
|
||||
variant.responseSchema
|
||||
? t('aiObservability.playground.schemaOn')
|
||||
: t('aiObservability.playground.schema')
|
||||
"
|
||||
:class="schemaButton.tint"
|
||||
:title="schemaButton.title"
|
||||
:data-test="`ai-playground-schema-btn-${variant.id}`"
|
||||
@click="schemaOpen = true"
|
||||
/>
|
||||
@@ -119,6 +115,18 @@
|
||||
:data-test="`ai-playground-variant-experiment-${label}`"
|
||||
@click="emit('create-experiment')"
|
||||
/>
|
||||
<!-- Scoped to this bench alone: the page-level Reset wipes every bench and
|
||||
confirms first, because there is nothing left to recover from. This one
|
||||
only throws away one column's own edits, so it stays a single click. -->
|
||||
<OButton
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
class="shrink-0"
|
||||
icon-left="refresh"
|
||||
:title="t('aiObservability.playground.resetVariant')"
|
||||
:data-test="`ai-playground-variant-reset-${label}`"
|
||||
@click="emit('reset')"
|
||||
/>
|
||||
<OButton
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
@@ -133,6 +141,7 @@
|
||||
<PlaygroundSchemaDialog
|
||||
v-model:open="schemaOpen"
|
||||
:schema="variant.responseSchema"
|
||||
:dropped="schemaDropped"
|
||||
@apply="(responseSchema) => patch({ responseSchema })"
|
||||
/>
|
||||
</div>
|
||||
@@ -152,6 +161,7 @@ import {
|
||||
type PlaygroundVariant,
|
||||
} from "@/enterprise/views/AIObservability/playgroundDraft";
|
||||
import type { Provider } from "@/services/online-evals.service";
|
||||
import { providerDropsResponseSchema } from "@/services/llm-playground.service";
|
||||
|
||||
/** Provider id and model travel as one select value but stay two fields on the
|
||||
* variant — the run request, the experiment handoff and the draft titles all
|
||||
@@ -175,6 +185,7 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
change: [variant: PlaygroundVariant];
|
||||
duplicate: [];
|
||||
reset: [];
|
||||
remove: [];
|
||||
"create-experiment": [];
|
||||
}>();
|
||||
@@ -235,10 +246,47 @@ const modelOptions = computed<SelectOption[]>(() => {
|
||||
return options;
|
||||
});
|
||||
|
||||
const providerName = computed(
|
||||
() => props.providers.find((candidate) => candidate.id === props.variant.providerId)?.name ?? "",
|
||||
const selectedProvider = computed(() =>
|
||||
props.providers.find((candidate) => candidate.id === props.variant.providerId),
|
||||
);
|
||||
|
||||
const providerName = computed(() => selectedProvider.value?.name ?? "");
|
||||
|
||||
/** Whether the schema this variant carries actually reaches the model. Unknown
|
||||
* to the provider list — a draft restored from another org — is treated as
|
||||
* carrying it: the run is what finds out, and a warning on every unresolved
|
||||
* provider would cry wolf. */
|
||||
const schemaDropped = computed(() => {
|
||||
const provider = selectedProvider.value;
|
||||
return providerDropsResponseSchema(provider?.providerType ?? provider?.provider_type);
|
||||
});
|
||||
|
||||
/** Icon-only, so variant, tint and tooltip are the whole message and move as
|
||||
* one. Three states, not two: an accent tint on a provider that drops the
|
||||
* schema claims "in force" about something the request never carries, and the
|
||||
* only other evidence is an answer that arrives as prose. */
|
||||
const schemaButton = computed(() => {
|
||||
if (!props.variant.responseSchema) {
|
||||
return {
|
||||
variant: "ghost-muted" as const,
|
||||
tint: "",
|
||||
title: t("aiObservability.playground.schema"),
|
||||
};
|
||||
}
|
||||
if (schemaDropped.value) {
|
||||
return {
|
||||
variant: "ghost-warning" as const,
|
||||
tint: "bg-banner-warning-bg!",
|
||||
title: t("aiObservability.playground.schemaIgnored"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
variant: "ghost-primary" as const,
|
||||
tint: "bg-accent/12!",
|
||||
title: t("aiObservability.playground.schemaOn"),
|
||||
};
|
||||
});
|
||||
|
||||
function keyFor(providerId: string, model: string): string {
|
||||
return `${providerId}${KEY_SEPARATOR}${model}`;
|
||||
}
|
||||
|
||||
@@ -327,38 +327,28 @@ const statusCounts = computed(() => {
|
||||
const rows = props.rows || [];
|
||||
let active = 0;
|
||||
let paused = 0;
|
||||
let degraded = 0;
|
||||
let draft = 0;
|
||||
let archived = 0;
|
||||
for (const r of rows) {
|
||||
const s = statusOf(r);
|
||||
if (s === "active") active += 1;
|
||||
else if (s === "paused") paused += 1;
|
||||
else if (s === "degraded") degraded += 1;
|
||||
else if (s === "draft") draft += 1;
|
||||
else if (s === "archived") archived += 1;
|
||||
}
|
||||
return { active, paused, degraded, draft, archived, total: rows.length };
|
||||
return { active, paused, draft, total: rows.length };
|
||||
});
|
||||
// The strip is the ONLY status filter (the redundant dropdown was removed), so it
|
||||
// carries every backend status. Attention-first order (degraded, paused, active,
|
||||
// then the inert draft/archived), "All" last — matching the Alerts / Incidents
|
||||
// strip. Tones echo the evalStatus chip exactly (degraded = orange, not red).
|
||||
// carries every status the UI can actually produce. "degraded" and "archived"
|
||||
// are both excluded on purpose: nothing in the product currently transitions a
|
||||
// job into either status (no health-check sets degraded; there's no archive
|
||||
// action in the UI), so their tiles would always read zero. Attention-first
|
||||
// order (paused, active, then the inert draft), "All" last — matching the
|
||||
// Alerts / Incidents strip.
|
||||
const summaryStats = computed<StatItem[]>(() => {
|
||||
const c = statusCounts.value;
|
||||
const has = c.total > 0;
|
||||
const v = (n: number): string | number => (has ? n : "—");
|
||||
const share = has ? c.total : undefined;
|
||||
return [
|
||||
{
|
||||
key: "degraded",
|
||||
label: t("onlineEvals.jobStatus.degraded"),
|
||||
value: v(c.degraded),
|
||||
icon: "error-outline",
|
||||
tone: "orange",
|
||||
max: share,
|
||||
dataTest: "eval-job-summary-degraded",
|
||||
},
|
||||
{
|
||||
key: "paused",
|
||||
label: t("onlineEvals.jobStatus.paused"),
|
||||
@@ -386,15 +376,6 @@ const summaryStats = computed<StatItem[]>(() => {
|
||||
max: share,
|
||||
dataTest: "eval-job-summary-draft",
|
||||
},
|
||||
{
|
||||
key: "archived",
|
||||
label: t("onlineEvals.jobStatus.archived"),
|
||||
value: v(c.archived),
|
||||
icon: "inventory-2",
|
||||
tone: "neutral",
|
||||
max: share,
|
||||
dataTest: "eval-job-summary-archived",
|
||||
},
|
||||
{
|
||||
key: "all",
|
||||
label: t("onlineEvals.summaryAll"),
|
||||
|
||||
@@ -108,7 +108,6 @@
|
||||
:boolean-trend-series="booleanTrendSeries"
|
||||
:categorical-rows="categoricalRows"
|
||||
:scope="detailScope"
|
||||
:scope-counts="selectedConfigScopeCounts"
|
||||
:runs="qualityRuns"
|
||||
:runs-counts="runsCounts"
|
||||
:runs-filter="runsFilter"
|
||||
@@ -151,7 +150,7 @@ import {
|
||||
combineWhere,
|
||||
type AgentFilterSelection,
|
||||
} from "./utils/agentFilterSql";
|
||||
import type { QualityScope, ScopeCounts } from "./utils/qualityScope";
|
||||
import type { QualityScope } from "./utils/qualityScope";
|
||||
import { b64EncodeUnicode } from "@/utils/zincutils";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -221,16 +220,6 @@ const selectedConfig = computed<ScoreConfig | null>(() => {
|
||||
return props.scoreConfigs.find((c) => String(c.id) === id) ?? null;
|
||||
});
|
||||
|
||||
const EMPTY_SCOPE_COUNTS: ScopeCounts = { span: 0, trace: 0, session: 0 };
|
||||
const selectedConfigScopeCounts = computed<ScopeCounts>(() => {
|
||||
const id = selectedConfigId.value;
|
||||
if (!id) return EMPTY_SCOPE_COUNTS;
|
||||
return (
|
||||
configRows.value.find((row) => String(row.config.id) === id || String(row.configId) === id)
|
||||
?.scopeCounts ?? EMPTY_SCOPE_COUNTS
|
||||
);
|
||||
});
|
||||
|
||||
const {
|
||||
isLoading: isDetailLoading,
|
||||
dataType: detailDataType,
|
||||
|
||||
@@ -36,22 +36,20 @@ the Free Software Foundation, either version 3 of the License, or
|
||||
data-test="score-config-library-search"
|
||||
/>
|
||||
|
||||
<div class="mb-2 flex items-center justify-between gap-3 pr-3 pl-4.25">
|
||||
<div
|
||||
class="border-border-default mb-3 flex items-center justify-between gap-3 border-b pr-3 pb-2 pl-4.25"
|
||||
>
|
||||
<div
|
||||
v-if="filteredEntries.length > 0"
|
||||
class="text-text-secondary inline-flex items-center gap-2 px-1 py-0.5 text-xs font-medium select-none"
|
||||
class="text-text-body inline-flex items-center gap-2 py-0.5 text-xs font-medium select-none"
|
||||
data-test="score-config-library-select-all"
|
||||
>
|
||||
<OCheckbox :model-value="allVisibleSelected" @update:model-value="toggleSelectAll" />
|
||||
<OCheckbox :model-value="selectAllState" @update:model-value="toggleSelectAll" />
|
||||
<span class="cursor-pointer" @click="toggleSelectAll">{{
|
||||
allVisibleSelected ? t("common.clearAll") : t("common.selectAll")
|
||||
selectAllState === true ? t("common.clearAll") : t("common.selectAll")
|
||||
}}</span>
|
||||
</div>
|
||||
<span class="text-text-secondary text-xs">
|
||||
{{
|
||||
t("onlineEvals.scoreConfigLibrary.scoreConfigsLabel", { count: filteredEntries.length })
|
||||
}}
|
||||
</span>
|
||||
<span class="text-text-secondary text-xs tabular-nums">{{ countLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto pb-4">
|
||||
@@ -71,10 +69,8 @@ the Free Software Foundation, either version 3 of the License, or
|
||||
<li
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.name"
|
||||
class="flex cursor-pointer items-center gap-2 border-l-4 px-3 py-2 transition-colors duration-200"
|
||||
:class="[
|
||||
isSelected(entry.name) ? 'bg-primary/5 border-primary' : 'border-transparent',
|
||||
]"
|
||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors duration-200"
|
||||
:class="[isSelected(entry.name) ? 'bg-accent/6' : 'hover:bg-table-row-hover-bg']"
|
||||
:data-test="`score-config-library-item-${entry.name}`"
|
||||
@click="toggle(entry)"
|
||||
>
|
||||
@@ -86,7 +82,13 @@ the Free Software Foundation, either version 3 of the License, or
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="text-sm font-medium">{{ entry.displayName }}</span>
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="
|
||||
isSelected(entry.name) ? 'text-text-heading font-semibold' : 'font-medium'
|
||||
"
|
||||
>{{ entry.displayName }}</span
|
||||
>
|
||||
<span v-if="entry.description" class="text-text-secondary block text-xs">
|
||||
{{ entry.description }}
|
||||
</span>
|
||||
@@ -193,6 +195,28 @@ const allVisibleSelected = computed(() => {
|
||||
return visible.every((e) => selectedNames.value.has(e.name));
|
||||
});
|
||||
|
||||
/** Three states, not two. With a plain boolean a part-selected list renders as
|
||||
* an EMPTY box, which reads as "nothing is selected". */
|
||||
const selectAllState = computed<boolean | "indeterminate">(() => {
|
||||
if (allVisibleSelected.value) return true;
|
||||
return filteredEntries.value.some((e) => selectedNames.value.has(e.name))
|
||||
? "indeterminate"
|
||||
: false;
|
||||
});
|
||||
|
||||
const countLabel = computed(() => {
|
||||
const selected = filteredEntries.value.filter((e) => selectedNames.value.has(e.name)).length;
|
||||
if (selected === 0) {
|
||||
return t("onlineEvals.scoreConfigLibrary.scoreConfigsLabel", {
|
||||
count: filteredEntries.value.length,
|
||||
});
|
||||
}
|
||||
return t("onlineEvals.scoreConfigLibrary.selectedOfTotal", {
|
||||
selected,
|
||||
total: filteredEntries.value.length,
|
||||
});
|
||||
});
|
||||
|
||||
function toggleSelectAll() {
|
||||
const next = new Set(selectedNames.value);
|
||||
if (allVisibleSelected.value) {
|
||||
|
||||
@@ -59,18 +59,18 @@ the Free Software Foundation, either version 3 of the License, or
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 flex items-center justify-between gap-3 pr-3 pl-4.25">
|
||||
<div
|
||||
class="border-border-default mb-3 flex items-center justify-between gap-3 border-b pr-3 pb-2 pl-4.25"
|
||||
>
|
||||
<label
|
||||
v-if="filteredEntries.length > 0"
|
||||
class="text-text-secondary inline-flex items-center gap-2 px-1 py-0.5 text-xs font-medium select-none"
|
||||
class="text-text-body inline-flex cursor-pointer items-center gap-2 py-0.5 text-xs font-medium select-none"
|
||||
data-test="scorer-library-select-all"
|
||||
>
|
||||
<OCheckbox :model-value="allVisibleSelected" @update:model-value="toggleSelectAll" />
|
||||
<span>{{ allVisibleSelected ? t("common.clearAll") : t("common.selectAll") }}</span>
|
||||
<OCheckbox :model-value="selectAllState" @update:model-value="toggleSelectAll" />
|
||||
<span>{{ selectAllState === true ? t("common.clearAll") : t("common.selectAll") }}</span>
|
||||
</label>
|
||||
<span class="text-text-secondary text-xs">
|
||||
{{ t("onlineEvals.scorerLibrary.scorerCountSuffix", { count: filteredEntries.length }) }}
|
||||
</span>
|
||||
<span class="text-text-secondary text-xs tabular-nums">{{ countLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
@@ -90,12 +90,8 @@ the Free Software Foundation, either version 3 of the License, or
|
||||
<li
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.name"
|
||||
class="flex cursor-pointer items-center gap-2 border-l-4 px-3 py-2 transition-colors duration-200"
|
||||
:class="[
|
||||
isSelected(entry.name)
|
||||
? 'selected-item border-primary bg-accent/6'
|
||||
: 'hover:bg-table-row-hover-bg border-transparent',
|
||||
]"
|
||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors duration-200"
|
||||
:class="[isSelected(entry.name) ? 'bg-accent/6' : 'hover:bg-table-row-hover-bg']"
|
||||
:data-test="`scorer-library-item-${entry.name}`"
|
||||
@click="toggle(entry)"
|
||||
>
|
||||
@@ -107,7 +103,13 @@ the Free Software Foundation, either version 3 of the License, or
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="text-sm font-medium">{{ entry.displayName }}</span>
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="
|
||||
isSelected(entry.name) ? 'text-text-heading font-semibold' : 'font-medium'
|
||||
"
|
||||
>{{ entry.displayName }}</span
|
||||
>
|
||||
<span v-if="entry.description" class="text-text-secondary block text-xs">
|
||||
{{ entry.description }}
|
||||
</span>
|
||||
@@ -223,6 +225,31 @@ const allVisibleSelected = computed(() => {
|
||||
return visible.every((e) => selectedNames.value.has(e.name));
|
||||
});
|
||||
|
||||
/** Three states, not two. With a plain boolean a part-selected list renders as
|
||||
* an EMPTY box, which reads as "nothing is selected" — and the next click then
|
||||
* looks like it selected everything from scratch rather than extending. */
|
||||
const selectAllState = computed<boolean | "indeterminate">(() => {
|
||||
if (allVisibleSelected.value) return true;
|
||||
return filteredEntries.value.some((e) => selectedNames.value.has(e.name))
|
||||
? "indeterminate"
|
||||
: false;
|
||||
});
|
||||
|
||||
/** The count is the only place a selection is visible while the list is
|
||||
* scrolled past the checked rows. */
|
||||
const countLabel = computed(() => {
|
||||
const selected = filteredEntries.value.filter((e) => selectedNames.value.has(e.name)).length;
|
||||
if (selected === 0) {
|
||||
return t("onlineEvals.scorerLibrary.scorerCountSuffix", {
|
||||
count: filteredEntries.value.length,
|
||||
});
|
||||
}
|
||||
return t("onlineEvals.scorerLibrary.selectedOfTotal", {
|
||||
selected,
|
||||
total: filteredEntries.value.length,
|
||||
});
|
||||
});
|
||||
|
||||
function isSelected(name: string) {
|
||||
return selectedNames.value.has(name);
|
||||
}
|
||||
|
||||
@@ -18,148 +18,120 @@
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<div class="py-4.5 [&_textarea]:max-h-55 [&_textarea]:overflow-y-auto [&_textarea]:font-mono">
|
||||
<section class="mb-6">
|
||||
<div class="border-dialog-header-border mb-3 flex items-center gap-2.5 border-b pb-2.5">
|
||||
<span
|
||||
class="text-text-secondary text-2xs bg-text-secondary/12 inline-flex h-5.5 w-5.5 items-center justify-center rounded-full font-mono font-bold"
|
||||
>{{ t("onlineEvals.provider.sectionStep1") }}</span
|
||||
>
|
||||
<div class="text-text-heading m-0 text-sm font-semibold">
|
||||
{{ t("onlineEvals.provider.sectionTitle") }}
|
||||
</div>
|
||||
<div class="provider-form__main flex min-h-0 flex-col gap-2 p-2">
|
||||
<!-- Provider details -->
|
||||
<section
|
||||
class="card-container rounded-default border-border-default bg-surface-base shrink-0 overflow-hidden border"
|
||||
data-test="provider-form-identity-section"
|
||||
>
|
||||
<div class="border-border-default flex items-center border-b px-3 py-2.5">
|
||||
<div class="rounded-default bg-theme-accent mr-2 h-4 w-0.75 shrink-0" />
|
||||
<span class="text-compact text-text-heading font-semibold tracking-[0.01em]">{{
|
||||
t("onlineEvals.provider.sectionTitle")
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3.5 max-[56.25rem]:grid-cols-1">
|
||||
<div class="mb-3">
|
||||
<div class="text-text-heading mb-1 flex items-center text-xs font-semibold">
|
||||
{{ t("onlineEvals.provider.nameLabel") }}
|
||||
<span class="text-status-error-text ml-0.5">*</span>
|
||||
<OIcon
|
||||
v-if="mode === 'edit'"
|
||||
name="lock"
|
||||
size="xs"
|
||||
class="text-text-secondary ml-1.5"
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-3">
|
||||
<div>
|
||||
<OFormInput
|
||||
name="name"
|
||||
:label="t('onlineEvals.provider.nameLabel')"
|
||||
:placeholder="raw('Production OpenAI')"
|
||||
size="sm"
|
||||
required
|
||||
:disabled="mode === 'edit'"
|
||||
:help-text="mode === 'edit' ? t('onlineEvals.provider.cannotRename') : undefined"
|
||||
data-test="provider-form-name-input"
|
||||
/>
|
||||
</div>
|
||||
<OFormInput
|
||||
name="name"
|
||||
:placeholder="raw('Production OpenAI')"
|
||||
size="sm"
|
||||
:disabled="mode === 'edit'"
|
||||
data-test="provider-form-name-input"
|
||||
/>
|
||||
<div v-if="mode === 'edit'" class="text-2xs text-text-secondary mt-1">
|
||||
{{ t("onlineEvals.provider.cannotRename") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-text-heading mb-1 flex items-center text-xs font-semibold">
|
||||
{{ t("onlineEvals.provider.typeLabel") }}
|
||||
<span class="text-status-error-text ml-0.5">*</span>
|
||||
<OIcon
|
||||
v-if="mode === 'edit'"
|
||||
name="lock"
|
||||
size="xs"
|
||||
class="text-text-secondary ml-1.5"
|
||||
<div>
|
||||
<OFormSelect
|
||||
name="providerType"
|
||||
:label="t('onlineEvals.provider.typeLabel')"
|
||||
:options="providerTypeOptions"
|
||||
size="md"
|
||||
required
|
||||
:disabled="mode === 'edit'"
|
||||
data-test="provider-form-type-select"
|
||||
/>
|
||||
</div>
|
||||
<OFormSelect
|
||||
name="providerType"
|
||||
:options="providerTypeOptions"
|
||||
size="md"
|
||||
:disabled="mode === 'edit'"
|
||||
data-test="provider-form-type-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-text-heading mb-1 flex items-center text-xs font-semibold">
|
||||
{{ t("onlineEvals.provider.endpointLabel") }}
|
||||
</div>
|
||||
<OFormInput
|
||||
name="endpoint"
|
||||
:placeholder="raw(endpointPlaceholder)"
|
||||
size="sm"
|
||||
data-test="provider-form-endpoint-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3.5 max-[56.25rem]:grid-cols-1">
|
||||
<div class="mb-3">
|
||||
<div class="text-text-heading mb-1 flex items-center text-xs font-semibold">
|
||||
{{ t("onlineEvals.provider.defaultModelLabel") }}
|
||||
<span class="text-status-error-text ml-0.5">*</span>
|
||||
</div>
|
||||
<div>
|
||||
<OFormInput
|
||||
name="defaultModel"
|
||||
:placeholder="raw('gpt-4o-mini')"
|
||||
name="endpoint"
|
||||
:label="t('onlineEvals.provider.endpointLabel')"
|
||||
:placeholder="raw(endpointPlaceholder)"
|
||||
size="sm"
|
||||
data-test="provider-form-default-model-input"
|
||||
data-test="provider-form-endpoint-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-text-heading mb-1 flex items-center text-xs font-semibold">
|
||||
{{ t("onlineEvals.provider.availableModelsLabel") }}
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-3">
|
||||
<div>
|
||||
<OFormInput
|
||||
name="defaultModel"
|
||||
:label="t('onlineEvals.provider.defaultModelLabel')"
|
||||
:placeholder="raw('gpt-4o-mini')"
|
||||
size="sm"
|
||||
required
|
||||
data-test="provider-form-default-model-input"
|
||||
/>
|
||||
</div>
|
||||
<OFormInput
|
||||
name="availableModels"
|
||||
:placeholder="raw('gpt-4o-mini, gpt-4.1')"
|
||||
size="sm"
|
||||
data-test="provider-form-available-models-input"
|
||||
/>
|
||||
<div class="text-2xs text-text-secondary mt-1">
|
||||
{{ t("onlineEvals.provider.availableModelsHelp") }}
|
||||
|
||||
<div>
|
||||
<OFormInput
|
||||
name="availableModels"
|
||||
:label="t('onlineEvals.provider.availableModelsLabel')"
|
||||
:placeholder="raw('gpt-4o-mini, gpt-4.1')"
|
||||
size="sm"
|
||||
:help-text="t('onlineEvals.provider.availableModelsHelp')"
|
||||
data-test="provider-form-available-models-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-6">
|
||||
<div class="border-dialog-header-border mb-3 flex items-center gap-2.5 border-b pb-2.5">
|
||||
<span
|
||||
class="text-text-secondary text-2xs bg-text-secondary/12 inline-flex h-5.5 w-5.5 items-center justify-center rounded-full font-mono font-bold"
|
||||
>{{ t("onlineEvals.provider.sectionStep2") }}</span
|
||||
<!-- Authentication -->
|
||||
<section
|
||||
class="card-container rounded-default border-border-default bg-surface-base shrink-0 overflow-hidden border"
|
||||
data-test="provider-form-auth-section"
|
||||
>
|
||||
<div class="border-border-default flex items-center border-b px-3 py-2.5">
|
||||
<div class="rounded-default bg-theme-accent mr-2 h-4 w-0.75 shrink-0" />
|
||||
<span class="text-compact text-text-heading font-semibold tracking-[0.01em]">{{
|
||||
t("onlineEvals.provider.authSection")
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 px-4 py-3.5">
|
||||
<div
|
||||
v-if="mode === 'edit'"
|
||||
class="rounded-default text-2xs text-text-secondary border-status-info-text/30 bg-status-info-text/12 flex items-start gap-2 border px-3 py-2 leading-[1.4]"
|
||||
>
|
||||
<div class="text-text-heading m-0 text-sm font-semibold">
|
||||
{{ t("onlineEvals.provider.authSection") }}
|
||||
<OIcon name="lock" size="xs" class="text-status-info-text mt-px shrink-0" />
|
||||
<span>{{ t("onlineEvals.provider.authEditNote") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="mode === 'edit'"
|
||||
class="provider-callout rounded-default text-2xs text-text-secondary border-status-info-text/30 bg-status-info-text/12 mb-3 flex items-start gap-2 border px-3 py-2 leading-[1.4]"
|
||||
>
|
||||
<OIcon name="lock" size="xs" class="text-status-info-text mt-px shrink-0" />
|
||||
<span>{{ t("onlineEvals.provider.authEditNote") }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-text-heading mb-1 flex items-center text-xs font-semibold">
|
||||
{{ t("onlineEvals.provider.apiKeyLabel") }}
|
||||
<span v-if="mode === 'create' && apiKeyRequired" class="text-status-error-text ml-0.5"
|
||||
>*</span
|
||||
>
|
||||
</div>
|
||||
<OFormInput
|
||||
name="apiKey"
|
||||
type="password"
|
||||
size="sm"
|
||||
:placeholder="t('onlineEvals.provider.apiKeyPlaceholder')"
|
||||
data-test="provider-form-api-key-input"
|
||||
/>
|
||||
<div class="text-2xs text-text-secondary mt-1">
|
||||
{{ t("onlineEvals.provider.apiKeyHelp") }}
|
||||
<div>
|
||||
<OFormInput
|
||||
name="apiKey"
|
||||
type="password"
|
||||
:label="t('onlineEvals.provider.apiKeyLabel')"
|
||||
:required="mode === 'create' && apiKeyRequired"
|
||||
size="sm"
|
||||
:placeholder="t('onlineEvals.provider.apiKeyPlaceholder')"
|
||||
:help-text="t('onlineEvals.provider.apiKeyHelp')"
|
||||
data-test="provider-form-api-key-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer
|
||||
class="border-dialog-header-border bg-card-bg sticky bottom-0 z-1 flex shrink-0 items-center justify-end gap-2 border-t px-5.5 py-3"
|
||||
class="border-border-default bg-surface-base sticky bottom-0 z-1 flex shrink-0 items-center justify-end gap-2 border-t px-5.5 py-3"
|
||||
>
|
||||
<OButton
|
||||
data-test="provider-form-cancel-btn"
|
||||
|
||||
@@ -33,7 +33,6 @@ function mountPanel(scope: "all" | "span" | "trace" | "session" = "all") {
|
||||
booleanTrendSeries: [],
|
||||
categoricalRows: [],
|
||||
scope,
|
||||
scopeCounts: { span: 1200, trace: 50, session: 5 },
|
||||
runs: [],
|
||||
runsCounts: { all: 0, unhealthy: null },
|
||||
runsFilter: "all",
|
||||
@@ -56,13 +55,13 @@ function mountPanel(scope: "all" | "span" | "trace" | "session" = "all") {
|
||||
}
|
||||
|
||||
describe("QualityDetailPanel", () => {
|
||||
it("shows all scope counts and emits a diagnostic scope change", async () => {
|
||||
it("shows every scope option and emits a diagnostic scope change", async () => {
|
||||
const wrapper = mountPanel();
|
||||
|
||||
expect(wrapper.find('[data-test="quality-detail-scope-all"]').text()).toBe("All1.3k");
|
||||
expect(wrapper.find('[data-test="quality-detail-scope-span"]').text()).toBe("Span1.2k");
|
||||
expect(wrapper.find('[data-test="quality-detail-scope-trace"]').text()).toBe("Trace50");
|
||||
expect(wrapper.find('[data-test="quality-detail-scope-session"]').text()).toBe("Session5");
|
||||
expect(wrapper.find('[data-test="quality-detail-scope-all"]').text()).toBe("All");
|
||||
expect(wrapper.find('[data-test="quality-detail-scope-span"]').text()).toBe("Span");
|
||||
expect(wrapper.find('[data-test="quality-detail-scope-trace"]').text()).toBe("Trace");
|
||||
expect(wrapper.find('[data-test="quality-detail-scope-session"]').text()).toBe("Session");
|
||||
|
||||
await wrapper.find('[data-test="quality-detail-scope-trace"]').trigger("click");
|
||||
|
||||
|
||||
@@ -27,17 +27,12 @@
|
||||
>
|
||||
<OToggleGroupItem
|
||||
v-for="option in scopeOptions"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
:key="option"
|
||||
:value="option"
|
||||
size="sm"
|
||||
:data-test="`quality-detail-scope-${option.id}`"
|
||||
:data-test="`quality-detail-scope-${option}`"
|
||||
>
|
||||
<span>{{ t(`onlineEvals.quality.scopes.${option.id}`) }}</span>
|
||||
<span
|
||||
class="bg-surface-subtle text-3xs text-text-secondary ml-1 rounded-full px-1.5 py-0.5 leading-none [font-variant-numeric:tabular-nums]"
|
||||
>
|
||||
{{ compactCount(option.count) }}
|
||||
</span>
|
||||
{{ t(`onlineEvals.quality.scopes.${option}`) }}
|
||||
</OToggleGroupItem>
|
||||
</OToggleGroup>
|
||||
</div>
|
||||
@@ -254,7 +249,6 @@
|
||||
<QualityRunsTable
|
||||
:config="config"
|
||||
:rows="runs"
|
||||
:counts="runsCounts"
|
||||
:active-filter="runsFilter"
|
||||
:current-page="runsCurrentPage"
|
||||
:page-size="runsPageSize"
|
||||
@@ -289,7 +283,7 @@ import type {
|
||||
DistributionBucket,
|
||||
TrendPoint,
|
||||
} from "../composables/useQualityDetailCharts";
|
||||
import type { QualityScope, ScopeCounts } from "../utils/qualityScope";
|
||||
import type { QualityScope } from "../utils/qualityScope";
|
||||
import { healthyBooleanValue } from "../utils/qualitySummary";
|
||||
import { thresholdForConfig } from "../utils/scoreThreshold";
|
||||
import type {
|
||||
@@ -320,7 +314,6 @@ const props = defineProps<{
|
||||
booleanTrendSeries: BooleanTrendSeries[];
|
||||
categoricalRows: CategoricalAggRow[];
|
||||
scope: QualityScope;
|
||||
scopeCounts: ScopeCounts;
|
||||
runs: QualityRunRow[];
|
||||
runsCounts: QualityRunCounts;
|
||||
runsFilter: QualityRunFilter;
|
||||
@@ -371,15 +364,7 @@ const healthSummary = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const scopeOptions = computed(() => [
|
||||
{
|
||||
id: "all" as const,
|
||||
count: props.scopeCounts.span + props.scopeCounts.trace + props.scopeCounts.session,
|
||||
},
|
||||
{ id: "span" as const, count: props.scopeCounts.span },
|
||||
{ id: "trace" as const, count: props.scopeCounts.trace },
|
||||
{ id: "session" as const, count: props.scopeCounts.session },
|
||||
]);
|
||||
const scopeOptions = ["all", "span", "trace", "session"] as const;
|
||||
|
||||
const emptyDescription = computed(() => {
|
||||
if (props.scope === "all") {
|
||||
@@ -423,12 +408,6 @@ function toNumber(v: unknown): number {
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function compactCount(value: number): string {
|
||||
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
const booleanCounts = computed(() => ({
|
||||
trueCount: toNumber(props.booleanAgg?.trues),
|
||||
falseCount: toNumber(props.booleanAgg?.falses),
|
||||
|
||||
@@ -67,7 +67,6 @@ function mountTable(config: ScoreConfig) {
|
||||
props: {
|
||||
config,
|
||||
rows: [run("healthy"), run("second")],
|
||||
counts: { all: 42, unhealthy: 7 },
|
||||
activeFilter: "all",
|
||||
currentPage: 2,
|
||||
pageSize: 10,
|
||||
@@ -86,7 +85,7 @@ function mountTable(config: ScoreConfig) {
|
||||
}
|
||||
|
||||
describe("QualityRunsTable", () => {
|
||||
it("uses server pagination and exposes global unhealthy counts", async () => {
|
||||
it("uses server pagination and switches the active filter", async () => {
|
||||
const wrapper = mountTable({
|
||||
id: "config-1",
|
||||
name: "Answer relevance",
|
||||
@@ -99,7 +98,7 @@ describe("QualityRunsTable", () => {
|
||||
expect(table.props("sorting")).toBe("none");
|
||||
expect(table.props("totalCount")).toBe(42);
|
||||
expect(wrapper.get('[data-test="stub-row-count"]').text()).toBe("2");
|
||||
expect(wrapper.get('[data-test="quality-runs-filter-unhealthy"]').text()).toContain("7");
|
||||
expect(wrapper.get('[data-test="quality-runs-filter-unhealthy"]').text()).toBe("Unhealthy");
|
||||
|
||||
await wrapper.get('[data-test="quality-runs-filter-unhealthy"]').trigger("click");
|
||||
expect(wrapper.emitted("filter-change")).toEqual([["unhealthy"]]);
|
||||
|
||||
@@ -30,12 +30,7 @@
|
||||
:disabled="option.disabled"
|
||||
:data-test="`quality-runs-filter-${option.id}`"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<span
|
||||
class="bg-surface-subtle text-3xs text-text-secondary ml-1 rounded-full px-1.5 py-0.5 leading-none [font-variant-numeric:tabular-nums]"
|
||||
>
|
||||
{{ option.count }}
|
||||
</span>
|
||||
{{ option.label }}
|
||||
</OToggleGroupItem>
|
||||
</OToggleGroup>
|
||||
</header>
|
||||
@@ -76,6 +71,8 @@
|
||||
:empty-message="emptyMessage"
|
||||
:enable-column-resize="true"
|
||||
:persist-columns="true"
|
||||
:horizontal-scroll="true"
|
||||
:row-height="56"
|
||||
table-id="quality-config-scores-v1"
|
||||
:row-class="runRowClass"
|
||||
:get-row-status-color="runStatusColor"
|
||||
@@ -109,10 +106,7 @@
|
||||
</template>
|
||||
|
||||
<template #cell-reasoning="{ row }">
|
||||
<span
|
||||
class="text-2xs text-text-secondary block max-w-full truncate"
|
||||
:title="reasoningFor(row)"
|
||||
>
|
||||
<span class="block max-w-full truncate" :title="reasoningFor(row)">
|
||||
{{ reasoningFor(row) }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -133,21 +127,26 @@
|
||||
|
||||
<template #cell-agentName="{ row }">
|
||||
<div class="flex min-w-0 flex-col items-start gap-0.5 leading-tight">
|
||||
<span class="text-2xs text-text-heading max-w-full truncate" :title="agentLabel(row)">
|
||||
<span class="max-w-full truncate" :title="agentLabel(row)">
|
||||
{{ agentLabel(row) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.agentName && row.agentId"
|
||||
class="text-3xs text-text-tertiary max-w-full truncate font-mono"
|
||||
:title="row.agentId"
|
||||
>
|
||||
{{ shortId(row.agentId) }}
|
||||
</span>
|
||||
<OAgentBadges
|
||||
:env="row.agentEnv"
|
||||
:version="row.agentVersion"
|
||||
data-test="quality-runs-agent-badges"
|
||||
/>
|
||||
<!-- id + badges share the second line, matching how OAgentBadges sits
|
||||
inline everywhere else it's used — stacking the badges as their
|
||||
own third line was what made this cell read as cramped. -->
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-if="row.agentName && row.agentId"
|
||||
class="text-3xs text-text-tertiary max-w-full truncate font-mono"
|
||||
:title="row.agentId"
|
||||
>
|
||||
{{ shortId(row.agentId) }}
|
||||
</span>
|
||||
<OAgentBadges
|
||||
:env="row.agentEnv"
|
||||
:version="row.agentVersion"
|
||||
data-test="quality-runs-agent-badges"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</OTable>
|
||||
@@ -164,7 +163,6 @@ import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
|
||||
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
|
||||
import type { ScoreConfig } from "@/services/online-evals.service";
|
||||
import type {
|
||||
QualityRunCounts,
|
||||
QualityRunFilter,
|
||||
QualityRunRow,
|
||||
QualityRunsPagination,
|
||||
@@ -174,7 +172,6 @@ import { thresholdForConfig } from "../utils/scoreThreshold";
|
||||
const props = defineProps<{
|
||||
config: ScoreConfig;
|
||||
rows: QualityRunRow[];
|
||||
counts: QualityRunCounts;
|
||||
activeFilter: QualityRunFilter;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
@@ -197,13 +194,11 @@ const filterOptions = computed(() => [
|
||||
{
|
||||
id: "all" as const,
|
||||
label: t("onlineEvals.quality.runs.filters.all"),
|
||||
count: props.counts.all,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "unhealthy" as const,
|
||||
label: t("onlineEvals.quality.runs.filters.unhealthy"),
|
||||
count: props.counts.unhealthy ?? 0,
|
||||
disabled: !hasThreshold.value,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -476,7 +476,7 @@ const columns = computed(() => [
|
||||
accessorKey: "version",
|
||||
hideable: true,
|
||||
sortable: false,
|
||||
size: 90,
|
||||
size: 140,
|
||||
meta: { align: "left" },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { reactive } from "vue";
|
||||
import {
|
||||
@@ -33,12 +33,15 @@ vi.mock("vuex", () => ({
|
||||
useStore: () => ({ state: { selectedOrganization: { identifier: "acme" } } }),
|
||||
}));
|
||||
const get = vi.fn();
|
||||
const getRow = vi.fn();
|
||||
const listRows = vi.fn();
|
||||
const clone = vi.fn();
|
||||
const toast = vi.fn();
|
||||
vi.mock("@/services/llm-experiments.service", () => ({
|
||||
default: {
|
||||
get: (...a: any[]) => get(...a),
|
||||
getRow: vi.fn(),
|
||||
getRow: (...a: any[]) => getRow(...a),
|
||||
listRows: (...a: any[]) => listRows(...a),
|
||||
cancel: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
clone: (...a: any[]) => clone(...a),
|
||||
@@ -54,6 +57,16 @@ vi.mock("@/services/llm-datasets.service", () => ({
|
||||
|
||||
import ExperimentDetailPage from "@/enterprise/views/AIObservability/ExperimentDetailPage.vue";
|
||||
|
||||
beforeEach(() => {
|
||||
getRow.mockReset();
|
||||
getRow.mockResolvedValue(undefined);
|
||||
listRows.mockReset();
|
||||
listRows.mockResolvedValue({
|
||||
rows: [],
|
||||
pagination: { page: 1, pageSize: 100, totalRows: 0, hasMore: false },
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExperimentDetailPage", () => {
|
||||
it("renders the experiment without runtime errors", async () => {
|
||||
const experiment = makeExperiment({ id: "exp-1", name: "run one", datasetId: "ds-1" });
|
||||
@@ -97,6 +110,7 @@ describe("ExperimentDetailPage", () => {
|
||||
incomplete: false,
|
||||
incompleteTaskSlots: 0,
|
||||
incompleteScoreDimensions: 0,
|
||||
errorTaskSlots: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -122,6 +136,7 @@ describe("ExperimentDetailPage", () => {
|
||||
incomplete: false,
|
||||
incompleteTaskSlots: 0,
|
||||
incompleteScoreDimensions: 0,
|
||||
errorTaskSlots: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -247,6 +262,7 @@ describe("ExperimentDetailPage", () => {
|
||||
],
|
||||
});
|
||||
const taskStatuses = ["pending", "in_progress", "ok", "skipped", "error"] as const;
|
||||
const slotStatuses = ["pending", "running", "completed", "skipped", "task_failed"] as const;
|
||||
const detail = makeExperimentDetail(experiment, {
|
||||
results: {
|
||||
executions: [],
|
||||
@@ -261,6 +277,7 @@ describe("ExperimentDetailPage", () => {
|
||||
trialIndex: 0,
|
||||
input: `input-${index}`,
|
||||
expectedOutput: null,
|
||||
status: slotStatuses[index],
|
||||
taskStatus,
|
||||
execution: null,
|
||||
scores:
|
||||
@@ -291,13 +308,48 @@ describe("ExperimentDetailPage", () => {
|
||||
},
|
||||
});
|
||||
get.mockResolvedValue(detail);
|
||||
listRows.mockResolvedValue({
|
||||
rows: taskStatuses.map((_, index) => ({
|
||||
rowIndex: index,
|
||||
rowId: `row-${index}`,
|
||||
logicalId: `case-${index}`,
|
||||
input: `input-${index}`,
|
||||
expectedOutput: null,
|
||||
trialCount: 1,
|
||||
status: slotStatuses[index],
|
||||
output: null,
|
||||
p50LatencyMs: null,
|
||||
dispersion: null,
|
||||
scoreSummaries:
|
||||
index === 2
|
||||
? [
|
||||
{
|
||||
scorerId: "numeric",
|
||||
scorerVersion: 1,
|
||||
value: { kind: "numeric", mean: 0.718418 },
|
||||
},
|
||||
{
|
||||
scorerId: "boolean",
|
||||
scorerVersion: 1,
|
||||
value: { kind: "boolean", trueCount: 0, falseCount: 1 },
|
||||
},
|
||||
{
|
||||
scorerId: "category",
|
||||
scorerVersion: 1,
|
||||
value: { kind: "categorical", counts: { safe: 1 } },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
})),
|
||||
pagination: { page: 1, pageSize: 100, totalRows: 5, hasMore: false },
|
||||
});
|
||||
|
||||
const wrapper = mount(ExperimentDetailPage, {
|
||||
global: {
|
||||
stubs: {
|
||||
OTable: {
|
||||
props: ["data", "columns", "getRowStyle", "rowClass"],
|
||||
template: `<table><slot name="toolbar-trailing" /><tbody><tr v-for="row in data" :key="row.slotKey"
|
||||
template: `<table><slot name="toolbar-trailing" /><tbody><tr v-for="row in data" :key="row.rowKey"
|
||||
:data-rail="getRowStyle ? 'yes' : ''">
|
||||
<td v-for="column in columns" :key="column.id">
|
||||
<slot :name="'cell-' + column.id" :row="row">{{ row[column.accessorKey] }}</slot>
|
||||
@@ -316,21 +368,19 @@ describe("ExperimentDetailPage", () => {
|
||||
expect(wrapper.text()).toContain("0.718");
|
||||
expect(wrapper.text()).toContain("false");
|
||||
expect(wrapper.text()).toContain("safe");
|
||||
// Every slot state is a labelled chip in its own column — status is carried
|
||||
// by TEXT, so it never depends on a reader separating red from green.
|
||||
// Text labels keep row status accessible without relying on color.
|
||||
const chipText = taskStatuses.map((_, index) =>
|
||||
wrapper.get(`[data-test="ai-experiment-slot-status-row-${index}:0"]`).text(),
|
||||
wrapper.get(`[data-test="ai-experiment-row-status-row-${index}"]`).text(),
|
||||
);
|
||||
expect(chipText.every((label) => label.length > 0)).toBe(true);
|
||||
expect(new Set(chipText).size).toBe(taskStatuses.length);
|
||||
expect(chipText).toContain("Task Failed");
|
||||
|
||||
// No row rail: the chip is the only status device on the row.
|
||||
expect(wrapper.findAll("tbody tr").every((row) => !row.attributes("data-rail"))).toBe(true);
|
||||
});
|
||||
|
||||
// The whole run is loaded in one request, so search and the status filter
|
||||
// cover every slot rather than the page that happened to be fetched.
|
||||
it("asks for every slot in one request and paginates on the client", async () => {
|
||||
it("asks for the largest row page and paginates on the client", async () => {
|
||||
get.mockResolvedValue(makeExperimentDetail(makeExperiment({ id: "exp-1" })));
|
||||
const seen: any[] = [];
|
||||
const wrapper = mount(ExperimentDetailPage, {
|
||||
@@ -341,7 +391,7 @@ describe("ExperimentDetailPage", () => {
|
||||
mounted() {
|
||||
seen.push({ pagination: this.pagination, rows: this.data.length });
|
||||
},
|
||||
template: `<table />`,
|
||||
template: `<table><slot name="toolbar" /></table>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -351,45 +401,132 @@ describe("ExperimentDetailPage", () => {
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
"acme",
|
||||
"exp-1",
|
||||
// Pinned to the server's MAX_RESULT_PAGE_SIZE until that cap is lifted.
|
||||
expect.objectContaining({ resultPage: 1, resultPageSize: 50 }),
|
||||
expect.objectContaining({ resultPage: 1, resultPageSize: 1 }),
|
||||
);
|
||||
expect(listRows).toHaveBeenCalledWith(
|
||||
"acme",
|
||||
"exp-1",
|
||||
expect.objectContaining({ page: 1, pageSize: 100, sort: "dataset" }),
|
||||
);
|
||||
expect(wrapper.find('[data-test="ai-experiment-detail-sort-dispersion"]').exists()).toBe(false);
|
||||
expect(seen[0].pagination).toBe("client");
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("filters the loaded slots by search text and by status", async () => {
|
||||
it("walks every result-row page so late dataset cases are visible", async () => {
|
||||
const experiment = makeExperiment({ id: "exp-1" });
|
||||
const row = (index: number, status: string) => ({
|
||||
rowIndex: index,
|
||||
rowId: `row-${index}`,
|
||||
logicalId: `case-${index}`,
|
||||
input: `q${index}`,
|
||||
expectedOutput: null,
|
||||
trialCount: 1,
|
||||
status,
|
||||
output: null,
|
||||
scoreSummaries: [],
|
||||
p50LatencyMs: null,
|
||||
dispersion: null,
|
||||
});
|
||||
get.mockResolvedValue(makeExperimentDetail(experiment));
|
||||
listRows.mockImplementation((_org: string, _id: string, options: any) =>
|
||||
Promise.resolve(
|
||||
options.page === 1
|
||||
? {
|
||||
rows: Array.from({ length: 100 }, (_, index) => row(index, "completed")),
|
||||
pagination: { page: 1, pageSize: 100, totalRows: 101, hasMore: true },
|
||||
}
|
||||
: {
|
||||
rows: [row(100, "task_failed")],
|
||||
pagination: { page: 2, pageSize: 100, totalRows: 101, hasMore: false },
|
||||
},
|
||||
),
|
||||
);
|
||||
const wrapper = mount(ExperimentDetailPage, {
|
||||
global: {
|
||||
stubs: {
|
||||
OTable: {
|
||||
props: ["data"],
|
||||
template: `<table :data-rows="data.length" />`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(listRows).toHaveBeenCalledWith(
|
||||
"acme",
|
||||
"exp-1",
|
||||
expect.objectContaining({ page: 2, pageSize: 100 }),
|
||||
);
|
||||
expect(wrapper.get('[data-test="ai-experiment-detail-table"]').attributes("data-rows")).toBe(
|
||||
"101",
|
||||
);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
// "Incomplete" can be zero on a failed run — errors are terminal — so the
|
||||
// retry affordance keys off the error count instead.
|
||||
it("offers retry when the run holds errored slots", async () => {
|
||||
const experiment = makeExperiment({ id: "exp-1", status: "failed" });
|
||||
const detail = makeExperimentDetail(experiment, {
|
||||
results: {
|
||||
executions: [],
|
||||
scores: [],
|
||||
slots: [
|
||||
{
|
||||
rowId: "r0",
|
||||
logicalId: "c0",
|
||||
trialIndex: 0,
|
||||
input: "alpha question",
|
||||
expectedOutput: null,
|
||||
taskStatus: "ok",
|
||||
execution: null,
|
||||
scores: [],
|
||||
},
|
||||
{
|
||||
rowId: "r1",
|
||||
logicalId: "c1",
|
||||
trialIndex: 0,
|
||||
input: "beta question",
|
||||
expectedOutput: null,
|
||||
taskStatus: "error",
|
||||
execution: null,
|
||||
scores: [],
|
||||
},
|
||||
],
|
||||
aggregateSummary: {
|
||||
p50LatencyMs: null,
|
||||
totalCost: 0,
|
||||
incomplete: false,
|
||||
incompleteTaskSlots: 0,
|
||||
incompleteScoreDimensions: 0,
|
||||
errorTaskSlots: 4,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
});
|
||||
get.mockResolvedValue(detail);
|
||||
|
||||
const wrapper = mount(ExperimentDetailPage);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-test="ai-experiment-detail-retry"]').exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("filters the loaded dataset rows by search text and aggregate status", async () => {
|
||||
const experiment = makeExperiment({ id: "exp-1" });
|
||||
get.mockResolvedValue(makeExperimentDetail(experiment));
|
||||
listRows.mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
rowIndex: 0,
|
||||
rowId: "r0",
|
||||
logicalId: "c0",
|
||||
input: "alpha question",
|
||||
expectedOutput: null,
|
||||
trialCount: 2,
|
||||
status: "completed",
|
||||
output: null,
|
||||
scoreSummaries: [],
|
||||
p50LatencyMs: 10,
|
||||
dispersion: null,
|
||||
},
|
||||
{
|
||||
rowIndex: 1,
|
||||
rowId: "r1",
|
||||
logicalId: "c1",
|
||||
input: "beta question",
|
||||
expectedOutput: null,
|
||||
trialCount: 2,
|
||||
status: "task_failed",
|
||||
output: null,
|
||||
scoreSummaries: [],
|
||||
p50LatencyMs: 20,
|
||||
dispersion: null,
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, pageSize: 100, totalRows: 2, hasMore: false },
|
||||
});
|
||||
|
||||
let rows: any[] = [];
|
||||
const wrapper = mount(ExperimentDetailPage, {
|
||||
global: {
|
||||
@@ -417,9 +554,126 @@ describe("ExperimentDetailPage", () => {
|
||||
expect(rows.map((r) => r.input)).toEqual(["alpha question"]);
|
||||
|
||||
(wrapper.vm as any).rowSearch = "";
|
||||
(wrapper.vm as any).statusFilter = "error";
|
||||
(wrapper.vm as any).statusFilter = "task_failed";
|
||||
await flushPromises();
|
||||
expect(rows.map((r) => r.taskStatus)).toEqual(["error"]);
|
||||
expect(rows.map((r) => r.status)).toEqual(["task_failed"]);
|
||||
});
|
||||
|
||||
it("renders one dataset row with aggregate scores and opens all of its trials", async () => {
|
||||
const experiment = makeExperiment({
|
||||
id: "exp-1",
|
||||
trialCount: 3,
|
||||
scorers: [{ id: "judge", version: 1 }],
|
||||
});
|
||||
get.mockResolvedValue(
|
||||
makeExperimentDetail(experiment, {
|
||||
results: {
|
||||
executions: [],
|
||||
scores: [],
|
||||
dispersionSummary: { highDispersionRowCount: 1 },
|
||||
scoreSummaries: [
|
||||
{
|
||||
scorerId: "judge",
|
||||
scorerVersion: 1,
|
||||
name: "answer_quality",
|
||||
scoreConfigId: "quality",
|
||||
scoreConfigName: "answer_quality",
|
||||
scoreConfigVersion: 2,
|
||||
sampleCount: 3,
|
||||
errorCount: 0,
|
||||
pendingCount: 0,
|
||||
noReferenceCount: 0,
|
||||
noTraceCount: 0,
|
||||
skippedCount: 0,
|
||||
value: { kind: "numeric", mean: 0.75 },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
listRows.mockResolvedValue({
|
||||
rows: [
|
||||
{
|
||||
rowIndex: 0,
|
||||
rowId: "row-1",
|
||||
logicalId: "case-1",
|
||||
input: "question",
|
||||
expectedOutput: null,
|
||||
trialCount: 3,
|
||||
status: "completed",
|
||||
output: null,
|
||||
scoreSummaries: [
|
||||
{
|
||||
scorerId: "judge",
|
||||
scorerVersion: 1,
|
||||
value: { kind: "numeric", mean: 0.75 },
|
||||
},
|
||||
],
|
||||
p50LatencyMs: 840,
|
||||
dispersion: {
|
||||
rowId: "row-1",
|
||||
logicalId: "case-1",
|
||||
maxNormalized: 0.42,
|
||||
high: true,
|
||||
outlierTrialIndex: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, pageSize: 100, totalRows: 1, hasMore: false },
|
||||
});
|
||||
const wrapper = mount(ExperimentDetailPage, {
|
||||
global: {
|
||||
stubs: {
|
||||
OTable: {
|
||||
props: ["data", "columns"],
|
||||
emits: ["row-click"],
|
||||
template: `<table><slot name="toolbar" /><tbody><tr v-for="row in data" :key="row.rowKey"
|
||||
@click="$emit('row-click', row)"><td v-for="column in columns" :key="column.id">
|
||||
<slot :name="'cell-' + column.id" :row="row">{{ row[column.accessorKey] }}</slot>
|
||||
</td></tr></tbody></table>`,
|
||||
},
|
||||
OTag: {
|
||||
props: ["label"],
|
||||
template: `<span>{{ label }}<slot /></span>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll("tbody tr")).toHaveLength(1);
|
||||
expect(wrapper.text()).toContain("3 trials");
|
||||
expect(wrapper.text()).toContain("0.75");
|
||||
expect(wrapper.text()).toContain("High");
|
||||
expect(wrapper.text()).toContain("42%");
|
||||
const sortButton = wrapper.get('[data-test="ai-experiment-detail-sort-dispersion"]');
|
||||
const dispersionCard = wrapper.get('[data-test="ai-experiment-detail-dispersion"]');
|
||||
expect(dispersionCard.element.tagName).toBe("BUTTON");
|
||||
|
||||
await wrapper.get("tbody tr").trigger("click");
|
||||
await flushPromises();
|
||||
expect(getRow).toHaveBeenCalledWith("acme", "exp-1", "row-1");
|
||||
|
||||
listRows.mockClear();
|
||||
await sortButton.trigger("click");
|
||||
await flushPromises();
|
||||
expect(listRows).toHaveBeenCalledWith(
|
||||
"acme",
|
||||
"exp-1",
|
||||
expect.objectContaining({ sort: "dispersion_desc", highDispersionOnly: false }),
|
||||
);
|
||||
expect(sortButton.attributes("aria-pressed")).toBe("true");
|
||||
|
||||
listRows.mockClear();
|
||||
await dispersionCard.trigger("click");
|
||||
await flushPromises();
|
||||
expect(listRows).toHaveBeenCalledWith(
|
||||
"acme",
|
||||
"exp-1",
|
||||
expect.objectContaining({ sort: "dispersion_desc", highDispersionOnly: true }),
|
||||
);
|
||||
expect(sortButton.attributes("aria-pressed")).toBe("true");
|
||||
expect(dispersionCard.attributes("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
// A clone costs a full run, and it is normally made in order to change
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{{ t("aiObservability.experiments.cancel") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
v-else-if="failedSlotCount > 0"
|
||||
v-else-if="detail?.experiment.status === 'failed' || failedSlotCount > 0"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="acting"
|
||||
@@ -112,7 +112,17 @@
|
||||
:key="card.key"
|
||||
:label="card.label"
|
||||
:icon="card.icon"
|
||||
:as="isMetricCardActionable(card) ? 'button' : 'div'"
|
||||
:type="isMetricCardActionable(card) ? 'button' : undefined"
|
||||
:aria-pressed="isMetricCardActionable(card) ? highDispersionOnly : undefined"
|
||||
:class="{
|
||||
'focus-visible:ring-button-outline-hover-border cursor-pointer text-left focus-visible:ring-3':
|
||||
isMetricCardActionable(card),
|
||||
'ring-button-outline-hover-border ring-2':
|
||||
isMetricCardActionable(card) && highDispersionOnly,
|
||||
}"
|
||||
:data-test="card.dataTest"
|
||||
@click="handleMetricCardClick(card)"
|
||||
>
|
||||
<template #value>
|
||||
<span class="text-text-secondary text-2xl leading-none font-bold">
|
||||
@@ -130,10 +140,10 @@
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<OTable
|
||||
:data="visibleSlots"
|
||||
:data="visibleRows"
|
||||
:columns="columns"
|
||||
row-key="slotKey"
|
||||
:loading="loading"
|
||||
row-key="rowKey"
|
||||
:loading="loading || rowsLoading"
|
||||
:show-global-filter="false"
|
||||
:default-columns="false"
|
||||
:enable-column-resize="true"
|
||||
@@ -150,7 +160,13 @@
|
||||
<OInput
|
||||
v-model="rowSearch"
|
||||
class="min-w-0 flex-1"
|
||||
:placeholder="t('aiObservability.experiments.detail.searchPlaceholder')"
|
||||
:placeholder="
|
||||
t(
|
||||
isMultiTrial
|
||||
? 'aiObservability.experiments.detail.searchInputPlaceholder'
|
||||
: 'aiObservability.experiments.detail.searchPlaceholder',
|
||||
)
|
||||
"
|
||||
clearable
|
||||
data-test="ai-experiment-detail-search"
|
||||
/>
|
||||
@@ -164,6 +180,19 @@
|
||||
clearable
|
||||
data-test="ai-experiment-detail-status-filter"
|
||||
/>
|
||||
<OButton
|
||||
v-if="isMultiTrial"
|
||||
class="shrink-0"
|
||||
size="sm-toolbar"
|
||||
variant="outline"
|
||||
icon-left="swap-vert"
|
||||
:active="sortByDispersion"
|
||||
:aria-pressed="sortByDispersion"
|
||||
data-test="ai-experiment-detail-sort-dispersion"
|
||||
@click="toggleDispersionSort"
|
||||
>
|
||||
{{ t("aiObservability.experiments.detail.sortByDispersion") }}
|
||||
</OButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -183,9 +212,9 @@
|
||||
<template #cell-slotStatus="{ row }: { row: any }">
|
||||
<OTag
|
||||
size="sm"
|
||||
:variant="statusVariant(row.taskStatus, 'eval').variant"
|
||||
:label="statusVariant(row.taskStatus, 'eval').label"
|
||||
:data-test="`ai-experiment-slot-status-${row.slotKey}`"
|
||||
:variant="statusVariant(row.status, 'eval').variant"
|
||||
:label="statusVariant(row.status, 'eval').label"
|
||||
:data-test="`ai-experiment-row-status-${row.rowKey}`"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -199,10 +228,43 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-trialCount="{ row }: { row: any }">
|
||||
<span class="text-text-secondary">{{ raw(row.trialLabel) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-dispersion="{ row }: { row: any }">
|
||||
<OTag
|
||||
v-if="row.highDispersion"
|
||||
size="sm"
|
||||
variant="warning"
|
||||
:label="
|
||||
raw(
|
||||
`${t('aiObservability.experiments.detail.highDispersionBadge')} · ${row.dispersionLabel}`,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span v-else class="text-text-secondary">{{ raw(row.dispersionLabel) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-latency="{ row }: { row: any }">
|
||||
<span class="text-text-secondary">{{ raw(row.latency) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Only boolean scores with a configured healthy value ever set
|
||||
row.violations[id] — everything else (numeric, categorical, or
|
||||
a boolean with no healthy value set yet) renders exactly as
|
||||
before: plain text, no false "everything is fine" green. -->
|
||||
<template v-for="id in scorerIds" :key="id" #[`cell-score:${id}`]="{ row }: { row: any }">
|
||||
<OTag
|
||||
v-if="row.violations[id]"
|
||||
size="sm"
|
||||
variant="error"
|
||||
:label="raw(row[`score:${id}`])"
|
||||
:data-test="`ai-experiment-score-violation-${row.rowKey}-${id}`"
|
||||
/>
|
||||
<span v-else class="text-text-secondary">{{ raw(row[`score:${id}`]) }}</span>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<OEmptyState size="hero" preset="no-experiments" />
|
||||
@@ -254,9 +316,13 @@ import { COL, type OTableColumnDef } from "@/lib/core/Table/OTable.types";
|
||||
import type { IconName } from "@/lib/core/Icon/OIcon.icons";
|
||||
import { statusVariant } from "@/lib/core/Table/cells/statusVariant";
|
||||
import { toast } from "@/lib/feedback/Toast/useToast";
|
||||
import onlineEvalsService, { type ScoreConfig } from "@/services/online-evals.service";
|
||||
import { healthyBooleanValue } from "@/enterprise/components/onlineEvals/utils/qualitySummary";
|
||||
import llmExperimentsService, {
|
||||
type ExperimentDetail,
|
||||
type ExperimentExecution,
|
||||
type ExperimentResultRow,
|
||||
type ExperimentResultRowSort,
|
||||
type ExperimentResultSlot,
|
||||
type ExperimentRowDetail,
|
||||
} from "@/services/llm-experiments.service";
|
||||
@@ -273,7 +339,7 @@ import {
|
||||
durationUnit,
|
||||
formatDuration,
|
||||
} from "@/enterprise/components/AIObservability/experimentRowContent";
|
||||
import { experimentScoreValue, openExperimentTrace } from "./experimentResults";
|
||||
import { experimentScoreSummaryValue, openExperimentTrace } from "./experimentResults";
|
||||
|
||||
defineOptions({ name: "AIExperimentDetailPage" });
|
||||
|
||||
@@ -286,15 +352,15 @@ const orgId = computed<string>(() => store.state.selectedOrganization?.identifie
|
||||
const experimentId = computed<string>(() => String(route.params.id ?? ""));
|
||||
const detail = ref<ExperimentDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const rowsLoading = ref(false);
|
||||
const acting = ref(false);
|
||||
const comparePickerOpen = ref(false);
|
||||
// Meant to fetch the whole run in one request, so the search box and the status
|
||||
// filter below cover every slot. Pinned to the server's current
|
||||
// MAX_RESULT_PAGE_SIZE until that cap is lifted — while it stands, both controls
|
||||
// only see the first page of a longer run.
|
||||
const ALL_RESULTS_PAGE_SIZE = 50;
|
||||
const RESULTS_PAGE_SIZE = 100;
|
||||
const resultRows = ref<ExperimentResultRow[]>([]);
|
||||
const rowSearch = ref("");
|
||||
const statusFilter = ref("");
|
||||
const sortByDispersion = ref(false);
|
||||
const highDispersionOnly = ref(false);
|
||||
const rowDrawerOpen = ref(false);
|
||||
const retryingRow = ref(false);
|
||||
const selectedRowDetail = ref<ExperimentRowDetail | null>(null);
|
||||
@@ -303,12 +369,44 @@ const backTarget = computed(() => ({
|
||||
label: t("aiObservability.nav.experiments"),
|
||||
to: aiExperimentsRoute(orgId.value),
|
||||
}));
|
||||
const slots = computed<ExperimentResultSlot[]>(() => detail.value?.results.slots ?? []);
|
||||
const isMultiTrial = computed(() => (detail.value?.experiment.trialCount ?? 1) > 1);
|
||||
|
||||
/** Scorer columns come from the pinned scorers, so a run with two scorers gets
|
||||
* two score columns exactly like the dataset grouping on the list page. */
|
||||
const scorerIds = computed(() => (detail.value?.preview.pinnedScorers ?? []).map((s) => s.id));
|
||||
|
||||
// Fetched once per org, not re-fetched on every refresh() — Score Configs
|
||||
// change far less often than an Experiment's results.
|
||||
const scoreConfigs = ref<ScoreConfig[]>([]);
|
||||
async function loadScoreConfigs() {
|
||||
if (!orgId.value) return;
|
||||
try {
|
||||
scoreConfigs.value = await onlineEvalsService.scoreConfigs.list(orgId.value);
|
||||
} catch {
|
||||
// Non-fatal: without configs, boolean cells just render unhighlighted —
|
||||
// same as before this feature existed.
|
||||
scoreConfigs.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** scorerId -> the healthy boolean for its produced Score Config, when that
|
||||
* config is boolean-typed and has one configured. `undefined` means "don't
|
||||
* know" (non-boolean, no config found, or no healthy value set) — the cell
|
||||
* renders plain in that case rather than guessing. */
|
||||
const scorerHealthyBoolean = computed<Record<string, boolean | undefined>>(() => {
|
||||
const map: Record<string, boolean | undefined> = {};
|
||||
for (const summary of detail.value?.results.scoreSummaries ?? []) {
|
||||
if (!summary.scoreConfigId) continue;
|
||||
const config = scoreConfigs.value.find(
|
||||
(c) => c.entityId === summary.scoreConfigId || c.id === summary.scoreConfigId,
|
||||
);
|
||||
if (!config) continue;
|
||||
const healthy = healthyBooleanValue(config);
|
||||
if (healthy !== null) map[summary.scorerId] = healthy;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
/** Score summaries carry the pinned Score Config name even before the first
|
||||
* Score exists. Completed Score records remain a compatibility fallback. */
|
||||
const scorerNames = computed<Record<string, string>>(() => {
|
||||
@@ -325,31 +423,59 @@ const scorerNames = computed<Record<string, string>>(() => {
|
||||
}
|
||||
}
|
||||
for (const score of detail.value?.results.scores ?? []) record(score);
|
||||
for (const slot of slots.value) {
|
||||
for (const entry of slot.scores) record(entry.score as Record<string, unknown> | undefined);
|
||||
}
|
||||
return names;
|
||||
});
|
||||
|
||||
const slotRows = computed(() =>
|
||||
slots.value.map((slot) => {
|
||||
const tableRows = computed(() =>
|
||||
resultRows.value.map((row) => {
|
||||
const scores: Record<string, string> = {};
|
||||
// Which score columns are a violated boolean policy on THIS row — set
|
||||
// only when the config's healthy value is known and this row disagrees
|
||||
// with it, so a normal (non-boolean, or unconfigured) score never lights
|
||||
// up red by accident.
|
||||
const violations: Record<string, boolean> = {};
|
||||
for (const id of scorerIds.value) {
|
||||
const entry = slot.scores.find((s) => s.scorerId === id);
|
||||
scores[`score:${id}`] = entry?.status === "success" ? experimentScoreValue(entry.score) : "—";
|
||||
const summary = row.scoreSummaries.find((candidate) => candidate.scorerId === id);
|
||||
scores[`score:${id}`] = experimentScoreSummaryValue(summary?.value ?? null);
|
||||
const healthy = scorerHealthyBoolean.value[id];
|
||||
const aggregate = summary?.value as Record<string, unknown> | null | undefined;
|
||||
if (healthy !== undefined && aggregate?.kind === "boolean") {
|
||||
const trueCount = Number(aggregate.trueCount ?? aggregate.true_count ?? 0);
|
||||
const falseCount = Number(aggregate.falseCount ?? aggregate.false_count ?? 0);
|
||||
const unhealthyCount = healthy ? falseCount : trueCount;
|
||||
if (unhealthyCount > 0) violations[id] = true;
|
||||
}
|
||||
}
|
||||
const maxDispersion = row.dispersion?.maxNormalized ?? null;
|
||||
return {
|
||||
...slot,
|
||||
...row,
|
||||
...scores,
|
||||
slotKey: `${slot.rowId}:${slot.trialIndex}`,
|
||||
input: slot.input ?? "—",
|
||||
output: slot.execution?.output ?? "—",
|
||||
latency: durationLabel(slot.execution?.latencyMs),
|
||||
violations,
|
||||
rowKey: row.rowId,
|
||||
input: row.input ?? "—",
|
||||
output: row.output ?? "—",
|
||||
trialLabel: gt(
|
||||
"aiObservability.experiments.detail.trialCount",
|
||||
{ count: row.trialCount },
|
||||
row.trialCount,
|
||||
),
|
||||
latency: durationLabel(row.p50LatencyMs),
|
||||
dispersion: maxDispersion ?? -1,
|
||||
dispersionLabel: maxDispersion === null ? "—" : `${Math.round(maxDispersion * 100)}%`,
|
||||
highDispersion: row.dispersion?.high ?? false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const STATUS_FILTERS = ["pending", "in_progress", "ok", "skipped", "error"] as const;
|
||||
const STATUS_FILTERS = [
|
||||
"pending",
|
||||
"running",
|
||||
"scoring",
|
||||
"completed",
|
||||
"skipped",
|
||||
"task_failed",
|
||||
"score_failed",
|
||||
] as const;
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
STATUS_FILTERS.map((status) => ({
|
||||
@@ -358,12 +484,10 @@ const statusOptions = computed(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
// Every slot is loaded, so filtering here covers the whole run rather than the
|
||||
// page in view.
|
||||
const visibleSlots = computed(() => {
|
||||
const visibleRows = computed(() => {
|
||||
const term = rowSearch.value.trim().toLowerCase();
|
||||
return slotRows.value.filter((row) => {
|
||||
if (statusFilter.value && row.taskStatus !== statusFilter.value) return false;
|
||||
return tableRows.value.filter((row) => {
|
||||
if (statusFilter.value && row.status !== statusFilter.value) return false;
|
||||
if (!term) return true;
|
||||
return `${row.input} ${row.output}`.toLowerCase().includes(term);
|
||||
});
|
||||
@@ -489,11 +613,27 @@ const metricCards = computed<MetricCard[]>(() => {
|
||||
return cards;
|
||||
});
|
||||
|
||||
function isMetricCardActionable(card: MetricCard) {
|
||||
return card.key === "dispersion" && isMultiTrial.value;
|
||||
}
|
||||
|
||||
function toggleDispersionSort() {
|
||||
sortByDispersion.value = !sortByDispersion.value;
|
||||
}
|
||||
|
||||
function toggleHighDispersionFilter() {
|
||||
highDispersionOnly.value = !highDispersionOnly.value;
|
||||
}
|
||||
|
||||
function handleMetricCardClick(card: MetricCard) {
|
||||
if (isMetricCardActionable(card)) toggleHighDispersionFilter();
|
||||
}
|
||||
|
||||
const columns = computed<OTableColumnDef[]>(() => [
|
||||
{
|
||||
id: "slotStatus",
|
||||
header: t("aiObservability.experiments.detail.slotStatus"),
|
||||
accessorKey: "taskStatus",
|
||||
accessorKey: "status",
|
||||
sortable: true,
|
||||
size: COL.status,
|
||||
meta: { align: "left" as const },
|
||||
@@ -507,14 +647,27 @@ const columns = computed<OTableColumnDef[]>(() => [
|
||||
minSize: 160,
|
||||
meta: { align: "left" as const, flex: true, isName: true },
|
||||
},
|
||||
{
|
||||
id: "output",
|
||||
header: t("aiObservability.experiments.detail.columns.output"),
|
||||
accessorKey: "output",
|
||||
sortable: false,
|
||||
size: 320,
|
||||
meta: { align: "left" as const },
|
||||
},
|
||||
...(isMultiTrial.value
|
||||
? [
|
||||
{
|
||||
id: "trialCount",
|
||||
header: t("aiObservability.experiments.detail.columns.trials"),
|
||||
accessorKey: "trialCount",
|
||||
sortable: true,
|
||||
size: 120,
|
||||
meta: { align: "left" as const },
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: "output",
|
||||
header: t("aiObservability.experiments.detail.columns.output"),
|
||||
accessorKey: "output",
|
||||
sortable: false,
|
||||
size: 320,
|
||||
meta: { align: "left" as const },
|
||||
},
|
||||
]),
|
||||
...scorerIds.value.map((id) => ({
|
||||
id: `score:${id}`,
|
||||
header: raw(scorerNames.value[id] ?? id),
|
||||
@@ -524,9 +677,23 @@ const columns = computed<OTableColumnDef[]>(() => [
|
||||
size: 140,
|
||||
meta: { align: "left" as const },
|
||||
})),
|
||||
...(isMultiTrial.value
|
||||
? [
|
||||
{
|
||||
id: "dispersion",
|
||||
header: t("aiObservability.experiments.detail.columns.dispersion"),
|
||||
accessorKey: "dispersion",
|
||||
sortable: true,
|
||||
size: 130,
|
||||
meta: { align: "left" as const },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "latency",
|
||||
header: t("aiObservability.experiments.detail.columns.latency"),
|
||||
header: isMultiTrial.value
|
||||
? t("aiObservability.experiments.detail.columns.p50Latency")
|
||||
: t("aiObservability.experiments.detail.columns.latency"),
|
||||
accessorKey: "latency",
|
||||
sortable: true,
|
||||
size: 120,
|
||||
@@ -534,22 +701,72 @@ const columns = computed<OTableColumnDef[]>(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
function resultRowOptions(page: number) {
|
||||
const sort: ExperimentResultRowSort = sortByDispersion.value ? "dispersion_desc" : "dataset";
|
||||
return {
|
||||
page,
|
||||
pageSize: RESULTS_PAGE_SIZE,
|
||||
sort,
|
||||
highDispersionOnly: highDispersionOnly.value,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAllResultRows() {
|
||||
const rows: ExperimentResultRow[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
while (hasMore) {
|
||||
const next = await llmExperimentsService.listRows(
|
||||
orgId.value,
|
||||
experimentId.value,
|
||||
resultRowOptions(page),
|
||||
);
|
||||
rows.push(...next.rows);
|
||||
hasMore = next.pagination.hasMore;
|
||||
page += 1;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function showLoadError(error: any) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message:
|
||||
raw(error?.response?.data?.message) || t("aiObservability.experiments.detail.loadError"),
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshRows() {
|
||||
if (!orgId.value || !experimentId.value) return;
|
||||
rowsLoading.value = true;
|
||||
try {
|
||||
resultRows.value = await fetchAllResultRows();
|
||||
} catch (error: any) {
|
||||
showLoadError(error);
|
||||
} finally {
|
||||
rowsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!orgId.value || !experimentId.value) return;
|
||||
loading.value = true;
|
||||
rowsLoading.value = true;
|
||||
try {
|
||||
detail.value = await llmExperimentsService.get(orgId.value, experimentId.value, {
|
||||
resultPage: 1,
|
||||
resultPageSize: ALL_RESULTS_PAGE_SIZE,
|
||||
});
|
||||
const [nextDetail, rows] = await Promise.all([
|
||||
llmExperimentsService.get(orgId.value, experimentId.value, {
|
||||
resultPage: 1,
|
||||
resultPageSize: 1,
|
||||
}),
|
||||
fetchAllResultRows(),
|
||||
]);
|
||||
detail.value = nextDetail;
|
||||
resultRows.value = rows;
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message:
|
||||
raw(error?.response?.data?.message) || t("aiObservability.experiments.detail.loadError"),
|
||||
});
|
||||
showLoadError(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
rowsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,4 +889,6 @@ async function runAction(
|
||||
}
|
||||
|
||||
watch([orgId, experimentId], refresh, { immediate: true });
|
||||
watch([sortByDispersion, highDispersionOnly], refreshRows);
|
||||
watch(orgId, loadScoreConfigs, { immediate: true });
|
||||
</script>
|
||||
|
||||
@@ -120,6 +120,7 @@ function vm(wrapper: Awaited<ReturnType<typeof mountPage>>) {
|
||||
expectedRequired: boolean;
|
||||
setTools: (tools: import("./playgroundDraft").PlaygroundTool[]) => void;
|
||||
scoreAll: () => Promise<void>;
|
||||
addOutputToMessages: (variantId: string) => void;
|
||||
totalCells: number;
|
||||
};
|
||||
}
|
||||
@@ -137,6 +138,7 @@ describe("PlaygroundPage", () => {
|
||||
runPlayground.mockResolvedValue({
|
||||
text: "an answer",
|
||||
toolCall: null,
|
||||
reasoningContent: null,
|
||||
usage: { promptTokens: 5, completionTokens: 2, costUsd: 0.001, latencyMs: 100 },
|
||||
});
|
||||
});
|
||||
@@ -201,6 +203,56 @@ describe("PlaygroundPage", () => {
|
||||
expect(request.messages).toEqual([{ role: "user", content: "Hello" }]);
|
||||
});
|
||||
|
||||
it("continues a returned tool call with a linked tool result", async () => {
|
||||
runPlayground.mockResolvedValueOnce({
|
||||
text: "",
|
||||
toolCall: {
|
||||
id: "call_1",
|
||||
name: "lookup_order",
|
||||
arguments: '{"order_id":"123"}',
|
||||
},
|
||||
reasoningContent: "I should look up this order before answering.",
|
||||
usage: { promptTokens: 5, completionTokens: 2, costUsd: 0.001, latencyMs: 100 },
|
||||
});
|
||||
const wrapper = await mountPage();
|
||||
const page = vm(wrapper);
|
||||
const variant = page.draft.variants[0];
|
||||
variant.messages[1].content = "Where is order 123?";
|
||||
|
||||
await page.runVariant(variant.id);
|
||||
page.addOutputToMessages(variant.id);
|
||||
|
||||
const resultMessage = variant.messages.at(-1)!;
|
||||
expect(resultMessage).toMatchObject({
|
||||
role: "tool",
|
||||
content: "",
|
||||
toolName: "lookup_order",
|
||||
toolCallId: "call_1",
|
||||
toolArguments: '{"order_id":"123"}',
|
||||
reasoningContent: "I should look up this order before answering.",
|
||||
});
|
||||
|
||||
resultMessage.content = '{"status":"shipped"}';
|
||||
await page.runVariant(variant.id);
|
||||
|
||||
expect(runPlayground.mock.calls[1][1].messages).toEqual([
|
||||
{ role: "user", content: "Where is order 123?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoningContent: "I should look up this order before answering.",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call_1",
|
||||
name: "lookup_order",
|
||||
arguments: '{"order_id":"123"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", content: '{"status":"shipped"}', toolCallId: "call_1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("records a failed run as a retryable error rather than an empty answer", async () => {
|
||||
const { PlaygroundRunError } = await import("@/services/llm-playground.service");
|
||||
runPlayground.mockRejectedValue(new PlaygroundRunError("provider 429", true));
|
||||
|
||||
@@ -48,16 +48,6 @@
|
||||
</ODropdownItem>
|
||||
</ODropdown>
|
||||
|
||||
<OButton
|
||||
v-if="running"
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
icon-left="close"
|
||||
data-test="ai-playground-stop-btn"
|
||||
@click="stopAll"
|
||||
>
|
||||
{{ t("aiObservability.playground.stop") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm-action"
|
||||
@@ -97,18 +87,25 @@
|
||||
{{ t("aiObservability.playground.share") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
v-if="runningAll"
|
||||
variant="cancel-query"
|
||||
size="sm-action"
|
||||
:title="t('common.cancel')"
|
||||
data-test="ai-playground-run-all-cancel-btn"
|
||||
@click="stopAll()"
|
||||
>
|
||||
{{ t("common.cancel") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
v-else
|
||||
variant="primary"
|
||||
size="sm-action"
|
||||
:loading="running"
|
||||
:disabled="runDisabled"
|
||||
:title="t('aiObservability.playground.runAllTooltip')"
|
||||
data-test="ai-playground-run-all-btn"
|
||||
@click="onRunAll()"
|
||||
>
|
||||
<template v-if="running">
|
||||
{{ t("aiObservability.playground.running", { done: completedCount, total: totalCells }) }}
|
||||
</template>
|
||||
<template v-else>{{ t("aiObservability.playground.runAll") }}</template>
|
||||
{{ t("aiObservability.playground.runAll") }}
|
||||
</OButton>
|
||||
</template>
|
||||
|
||||
@@ -171,6 +168,7 @@
|
||||
</OBanner>
|
||||
|
||||
<PlaygroundVariableBar
|
||||
ref="variableBarRef"
|
||||
class="shrink-0"
|
||||
:var-names="varNames"
|
||||
:vars="draft.vars"
|
||||
@@ -196,9 +194,9 @@
|
||||
<div v-if="canScrollLeft" class="absolute start-1.5 top-1/2 z-1 -translate-y-1/2">
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon-circle-sm"
|
||||
size="icon-circle"
|
||||
icon-left="chevron-left"
|
||||
class="bg-surface-base shadow-md"
|
||||
class="bg-surface-panel! text-accent! border-border-strong! hover:bg-accent/12! hover:border-accent! shadow-lg"
|
||||
:aria-label="t('aiObservability.playground.scrollLeft')"
|
||||
data-test="ai-playground-scroll-left"
|
||||
@click="scrollByStep(-1)"
|
||||
@@ -207,9 +205,9 @@
|
||||
<div v-if="canScrollRight" class="absolute end-1.5 top-1/2 z-1 -translate-y-1/2">
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="icon-circle-sm"
|
||||
size="icon-circle"
|
||||
icon-left="chevron-right"
|
||||
class="bg-surface-base shadow-md"
|
||||
class="bg-surface-panel! text-accent! border-border-strong! hover:bg-accent/12! hover:border-accent! shadow-lg"
|
||||
:aria-label="t('aiObservability.playground.scrollRight')"
|
||||
data-test="ai-playground-scroll-right"
|
||||
@click="scrollByStep(1)"
|
||||
@@ -228,14 +226,15 @@
|
||||
:providers="providers"
|
||||
:var-names="varNames"
|
||||
:vars="draft.vars"
|
||||
:solo="draft.variants.length === 1"
|
||||
:running="isVariantRunning(variant.id)"
|
||||
:run-disabled="runDisabled"
|
||||
:run-disabled="variantRunDisabled(variant.id)"
|
||||
:can-remove="draft.variants.length > 1"
|
||||
:can-duplicate="draft.variants.length < MAX_VARIANTS"
|
||||
@change="updateVariant"
|
||||
@run="runVariant(variant.id)"
|
||||
@cancel="cancelVariant(variant.id)"
|
||||
@duplicate="duplicate(variant.id)"
|
||||
@reset="resetVariant(variant.id)"
|
||||
@remove="removeVariant(variant.id)"
|
||||
@copy="copyOutput(variant.id, SINGLE_ROW_KEY)"
|
||||
@add-to-messages="addOutputToMessages(variant.id)"
|
||||
@@ -310,11 +309,12 @@ import {
|
||||
cellAt,
|
||||
cloneVariant,
|
||||
draftFromSnapshot,
|
||||
emptyVariant,
|
||||
extractVars,
|
||||
hasReference as benchHasReference,
|
||||
idleCell,
|
||||
playgroundId,
|
||||
renderedMessages,
|
||||
requestMessages,
|
||||
renderTemplate,
|
||||
scorerEvidence,
|
||||
settledResults,
|
||||
@@ -327,6 +327,7 @@ import {
|
||||
type PlaygroundTool,
|
||||
type PlaygroundVariant,
|
||||
} from "./playgroundDraft";
|
||||
import { takeHandoff } from "./playgroundHandoff";
|
||||
import { aiExperimentCreateRoute } from "./experimentRoutes";
|
||||
import { useConfirmDialog } from "@/composables/useConfirmDialog";
|
||||
import { useHorizontalOverflow } from "@/composables/useHorizontalOverflow";
|
||||
@@ -381,8 +382,6 @@ const varNames = computed(() => {
|
||||
/** Referenced by a message somewhere; the rest are declared and never read. */
|
||||
const usedVarNames = computed(() => extractVars(draft.variants));
|
||||
|
||||
const totalCells = computed(() => draft.variants.length);
|
||||
|
||||
const streamingVariants = computed(() =>
|
||||
draft.variants
|
||||
.filter((variant) => cellAt(results, variant.id, SINGLE_ROW_KEY)?.status === "streaming")
|
||||
@@ -390,21 +389,43 @@ const streamingVariants = computed(() =>
|
||||
);
|
||||
|
||||
const running = computed(() => streamingVariants.value.length > 0);
|
||||
|
||||
const completedCount = computed(() => {
|
||||
let done = 0;
|
||||
for (const variant of draft.variants) {
|
||||
const status = cellAt(results, variant.id, SINGLE_ROW_KEY)?.status;
|
||||
if (status === "done" || status === "error") done += 1;
|
||||
}
|
||||
return done;
|
||||
});
|
||||
/** True only while an actual Run All is in flight — a single bench's own Run
|
||||
* streaming must not make the Run All button look like it started the work. */
|
||||
const runningAll = ref(false);
|
||||
|
||||
/** Nothing to run against, or nothing to run with. */
|
||||
const runDisabled = computed(
|
||||
() => running.value || !providers.value.length || !draft.variants.length,
|
||||
() =>
|
||||
running.value ||
|
||||
!providers.value.length ||
|
||||
!draft.variants.length ||
|
||||
draft.variants.some(hasIncompleteToolResult),
|
||||
);
|
||||
|
||||
/**
|
||||
* A single bench's own Run button. Unlike Run All, one variant streaming must
|
||||
* not block the others — runCell() already keys state per variant id, so
|
||||
* running two at once is safe; only this variant's own in-flight run (or no
|
||||
* providers/variants at all) should disable it.
|
||||
*/
|
||||
function variantRunDisabled(variantId: string) {
|
||||
const variant = draft.variants.find((candidate) => candidate.id === variantId);
|
||||
return (
|
||||
!providers.value.length ||
|
||||
!draft.variants.length ||
|
||||
isVariantRunning(variantId) ||
|
||||
Boolean(variant && hasIncompleteToolResult(variant))
|
||||
);
|
||||
}
|
||||
|
||||
function hasIncompleteToolResult(variant: PlaygroundVariant): boolean {
|
||||
return variant.messages.some(
|
||||
(message) =>
|
||||
message.role === "tool" &&
|
||||
(!message.toolName?.trim() || !message.toolArguments?.trim() || !message.content.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Back to an empty bench. Confirmed because the draft is the only copy of the
|
||||
* work — it is deliberately never saved, so there is nothing to recover it
|
||||
@@ -450,6 +471,7 @@ onMounted(async () => {
|
||||
// First, and synchronously: the bench is the work, and it must be on screen
|
||||
// before anything that can fail or take a round trip.
|
||||
restoreSession();
|
||||
applyHandoff();
|
||||
await Promise.all([loadProviders(), loadDatasets(), loadScorers()]);
|
||||
applyEntryParams();
|
||||
loadRecentDrafts();
|
||||
@@ -510,6 +532,31 @@ function seedDefaultProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the conversation Trace Details stashed for us. Everything arrives as
|
||||
* ordinary editable content — the whole point of the entry is to change the
|
||||
* call and re-run it, so nothing is pinned readonly.
|
||||
*/
|
||||
function applyHandoff() {
|
||||
if (String(route.query.from ?? "") !== "span") return;
|
||||
const handoff = takeHandoff();
|
||||
if (!handoff) return;
|
||||
const variant = emptyVariant();
|
||||
variant.messages = handoff.messages;
|
||||
// Provider and model are left to seedDefaultProvider: the trace's model may
|
||||
// not exist on any provider configured here.
|
||||
variant.temperature = handoff.temperature;
|
||||
// A fresh draft, not a merge: the imported call is the subject of the bench,
|
||||
// and leaving a restored session's variants beside it would silently compare
|
||||
// the trace against whatever the user last had open.
|
||||
Object.assign(draft, starterDraft());
|
||||
draft.variants = [variant];
|
||||
draft.provenance = {
|
||||
type: "trace",
|
||||
label: t("aiObservability.playground.fromTrace", { id: handoff.sourceId }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Entry params are read once. The URL is an entry address, not a save file. */
|
||||
function applyEntryParams() {
|
||||
const experimentId = String(route.query.experiment ?? "");
|
||||
@@ -527,16 +574,21 @@ function setCell(variantId: string, rowKey: string, changes: Partial<PlaygroundC
|
||||
byRow[rowKey] = { ...(byRow[rowKey] ?? idleCell()), ...changes };
|
||||
}
|
||||
|
||||
function onRunAll() {
|
||||
async function onRunAll() {
|
||||
if (runDisabled.value) return;
|
||||
for (const variant of draft.variants) runVariant(variant.id, true);
|
||||
runningAll.value = true;
|
||||
try {
|
||||
await Promise.allSettled(draft.variants.map((variant) => runVariant(variant.id, true)));
|
||||
} finally {
|
||||
runningAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function runVariant(variantId: string, skipGate = false) {
|
||||
if (!skipGate && runDisabled.value) return;
|
||||
if (!skipGate && variantRunDisabled(variantId)) return Promise.resolve();
|
||||
const variant = draft.variants.find((candidate) => candidate.id === variantId);
|
||||
if (!variant) return;
|
||||
runCell(variant, SINGLE_ROW_KEY);
|
||||
if (!variant) return Promise.resolve();
|
||||
return runCell(variant, SINGLE_ROW_KEY);
|
||||
}
|
||||
|
||||
async function runCell(variant: PlaygroundVariant, rowKey: string) {
|
||||
@@ -548,6 +600,7 @@ async function runCell(variant: PlaygroundVariant, rowKey: string) {
|
||||
setCell(variant.id, rowKey, {
|
||||
status: "streaming",
|
||||
text: "",
|
||||
reasoningContent: null,
|
||||
toolCall: null,
|
||||
usage: null,
|
||||
error: null,
|
||||
@@ -557,9 +610,7 @@ async function runCell(variant: PlaygroundVariant, rowKey: string) {
|
||||
});
|
||||
|
||||
const vars = { ...draft.vars };
|
||||
const messages = renderedMessages(variant, vars)
|
||||
.filter((message) => message.content.trim().length > 0)
|
||||
.map((message) => ({ role: message.role, content: message.content }));
|
||||
const messages = requestMessages(variant, vars);
|
||||
// The provider's type decides how tools and the response schema are shaped
|
||||
// for the wire — the server forwards both to the provider untouched.
|
||||
const provider = providers.value.find((candidate) => candidate.id === variant.providerId);
|
||||
@@ -592,6 +643,7 @@ async function runCell(variant: PlaygroundVariant, rowKey: string) {
|
||||
setCell(variant.id, rowKey, {
|
||||
status: "done",
|
||||
text: result.text,
|
||||
reasoningContent: result.reasoningContent,
|
||||
toolCall: result.toolCall,
|
||||
usage: result.usage,
|
||||
});
|
||||
@@ -625,6 +677,17 @@ function stopAll() {
|
||||
}
|
||||
}
|
||||
|
||||
/** One bench's own Cancel — mirrors stopAll() but only for that variant, so
|
||||
* cancelling one run does not touch the others. */
|
||||
function cancelVariant(variantId: string) {
|
||||
const key = `${variantId}:${SINGLE_ROW_KEY}`;
|
||||
controllers.get(key)?.abort();
|
||||
controllers.delete(key);
|
||||
if (cellAt(results, variantId, SINGLE_ROW_KEY)?.status === "streaming") {
|
||||
setCell(variantId, SINGLE_ROW_KEY, { status: "idle" });
|
||||
}
|
||||
}
|
||||
|
||||
function safeParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
@@ -644,7 +707,8 @@ function onKeydown(event: KeyboardEvent) {
|
||||
|
||||
const hasReference = computed(() => benchHasReference(draft.expectedSingle));
|
||||
|
||||
const expectedBarRef = ref<{ focus: () => void } | null>(null);
|
||||
const expectedBarRef = ref<{ focus: () => void; flash: () => void } | null>(null);
|
||||
const variableBarRef = ref<{ flash: () => void } | null>(null);
|
||||
|
||||
/** A selected scorer reads `{{expected_output}}` and the bench has none, so the
|
||||
* field has to be on screen for the Score panel's notice to point at. */
|
||||
@@ -780,12 +844,55 @@ function removeVariant(variantId: string) {
|
||||
delete results[variantId];
|
||||
}
|
||||
|
||||
function applySample(sample: PlaygroundSample, item: LlmDatasetItem) {
|
||||
/** One bench back to blank — the scoped alternative to Reset, which wipes
|
||||
* every bench and confirms first. Keeps the variant's id so it stays the
|
||||
* same column (same label, same position) rather than reflowing the bench. */
|
||||
async function resetVariant(variantId: string) {
|
||||
const index = draft.variants.findIndex((variant) => variant.id === variantId);
|
||||
if (index === -1) return;
|
||||
const ok = await confirm({
|
||||
title: t("aiObservability.playground.resetVariantTitle"),
|
||||
message: t("aiObservability.playground.resetVariantMessage"),
|
||||
confirmLabel: t("aiObservability.playground.reset"),
|
||||
cancelLabel: t("common.cancel"),
|
||||
});
|
||||
if (!ok) return;
|
||||
cancelVariant(variantId);
|
||||
const fresh = emptyVariant();
|
||||
fresh.id = variantId;
|
||||
draft.variants[index] = fresh;
|
||||
delete results[variantId];
|
||||
seedDefaultProvider();
|
||||
}
|
||||
|
||||
function applySample(sample: PlaygroundSample, item: LlmDatasetItem, notify = true) {
|
||||
draft.sample = sample;
|
||||
draft.vars = { ...draft.vars, input: item.inputPreview || item.input };
|
||||
draft.expectedSingle = item.expectedOutput;
|
||||
// New question ⇒ the answers on screen are answers to the old one.
|
||||
for (const key of Object.keys(results)) delete results[key];
|
||||
// Sampling only ever fills a value — it never rewrites a message the user
|
||||
// wrote — so without this, the value sits there with nothing telling the
|
||||
// user it arrived or how to reach it. Only on the initial pick, though —
|
||||
// stepSample() passes notify=false, since a toast on every Prev/Next click
|
||||
// was noise once you're already stepping through rows you know are sampled.
|
||||
if (notify) {
|
||||
toast({
|
||||
variant: "success",
|
||||
message: t("aiObservability.playground.sampleApplied", {
|
||||
index: sample.index + 1,
|
||||
total: sample.total,
|
||||
token: "{{input}}",
|
||||
}),
|
||||
});
|
||||
// The toast fades; the flash on the two fields the values actually landed
|
||||
// in — Variables and Expected Output — is what still says so a moment
|
||||
// later, without the user having to already know to look there. flash(),
|
||||
// not focus() — a sample can land while the cursor is anywhere else on
|
||||
// the page, and it must stay there.
|
||||
variableBarRef.value?.flash();
|
||||
expectedBarRef.value?.flash();
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk to a neighbouring dataset item without leaving the bench — this is what
|
||||
@@ -803,7 +910,7 @@ async function stepSample(delta: number) {
|
||||
});
|
||||
const item = page.items[0];
|
||||
if (!item) return;
|
||||
applySample({ ...sample, itemId: item.id, index, total: page.total }, item);
|
||||
applySample({ ...sample, itemId: item.id, index, total: page.total }, item, false);
|
||||
} catch {
|
||||
toast({ variant: "error", message: t("aiObservability.playground.sampleLoadError") });
|
||||
} finally {
|
||||
@@ -822,21 +929,52 @@ async function copyOutput(variantId: string, rowKey: string) {
|
||||
toast({ variant: "success", message: t("aiObservability.playground.copied") });
|
||||
}
|
||||
|
||||
/** Continue the conversation: the answer becomes context, and an empty user
|
||||
* turn is added so there is somewhere to type the follow-up. */
|
||||
/** Continue with text, or open the result field for a returned tool call. */
|
||||
function addOutputToMessages(variantId: string) {
|
||||
const cell = cellAt(results, variantId, SINGLE_ROW_KEY);
|
||||
if (!cell || cell.status !== "done" || !cell.text) return;
|
||||
if (!cell || cell.status !== "done" || (!cell.text && !cell.toolCall)) return;
|
||||
const variant = draft.variants.find((candidate) => candidate.id === variantId);
|
||||
if (!variant) return;
|
||||
|
||||
variant.messages = [
|
||||
...variant.messages,
|
||||
{ id: playgroundId("msg"), role: "assistant", content: cell.text },
|
||||
{ id: playgroundId("msg"), role: "user", content: "" },
|
||||
];
|
||||
setCell(variantId, SINGLE_ROW_KEY, { status: "idle", text: "", usage: null });
|
||||
toast({ variant: "info", message: t("aiObservability.playground.addedToMessages") });
|
||||
if (cell.toolCall) {
|
||||
variant.messages = [
|
||||
...variant.messages,
|
||||
{
|
||||
id: playgroundId("msg"),
|
||||
role: "tool",
|
||||
content: "",
|
||||
toolName: cell.toolCall.name,
|
||||
toolCallId: cell.toolCall.id,
|
||||
toolArguments: cell.toolCall.arguments,
|
||||
...(cell.reasoningContent != null ? { reasoningContent: cell.reasoningContent } : {}),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
variant.messages = [
|
||||
...variant.messages,
|
||||
{
|
||||
id: playgroundId("msg"),
|
||||
role: "assistant",
|
||||
content: cell.text,
|
||||
...(cell.reasoningContent != null ? { reasoningContent: cell.reasoningContent } : {}),
|
||||
},
|
||||
{ id: playgroundId("msg"), role: "user", content: "" },
|
||||
];
|
||||
}
|
||||
setCell(variantId, SINGLE_ROW_KEY, {
|
||||
status: "idle",
|
||||
text: "",
|
||||
toolCall: null,
|
||||
usage: null,
|
||||
});
|
||||
toast({
|
||||
variant: "info",
|
||||
message: t(
|
||||
cell.toolCall
|
||||
? "aiObservability.playground.toolCallAddedToMessages"
|
||||
: "aiObservability.playground.addedToMessages",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// ── sharing ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
*/
|
||||
|
||||
import type { I18nText } from "@/types/i18n";
|
||||
import type { PlaygroundScoreResult } from "@/services/llm-playground.service";
|
||||
import type {
|
||||
PlaygroundRequestMessage,
|
||||
PlaygroundScoreResult,
|
||||
} from "@/services/llm-playground.service";
|
||||
|
||||
/** Golden answers feed scorers only. Binding one into a task prompt would leak
|
||||
* the answer into the question, so this token is never substituted. */
|
||||
@@ -30,12 +33,14 @@ export interface PlaygroundMessage {
|
||||
content: string;
|
||||
/** Carried in from a source trace — removable, never editable. */
|
||||
readonly?: boolean;
|
||||
/**
|
||||
* `tool` role only: which of the variant's tools this response answers.
|
||||
* Draft-side only — the run request carries `role` + `content` and nothing
|
||||
* else, so this labels the message for the author, not for the provider.
|
||||
*/
|
||||
/** `tool` role only: the function this result answers. */
|
||||
toolName?: string;
|
||||
/** `tool` role only: stable identity shared by the call and its result. */
|
||||
toolCallId?: string;
|
||||
/** `tool` role only: JSON arguments passed into the function. */
|
||||
toolArguments?: string;
|
||||
/** Assistant reasoning replayed verbatim for DeepSeek tool-enabled turns. */
|
||||
reasoningContent?: string;
|
||||
}
|
||||
|
||||
export interface PlaygroundTool {
|
||||
@@ -68,7 +73,7 @@ export interface PlaygroundSample {
|
||||
}
|
||||
|
||||
export interface PlaygroundProvenance {
|
||||
type: "experiment" | "dataset";
|
||||
type: "experiment" | "dataset" | "trace";
|
||||
label: I18nText;
|
||||
}
|
||||
|
||||
@@ -95,6 +100,7 @@ export interface PlaygroundUsage {
|
||||
}
|
||||
|
||||
export interface PlaygroundToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
@@ -111,6 +117,7 @@ export interface PlaygroundCellError {
|
||||
export interface PlaygroundCell {
|
||||
status: "idle" | "streaming" | "done" | "error";
|
||||
text: string;
|
||||
reasoningContent: string | null;
|
||||
toolCall: PlaygroundToolCall | null;
|
||||
usage: PlaygroundUsage | null;
|
||||
error: PlaygroundCellError | null;
|
||||
@@ -282,7 +289,14 @@ export function withFieldInserted(variant: PlaygroundVariant, field: string): Pl
|
||||
// ── run keys and cells ────────────────────────────────────────────
|
||||
|
||||
export function idleCell(): PlaygroundCell {
|
||||
return { status: "idle", text: "", toolCall: null, usage: null, error: null };
|
||||
return {
|
||||
status: "idle",
|
||||
text: "",
|
||||
reasoningContent: null,
|
||||
toolCall: null,
|
||||
usage: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function cellAt(
|
||||
@@ -305,17 +319,75 @@ export function renderedMessages(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider conversation for one variant.
|
||||
*
|
||||
* A Tool row is a compact authoring form for the protocol's two-message
|
||||
* exchange: the assistant call followed by the tool result that answers it.
|
||||
*/
|
||||
export function requestMessages(
|
||||
variant: PlaygroundVariant,
|
||||
vars: Record<string, string>,
|
||||
): PlaygroundRequestMessage[] {
|
||||
const messages: PlaygroundRequestMessage[] = [];
|
||||
for (const message of renderedMessages(variant, vars)) {
|
||||
if (message.role !== "tool") {
|
||||
if (message.content.trim()) {
|
||||
messages.push({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
...(message.role === "assistant" && message.reasoningContent !== undefined
|
||||
? { reasoningContent: message.reasoningContent }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = message.toolName?.trim();
|
||||
if (!name || !message.content.trim()) continue;
|
||||
const id = message.toolCallId || toolCallId(message.id);
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
...(message.reasoningContent !== undefined
|
||||
? { reasoningContent: message.reasoningContent }
|
||||
: {}),
|
||||
toolCalls: [
|
||||
{
|
||||
id,
|
||||
name,
|
||||
arguments: message.toolArguments?.trim() || "{}",
|
||||
},
|
||||
],
|
||||
});
|
||||
messages.push({ role: "tool", content: message.content, toolCallId: id });
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** Stable across edits, snapshots and retries so a result never changes call. */
|
||||
export function toolCallId(messageId: string): string {
|
||||
return `call_${messageId.replace(/[^A-Za-z0-9_-]/g, "_")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retype one message.
|
||||
*
|
||||
* `toolName` is dropped on the way out of `tool`: it labels which tool a
|
||||
* response answers, so on any other role it is invisible state that would come
|
||||
* back the moment the row was switched to `tool` again.
|
||||
* Tool protocol fields are dropped on the way out of `tool`; otherwise they
|
||||
* would return as stale invisible state when the row is switched back.
|
||||
*/
|
||||
export function withRole(message: PlaygroundMessage, role: PlaygroundRole): PlaygroundMessage {
|
||||
if (role === "tool") return { ...message, role };
|
||||
const { toolName: _dropped, ...rest } = message;
|
||||
return { ...rest, role };
|
||||
const {
|
||||
toolName: _toolName,
|
||||
toolCallId: _toolCallId,
|
||||
toolArguments: _toolArguments,
|
||||
...rest
|
||||
} = message;
|
||||
if (role === "assistant") return { ...rest, role };
|
||||
const { reasoningContent: _reasoningContent, ...withoutReasoning } = rest;
|
||||
return { ...withoutReasoning, role };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { handoffFromSpan, stashHandoff, takeHandoff } from "./playgroundHandoff";
|
||||
|
||||
describe("handoffFromSpan", () => {
|
||||
it("carries the input turns and the answer as ordinary editable messages", () => {
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "span-1",
|
||||
gen_ai_request_model: "gpt-4o-mini",
|
||||
gen_ai_input_messages: [
|
||||
{ role: "system", content: "Be brief." },
|
||||
{ role: "user", content: "Why is the sky blue?" },
|
||||
],
|
||||
gen_ai_output_messages: { role: "assistant", content: "Rayleigh scattering." },
|
||||
llm_request_parameters: { temperature: 0.2 },
|
||||
});
|
||||
|
||||
expect(handoff?.messages.map((m) => [m.role, m.content])).toEqual([
|
||||
["system", "Be brief."],
|
||||
["user", "Why is the sky blue?"],
|
||||
["assistant", "Rayleigh scattering."],
|
||||
]);
|
||||
// Nothing is pinned: the entry exists so the call can be CHANGED and re-run.
|
||||
expect(handoff?.messages.every((m) => !m.readonly)).toBe(true);
|
||||
expect(handoff?.temperature).toBe("0.2");
|
||||
});
|
||||
|
||||
it("leaves the provider and model to the bench's own default", () => {
|
||||
// A trace records whatever served it in production. Carrying that model over
|
||||
// points the bench at a provider the user may not have configured, and the
|
||||
// failure would only surface at Run.
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "s",
|
||||
gen_ai_request_model: "gpt-4o-mini",
|
||||
gen_ai_input_messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(handoff).not.toHaveProperty("model");
|
||||
});
|
||||
|
||||
it("parses messages that arrive as a JSON string", () => {
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "span-2",
|
||||
gen_ai_input_messages: JSON.stringify([{ role: "user", content: "hi" }]),
|
||||
});
|
||||
expect(handoff?.messages.map((m) => m.role)).toEqual(["system", "user"]);
|
||||
expect(handoff?.messages[1].content).toBe("hi");
|
||||
});
|
||||
|
||||
it("keeps a bare prompt string as a message instead of dropping it", () => {
|
||||
const handoff = handoffFromSpan({ span_id: "s", gen_ai_input_messages: "just text" });
|
||||
expect(handoff?.messages).toEqual([
|
||||
expect.objectContaining({ role: "system", content: "" }),
|
||||
expect.objectContaining({ role: "user", content: "just text" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("flattens multimodal content to the text the bench can edit", () => {
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "s",
|
||||
gen_ai_input_messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "part one" },
|
||||
{ type: "text", text: "part two" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(handoff?.messages[1].content).toBe("part one\n\npart two");
|
||||
});
|
||||
|
||||
it("maps Gemini's `model` role onto assistant", () => {
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "s",
|
||||
gen_ai_input_messages: [{ role: "user", content: "q" }],
|
||||
gen_ai_output_messages: [{ role: "model", content: "a" }],
|
||||
});
|
||||
expect(handoff?.messages[2].role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("falls back to the evaluator attributes a non-GenAI span uses", () => {
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "s",
|
||||
attributes_prompt: "graded prompt",
|
||||
attributes_response: "graded answer",
|
||||
});
|
||||
expect(handoff?.messages.map((m) => m.role)).toEqual(["system", "user", "assistant"]);
|
||||
});
|
||||
|
||||
it("always yields a system row, empty when the span had none", () => {
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "s",
|
||||
gen_ai_input_messages: [{ role: "user", content: "Hello how are you" }],
|
||||
});
|
||||
|
||||
// The bench needs somewhere to type a system prompt even when the trace had
|
||||
// none — no row at all reads as the import having lost it.
|
||||
expect(handoff?.messages[0]).toMatchObject({ role: "system", content: "" });
|
||||
expect(handoff?.messages.filter((m) => m.role === "system")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("imports system instructions from their own span field", () => {
|
||||
// Providers that keep the system turn out of the messages array put it
|
||||
// here; reading only gen_ai_input_messages drops the prompt that shaped
|
||||
// the answer.
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "s",
|
||||
gen_ai_system_instructions: [
|
||||
{ type: "text", content: "You are an SRE assistant." },
|
||||
{ type: "text", content: "Be concise." },
|
||||
],
|
||||
gen_ai_input_messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
|
||||
expect(handoff?.messages[0]).toMatchObject({
|
||||
role: "system",
|
||||
content: "You are an SRE assistant.\n\nBe concise.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the messages array's own system turn over the separate field", () => {
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "s",
|
||||
gen_ai_system_instructions: "from the field",
|
||||
gen_ai_input_messages: [
|
||||
{ role: "system", content: "from the messages" },
|
||||
{ role: "user", content: "hi" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(handoff?.messages[0].content).toBe("from the messages");
|
||||
expect(handoff?.messages.filter((m) => m.role === "system")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hoists a system turn to the top even when the span lists it later", () => {
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "s",
|
||||
gen_ai_input_messages: [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "system", content: "rules" },
|
||||
],
|
||||
});
|
||||
expect(handoff?.messages.map((m) => m.role)).toEqual(["system", "user"]);
|
||||
});
|
||||
|
||||
it("returns null when the span carries no conversation", () => {
|
||||
expect(handoffFromSpan({ span_id: "s", gen_ai_request_model: "gpt-4o" })).toBeNull();
|
||||
expect(handoffFromSpan(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stash / take", () => {
|
||||
beforeEach(() => sessionStorage.clear());
|
||||
|
||||
it("round-trips a hand-off", () => {
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "s",
|
||||
gen_ai_input_messages: [{ role: "user", content: "hello" }],
|
||||
})!;
|
||||
expect(stashHandoff(handoff)).toBe(true);
|
||||
expect(takeHandoff()?.messages[1].content).toBe("hello");
|
||||
});
|
||||
|
||||
it("consumes the hand-off so a refresh cannot re-import over later edits", () => {
|
||||
stashHandoff(
|
||||
handoffFromSpan({ span_id: "s", gen_ai_input_messages: [{ role: "user", content: "x" }] })!,
|
||||
);
|
||||
expect(takeHandoff()).not.toBeNull();
|
||||
expect(takeHandoff()).toBeNull();
|
||||
});
|
||||
|
||||
it("regenerates message ids so they cannot collide with a restored session", () => {
|
||||
const handoff = handoffFromSpan({
|
||||
span_id: "s",
|
||||
gen_ai_input_messages: [{ role: "user", content: "x" }],
|
||||
})!;
|
||||
const originalId = handoff.messages[0].id;
|
||||
stashHandoff(handoff);
|
||||
expect(takeHandoff()?.messages[0].id).not.toBe(originalId);
|
||||
});
|
||||
|
||||
it("reports nothing rather than throwing on unreadable storage", () => {
|
||||
sessionStorage.setItem("o2-playground-handoff", "{not json");
|
||||
expect(takeHandoff()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
// Carries one LLM span's conversation from Trace Details into the Playground.
|
||||
//
|
||||
// The messages travel through sessionStorage rather than the URL: a real prompt
|
||||
// is far past any safe query-string length, and encoding it would put customer
|
||||
// data in browser history and server logs. A snapshot record would work too, but
|
||||
// it persists a row for what is a transient hand-off.
|
||||
//
|
||||
// Everything imported is ORDINARY, editable draft content. Nothing is marked
|
||||
// `readonly` — the point of the button is to change the call and re-run it.
|
||||
|
||||
import { playgroundId, type PlaygroundMessage, type PlaygroundRole } from "./playgroundDraft";
|
||||
|
||||
/** One-shot: read by the Playground on entry, then dropped. */
|
||||
const HANDOFF_KEY = "o2-playground-handoff";
|
||||
|
||||
const ROLES: PlaygroundRole[] = ["system", "user", "assistant", "tool"];
|
||||
|
||||
export interface PlaygroundHandoff {
|
||||
messages: PlaygroundMessage[];
|
||||
temperature: string;
|
||||
/** The span this came from, for the provenance chip. */
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
// Deliberately NOT carried over: the span's provider and model. A trace records
|
||||
// whatever served it in production, which the user may have no credentials for
|
||||
// here — a model string pointing at an unconfigured provider fails at Run with
|
||||
// nothing on screen explaining why. The bench seeds its own default provider
|
||||
// instead, and the conversation is the part worth importing.
|
||||
|
||||
/** Gemini answers as `model`; everything else already matches our four roles. */
|
||||
function toRole(value: unknown, fallback: PlaygroundRole): PlaygroundRole {
|
||||
const role = String(value ?? "").toLowerCase();
|
||||
if (role === "model") return "assistant";
|
||||
return ROLES.includes(role as PlaygroundRole) ? (role as PlaygroundRole) : fallback;
|
||||
}
|
||||
|
||||
/** Multimodal content is a parts array; the bench edits text, so the text parts
|
||||
* are joined and anything else is kept as JSON rather than dropped. */
|
||||
function toContent(content: unknown): string {
|
||||
if (content === null || content === undefined) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content.map((part: any) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (part && typeof part === "object") {
|
||||
// Chat parts key the text as `text`; system-instruction parts use
|
||||
// `content`. Both shapes come off the same spans.
|
||||
if (typeof part.text === "string") return part.text;
|
||||
if (typeof part.content === "string") return part.content;
|
||||
}
|
||||
return JSON.stringify(part);
|
||||
});
|
||||
return parts.filter(Boolean).join("\n\n");
|
||||
}
|
||||
return JSON.stringify(content);
|
||||
}
|
||||
|
||||
/** A span field may arrive as an array, a single message, or a JSON string of
|
||||
* either — the same three shapes the trace preview already handles. */
|
||||
function parseMessages(value: unknown, fallbackRole: PlaygroundRole): PlaygroundMessage[] {
|
||||
if (value === null || value === undefined || value === "") return [];
|
||||
|
||||
let parsed: unknown = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
// Not JSON: a bare prompt string is still a message.
|
||||
return [{ id: playgroundId("msg"), role: fallbackRole, content: value }];
|
||||
}
|
||||
}
|
||||
|
||||
const list = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return list
|
||||
.map((entry: any) => {
|
||||
if (entry === null || entry === undefined) return null;
|
||||
if (typeof entry === "string") {
|
||||
return { id: playgroundId("msg"), role: fallbackRole, content: entry };
|
||||
}
|
||||
const content = toContent(entry.content ?? entry.parts ?? entry.text);
|
||||
if (!content) return null;
|
||||
return { id: playgroundId("msg"), role: toRole(entry.role, fallbackRole), content };
|
||||
})
|
||||
.filter((message): message is PlaygroundMessage => message !== null);
|
||||
}
|
||||
|
||||
function parseSystemInstructions(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
let parsed: unknown = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (typeof parsed === "string") return parsed;
|
||||
return toContent(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly one system row, always first.
|
||||
*
|
||||
* Present on the span or not, the bench needs the field: a conversation with no
|
||||
* system turn still needs somewhere to type one, and no row at all reads as the
|
||||
* import having lost it. Empty is the input; filled is the prompt that produced
|
||||
* the trace.
|
||||
*/
|
||||
function withSystemRow(messages: PlaygroundMessage[], instructions: string): PlaygroundMessage[] {
|
||||
const existing = messages.find((message) => message.role === "system");
|
||||
const rest = messages.filter((message) => message.role !== "system");
|
||||
// A system turn inside the messages array wins: the separate field is what
|
||||
// providers use INSTEAD of one, never as well as one.
|
||||
const content = existing?.content || instructions;
|
||||
return [{ id: playgroundId("msg"), role: "system", content }, ...rest];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a hand-off from a trace span. Returns null when the span carries no
|
||||
* conversation — the button is hidden in that case rather than opening an empty
|
||||
* bench.
|
||||
*/
|
||||
export function handoffFromSpan(span: any): PlaygroundHandoff | null {
|
||||
if (!span) return null;
|
||||
|
||||
const input = parseMessages(span.gen_ai_input_messages ?? span.attributes_prompt, "user");
|
||||
const output = parseMessages(
|
||||
span.gen_ai_output_messages ?? span.attributes_response,
|
||||
"assistant",
|
||||
);
|
||||
const instructions = parseSystemInstructions(span.gen_ai_system_instructions);
|
||||
if (!input.length && !output.length && !instructions) return null;
|
||||
|
||||
const messages = withSystemRow([...input, ...output], instructions);
|
||||
|
||||
const temperature = span.llm_request_parameters?.temperature;
|
||||
|
||||
return {
|
||||
messages,
|
||||
temperature: temperature === null || temperature === undefined ? "" : String(temperature),
|
||||
sourceId: String(span.span_id ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** False when storage is unavailable or full, so the caller can decline to
|
||||
* navigate rather than landing the user on an empty bench. */
|
||||
export function stashHandoff(handoff: PlaygroundHandoff): boolean {
|
||||
try {
|
||||
sessionStorage.setItem(HANDOFF_KEY, JSON.stringify(handoff));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads and CONSUMES the hand-off: a refresh must not silently re-import over
|
||||
* edits the user has since made to the bench. */
|
||||
export function takeHandoff(): PlaygroundHandoff | null {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(HANDOFF_KEY);
|
||||
if (!stored) return null;
|
||||
sessionStorage.removeItem(HANDOFF_KEY);
|
||||
const parsed = JSON.parse(stored) as PlaygroundHandoff;
|
||||
// Ids are regenerated: a stale id colliding with a restored session's
|
||||
// message would make two rows share a key.
|
||||
parsed.messages = (parsed.messages ?? []).map((message) => ({
|
||||
...message,
|
||||
id: playgroundId("msg"),
|
||||
}));
|
||||
return parsed.messages.length ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export interface AiPlaygroundRouteOptions {
|
||||
datasetId?: string;
|
||||
/** Clones this run's task config into the first variant. */
|
||||
experimentId?: string;
|
||||
/** Loads the conversation stashed by Trace Details (see playgroundHandoff). */
|
||||
fromSpan?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,6 +25,7 @@ export function aiPlaygroundRoute(
|
||||
org_identifier: orgIdentifier,
|
||||
...(options.datasetId ? { dataset: options.datasetId } : {}),
|
||||
...(options.experimentId ? { experiment: options.experimentId } : {}),
|
||||
...(options.fromSpan ? { from: "span" } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ export type ButtonVariant =
|
||||
| "ghost-warning"
|
||||
| "ghost-neutral"
|
||||
| "outline-destructive"
|
||||
// Solid "stop this running operation" red — distinct from destructive
|
||||
// (delete/danger): a query/task actively in flight that a click will abort
|
||||
| "cancel-query"
|
||||
| "sidebar-toggle"
|
||||
// Dedicated variant for sidebar panel collapse/expand icon button
|
||||
| "panel-collapse"
|
||||
|
||||
@@ -125,6 +125,16 @@ const variantClasses: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
||||
"focus-visible:ring-3 focus-visible:ring-button-ghost-destructive-focus-ring",
|
||||
"disabled:opacity-50",
|
||||
].join(" "),
|
||||
// Stop a running operation (query/task in flight) — distinct from
|
||||
// destructive (delete/danger). No dedicated hover/active token yet, so
|
||||
// states are opacity-derived from the one cancel-query-bg color.
|
||||
"cancel-query": [
|
||||
"bg-cancel-query-bg text-button-primary-foreground border-0",
|
||||
"enabled:hover:opacity-90",
|
||||
"enabled:active:opacity-80",
|
||||
"focus-visible:ring-3 focus-visible:ring-cancel-query-bg",
|
||||
"disabled:opacity-60",
|
||||
].join(" "),
|
||||
// Panel collapse: ghost muted — for sidebar panel header collapse/expand.
|
||||
// Transparent bg, muted icon color, subtle hover. Blends into header without visual weight.
|
||||
"panel-collapse": [
|
||||
|
||||
@@ -125,9 +125,8 @@ const DOMAIN_TONE: Record<string, Record<string, StatusTone>> = {
|
||||
// Services / nodes catalog: "warning" sits between degraded and critical.
|
||||
service: { warning: "warning", degraded: "warning", critical: "error" },
|
||||
node: { warning: "warning" },
|
||||
// Eval jobs: "archived" is a deliberate end-state, keep neutral; "degraded"
|
||||
// means the job is firing but unhealthy → warning.
|
||||
eval: { archived: "neutral", degraded: "warning" },
|
||||
// Eval: "archived" is a deliberate end-state, "degraded" fires but unhealthy, "scoring" is in-flight.
|
||||
eval: { archived: "neutral", degraded: "warning", scoring: "info" },
|
||||
};
|
||||
|
||||
export interface StatusVariantResult {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import OBanner from "./OBanner.vue";
|
||||
|
||||
const stubs = { OIcon: true };
|
||||
|
||||
describe("OBanner — preserveWhitespace", () => {
|
||||
// Off by default: every existing caller of OBanner keeps its exact current
|
||||
// layout unless it opts in.
|
||||
it("does not force wrapping by default", () => {
|
||||
const wrapper = mount(OBanner, {
|
||||
props: { variant: "error" },
|
||||
slots: { default: "a very long unbroken run of text" },
|
||||
global: { stubs },
|
||||
});
|
||||
const row = wrapper.get(":scope > div:first-child");
|
||||
const content = row.get(":scope > div:last-child");
|
||||
expect(content.classes()).not.toContain("whitespace-pre-wrap");
|
||||
expect(content.classes()).not.toContain("min-w-0");
|
||||
expect(row.classes()).not.toContain("min-w-0");
|
||||
});
|
||||
|
||||
// The actual fix: preserve newlines/spaces, allow a long unbroken token to
|
||||
// wrap, and let the flex row shrink far enough for that wrapping to ever
|
||||
// kick in (min-w-0) — without min-w-0 the wrap classes alone do nothing.
|
||||
it("wraps content and lets the row shrink when set", () => {
|
||||
const wrapper = mount(OBanner, {
|
||||
props: { variant: "error", preserveWhitespace: true },
|
||||
slots: { default: "a very long unbroken run of text" },
|
||||
global: { stubs },
|
||||
});
|
||||
const row = wrapper.get(":scope > div:first-child");
|
||||
const content = row.get(":scope > div:last-child");
|
||||
expect(row.classes()).toContain("min-w-0");
|
||||
expect(content.classes()).toContain("min-w-0");
|
||||
expect(content.classes()).toContain("whitespace-pre-wrap");
|
||||
expect(content.classes()).toContain("wrap-break-word");
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,14 @@ interface Props {
|
||||
*/
|
||||
role?: "status" | "alert" | "banner";
|
||||
dataTest?: string;
|
||||
/**
|
||||
* Wrap the content instead of overflowing — preserves newlines/spaces and
|
||||
* lets a long unbroken run (a raw error message, JSON) break. Off by
|
||||
* default: every existing caller keeps its current layout unless it opts
|
||||
* in, since forcing this everywhere would change banners that intentionally
|
||||
* stay on one line.
|
||||
*/
|
||||
preserveWhitespace?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -38,6 +46,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
inlineActions: false,
|
||||
bar: false,
|
||||
center: false,
|
||||
preserveWhitespace: false,
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
@@ -119,7 +128,13 @@ const barVariantClass = computed(() => {
|
||||
<div
|
||||
:class="[
|
||||
'flex flex-row gap-3',
|
||||
bar ? (center ? 'min-w-0 items-center' : 'min-w-0 flex-1 items-center') : 'items-start',
|
||||
bar
|
||||
? center
|
||||
? 'min-w-0 items-center'
|
||||
: 'min-w-0 flex-1 items-center'
|
||||
: preserveWhitespace
|
||||
? 'min-w-0 items-start'
|
||||
: 'items-start',
|
||||
inlineActions && !bar ? 'flex-1' : '',
|
||||
]"
|
||||
>
|
||||
@@ -129,7 +144,12 @@ const barVariantClass = computed(() => {
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div :class="bar ? 'text-compact' : 'flex-1 text-sm'">
|
||||
<div
|
||||
:class="[
|
||||
bar ? 'text-compact' : 'flex-1 text-sm',
|
||||
preserveWhitespace ? 'min-w-0 wrap-break-word whitespace-pre-wrap' : '',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
<template v-if="showContentProp">{{ content }}</template>
|
||||
</div>
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "Element",
|
||||
"versions": "Versionen"
|
||||
},
|
||||
"versionPill": "v{version} · {count} Version | v{version} · {count} Versionen",
|
||||
"versionPill": "{count} Version | {count} Versionen",
|
||||
"sourceSection": "Quelle",
|
||||
"sourceTrace": "Trace",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "Ausgabe auf ein JSON-Schema beschränken",
|
||||
"schemaHelp": "Die Ausgabe wird im JSON-Format dargestellt, wenn ein Schema festgelegt ist.",
|
||||
"schemaInvalidJson": "Das Schema muss gültiges JSON sein.",
|
||||
"schemaIgnored": "Schema · Von diesem Anbieter ignoriert",
|
||||
"schemaUnsupported": "Dieser Anbieter hat kein Feld für strukturierte Ausgabe. Das Schema bleibt in dieser Spalte erhalten, wird aber nicht gesendet, und das Modell antwortet mit reinem Text.",
|
||||
"outputPlaceholder": "Die Ausgabe erscheint hier — drücken Sie „Ausführen“.",
|
||||
"outputNotRun": "Nicht ausgeführt",
|
||||
"retry": "Wiederholen",
|
||||
|
||||
@@ -730,7 +730,7 @@
|
||||
},
|
||||
"copied": "Copied",
|
||||
"copiedExclaim": "Copied!",
|
||||
"clearAll": "Clear all",
|
||||
"clearAll": "Clear All",
|
||||
"unknown": "Unknown",
|
||||
"totalFields": "Total Fields: {count}",
|
||||
"providerLogo": "{name} logo",
|
||||
@@ -10624,6 +10624,10 @@
|
||||
"success": "Added to the dataset",
|
||||
"loadError": "Failed to load datasets",
|
||||
"error": "Failed to add this item to the dataset"
|
||||
},
|
||||
"playground": {
|
||||
"button": "Open in Playground",
|
||||
"hint": "Load this call's messages into the Playground to edit and re-run it"
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
@@ -10991,7 +10995,10 @@
|
||||
"columns": {
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"latency": "Latency"
|
||||
"trials": "Trials",
|
||||
"dispersion": "Dispersion",
|
||||
"latency": "Latency",
|
||||
"p50Latency": "P50 Latency"
|
||||
},
|
||||
"retryFailed": "Retry Failed Slots",
|
||||
"loadError": "Failed to load the experiment",
|
||||
@@ -11029,8 +11036,12 @@
|
||||
},
|
||||
"slotStatus": "Status",
|
||||
"searchPlaceholder": "Search input or output",
|
||||
"searchInputPlaceholder": "Search input",
|
||||
"statusFilter": "Status",
|
||||
"statusFilterAll": "All Statuses"
|
||||
"statusFilterAll": "All Statuses",
|
||||
"sortByDispersion": "Sort by dispersion",
|
||||
"highDispersionBadge": "High",
|
||||
"trialNumber": "Trial {n}"
|
||||
},
|
||||
"rowDetail": {
|
||||
"fallbackTitle": "Experiment Row",
|
||||
@@ -11121,10 +11132,13 @@
|
||||
"trial": "Trial {index}",
|
||||
"noContent": "Not provided",
|
||||
"noOutput": "No output",
|
||||
"absentSide": "Not present in this run"
|
||||
"absentSide": "Not present in this run",
|
||||
"enterFullscreen": "Enter Fullscreen",
|
||||
"exitFullscreen": "Exit Fullscreen"
|
||||
}
|
||||
},
|
||||
"selectionCount": "{count} of 2 selected"
|
||||
"selectionCount": "{count} of 2 selected",
|
||||
"clearBaseline": "Clear baseline"
|
||||
},
|
||||
"datasets": {
|
||||
"listTitle": "Datasets",
|
||||
@@ -11193,7 +11207,7 @@
|
||||
"expectedOutput": "Expected Output",
|
||||
"source": "Source",
|
||||
"tags": "Tags",
|
||||
"version": "Version",
|
||||
"version": "Dataset Version",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"deleteItemTitle": "Delete Item",
|
||||
@@ -11207,7 +11221,7 @@
|
||||
"item": "Item",
|
||||
"versions": "Versions"
|
||||
},
|
||||
"versionPill": "v{version} · {count} Version | v{version} · {count} Versions",
|
||||
"versionPill": "{count} Version | {count} Versions",
|
||||
"sourceSection": "Source",
|
||||
"sourceTrace": "Trace",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11672,8 +11686,6 @@
|
||||
"title": "Playground",
|
||||
"runAll": "Run All",
|
||||
"runAllTooltip": "Run every variant",
|
||||
"running": "Running… ({done}/{total})",
|
||||
"stop": "Stop",
|
||||
"recentDrafts": "Recent Drafts",
|
||||
"recentDraftsSection": "This browser · last 10",
|
||||
"draftVersus": "vs",
|
||||
@@ -11682,6 +11694,7 @@
|
||||
"fromExperiment": "Cloned from experiment {name}",
|
||||
"variables": "Variables",
|
||||
"noVariables": "No {token} in the messages — add one to parameterize the input.",
|
||||
"unusedSampledValue": "Not yet referenced in a message: {tokens}. Type it into a message to use it.",
|
||||
"variablePlaceholder": "Value for {name}",
|
||||
"addExpected": "+ Expected Output",
|
||||
"expectedLabel": "Expected",
|
||||
@@ -11696,8 +11709,9 @@
|
||||
"variant": "Variant {label}",
|
||||
"variantLimit": "The bench holds at most {max} variants.",
|
||||
"duplicateVariant": "Duplicate Variant",
|
||||
"resetVariant": "Reset Bench",
|
||||
"removeVariant": "Remove Variant",
|
||||
"runVariant": "Run This Variant",
|
||||
"runVariant": "Run",
|
||||
"createExperiment": "Create Experiment",
|
||||
"modelPlaceholder": "Select or type a model",
|
||||
"providerUnknown": "Unknown Provider",
|
||||
@@ -11723,6 +11737,13 @@
|
||||
"toolsEmpty": "No tools defined.",
|
||||
"toolCall": "Tool Call",
|
||||
"toolCallNote": "Run stops here — the Playground does not execute tools.",
|
||||
"toolCallAddToMessages": "Add to messages",
|
||||
"toolCallContinueTooltip": "Adds this call to the conversation and opens its result field.",
|
||||
"toolCallAddedToMessages": "Tool call added — enter the tool result and re-run.",
|
||||
"toolArgumentsPlaceholder": "Tool arguments (JSON)",
|
||||
"toolResultPlaceholder": "Enter the tool result…",
|
||||
"toolResultRequired": "Enter the tool result above to continue.",
|
||||
"toolSetupRequired": "Select a tool and provide its arguments to continue.",
|
||||
"toolInvalidJson": "Parameters must be valid JSON.",
|
||||
"schema": "Schema",
|
||||
"schemaOn": "Schema · On",
|
||||
@@ -11730,6 +11751,8 @@
|
||||
"schemaToggle": "Constrain output to a JSON schema",
|
||||
"schemaHelp": "Output renders with JSON formatting when a schema is set.",
|
||||
"schemaInvalidJson": "Schema must be valid JSON.",
|
||||
"schemaIgnored": "Schema · Ignored by this provider",
|
||||
"schemaUnsupported": "This provider has no structured-output field. The schema is kept on this column, but it is not sent and the model answers with plain text.",
|
||||
"outputPlaceholder": "Output appears here — press Run.",
|
||||
"outputNotRun": "Not run",
|
||||
"retry": "Retry",
|
||||
@@ -11747,6 +11770,7 @@
|
||||
"sampleDatasetPlaceholder": "Select a dataset",
|
||||
"sampleSubmit": "Sample",
|
||||
"sampleLoadError": "Could not load rows from that dataset.",
|
||||
"sampleApplied": "Loaded sample {index} of {total} — reference it with {token} in a message.",
|
||||
"sampleEmpty": "This dataset has no items yet.",
|
||||
"noProviderTitle": "The Playground needs an LLM provider",
|
||||
"noProviderBody": "Connect a provider to run prompts. It takes about a minute.",
|
||||
@@ -11759,6 +11783,8 @@
|
||||
"reset": "Reset",
|
||||
"resetTitle": "Reset Playground",
|
||||
"resetMessage": "Clear every variant, row, and output and start again? This cannot be undone.",
|
||||
"resetVariantTitle": "Reset Bench",
|
||||
"resetVariantMessage": "Clear this bench's prompt and output? This can't be undone.",
|
||||
"scrollLeft": "Scroll Left",
|
||||
"scrollRight": "Scroll Right",
|
||||
"addMessage": "Message",
|
||||
@@ -11774,6 +11800,7 @@
|
||||
"variantSettings": "Model Settings",
|
||||
"messageToolPlaceholder": "Select Tool",
|
||||
"variablesCount": "Variables · {count}",
|
||||
"newValueTag": "New",
|
||||
"variableUsed": "Referenced by this variant",
|
||||
"variableUnused": "Not referenced by this variant — renders empty here",
|
||||
"addVariable": "Add Variable",
|
||||
@@ -11822,7 +11849,9 @@
|
||||
"sampleFullView": "Full View",
|
||||
"sampleBack": "Back to List",
|
||||
"sampleInput": "Input",
|
||||
"sampleExpected": "Expected Output"
|
||||
"sampleExpected": "Expected Output",
|
||||
"fromTrace": "From trace span {id}",
|
||||
"openInPlaygroundFailed": "Could not open this span in the Playground"
|
||||
}
|
||||
},
|
||||
"onlineEvals": {
|
||||
@@ -11855,12 +11884,14 @@
|
||||
},
|
||||
"scoreConfigLibrary": {
|
||||
"searchPlaceholder": "Search Score Configs...",
|
||||
"scoreConfigsLabel": "{count} score config | {count} score configs"
|
||||
"scoreConfigsLabel": "{count} score config | {count} score configs",
|
||||
"selectedOfTotal": "{selected} of {total} selected"
|
||||
},
|
||||
"scorerLibrary": {
|
||||
"noProvidersMessage": "Create a Provider first before importing LLM Judge scorers.",
|
||||
"searchPlaceholder": "Search Scorers...",
|
||||
"scorerCountSuffix": "{count} scorer | {count} scorers"
|
||||
"scorerCountSuffix": "{count} scorer | {count} scorers",
|
||||
"selectedOfTotal": "{selected} of {total} selected"
|
||||
},
|
||||
"title": "Evaluations",
|
||||
"loadError": "Failed to load online evals",
|
||||
@@ -11977,7 +12008,8 @@
|
||||
"legendFalseCount": "false ({count})",
|
||||
"kpis": {
|
||||
"average": "Average",
|
||||
"p50p95": "P50 / P95",
|
||||
"p50": "P50",
|
||||
"p95": "P95",
|
||||
"unhealthy": "Unhealthy",
|
||||
"healthy": "Healthy",
|
||||
"trueRate": "True rate",
|
||||
@@ -12086,8 +12118,6 @@
|
||||
"remoteCta": "Configure Endpoint"
|
||||
},
|
||||
"provider": {
|
||||
"sectionStep1": "01",
|
||||
"sectionStep2": "02",
|
||||
"backTo": "Back to Providers",
|
||||
"createTitle": "Create LLM Provider",
|
||||
"editTitle": "Edit LLM Provider",
|
||||
@@ -12096,7 +12126,7 @@
|
||||
"nameLabel": "Name",
|
||||
"typeLabel": "Provider type",
|
||||
"endpointLabel": "Endpoint",
|
||||
"endpointPlaceholder": "Optional custom endpoint",
|
||||
"endpointPlaceholder": "Full completions URL, e.g. https://your-host/v1/chat/completions",
|
||||
"defaultModelLabel": "Default model",
|
||||
"availableModelsLabel": "Available models",
|
||||
"apiKeyLabel": "API Key",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "Elemento",
|
||||
"versions": "Versiones"
|
||||
},
|
||||
"versionPill": "v{version} · {count} Versión | v{version} · {count} Versiones",
|
||||
"versionPill": "{count} Versión | {count} Versiones",
|
||||
"sourceSection": "Origen",
|
||||
"sourceTrace": "Traza",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "Restringir la salida a un esquema JSON",
|
||||
"schemaHelp": "La salida se muestra con formato JSON cuando se define un esquema.",
|
||||
"schemaInvalidJson": "El esquema debe ser JSON válido.",
|
||||
"schemaIgnored": "Esquema · Ignorado por este proveedor",
|
||||
"schemaUnsupported": "Este proveedor no tiene un campo de salida estructurada. El esquema se conserva en esta columna, pero no se envía y el modelo responde con texto plano.",
|
||||
"outputPlaceholder": "La salida aparece aquí — pulse Ejecutar.",
|
||||
"outputNotRun": "No ejecutado",
|
||||
"retry": "Reintentar",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "Élément",
|
||||
"versions": "Versions"
|
||||
},
|
||||
"versionPill": "v{version} · {count} version | v{version} · {count} versions",
|
||||
"versionPill": "{count} version | {count} versions",
|
||||
"sourceSection": "Source",
|
||||
"sourceTrace": "Trace",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "Contraindre la sortie à un schéma JSON",
|
||||
"schemaHelp": "La sortie s'affiche avec un formatage JSON lorsqu'un schéma est défini.",
|
||||
"schemaInvalidJson": "Le schéma doit être un JSON valide.",
|
||||
"schemaIgnored": "Schéma · Ignoré par ce fournisseur",
|
||||
"schemaUnsupported": "Ce fournisseur ne dispose pas de champ de sortie structurée. Le schéma est conservé dans cette colonne, mais il n'est pas envoyé et le modèle répond en texte brut.",
|
||||
"outputPlaceholder": "La sortie apparaît ici — appuyez sur Exécuter.",
|
||||
"outputNotRun": "Non exécuté",
|
||||
"retry": "Réessayer",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "Elemento",
|
||||
"versions": "Versioni"
|
||||
},
|
||||
"versionPill": "v{version} · {count} Versione | v{version} · {count} Versioni",
|
||||
"versionPill": "{count} Versione | {count} Versioni",
|
||||
"sourceSection": "Origine",
|
||||
"sourceTrace": "Traccia",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "Vincola l'output a uno schema JSON",
|
||||
"schemaHelp": "L'output viene visualizzato con formattazione JSON quando è impostato uno schema.",
|
||||
"schemaInvalidJson": "Lo schema deve essere JSON valido.",
|
||||
"schemaIgnored": "Schema · Ignorato da questo provider",
|
||||
"schemaUnsupported": "Questo provider non ha un campo per l'output strutturato. Lo schema viene mantenuto in questa colonna, ma non viene inviato e il modello risponde con testo semplice.",
|
||||
"outputPlaceholder": "L'output appare qui — premi Esegui.",
|
||||
"outputNotRun": "Non eseguito",
|
||||
"retry": "Riprova",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "アイテム",
|
||||
"versions": "バージョン"
|
||||
},
|
||||
"versionPill": "v{version} · {count} バージョン | v{version} · {count} バージョン",
|
||||
"versionPill": "{count} バージョン | {count} バージョン",
|
||||
"sourceSection": "ソース",
|
||||
"sourceTrace": "トレース",
|
||||
"sourceSpan": "スパン",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "出力をJSONスキーマに制約する",
|
||||
"schemaHelp": "スキーマが設定されている場合、出力はJSON形式で表示されます。",
|
||||
"schemaInvalidJson": "スキーマは有効なJSONである必要があります。",
|
||||
"schemaIgnored": "スキーマ · このプロバイダーでは無視されます",
|
||||
"schemaUnsupported": "このプロバイダーには構造化出力のフィールドがありません。スキーマはこの列に保持されますが送信されず、モデルはプレーンテキストで応答します。",
|
||||
"outputPlaceholder": "ここに出力が表示されます — 実行を押してください。",
|
||||
"outputNotRun": "未実行",
|
||||
"retry": "再試行",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "항목",
|
||||
"versions": "버전"
|
||||
},
|
||||
"versionPill": "v{version} · {count} 버전 | v{version} · {count} 버전",
|
||||
"versionPill": "{count} 버전 | {count} 버전",
|
||||
"sourceSection": "소스",
|
||||
"sourceTrace": "트레이스",
|
||||
"sourceSpan": "스팬",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "출력을 JSON 스키마로 제한",
|
||||
"schemaHelp": "스키마가 설정되면 출력이 JSON 형식으로 렌더링됩니다.",
|
||||
"schemaInvalidJson": "스키마는 유효한 JSON이어야 합니다.",
|
||||
"schemaIgnored": "스키마 · 이 공급자가 무시함",
|
||||
"schemaUnsupported": "이 공급자에는 구조화 출력 필드가 없습니다. 스키마는 이 열에 유지되지만 전송되지 않으며 모델은 일반 텍스트로 응답합니다.",
|
||||
"outputPlaceholder": "출력이 여기에 표시됩니다 — 실행을 누르세요.",
|
||||
"outputNotRun": "실행되지 않음",
|
||||
"retry": "재시도",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "Item",
|
||||
"versions": "Versies"
|
||||
},
|
||||
"versionPill": "v{version} · {count} versie | v{version} · {count} versies",
|
||||
"versionPill": "{count} versie | {count} versies",
|
||||
"sourceSection": "Bron",
|
||||
"sourceTrace": "Trace",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "Beperk uitvoer tot een JSON-schema",
|
||||
"schemaHelp": "De uitvoer wordt met JSON-opmaak weergegeven wanneer een schema is ingesteld.",
|
||||
"schemaInvalidJson": "Schema moet geldige JSON zijn.",
|
||||
"schemaIgnored": "Schema · Genegeerd door deze provider",
|
||||
"schemaUnsupported": "Deze provider heeft geen veld voor gestructureerde uitvoer. Het schema blijft in deze kolom bewaard, maar wordt niet verzonden en het model antwoordt met platte tekst.",
|
||||
"outputPlaceholder": "Uitvoer verschijnt hier — druk op Uitvoeren.",
|
||||
"outputNotRun": "Niet uitgevoerd",
|
||||
"retry": "Opnieuw proberen",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "Element",
|
||||
"versions": "Wersje"
|
||||
},
|
||||
"versionPill": "v{version} · {count} wersja | v{version} · {count} wersje",
|
||||
"versionPill": "{count} wersja | {count} wersje",
|
||||
"sourceSection": "Źródło",
|
||||
"sourceTrace": "Ślad",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "Ogranicz wyjście do schematu JSON",
|
||||
"schemaHelp": "Wyjście jest formatowane jako JSON, gdy ustawiony jest schemat.",
|
||||
"schemaInvalidJson": "Schemat musi być poprawnym JSON.",
|
||||
"schemaIgnored": "Schemat · Ignorowany przez tego dostawcę",
|
||||
"schemaUnsupported": "Ten dostawca nie ma pola danych strukturalnych. Schemat pozostaje w tej kolumnie, ale nie jest wysyłany, a model odpowiada zwykłym tekstem.",
|
||||
"outputPlaceholder": "Wyjście pojawi się tutaj — naciśnij Uruchom.",
|
||||
"outputNotRun": "Nie uruchomiono",
|
||||
"retry": "Ponów",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "Item",
|
||||
"versions": "Versões"
|
||||
},
|
||||
"versionPill": "v{version} · {count} Versão | v{version} · {count} Versões",
|
||||
"versionPill": "{count} Versão | {count} Versões",
|
||||
"sourceSection": "Origem",
|
||||
"sourceTrace": "Rastro",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "Restringir saída a um esquema JSON",
|
||||
"schemaHelp": "A saída é renderizada com formatação JSON quando um esquema é definido.",
|
||||
"schemaInvalidJson": "O esquema deve ser JSON válido.",
|
||||
"schemaIgnored": "Esquema · Ignorado por este fornecedor",
|
||||
"schemaUnsupported": "Este fornecedor não tem um campo de saída estruturada. O esquema é mantido nesta coluna, mas não é enviado e o modelo responde com texto simples.",
|
||||
"outputPlaceholder": "A saída aparece aqui — pressione Executar.",
|
||||
"outputNotRun": "Não executado",
|
||||
"retry": "Tentar novamente",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "Элемент",
|
||||
"versions": "Версии"
|
||||
},
|
||||
"versionPill": "v{version} · {count} версия | v{version} · {count} версий",
|
||||
"versionPill": "{count} версия | {count} версий",
|
||||
"sourceSection": "Источник",
|
||||
"sourceTrace": "Трейс",
|
||||
"sourceSpan": "Спан",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "Ограничить вывод JSON-схемой",
|
||||
"schemaHelp": "При заданной схеме вывод отображается в формате JSON.",
|
||||
"schemaInvalidJson": "Схема должна быть корректным JSON.",
|
||||
"schemaIgnored": "Схема · Игнорируется этим провайдером",
|
||||
"schemaUnsupported": "У этого провайдера нет поля структурированного вывода. Схема сохраняется в этом столбце, но не отправляется, и модель отвечает обычным текстом.",
|
||||
"outputPlaceholder": "Вывод появится здесь — нажмите «Запустить».",
|
||||
"outputNotRun": "Не запущено",
|
||||
"retry": "Повторить",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "Öğe",
|
||||
"versions": "Sürümler"
|
||||
},
|
||||
"versionPill": "v{version} · {count} Sürüm | v{version} · {count} Sürüm",
|
||||
"versionPill": "{count} Sürüm | {count} Sürüm",
|
||||
"sourceSection": "Kaynak",
|
||||
"sourceTrace": "İz",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "Çıktıyı JSON şemasıyla sınırla",
|
||||
"schemaHelp": "Şema ayarlandığında çıktı JSON biçiminde oluşturulur.",
|
||||
"schemaInvalidJson": "Şema geçerli JSON olmalıdır.",
|
||||
"schemaIgnored": "Şema · Bu sağlayıcı tarafından yok sayılıyor",
|
||||
"schemaUnsupported": "Bu sağlayıcının yapılandırılmış çıktı alanı yok. Şema bu sütunda saklanır ancak gönderilmez ve model düz metinle yanıt verir.",
|
||||
"outputPlaceholder": "Çıktı burada görünür — Çalıştır'a basın.",
|
||||
"outputNotRun": "Çalıştırılmadı",
|
||||
"retry": "Yeniden Dene",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "Mục",
|
||||
"versions": "Phiên bản"
|
||||
},
|
||||
"versionPill": "v{version} · {count} phiên bản | v{version} · {count} phiên bản",
|
||||
"versionPill": "{count} phiên bản | {count} phiên bản",
|
||||
"sourceSection": "Nguồn",
|
||||
"sourceTrace": "Trace",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "Ràng buộc đầu ra theo một JSON schema",
|
||||
"schemaHelp": "Đầu ra hiển thị với định dạng JSON khi schema được thiết lập.",
|
||||
"schemaInvalidJson": "Schema phải là JSON hợp lệ.",
|
||||
"schemaIgnored": "Lược đồ · Bị nhà cung cấp này bỏ qua",
|
||||
"schemaUnsupported": "Nhà cung cấp này không có trường đầu ra có cấu trúc. Lược đồ vẫn được giữ trong cột này nhưng không được gửi, và mô hình trả lời bằng văn bản thuần.",
|
||||
"outputPlaceholder": "Đầu ra sẽ xuất hiện tại đây — nhấn Chạy.",
|
||||
"outputNotRun": "Chưa chạy",
|
||||
"retry": "Thử lại",
|
||||
|
||||
@@ -11200,7 +11200,7 @@
|
||||
"item": "条目",
|
||||
"versions": "版本"
|
||||
},
|
||||
"versionPill": "v{version} · {count} 个版本 | v{version} · {count} 个版本",
|
||||
"versionPill": "{count} 个版本 | {count} 个版本",
|
||||
"sourceSection": "来源",
|
||||
"sourceTrace": "追踪",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11723,6 +11723,8 @@
|
||||
"schemaToggle": "将输出限制为 JSON 模式",
|
||||
"schemaHelp": "设置模式后,输出将使用 JSON 格式呈现。",
|
||||
"schemaInvalidJson": "模式必须是有效的 JSON。",
|
||||
"schemaIgnored": "模式 · 此提供商已忽略",
|
||||
"schemaUnsupported": "此提供商没有结构化输出字段。模式会保留在此列中,但不会发送,模型将以纯文本回答。",
|
||||
"outputPlaceholder": "输出将显示在此处 — 点击运行。",
|
||||
"outputNotRun": "未运行",
|
||||
"retry": "重试",
|
||||
|
||||
@@ -11195,7 +11195,7 @@
|
||||
"item": "項目",
|
||||
"versions": "版本"
|
||||
},
|
||||
"versionPill": "v{version} · {count} 版本 | v{version} · {count} 版本",
|
||||
"versionPill": "{count} 版本 | {count} 版本",
|
||||
"sourceSection": "來源",
|
||||
"sourceTrace": "追蹤",
|
||||
"sourceSpan": "Span",
|
||||
@@ -11718,6 +11718,8 @@
|
||||
"schemaToggle": "將輸出限制為 JSON 結構描述",
|
||||
"schemaHelp": "設定結構描述後,輸出將以 JSON 格式呈現。",
|
||||
"schemaInvalidJson": "結構描述必須是有效的 JSON。",
|
||||
"schemaIgnored": "結構描述 · 此提供者已忽略",
|
||||
"schemaUnsupported": "此提供者沒有結構化輸出欄位。結構描述會保留在此資料欄中,但不會傳送,模型將以純文字回答。",
|
||||
"outputPlaceholder": "輸出會顯示在此處 — 按一下執行。",
|
||||
"outputNotRun": "尚未執行",
|
||||
"retry": "重試",
|
||||
|
||||
@@ -348,6 +348,19 @@ describe("SessionDetails — not found state", () => {
|
||||
});
|
||||
|
||||
describe("SessionDetails — KPI strip", () => {
|
||||
it("shows the selected session user ID in the header", async () => {
|
||||
mockFetchSession.mockResolvedValue({
|
||||
detail: makeDetail({ userId: "user-a" }),
|
||||
traces: [makeTrace()],
|
||||
});
|
||||
|
||||
const wrapper = await mountComponent();
|
||||
const userId = wrapper.find('[data-test="session-detail-user-id"]');
|
||||
|
||||
expect(userId.exists()).toBe(true);
|
||||
expect(userId.text()).toBe("user-a");
|
||||
});
|
||||
|
||||
it("renders KPI strip with Turns value", async () => {
|
||||
mockFetchSession.mockResolvedValue({
|
||||
detail: makeDetail({ turns: 5 }),
|
||||
|
||||
@@ -42,6 +42,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@click="copySessionId"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="detail?.userId"
|
||||
class="rounded-default border-border-default bg-surface-base text-text-body inline-flex min-w-0 items-center gap-1.5 border px-2 py-1"
|
||||
data-test="session-detail-user-id"
|
||||
>
|
||||
<OIcon name="person" size="xs" class="text-text-secondary flex-shrink-0" />
|
||||
<span class="max-w-64 truncate text-sm">{{ detail.userId }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
|
||||
@@ -222,6 +222,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OIcon name="table-chart" size="sm" />
|
||||
<OTooltip side="bottom" :content="t('aiObservability.traceActions.dataset.button')" />
|
||||
</OButton>
|
||||
|
||||
<OButton
|
||||
v-if="canOpenPlayground"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
:aria-label="t('aiObservability.traceActions.playground.button')"
|
||||
data-test="trace-details-sidebar-playground-span-btn"
|
||||
@click.stop="openInPlayground"
|
||||
>
|
||||
<OIcon name="play-circle" size="sm" />
|
||||
<OTooltip
|
||||
side="bottom"
|
||||
:content="t('aiObservability.traceActions.playground.hint')"
|
||||
/>
|
||||
</OButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1698,6 +1713,44 @@ export default defineComponent({
|
||||
emit("add-to-dataset", props.span);
|
||||
};
|
||||
|
||||
// Gated on the raw fields rather than on a parsed hand-off: this runs on
|
||||
// every render, and parsing a whole conversation to decide whether to draw
|
||||
// a button is work the render loop should not be doing.
|
||||
const canOpenPlayground = computed(
|
||||
() =>
|
||||
props.showAnnotateButtons &&
|
||||
isLLMSpan.value &&
|
||||
Boolean(
|
||||
props.span?.gen_ai_input_messages ||
|
||||
props.span?.gen_ai_output_messages ||
|
||||
props.span?.gen_ai_system_instructions ||
|
||||
props.span?.attributes_prompt ||
|
||||
props.span?.attributes_response,
|
||||
),
|
||||
);
|
||||
|
||||
/** The Playground is enterprise-only, so its module is pulled on click
|
||||
* rather than bundled into the traces chunk. */
|
||||
const openInPlayground = async () => {
|
||||
const [{ handoffFromSpan, stashHandoff }, { aiPlaygroundRoute }] = await Promise.all([
|
||||
import("@/enterprise/views/AIObservability/playgroundHandoff"),
|
||||
import("@/enterprise/views/AIObservability/playgroundRoutes"),
|
||||
]);
|
||||
const handoff = handoffFromSpan(props.span);
|
||||
// Navigating on a failed stash would land the user on an empty bench with
|
||||
// no explanation, so the failure is reported where it happened.
|
||||
if (!handoff || !stashHandoff(handoff)) {
|
||||
toast({
|
||||
variant: "error",
|
||||
message: t("aiObservability.playground.openInPlaygroundFailed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.push(
|
||||
aiPlaygroundRoute(store.state.selectedOrganization?.identifier ?? "", { fromSpan: true }),
|
||||
);
|
||||
};
|
||||
|
||||
const getStartTime = computed(() => {
|
||||
return formatTimeWithSuffix(
|
||||
convertTimeFromNsToUs(props.span.start_time) - (props.baseTracePosition?.startTimeUs || 0),
|
||||
@@ -2229,6 +2282,8 @@ export default defineComponent({
|
||||
viewSpanLogs,
|
||||
evaluateSpan,
|
||||
addSpanToDataset,
|
||||
canOpenPlayground,
|
||||
openInPlayground,
|
||||
spanStartTimeUs,
|
||||
spanSourceStream,
|
||||
getStartTime,
|
||||
|
||||
@@ -604,6 +604,41 @@ describe("useSessions — fetchSession: SessionDetail derivation", () => {
|
||||
|
||||
expect(detail!.turns).toBe(2);
|
||||
});
|
||||
|
||||
it("uses the first non-empty user ID returned by the detail API", async () => {
|
||||
const hits = [
|
||||
{
|
||||
trace_id: "t1",
|
||||
start_time: "1000",
|
||||
end_time: "2000",
|
||||
spans: [1, 0],
|
||||
service_name: [],
|
||||
user_ids: ["", "user-a", "user-b"],
|
||||
gen_ai_usage_input_tokens: 0,
|
||||
gen_ai_usage_output_tokens: 0,
|
||||
gen_ai_usage_total_tokens: 0,
|
||||
gen_ai_usage_cost: 0,
|
||||
},
|
||||
{
|
||||
trace_id: "t2",
|
||||
start_time: "2000",
|
||||
end_time: "3000",
|
||||
spans: [1, 0],
|
||||
service_name: [],
|
||||
user_ids: ["user-c"],
|
||||
gen_ai_usage_input_tokens: 0,
|
||||
gen_ai_usage_output_tokens: 0,
|
||||
gen_ai_usage_total_tokens: 0,
|
||||
gen_ai_usage_cost: 0,
|
||||
},
|
||||
];
|
||||
setupDetailsMock(hits);
|
||||
|
||||
const { fetchSession } = useSessions();
|
||||
const { detail } = await fetchSession("stream", "sess-1", 1000, 5000);
|
||||
|
||||
expect(detail!.userId).toBe("user-a");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -392,6 +392,7 @@ export function useSessions() {
|
||||
let totalCacheReadSavings = 0;
|
||||
let totalNetCacheImpact = 0;
|
||||
let totalErrors = 0;
|
||||
let userId: string | null = null;
|
||||
let serviceName: string | null = null;
|
||||
for (let i = 0; i < accumulated.length; i++) {
|
||||
const r = accumulated[i];
|
||||
@@ -412,13 +413,19 @@ export function useSessions() {
|
||||
totalCacheReadSavings += t.cacheReadSavings;
|
||||
totalNetCacheImpact += t.netCacheImpact;
|
||||
totalErrors += t.errorCount;
|
||||
if (!userId && Array.isArray(r.user_ids)) {
|
||||
const firstUserId = r.user_ids.find(
|
||||
(value: unknown) => typeof value === "string" && value.trim().length > 0,
|
||||
);
|
||||
if (firstUserId) userId = firstUserId;
|
||||
}
|
||||
if (!serviceName && t.serviceName) serviceName = t.serviceName;
|
||||
}
|
||||
if (firstSeenNanos === Infinity) firstSeenNanos = 0;
|
||||
|
||||
const detail: SessionDetail = {
|
||||
sessionId,
|
||||
userId: null,
|
||||
userId,
|
||||
serviceName,
|
||||
firstSeenMicros: firstSeenNanos,
|
||||
durationNanos: lastSeenNanos > firstSeenNanos ? lastSeenNanos - firstSeenNanos : 0,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import http from "./http";
|
||||
import llmExperimentsService, {
|
||||
normalizeExperimentComparison,
|
||||
normalizeExperimentResultRowPage,
|
||||
normalizeExperimentRowDetail,
|
||||
} from "./llm-experiments.service";
|
||||
|
||||
@@ -52,6 +53,109 @@ describe("llm-experiments compare()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("get() slot status", () => {
|
||||
it("keeps the server rollup and derives one when the field is absent", async () => {
|
||||
const slot = (extra: Record<string, unknown>) => ({
|
||||
row_id: "r1",
|
||||
logical_id: "c1",
|
||||
trial_index: 0,
|
||||
input: "q",
|
||||
expected_output: null,
|
||||
execution: null,
|
||||
client_scores: [],
|
||||
...extra,
|
||||
});
|
||||
mockClient.get.mockResolvedValue({
|
||||
data: {
|
||||
experiment: { id: "exp-1", name: "run" },
|
||||
results: {
|
||||
slots: [
|
||||
slot({ status: "score_failed", task_status: "ok", scores: [] }),
|
||||
slot({ task_status: "error", scores: [] }),
|
||||
slot({ task_status: "ok", scores: [{ scorer_id: "s", status: "pending" }] }),
|
||||
slot({ task_status: "ok", scores: [{ scorer_id: "s", status: "success" }] }),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const detail = await llmExperimentsService.get("acme", "exp-1", { resultPage: 1 });
|
||||
|
||||
expect(detail.results.slots?.map((s) => s.status)).toEqual([
|
||||
"score_failed",
|
||||
"task_failed",
|
||||
"scoring",
|
||||
"completed",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listRows()", () => {
|
||||
it("normalizes row aggregates and sends dispersion ordering", async () => {
|
||||
mockClient.get.mockResolvedValue({
|
||||
data: {
|
||||
rows: [
|
||||
{
|
||||
row_index: 4,
|
||||
row_id: "row-4",
|
||||
logical_id: "case-4",
|
||||
input: "question",
|
||||
trial_count: 3,
|
||||
status: "completed",
|
||||
p50_latency_ms: 840,
|
||||
score_summaries: [
|
||||
{
|
||||
scorer_id: "judge",
|
||||
scorer_version: 2,
|
||||
score_config_name: "answer_quality",
|
||||
value: { kind: "numeric", mean: 0.75 },
|
||||
},
|
||||
],
|
||||
dispersion: {
|
||||
row_id: "row-4",
|
||||
logical_id: "case-4",
|
||||
max_normalized: 0.42,
|
||||
high: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, page_size: 100, total_rows: 1, has_more: false },
|
||||
},
|
||||
});
|
||||
|
||||
const page = await llmExperimentsService.listRows("acme", "exp-1", {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
sort: "dispersion_desc",
|
||||
highDispersionOnly: true,
|
||||
});
|
||||
|
||||
expect(mockClient.get).toHaveBeenCalledWith("/api/acme/experiments/exp-1/rows", {
|
||||
params: {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
sort: "dispersion_desc",
|
||||
highDispersionOnly: true,
|
||||
},
|
||||
});
|
||||
expect(page.rows[0]).toMatchObject({
|
||||
rowIndex: 4,
|
||||
rowId: "row-4",
|
||||
trialCount: 3,
|
||||
p50LatencyMs: 840,
|
||||
scoreSummaries: [{ scoreConfigName: "answer_quality" }],
|
||||
dispersion: { maxNormalized: 0.42, high: true },
|
||||
});
|
||||
expect(page.pagination).toEqual({ page: 1, pageSize: 100, totalRows: 1, hasMore: false });
|
||||
});
|
||||
|
||||
it("normalizes snake-case row pages directly", () => {
|
||||
expect(
|
||||
normalizeExperimentResultRowPage({ pagination: { total_rows: 0 }, rows: [] }),
|
||||
).toMatchObject({ pagination: { totalRows: 0 }, rows: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeExperimentComparison()", () => {
|
||||
it("normalizes score-config metadata and aggregate oriented deltas", () => {
|
||||
const dimension = {
|
||||
|
||||
@@ -183,6 +183,39 @@ export interface ExperimentRowDetail {
|
||||
scoreSummaries: ExperimentScoreSummary[];
|
||||
}
|
||||
|
||||
export type ExperimentResultRowSort = "dataset" | "dispersion_desc";
|
||||
|
||||
export interface ExperimentResultRow {
|
||||
rowIndex: number;
|
||||
rowId: string;
|
||||
logicalId: string;
|
||||
input: unknown;
|
||||
expectedOutput: unknown | null;
|
||||
trialCount: number;
|
||||
status: ExperimentSlotStatus;
|
||||
output: unknown | null;
|
||||
scoreSummaries: ExperimentScoreSummary[];
|
||||
p50LatencyMs: number | null;
|
||||
dispersion: ExperimentRowDispersion | null;
|
||||
}
|
||||
|
||||
export interface ExperimentResultRowPage {
|
||||
rows: ExperimentResultRow[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalRows: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExperimentResultRowPageOptions {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: ExperimentResultRowSort;
|
||||
highDispersionOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ExperimentRowDispersion {
|
||||
rowId: string;
|
||||
logicalId: string;
|
||||
@@ -218,7 +251,12 @@ export interface ExperimentResultScore {
|
||||
score: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type ExperimentSlotStatus =
|
||||
"pending" | "running" | "scoring" | "completed" | "skipped" | "task_failed" | "score_failed";
|
||||
|
||||
export interface ExperimentResultSlot extends ExperimentSlot {
|
||||
/** Single lifecycle rollup of task and score evidence — the list-surface field. */
|
||||
status: ExperimentSlotStatus;
|
||||
taskStatus: "pending" | "in_progress" | "ok" | "skipped" | "error";
|
||||
execution: ExperimentExecution | null;
|
||||
scores: ExperimentResultScore[];
|
||||
@@ -237,6 +275,7 @@ export interface ExperimentAggregateSummary {
|
||||
incomplete: boolean;
|
||||
incompleteTaskSlots: number;
|
||||
incompleteScoreDimensions: number;
|
||||
errorTaskSlots: number;
|
||||
}
|
||||
|
||||
export interface ExperimentProgress {
|
||||
@@ -357,6 +396,29 @@ export interface ExperimentResultQuery {
|
||||
|
||||
const TASK_RESULT_STATUSES = ["pending", "in_progress", "ok", "skipped", "error"] as const;
|
||||
const SCORE_RESULT_STATUSES = ["pending", "in_progress", "success", "skipped", "error"] as const;
|
||||
const SLOT_STATUSES = [
|
||||
"pending",
|
||||
"running",
|
||||
"scoring",
|
||||
"completed",
|
||||
"skipped",
|
||||
"task_failed",
|
||||
"score_failed",
|
||||
] as const;
|
||||
|
||||
// Mirrors the server rollup so a response predating the field still gets one.
|
||||
function deriveSlotStatus(
|
||||
taskStatus: ExperimentResultSlot["taskStatus"],
|
||||
scores: ExperimentResultScore[],
|
||||
): ExperimentSlotStatus {
|
||||
if (taskStatus === "pending") return "pending";
|
||||
if (taskStatus === "in_progress") return "running";
|
||||
if (taskStatus === "error") return "task_failed";
|
||||
if (taskStatus === "skipped") return "skipped";
|
||||
if (scores.some((s) => s.status === "pending" || s.status === "in_progress")) return "scoring";
|
||||
if (scores.some((s) => s.status === "error")) return "score_failed";
|
||||
return "completed";
|
||||
}
|
||||
|
||||
function normalizeTaskResultStatus(input: unknown): ExperimentResultSlot["taskStatus"] {
|
||||
return TASK_RESULT_STATUSES.includes(input as (typeof TASK_RESULT_STATUSES)[number])
|
||||
@@ -485,13 +547,9 @@ function normalizeResults(input: any): ExperimentResults {
|
||||
const aggregateSummary = value<any>(input, "aggregateSummary", "aggregate_summary", {});
|
||||
const dispersionSummary = value<any>(input, "dispersionSummary", "dispersion_summary", {});
|
||||
return {
|
||||
rowDispersions: value<any[]>(input, "rowDispersions", "row_dispersions", []).map((row) => ({
|
||||
rowId: String(value(row, "rowId", "row_id", "")),
|
||||
logicalId: String(value(row, "logicalId", "logical_id", "")),
|
||||
maxNormalized: numberOrNull(value(row, "maxNormalized", "max_normalized", null)),
|
||||
high: Boolean(row?.high),
|
||||
outlierTrialIndex: numberOrNull(value(row, "outlierTrialIndex", "outlier_trial_index", null)),
|
||||
})),
|
||||
rowDispersions: value<any[]>(input, "rowDispersions", "row_dispersions", []).map(
|
||||
normalizeRowDispersion,
|
||||
),
|
||||
dispersionSummary: {
|
||||
highDispersionRowCount: Number(
|
||||
value(dispersionSummary, "highDispersionRowCount", "high_dispersion_row_count", 0),
|
||||
@@ -533,6 +591,7 @@ function normalizeResults(input: any): ExperimentResults {
|
||||
incompleteScoreDimensions: Number(
|
||||
value(aggregateSummary, "incompleteScoreDimensions", "incomplete_score_dimensions", 0),
|
||||
),
|
||||
errorTaskSlots: Number(value(aggregateSummary, "errorTaskSlots", "error_task_slots", 0)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -558,20 +617,26 @@ function normalizeExecution(record: any): ExperimentExecution {
|
||||
}
|
||||
|
||||
function normalizeResultSlot(slot: any): ExperimentResultSlot {
|
||||
const taskStatus = normalizeTaskResultStatus(value(slot, "taskStatus", "task_status", "pending"));
|
||||
const scores: ExperimentResultScore[] = (slot.scores ?? []).map((score: any) => ({
|
||||
scorerId: value(score, "scorerId", "scorer_id", ""),
|
||||
scorerVersion: Number(value(score, "scorerVersion", "scorer_version", 0)),
|
||||
status: normalizeScoreResultStatus(score.status ?? "pending"),
|
||||
score: score.score ?? null,
|
||||
}));
|
||||
const status = SLOT_STATUSES.includes(slot.status)
|
||||
? (slot.status as ExperimentSlotStatus)
|
||||
: deriveSlotStatus(taskStatus, scores);
|
||||
return {
|
||||
rowId: value(slot, "rowId", "row_id", ""),
|
||||
logicalId: value(slot, "logicalId", "logical_id", ""),
|
||||
trialIndex: Number(value(slot, "trialIndex", "trial_index", 0)),
|
||||
input: slot.input,
|
||||
expectedOutput: value(slot, "expectedOutput", "expected_output", null),
|
||||
taskStatus: normalizeTaskResultStatus(value(slot, "taskStatus", "task_status", "pending")),
|
||||
status,
|
||||
taskStatus,
|
||||
execution: slot.execution ? normalizeExecution(slot.execution) : null,
|
||||
scores: (slot.scores ?? []).map((score: any) => ({
|
||||
scorerId: value(score, "scorerId", "scorer_id", ""),
|
||||
scorerVersion: Number(value(score, "scorerVersion", "scorer_version", 0)),
|
||||
status: normalizeScoreResultStatus(score.status ?? "pending"),
|
||||
score: score.score ?? null,
|
||||
})),
|
||||
scores,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -595,6 +660,43 @@ function normalizeScoreSummary(summary: any): ExperimentScoreSummary {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRowDispersion(row: any): ExperimentRowDispersion {
|
||||
return {
|
||||
rowId: String(value(row, "rowId", "row_id", "")),
|
||||
logicalId: String(value(row, "logicalId", "logical_id", "")),
|
||||
maxNormalized: numberOrNull(value(row, "maxNormalized", "max_normalized", null)),
|
||||
high: Boolean(row?.high),
|
||||
outlierTrialIndex: numberOrNull(value(row, "outlierTrialIndex", "outlier_trial_index", null)),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeExperimentResultRowPage(input: any): ExperimentResultRowPage {
|
||||
const pagination = input?.pagination ?? {};
|
||||
return {
|
||||
rows: (input?.rows ?? []).map((row: any) => ({
|
||||
rowIndex: Number(value(row, "rowIndex", "row_index", 0)),
|
||||
rowId: String(value(row, "rowId", "row_id", "")),
|
||||
logicalId: String(value(row, "logicalId", "logical_id", "")),
|
||||
input: row?.input,
|
||||
expectedOutput: value(row, "expectedOutput", "expected_output", null),
|
||||
trialCount: Number(value(row, "trialCount", "trial_count", 0)),
|
||||
status: row?.status as ExperimentSlotStatus,
|
||||
output: row?.output ?? null,
|
||||
scoreSummaries: value<any[]>(row, "scoreSummaries", "score_summaries", []).map(
|
||||
normalizeScoreSummary,
|
||||
),
|
||||
p50LatencyMs: numberOrNull(value(row, "p50LatencyMs", "p50_latency_ms", null)),
|
||||
dispersion: row?.dispersion ? normalizeRowDispersion(row.dispersion) : null,
|
||||
})),
|
||||
pagination: {
|
||||
page: Number(pagination.page ?? 1),
|
||||
pageSize: Number(value(pagination, "pageSize", "page_size", 50)),
|
||||
totalRows: Number(value(pagination, "totalRows", "total_rows", 0)),
|
||||
hasMore: Boolean(value(pagination, "hasMore", "has_more", false)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeExperimentRowDetail(input: any): ExperimentRowDetail {
|
||||
const snapshot = input?.snapshot ?? {};
|
||||
const navigation = input?.navigation ?? {};
|
||||
@@ -780,6 +882,17 @@ const llmExperimentsService = {
|
||||
return normalizeExperimentRowDetail(response.data);
|
||||
},
|
||||
|
||||
async listRows(
|
||||
orgId: string,
|
||||
experimentId: string,
|
||||
options: ExperimentResultRowPageOptions = {},
|
||||
): Promise<ExperimentResultRowPage> {
|
||||
const response = await http().get(`${base(orgId)}/${experimentId}/rows`, {
|
||||
params: options,
|
||||
});
|
||||
return normalizeExperimentResultRowPage(response.data);
|
||||
},
|
||||
|
||||
async retry(orgId: string, experimentId: string): Promise<LlmExperiment> {
|
||||
const response = await http().post(`${base(orgId)}/${experimentId}/retry`);
|
||||
return normalizeExperiment(response.data);
|
||||
|
||||
@@ -7,6 +7,8 @@ vi.mock("@/services/http", () => ({ attemptTokenRefresh: vi.fn() }));
|
||||
|
||||
import {
|
||||
PlaygroundRunError,
|
||||
dropsResponseSchema,
|
||||
providerDropsResponseSchema,
|
||||
runPlayground,
|
||||
type PlaygroundRunRequest,
|
||||
} from "./llm-playground.service";
|
||||
@@ -159,10 +161,39 @@ describe("runPlayground — live adapter", () => {
|
||||
|
||||
const result = await runPlayground("org", request(), { onDelta: () => {} });
|
||||
|
||||
expect(result.toolCall).toEqual({ name: "get_weather", arguments: '{"city":"Paris"}' });
|
||||
expect(result.toolCall).toEqual({
|
||||
id: "call_1",
|
||||
name: "get_weather",
|
||||
arguments: '{"city":"Paris"}',
|
||||
});
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
it("preserves DeepSeek reasoning from the terminal stream frame", async () => {
|
||||
stubFetch(
|
||||
streamingResponse([
|
||||
frame({
|
||||
type: "toolCall",
|
||||
id: "call_1",
|
||||
name: "lookup_order",
|
||||
arguments: '{"order_id":"123"}',
|
||||
}),
|
||||
frame({
|
||||
type: "done",
|
||||
reasoningContent: "I need the order lookup before answering.",
|
||||
latencyMs: 9,
|
||||
usage: {},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await runPlayground("org", request(), { onDelta: () => {} });
|
||||
|
||||
expect((result as unknown as { reasoningContent: string | null }).reasoningContent).toBe(
|
||||
"I need the order lookup before answering.",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws a retryable error for an error frame inside an open stream", async () => {
|
||||
stubFetch(
|
||||
streamingResponse([
|
||||
@@ -230,6 +261,56 @@ describe("runPlayground — live adapter", () => {
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://api.test/api/org/playground/run");
|
||||
});
|
||||
|
||||
it("preserves a tool call and its linked result in the request body", async () => {
|
||||
const fetchMock = stubFetch(streamingResponse([]));
|
||||
|
||||
await runPlayground(
|
||||
"org",
|
||||
request({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoningContent: "I should look up this order.",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call_1",
|
||||
name: "lookup_order",
|
||||
arguments: '{"order_id":"123"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: '{"status":"shipped"}',
|
||||
toolCallId: "call_1",
|
||||
},
|
||||
],
|
||||
} as Partial<PlaygroundRunRequest>),
|
||||
{ onDelta: () => {} },
|
||||
);
|
||||
|
||||
expect(sentBody(fetchMock).column.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoningContent: "I should look up this order.",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call_1",
|
||||
name: "lookup_order",
|
||||
arguments: '{"order_id":"123"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: '{"status":"shipped"}',
|
||||
toolCallId: "call_1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits an empty model so the provider's default stands in", async () => {
|
||||
const fetchMock = stubFetch(
|
||||
streamingResponse([frame({ type: "done", latencyMs: 1, usage: {} })]),
|
||||
@@ -290,6 +371,26 @@ describe("runPlayground — live adapter", () => {
|
||||
expect(column.responseFormat).toBeUndefined();
|
||||
});
|
||||
|
||||
// The UI reads this to warn while the schema is being written, so it has to
|
||||
// answer for a provider type alone — before there is a request to inspect.
|
||||
it("names the provider kinds whose request cannot carry a schema", () => {
|
||||
expect(providerDropsResponseSchema("Anthropic")).toBe(true);
|
||||
expect(providerDropsResponseSchema(" anthropic ")).toBe(true);
|
||||
expect(providerDropsResponseSchema("openai")).toBe(false);
|
||||
expect(providerDropsResponseSchema(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a drop only when a schema was asked for", () => {
|
||||
const anthropic = { providerType: "anthropic" };
|
||||
expect(dropsResponseSchema(request({ ...anthropic, responseSchema: { type: "object" } }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(dropsResponseSchema(request({ ...anthropic, responseSchema: null }))).toBe(false);
|
||||
expect(
|
||||
dropsResponseSchema(request({ providerType: "openai", responseSchema: { type: "object" } })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("sends no tools key at all when the bench defines none", async () => {
|
||||
const fetchMock = stubFetch(
|
||||
streamingResponse([frame({ type: "done", latencyMs: 1, usage: {} })]),
|
||||
|
||||
@@ -21,9 +21,21 @@ import http, { attemptTokenRefresh } from "@/services/http";
|
||||
|
||||
export type PlaygroundMessageRole = "system" | "user" | "assistant" | "tool";
|
||||
|
||||
export interface PlaygroundRequestToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
export interface PlaygroundRequestMessage {
|
||||
role: PlaygroundMessageRole;
|
||||
content: string;
|
||||
/** DeepSeek thinking mode: must be replayed verbatim when tools are present. */
|
||||
reasoningContent?: string;
|
||||
/** Assistant role only: calls the model issued before a tool result. */
|
||||
toolCalls?: PlaygroundRequestToolCall[];
|
||||
/** Tool role only: the assistant call this result answers. */
|
||||
toolCallId?: string;
|
||||
}
|
||||
|
||||
export interface PlaygroundRequestTool {
|
||||
@@ -57,12 +69,15 @@ export interface PlaygroundRunUsage {
|
||||
}
|
||||
|
||||
export interface PlaygroundRunToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
export interface PlaygroundRunResult {
|
||||
text: string;
|
||||
/** Provider reasoning retained for a later tool-enabled turn, never rendered. */
|
||||
reasoningContent: string | null;
|
||||
/** Non-null when the model issued a tool call. The call IS the output and the
|
||||
* run ends there — the Playground does not execute tools. */
|
||||
toolCall: PlaygroundRunToolCall | null;
|
||||
@@ -164,6 +179,17 @@ function providerKind(request: PlaygroundRunRequest): string {
|
||||
return (request.providerType ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a provider of this kind carries no response schema at all.
|
||||
*
|
||||
* Exported because the UI has to say so BEFORE a run: a schema set against such
|
||||
* a provider never leaves the client, and the only other evidence is an answer
|
||||
* that comes back as prose for no stated reason.
|
||||
*/
|
||||
export function providerDropsResponseSchema(providerType?: string): boolean {
|
||||
return (providerType ?? "").trim().toLowerCase() === ANTHROPIC_KIND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool definitions in the provider's own shape.
|
||||
*
|
||||
@@ -201,7 +227,8 @@ function wireTools(request: PlaygroundRunRequest): unknown[] | undefined {
|
||||
*/
|
||||
function wireResponseFormat(request: PlaygroundRunRequest): unknown | undefined {
|
||||
if (!request.responseSchema) return undefined;
|
||||
if (providerKind(request) === ANTHROPIC_KIND) return undefined;
|
||||
// The same predicate the UI warns from, so the warning cannot outlive the drop.
|
||||
if (providerDropsResponseSchema(request.providerType)) return undefined;
|
||||
return {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
@@ -214,7 +241,7 @@ function wireResponseFormat(request: PlaygroundRunRequest): unknown | undefined
|
||||
|
||||
/** True when a response schema was asked for but this provider cannot carry it. */
|
||||
export function dropsResponseSchema(request: PlaygroundRunRequest): boolean {
|
||||
return Boolean(request.responseSchema) && providerKind(request) === ANTHROPIC_KIND;
|
||||
return Boolean(request.responseSchema) && providerDropsResponseSchema(request.providerType);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,6 +258,11 @@ function wireBody(request: PlaygroundRunRequest): Record<string, unknown> {
|
||||
messages: request.messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
...(message.reasoningContent !== undefined
|
||||
? { reasoningContent: message.reasoningContent }
|
||||
: {}),
|
||||
...(message.toolCalls?.length ? { toolCalls: message.toolCalls } : {}),
|
||||
...(message.toolCallId ? { toolCallId: message.toolCallId } : {}),
|
||||
})),
|
||||
params: {
|
||||
temperature: request.params.temperature ?? 0,
|
||||
@@ -295,6 +327,7 @@ async function runLive(
|
||||
let buffer = "";
|
||||
let text = "";
|
||||
let toolCall: PlaygroundRunToolCall | null = null;
|
||||
let reasoningContent: string | null = null;
|
||||
let usage: PlaygroundRunUsage | null = null;
|
||||
|
||||
// Drains until the reader reports done.
|
||||
@@ -324,6 +357,7 @@ async function runLive(
|
||||
// The model may emit several; the cell shows one, so the first wins.
|
||||
if (!toolCall) {
|
||||
toolCall = {
|
||||
id: String(data.id ?? ""),
|
||||
name: String(data.name ?? ""),
|
||||
arguments: String(data.arguments ?? ""),
|
||||
};
|
||||
@@ -331,6 +365,8 @@ async function runLive(
|
||||
break;
|
||||
case "done":
|
||||
usage = normalizeUsage(data);
|
||||
reasoningContent =
|
||||
typeof data.reasoningContent === "string" ? data.reasoningContent : null;
|
||||
break;
|
||||
case "error":
|
||||
// Once the stream is open the server has no status code left, so an
|
||||
@@ -345,7 +381,7 @@ async function runLive(
|
||||
}
|
||||
}
|
||||
|
||||
return { text, toolCall, usage: usage ?? emptyUsage() };
|
||||
return { text, reasoningContent, toolCall, usage: usage ?? emptyUsage() };
|
||||
}
|
||||
|
||||
/** The server's error message, falling back to the bare status. */
|
||||
|
||||
@@ -63,11 +63,45 @@ export interface SessionDetailsApiResponse {
|
||||
total: number;
|
||||
from: number;
|
||||
size: number;
|
||||
hits: any[];
|
||||
hits: SessionDetailsApiHit[];
|
||||
trace_id?: string;
|
||||
function_error?: string;
|
||||
}
|
||||
|
||||
/** Per-trace turn summary returned by the session-detail endpoint. */
|
||||
export interface SessionDetailsApiHit {
|
||||
trace_id: string;
|
||||
/** All distinct, non-empty user IDs found on spans in this trace. */
|
||||
user_ids: string[];
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
duration: number;
|
||||
spans: [number, number];
|
||||
service_name: Array<{
|
||||
service_name: string;
|
||||
count: number;
|
||||
duration: number;
|
||||
service_type?: string;
|
||||
}>;
|
||||
first_event: {
|
||||
service_name: string;
|
||||
operation_name: string;
|
||||
};
|
||||
gen_ai_usage_input_tokens: number;
|
||||
gen_ai_usage_output_tokens: number;
|
||||
gen_ai_usage_total_tokens: number;
|
||||
gen_ai_usage_cost: number;
|
||||
gen_ai_usage_cache_read_input_tokens: number;
|
||||
gen_ai_usage_cache_creation_input_tokens: number;
|
||||
gen_ai_usage_cost_cache_read_input: number;
|
||||
gen_ai_usage_cost_cache_creation_input: number;
|
||||
gen_ai_usage_cost_estimated_without_cache: number;
|
||||
gen_ai_usage_cost_cache_read_savings: number;
|
||||
gen_ai_usage_cost_net_cache_impact: number;
|
||||
gen_ai_input_messages?: unknown;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
const sessions = {
|
||||
/**
|
||||
* Fetch the paginated session list from
|
||||
|
||||
Reference in New Issue
Block a user