Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::terminal::model::session::Session;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WarpAiOsContext {
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub category: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub distribution: Option<String>,
|
||||
}
|
||||
|
||||
/// The execution context of the active session. This struct
|
||||
/// is sent as a JSON blob in our AI prompts.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WarpAiExecutionContext {
|
||||
pub os: WarpAiOsContext,
|
||||
pub shell_name: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub shell_version: Option<String>,
|
||||
}
|
||||
|
||||
impl WarpAiExecutionContext {
|
||||
pub fn new(session: &Arc<Session>) -> Self {
|
||||
WarpAiExecutionContext {
|
||||
os: WarpAiOsContext {
|
||||
category: session.host_info().os_category.clone(),
|
||||
distribution: session.host_info().linux_distribution.clone(),
|
||||
},
|
||||
shell_name: session.shell().shell_type().name().to_owned(),
|
||||
shell_version: session.shell().version().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl WarpAiExecutionContext {
|
||||
pub fn to_json_string(&self) -> Option<String> {
|
||||
serde_json::to_string(self).ok()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! AI Assistant has since been renamed to "Warp AI" in the product.
|
||||
use std::{collections::HashSet, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
ai::{RequestLimitInfo, RequestLimitRefreshDuration},
|
||||
server::telemetry::OpenedWarpAISource,
|
||||
terminal::model::terminal_model::BlockIndex,
|
||||
workflows::workflow::{Argument, Workflow},
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use pathfinder_color::ColorU;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_core::command::ExitCode;
|
||||
use warp_graphql::{
|
||||
ai::{
|
||||
RequestLimitInfo as RequestLimitInfoGraphql,
|
||||
RequestLimitRefreshDuration as RequestLimitRefreshDurationGraphql,
|
||||
},
|
||||
mutations::generate_commands::{GenerateCommandsFailureType, GeneratedCommand},
|
||||
};
|
||||
|
||||
pub mod execution_context;
|
||||
pub mod panel;
|
||||
pub mod requests;
|
||||
pub mod transcript;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_util;
|
||||
|
||||
/// We want to make sure the user doesn't send a prompt too large.s
|
||||
/// Since a token is ~ 4 chars, the limit we impose here is 250 tokens.
|
||||
/// This is also roughly the limit at which the editor starts degrading.
|
||||
pub const PROMPT_CHARACTER_LIMIT: usize = 1000;
|
||||
|
||||
pub const AI_ASSISTANT_FEATURE_NAME: &str = "Warp AI";
|
||||
pub const ASK_AI_ASSISTANT_TEXT: &str = "Ask Warp AI";
|
||||
|
||||
pub const AI_ASSISTANT_SVG_PATH: &str = "bundled/svg/ai-assistant.svg";
|
||||
|
||||
lazy_static! {
|
||||
pub static ref AI_ASSISTANT_LOGO_COLOR: ColorU = ColorU::new(243, 185, 17, 255);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AskAIType {
|
||||
/// Covers all possible origins of text selection, including the block list terminal,
|
||||
/// the alt-screen terminal, and the input area. Not all instances will require
|
||||
/// `populate_input_box`, which determines whether we should automatically render
|
||||
/// something like "Explain the following" within the user's input box.
|
||||
FromTextSelection {
|
||||
text: Arc<String>,
|
||||
populate_input_box: bool,
|
||||
},
|
||||
/// Data about a block to inform Agent Mode.
|
||||
FromBlock {
|
||||
input: Arc<String>,
|
||||
output: Arc<String>,
|
||||
exit_code: ExitCode,
|
||||
block_index: BlockIndex,
|
||||
},
|
||||
/// Which blocks to attach to a block list AI query.
|
||||
FromBlocks {
|
||||
block_indices: HashSet<BlockIndex>,
|
||||
},
|
||||
FromAICommandSearch {
|
||||
query: Arc<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&AskAIType> for OpenedWarpAISource {
|
||||
fn from(value: &AskAIType) -> Self {
|
||||
match value {
|
||||
AskAIType::FromAICommandSearch { .. } => OpenedWarpAISource::FromAICommandSearch,
|
||||
AskAIType::FromBlock { .. } | AskAIType::FromBlocks { .. } => {
|
||||
OpenedWarpAISource::HelpWithBlock
|
||||
}
|
||||
AskAIType::FromTextSelection { .. } => OpenedWarpAISource::HelpWithTextSelection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AIGeneratedCommand {
|
||||
command: String,
|
||||
description: String,
|
||||
parameters: Vec<AIGeneratedCommandParameter>,
|
||||
}
|
||||
|
||||
pub struct AIGeneratedCommandParameter {
|
||||
id: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
impl From<AIGeneratedCommand> for Workflow {
|
||||
fn from(ai_command: AIGeneratedCommand) -> Self {
|
||||
// Note that we use the AI generated description as the _title_ of the workflow.
|
||||
Workflow::new(ai_command.description, ai_command.command).with_arguments(
|
||||
ai_command
|
||||
.parameters
|
||||
.into_iter()
|
||||
.map(|p| Argument {
|
||||
name: p.id,
|
||||
description: Some(p.description),
|
||||
default_value: None,
|
||||
arg_type: Default::default(),
|
||||
})
|
||||
.collect_vec(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GeneratedCommand> for AIGeneratedCommand {
|
||||
fn from(value: GeneratedCommand) -> Self {
|
||||
AIGeneratedCommand {
|
||||
command: value.command,
|
||||
description: value.description,
|
||||
parameters: value
|
||||
.parameters
|
||||
.into_iter()
|
||||
.map(|p| AIGeneratedCommandParameter {
|
||||
id: p.id,
|
||||
description: p.description,
|
||||
})
|
||||
.collect_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum GenerateCommandsFromNaturalLanguageError {
|
||||
BadPrompt,
|
||||
AiProviderError,
|
||||
RateLimited,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl From<GenerateCommandsFailureType> for GenerateCommandsFromNaturalLanguageError {
|
||||
fn from(value: GenerateCommandsFailureType) -> Self {
|
||||
match value {
|
||||
GenerateCommandsFailureType::BadPrompt => Self::BadPrompt,
|
||||
GenerateCommandsFailureType::AiProviderError => Self::AiProviderError,
|
||||
GenerateCommandsFailureType::RateLimited => Self::RateLimited,
|
||||
GenerateCommandsFailureType::Other => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RequestLimitRefreshDurationGraphql> for RequestLimitRefreshDuration {
|
||||
fn from(value: RequestLimitRefreshDurationGraphql) -> Self {
|
||||
match value {
|
||||
RequestLimitRefreshDurationGraphql::Monthly => RequestLimitRefreshDuration::Monthly,
|
||||
RequestLimitRefreshDurationGraphql::Weekly => RequestLimitRefreshDuration::Weekly,
|
||||
RequestLimitRefreshDurationGraphql::EveryTwoWeeks => {
|
||||
RequestLimitRefreshDuration::EveryTwoWeeks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RequestLimitInfoGraphql> for RequestLimitInfo {
|
||||
fn from(value: RequestLimitInfoGraphql) -> Self {
|
||||
RequestLimitInfo {
|
||||
is_unlimited: value.is_unlimited,
|
||||
limit: value.request_limit as usize,
|
||||
num_requests_used_since_refresh: value.requests_used_since_last_refresh as usize,
|
||||
next_refresh_time: value.next_refresh_time,
|
||||
request_limit_refresh_duration: value.request_limit_refresh_duration.into(),
|
||||
is_unlimited_voice: value.is_unlimited_voice,
|
||||
voice_request_limit: value.voice_request_limit as usize,
|
||||
voice_requests_used_since_last_refresh: value.voice_requests_used_since_last_refresh
|
||||
as usize,
|
||||
is_unlimited_codebase_indices: value.is_unlimited_codebase_indices,
|
||||
max_codebase_indices: value.max_codebase_indices as usize,
|
||||
max_files_per_repo: value.max_files_per_repo as usize,
|
||||
embedding_generation_batch_size: value.embedding_generation_batch_size as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
// TODO(roland): Delete all of this once agent mode fully replaces the AI assistant panel.
|
||||
// app/src/ai/request_usage_model duplicates much of this logic.
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{OutOfRangeError, Utc};
|
||||
use futures::stream::AbortHandle;
|
||||
|
||||
use warp_core::user_preferences::GetUserPreferences as _;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::{RequestLimitInfo, RequestUsageInfo},
|
||||
ai_assistant::utils::{AssistantTranscriptPart, TranscriptPartSubType},
|
||||
auth::AuthStateProvider,
|
||||
send_telemetry_from_ctx,
|
||||
server::{
|
||||
server_api::{ai::AIClient, ServerApi},
|
||||
telemetry::{TelemetryEvent, WarpAIRequestResult},
|
||||
},
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
|
||||
use super::{
|
||||
execution_context::WarpAiExecutionContext,
|
||||
utils::{markdown_segments_from_text, FormattedTranscriptMessage, TranscriptPart},
|
||||
};
|
||||
use anyhow::Result;
|
||||
|
||||
/// The key for the corresponding entry in UserDefaults.
|
||||
/// Not wiring through Settings for now since this data is only needed by the panel view.
|
||||
pub const REQUEST_LIMIT_INFO_CACHE_KEY: &str = "AIAssistantRequestLimitInfo";
|
||||
|
||||
/// Tracks the current request status for making Warp AI requests against server.
|
||||
pub enum RequestStatus {
|
||||
/// There isn't a request in flight right now.
|
||||
NotInFlight,
|
||||
|
||||
/// There's currently a request in flight.
|
||||
InFlight {
|
||||
/// The request itself (i.e. the prompt).
|
||||
request: FormattedTranscriptMessage,
|
||||
/// A handle to abort the request if desired.
|
||||
abort_handle: AbortHandle,
|
||||
},
|
||||
}
|
||||
|
||||
fn cache_request_limit_info(request_limit_info: RequestLimitInfo, app_mut: &mut AppContext) {
|
||||
if let Ok(serialized) = serde_json::to_string(&request_limit_info) {
|
||||
let _ = app_mut
|
||||
.private_user_preferences()
|
||||
.write_value(REQUEST_LIMIT_INFO_CACHE_KEY, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_cached_request_limit_info(app_mut: &mut AppContext) -> Option<RequestLimitInfo> {
|
||||
app_mut
|
||||
.private_user_preferences()
|
||||
.read_value(REQUEST_LIMIT_INFO_CACHE_KEY)
|
||||
.unwrap_or_default()
|
||||
.and_then(|serialized| serde_json::from_str(serialized.as_str()).ok())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GenerateDialogueResult {
|
||||
Success {
|
||||
answer: String,
|
||||
truncated: bool,
|
||||
request_limit_info: RequestLimitInfo,
|
||||
transcript_summarized: bool,
|
||||
},
|
||||
Failure {
|
||||
request_limit_info: RequestLimitInfo,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Requests {
|
||||
server_api: Arc<ServerApi>,
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
request_status: RequestStatus,
|
||||
request_limit_info: RequestLimitInfo,
|
||||
|
||||
/// The currently displayed transcript.
|
||||
current_transcript: Vec<TranscriptPart>,
|
||||
|
||||
/// Has the server summarized the current transcript because it's running long?
|
||||
current_transcript_summarized: bool,
|
||||
|
||||
/// When a user Restarts their transcript, we still remember
|
||||
/// the previous transcript parts for things like suggestions.
|
||||
/// This list is mutually exclusive from current_transcript.
|
||||
old_transcript_parts: Vec<TranscriptPart>,
|
||||
|
||||
ai_execution_context: Option<WarpAiExecutionContext>,
|
||||
}
|
||||
|
||||
impl Entity for Requests {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
RequestFinished { succeeded: bool },
|
||||
}
|
||||
|
||||
/// Private interface.
|
||||
impl Requests {
|
||||
fn remaining_time_to_refresh_std(&self) -> Result<std::time::Duration, OutOfRangeError> {
|
||||
self.request_limit_info
|
||||
.next_refresh_time
|
||||
.utc()
|
||||
.signed_duration_since(Utc::now())
|
||||
.to_std()
|
||||
}
|
||||
}
|
||||
|
||||
/// Public interface.
|
||||
impl Requests {
|
||||
pub fn new(
|
||||
server_api: Arc<ServerApi>,
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
// Check if the user has cached request limit info from before.
|
||||
// If not, let's just make an assumption about the server's default request limit
|
||||
// and fetch the true request limit later.
|
||||
let cached_request_limit_info = get_cached_request_limit_info(ctx);
|
||||
let request_limit_info = cached_request_limit_info.unwrap_or_default();
|
||||
|
||||
let requests = Self {
|
||||
server_api,
|
||||
ai_client,
|
||||
current_transcript: Vec::new(),
|
||||
current_transcript_summarized: false,
|
||||
old_transcript_parts: Vec::new(),
|
||||
request_status: RequestStatus::NotInFlight,
|
||||
request_limit_info,
|
||||
ai_execution_context: None,
|
||||
};
|
||||
|
||||
if cached_request_limit_info.is_none()
|
||||
&& AuthStateProvider::as_ref(ctx).get().is_logged_in()
|
||||
{
|
||||
let ai_client = requests.ai_client.clone();
|
||||
let _ = ctx.spawn(
|
||||
async move { ai_client.get_request_limit_info().await },
|
||||
Self::update_request_limit_info,
|
||||
);
|
||||
}
|
||||
requests
|
||||
}
|
||||
|
||||
pub fn update_ai_execution_context(
|
||||
&mut self,
|
||||
ai_execution_context: Option<WarpAiExecutionContext>,
|
||||
) {
|
||||
self.ai_execution_context = ai_execution_context;
|
||||
}
|
||||
|
||||
pub fn update_request_limit_info(
|
||||
&mut self,
|
||||
result: Result<RequestUsageInfo>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match result {
|
||||
Ok(usage_info) => {
|
||||
self.request_limit_info = usage_info.request_limit_info;
|
||||
ctx.notify();
|
||||
cache_request_limit_info(usage_info.request_limit_info, ctx);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to retrieve initial request limit info: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a Warp AI request against the server with the given request prompt.
|
||||
pub fn issue_request(&mut self, request: String, ctx: &mut ModelContext<Self>) {
|
||||
let server_api = self.server_api.clone();
|
||||
let raw_request = request.trim();
|
||||
let request_for_api = raw_request.to_string();
|
||||
let transcript = self.current_transcript.clone();
|
||||
let transcript_part_index = transcript.len();
|
||||
let ai_execution_context = self.ai_execution_context.clone();
|
||||
|
||||
let request_in_markdown = markdown_segments_from_text(
|
||||
transcript_part_index,
|
||||
TranscriptPartSubType::Question,
|
||||
raw_request,
|
||||
);
|
||||
|
||||
let future_handle = ctx.spawn(
|
||||
async move {
|
||||
let start_time = Utc::now();
|
||||
(start_time, server_api
|
||||
.generate_dialogue_answer(transcript, request_for_api, ai_execution_context)
|
||||
.await)
|
||||
},
|
||||
move |model, (start_time, response), ctx| {
|
||||
let succeeded = response.is_ok();
|
||||
let end_time = Utc::now();
|
||||
let mut current_request_status = RequestStatus::NotInFlight;
|
||||
std::mem::swap(&mut model.request_status, &mut current_request_status);
|
||||
if let RequestStatus::InFlight { request, .. } = current_request_status {
|
||||
match response {
|
||||
Ok(GenerateDialogueResult::Success {
|
||||
mut answer,
|
||||
truncated,
|
||||
request_limit_info,
|
||||
transcript_summarized,
|
||||
}) => {
|
||||
if truncated {
|
||||
answer.push_str("...");
|
||||
}
|
||||
|
||||
let trimmed_response = answer.trim();
|
||||
let response_in_markdown = markdown_segments_from_text(
|
||||
transcript_part_index,
|
||||
TranscriptPartSubType::Answer,
|
||||
trimmed_response,
|
||||
);
|
||||
model.current_transcript.push(TranscriptPart {
|
||||
user: request,
|
||||
assistant: AssistantTranscriptPart {
|
||||
is_error: false,
|
||||
copy_all_tooltip_and_button_mouse_handles: Some((Default::default(), Default::default())),
|
||||
formatted_message: FormattedTranscriptMessage {
|
||||
markdown: response_in_markdown,
|
||||
raw: trimmed_response.to_string(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
cache_request_limit_info(request_limit_info, ctx);
|
||||
model.request_limit_info = request_limit_info;
|
||||
|
||||
// If the transcript was already marked as summarized before,
|
||||
// it will remain so until it's reset.
|
||||
model.current_transcript_summarized |= transcript_summarized;
|
||||
|
||||
|
||||
let req_latency = end_time.signed_duration_since(start_time).num_milliseconds();
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::WarpAIRequestIssued { result: WarpAIRequestResult::Succeeded { latency_ms: req_latency, truncated }},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
Ok(GenerateDialogueResult::Failure { request_limit_info }) if request_limit_info.limit <= request_limit_info.num_requests_used_since_refresh => {
|
||||
cache_request_limit_info(request_limit_info, ctx);
|
||||
model.request_limit_info = request_limit_info;
|
||||
let next_time = if let Some(next_refresh_time) = model.serialized_time_until_refresh() {
|
||||
format!("after {next_refresh_time}")
|
||||
} else {
|
||||
String::from("later")
|
||||
};
|
||||
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
||||
let response = if let Some(team) = UserWorkspaces::as_ref(ctx).current_team() {
|
||||
let current_user_email = auth_state.user_email().unwrap_or_default();
|
||||
let has_admin_permissions = team.has_admin_permissions(¤t_user_email);
|
||||
if team.billing_metadata.can_upgrade_to_higher_tier_plan() {
|
||||
if has_admin_permissions {
|
||||
let upgrade_url = UserWorkspaces::upgrade_link_for_team(team.uid);
|
||||
format!("It seems you're out of credits. Please try again {next_time}.\n\n[Upgrade]({upgrade_url}) for more credits.")
|
||||
} else {
|
||||
format!("It seems you're out of credits. Please try again {next_time}.\n\nContact a team admin to upgrade for more credits.")
|
||||
}
|
||||
} else {
|
||||
format!("It seems you're out of credits. Please try again {next_time}.")
|
||||
}
|
||||
} else {
|
||||
let user_id = auth_state.user_id().unwrap_or_default();
|
||||
let upgrade_url = UserWorkspaces::upgrade_link(user_id);
|
||||
format!("It seems you're out of credits. Please try again {next_time}.\n\n[Upgrade]({upgrade_url}) for more credits.")
|
||||
};
|
||||
let response_in_markdown = markdown_segments_from_text(
|
||||
transcript_part_index,
|
||||
TranscriptPartSubType::Answer,
|
||||
&response,
|
||||
);
|
||||
model.current_transcript.push(TranscriptPart {
|
||||
user: request,
|
||||
assistant: AssistantTranscriptPart {
|
||||
is_error: true,
|
||||
copy_all_tooltip_and_button_mouse_handles: None,
|
||||
formatted_message: FormattedTranscriptMessage {
|
||||
markdown: response_in_markdown,
|
||||
raw: response,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::WarpAIRequestIssued { result: WarpAIRequestResult::OutOfRequests},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let response = "We're experiencing technical difficulties right now. Please try again later.".to_owned();
|
||||
let response_in_markdown = markdown_segments_from_text(
|
||||
transcript_part_index,
|
||||
TranscriptPartSubType::Answer,
|
||||
&response,
|
||||
);
|
||||
model.current_transcript.push(TranscriptPart {
|
||||
user: request,
|
||||
assistant: AssistantTranscriptPart {
|
||||
is_error: true,
|
||||
copy_all_tooltip_and_button_mouse_handles: None,
|
||||
formatted_message: FormattedTranscriptMessage {
|
||||
markdown: response_in_markdown,
|
||||
raw: response,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::WarpAIRequestIssued { result: WarpAIRequestResult::Failed},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.emit(Event::RequestFinished { succeeded });
|
||||
ctx.notify();
|
||||
},
|
||||
);
|
||||
|
||||
self.request_status = RequestStatus::InFlight {
|
||||
request: FormattedTranscriptMessage {
|
||||
markdown: request_in_markdown,
|
||||
raw: raw_request.to_string(),
|
||||
},
|
||||
abort_handle: future_handle.abort_handle(),
|
||||
};
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if let RequestStatus::InFlight { abort_handle, .. } = &self.request_status {
|
||||
abort_handle.abort();
|
||||
}
|
||||
let mut old_transcript = Vec::new();
|
||||
std::mem::swap(&mut old_transcript, &mut self.current_transcript);
|
||||
self.old_transcript_parts.extend(old_transcript);
|
||||
self.request_status = RequestStatus::NotInFlight;
|
||||
self.current_transcript_summarized = false;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn transcript(&self) -> &[TranscriptPart] {
|
||||
self.current_transcript.as_slice()
|
||||
}
|
||||
|
||||
/// Includes the old transcript parts appended with the current
|
||||
/// transcript parts. You likely want to just be using the current transcript parts
|
||||
/// (exposed by the `Requests::transcript` API) in most use cases.
|
||||
fn total_transcript_history(&self) -> impl Iterator<Item = &TranscriptPart> {
|
||||
self.old_transcript_parts
|
||||
.iter()
|
||||
.chain(self.current_transcript.iter())
|
||||
}
|
||||
|
||||
pub fn all_past_transcript_prompts(&self) -> Vec<String> {
|
||||
self.total_transcript_history()
|
||||
.map(|p| p.raw_user_prompt().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn request_status(&self) -> &RequestStatus {
|
||||
&self.request_status
|
||||
}
|
||||
|
||||
pub fn current_transcript_summarized(&self) -> bool {
|
||||
self.current_transcript_summarized
|
||||
}
|
||||
|
||||
/// Returns the number of remaining requests the user has based on their latest rate limit info.
|
||||
/// If the current time is past the next refresh time, then the number of remaining reqs is the limit.
|
||||
pub fn num_remaining_reqs(&self) -> usize {
|
||||
match self.remaining_time_to_refresh_std() {
|
||||
Err(_) => self.request_limit_info.limit,
|
||||
Ok(t) if t.is_zero() => self.request_limit_info.limit,
|
||||
Ok(_t) => {
|
||||
self.request_limit_info.limit
|
||||
- self.request_limit_info.num_requests_used_since_refresh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn num_requests_used(&self) -> usize {
|
||||
self.request_limit_info.limit - self.num_remaining_reqs()
|
||||
}
|
||||
|
||||
pub fn request_limit(&self) -> usize {
|
||||
self.request_limit_info.limit
|
||||
}
|
||||
|
||||
/// Returns the next refresh time based on the latest rate limit info as a formatted string.
|
||||
/// If the current time is past the next refresh time, then returns None.
|
||||
pub fn serialized_time_until_refresh(&self) -> Option<String> {
|
||||
match self.remaining_time_to_refresh_std() {
|
||||
Err(_) => None,
|
||||
Ok(t) if t.is_zero() => None,
|
||||
Ok(t) => {
|
||||
let num_minutes = t.as_secs() / 60;
|
||||
let num_hours = num_minutes / 60;
|
||||
let num_days = num_hours / 24;
|
||||
let remaining_text = if num_days > 0 {
|
||||
format!("{num_days} days")
|
||||
} else if num_hours > 0 {
|
||||
format!("{num_hours} hours")
|
||||
} else {
|
||||
format!("{num_minutes} minutes")
|
||||
};
|
||||
Some(remaining_text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Requests {
|
||||
pub fn new_with_transcript(transcript: Vec<TranscriptPart>) -> Self {
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
|
||||
Self {
|
||||
server_api: ServerApiProvider::new_for_test().get(),
|
||||
ai_client: ServerApiProvider::new_for_test().get_ai_client(),
|
||||
current_transcript: transcript,
|
||||
current_transcript_summarized: false,
|
||||
old_transcript_parts: Vec::new(),
|
||||
request_status: RequestStatus::NotInFlight,
|
||||
request_limit_info: RequestLimitInfo::default(),
|
||||
ai_execution_context: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use crate::ai_assistant::utils::{
|
||||
AssistantTranscriptPart, CodeBlockIndex, FormattedTranscriptMessage, MarkdownSegment,
|
||||
};
|
||||
use markdown_parser::{CodeBlockText, FormattedText};
|
||||
|
||||
pub fn default_code_block_segment(code_block_index: CodeBlockIndex) -> MarkdownSegment {
|
||||
MarkdownSegment::CodeBlock {
|
||||
index: code_block_index,
|
||||
code: CodeBlockText {
|
||||
lang: String::from(""),
|
||||
code: String::from(""),
|
||||
},
|
||||
mouse_state_handles: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_other_segment() -> MarkdownSegment {
|
||||
MarkdownSegment::Other {
|
||||
formatted_text: FormattedText::new(vec![]),
|
||||
highlighted_hyperlink: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_formatted_message(segments: Vec<MarkdownSegment>) -> FormattedTranscriptMessage {
|
||||
FormattedTranscriptMessage {
|
||||
markdown: Some(segments),
|
||||
raw: String::from(""),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_assistant_transcript_part(
|
||||
formatted_transcript_message: FormattedTranscriptMessage,
|
||||
) -> AssistantTranscriptPart {
|
||||
AssistantTranscriptPart {
|
||||
is_error: false,
|
||||
formatted_message: formatted_transcript_message,
|
||||
copy_all_tooltip_and_button_mouse_handles: None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,979 @@
|
||||
use markdown_parser::markdown_parser::RUNNABLE_BLOCK_MARKDOWN_LANG;
|
||||
use markdown_parser::CodeBlockText;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::builder::AnimatedButtonOptions;
|
||||
use warpui::clipboard::ClipboardContent;
|
||||
use warpui::elements::{DispatchEventResult, Stack};
|
||||
use warpui::units::Pixels;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
|
||||
Container, CornerRadius, CrossAxisAlignment, EventHandler, Fill, Flex,
|
||||
FormattedTextElement, HyperlinkUrl, Icon, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle, ParentAnchor, ParentElement, Radius, SavePosition, ScrollbarWidth,
|
||||
Shrinkable, Text, Wrap,
|
||||
},
|
||||
keymap::Keystroke,
|
||||
platform::Cursor,
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
units::IntoPixels,
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
WeakViewHandle,
|
||||
};
|
||||
use warpui::{BlurContext, FocusContext};
|
||||
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
send_telemetry_from_ctx,
|
||||
server::telemetry::{SaveAsWorkflowModalSource, TelemetryEvent, WarpAIActionType},
|
||||
ui_components::blended_colors,
|
||||
};
|
||||
|
||||
use super::panel::HEADER_HEIGHT;
|
||||
use super::{
|
||||
panel::HEXAGON_ALERT_SVG_PATH,
|
||||
requests::{RequestStatus, Requests},
|
||||
utils::{
|
||||
code_block_position_id, markdown_segments_from_text, render_prepared_response_button,
|
||||
render_request_limit_info, save_as_workflow_position_id, AssistantTranscriptPart,
|
||||
CodeBlockIndex, FormattedTranscriptMessage, MarkdownSegment, TranscriptPartSubType,
|
||||
},
|
||||
AI_ASSISTANT_SVG_PATH,
|
||||
};
|
||||
|
||||
const TRANSCRIPT_POSITION_ID: &str = "ai_assistant::transcript";
|
||||
|
||||
const TERMINAL_INPUT_SVG_PATH: &str = "bundled/svg/terminal-input.svg";
|
||||
const USER_ICON_SVG_PATH: &str = "bundled/svg/user.svg";
|
||||
const SAVE_WORKFLOW_ICON_PATH: &str = "bundled/svg/workflow.svg";
|
||||
|
||||
const BODY_FONT_SIZE: f32 = 13.;
|
||||
const CODE_FONT_SIZE: f32 = 12.;
|
||||
const WARNING_MESSAGE_FONT_SIZE: f32 = 10.;
|
||||
|
||||
const PANEL_LEFT_MARGIN: f32 = 15.;
|
||||
const DETAILS_BOTTOM_MARGIN: f32 = 12.;
|
||||
|
||||
const COPY_BUTTON_SIZE: f32 = 14.;
|
||||
const TERMINAL_INPUT_BUTTON_SIZE: f32 = 20.;
|
||||
const SAVE_AS_WORKFLOW_BUTTON_SIZE: f32 = 20.;
|
||||
|
||||
const HOW_DO_I_FIX_PROMPT: &str = "How do I fix this?";
|
||||
const SHOW_EXAMPLES_PROMPT: &str = "Show examples.";
|
||||
const WHAT_TO_DO_NEXT_PROMPT: &str = "What should I do next?";
|
||||
const IN_FLIGHT_REQUEST_TEXT: &str = "Generating answer...";
|
||||
const ACCURACY_NOTICE_TEXT: &str = "AI responses can be inaccurate.";
|
||||
const MISSING_CONTEXT_NOTICE_TEXT: &str =
|
||||
"Warp AI might forget earlier answers as conversations get long.";
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref SCROLL_BUFFER_OFFSET_PX: Pixels = (10.).into_pixels();
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CodeBlockMouseStateHandles {
|
||||
pub play_button: MouseStateHandle,
|
||||
pub play_button_tooltip: MouseStateHandle,
|
||||
pub copy_button: MouseStateHandle,
|
||||
pub copy_button_tooltip: MouseStateHandle,
|
||||
pub save_as_workflow_button: MouseStateHandle,
|
||||
pub save_as_workflow_button_tooltip: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
show_examples_button: MouseStateHandle,
|
||||
what_to_do_next_button: MouseStateHandle,
|
||||
how_do_i_fix_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// A view to render a Q/A style transcript.
|
||||
pub struct Transcript {
|
||||
view_handle: WeakViewHandle<Transcript>,
|
||||
|
||||
requests_model: ModelHandle<Requests>,
|
||||
selected_code_block: Option<CodeBlockIndex>,
|
||||
|
||||
clipped_scroll_state: ClippedScrollStateHandle,
|
||||
mouse_state_handles: MouseStateHandles,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TranscriptAction {
|
||||
CopyAnswerToClipboard {
|
||||
transcript_part_index: usize,
|
||||
},
|
||||
CopyCodeToClipboard {
|
||||
code_block_index: CodeBlockIndex,
|
||||
},
|
||||
PasteInTerminalInput {
|
||||
code_block_index: CodeBlockIndex,
|
||||
},
|
||||
OpenWorkflowModal(CodeBlockIndex),
|
||||
ClickedCodeBlock {
|
||||
code_block_index: CodeBlockIndex,
|
||||
},
|
||||
ClickedUrl(HyperlinkUrl),
|
||||
Keydown(Keystroke),
|
||||
/// A mouse down event outside of the other clickable elements (e.g. buttons, code blocks, etc.)
|
||||
MouseDown,
|
||||
}
|
||||
|
||||
pub enum TranscriptEvent {
|
||||
PasteInTerminalInput { code_block_index: CodeBlockIndex },
|
||||
FocusEditor,
|
||||
FocusTranscript,
|
||||
ClickedCodeBlock,
|
||||
OpenWorkflowModalWithCommand(String),
|
||||
}
|
||||
|
||||
impl Entity for Transcript {
|
||||
type Event = TranscriptEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for Transcript {
|
||||
type Action = TranscriptAction;
|
||||
|
||||
fn handle_action(&mut self, action: &TranscriptAction, ctx: &mut ViewContext<Self>) {
|
||||
use TranscriptAction::*;
|
||||
|
||||
match action {
|
||||
CopyAnswerToClipboard {
|
||||
transcript_part_index,
|
||||
} => {
|
||||
let answer = self
|
||||
.requests_model
|
||||
.as_ref(ctx)
|
||||
.transcript()
|
||||
.get(*transcript_part_index)
|
||||
.map(|p| p.assistant.formatted_message.raw.clone());
|
||||
|
||||
if let Some(answer) = answer {
|
||||
ctx.clipboard().write(ClipboardContent::plain_text(answer));
|
||||
}
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::WarpAIAction {
|
||||
action_type: WarpAIActionType::CopyAnswer
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
CopyCodeToClipboard { code_block_index } => {
|
||||
self.copy_code_to_clipboard(*code_block_index, ctx);
|
||||
}
|
||||
PasteInTerminalInput { code_block_index } => {
|
||||
self.paste_in_terminal_input(*code_block_index, ctx);
|
||||
}
|
||||
OpenWorkflowModal(code_block_index) => self.open_workflow_modal(*code_block_index, ctx),
|
||||
ClickedUrl(url) => {
|
||||
ctx.open_url(&url.url);
|
||||
}
|
||||
ClickedCodeBlock { code_block_index } => {
|
||||
self.selected_code_block = Some(*code_block_index);
|
||||
ctx.emit(TranscriptEvent::ClickedCodeBlock);
|
||||
ctx.notify();
|
||||
}
|
||||
Keydown(keystroke) => self.handle_keydown(keystroke, ctx),
|
||||
MouseDown => {
|
||||
if self.selected_code_block.is_none() {
|
||||
ctx.emit(TranscriptEvent::FocusEditor);
|
||||
} else {
|
||||
ctx.emit(TranscriptEvent::FocusTranscript);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transcript {
|
||||
pub fn new(requests_model: &ModelHandle<Requests>, ctx: &mut ViewContext<Self>) -> Self {
|
||||
ctx.observe(requests_model, |_, _, ctx| ctx.notify());
|
||||
|
||||
Self {
|
||||
view_handle: ctx.handle(),
|
||||
requests_model: requests_model.to_owned(),
|
||||
selected_code_block: None,
|
||||
clipped_scroll_state: Default::default(),
|
||||
mouse_state_handles: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_code_to_clipboard(
|
||||
&mut self,
|
||||
code_block_index: CodeBlockIndex,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(code) = self.code_for_index(code_block_index, ctx) {
|
||||
ctx.clipboard().write(ClipboardContent::plain_text(code));
|
||||
}
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::WarpAIAction {
|
||||
action_type: WarpAIActionType::CopyCode
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
fn paste_in_terminal_input(
|
||||
&mut self,
|
||||
code_block_index: CodeBlockIndex,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
ctx.emit(TranscriptEvent::PasteInTerminalInput { code_block_index });
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::WarpAIAction {
|
||||
action_type: WarpAIActionType::InsertIntoInput
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
fn open_workflow_modal(
|
||||
&mut self,
|
||||
code_block_index: CodeBlockIndex,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(code) = self.code_for_index(code_block_index, ctx) {
|
||||
ctx.emit(TranscriptEvent::OpenWorkflowModalWithCommand(code));
|
||||
}
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SaveAsWorkflowModal {
|
||||
source: SaveAsWorkflowModalSource::WarpAIPanel
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_keydown(&mut self, keystroke: &Keystroke, ctx: &mut ViewContext<Self>) {
|
||||
let Some(selected_block_index) = self.selected_code_block else {
|
||||
return;
|
||||
};
|
||||
|
||||
if keystroke.key == "down" {
|
||||
let new_index = self.next_code_block_index(ctx);
|
||||
if new_index.is_some() {
|
||||
self.selected_code_block = new_index;
|
||||
} else if keystroke.cmd {
|
||||
self.selected_code_block = None;
|
||||
self.scroll_to_bottom_of_transcript(ctx);
|
||||
ctx.emit(TranscriptEvent::FocusEditor);
|
||||
}
|
||||
ctx.notify();
|
||||
} else if keystroke.key == "up" {
|
||||
let new_index = self.previous_code_block_index(ctx);
|
||||
if new_index.is_some() {
|
||||
self.selected_code_block = new_index;
|
||||
ctx.notify();
|
||||
}
|
||||
} else if keystroke.cmd && keystroke.key == "c" {
|
||||
self.copy_code_to_clipboard(selected_block_index, ctx);
|
||||
} else if keystroke.cmd && keystroke.key == "enter" {
|
||||
self.paste_in_terminal_input(selected_block_index, ctx);
|
||||
} else if keystroke.cmd && keystroke.key == "s" {
|
||||
self.open_workflow_modal(selected_block_index, ctx);
|
||||
} else if keystroke.key == "escape" {
|
||||
self.selected_code_block = None;
|
||||
ctx.emit(TranscriptEvent::FocusEditor);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
// If we took an action on a code block or changed code blocks, let's scroll to it
|
||||
// so the user knows what's going on.
|
||||
if let Some(selected_code_block) = self.selected_code_block {
|
||||
self.scroll_to_code_block(selected_code_block, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Only scrolls to the code block if it isn't already in the viewport.
|
||||
fn scroll_to_code_block(
|
||||
&mut self,
|
||||
code_block_index: CodeBlockIndex,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(transcript_pos) = ctx.element_position_by_id(TRANSCRIPT_POSITION_ID) else {
|
||||
return;
|
||||
};
|
||||
let Some(code_block_pos) =
|
||||
ctx.element_position_by_id(code_block_position_id(code_block_index))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let current_scroll_top_px = self.clipped_scroll_state.scroll_start();
|
||||
let viewable_transcript_height_px = transcript_pos.height().into_pixels();
|
||||
let code_block_start_y_px =
|
||||
code_block_pos.origin_y().into_pixels() - transcript_pos.origin_y().into_pixels();
|
||||
let code_block_end_y_px =
|
||||
code_block_pos.origin_y().into_pixels() + code_block_pos.height().into_pixels();
|
||||
|
||||
// We only need to scroll if either the start of the code block is cut off or the end is cut off.
|
||||
if code_block_start_y_px < Pixels::zero()
|
||||
|| code_block_end_y_px > viewable_transcript_height_px
|
||||
{
|
||||
// In the case that the new scroll top exceeds the max scroll top, the after_layout
|
||||
// of clipped scrollable will re-adjust accordingly, so this is safe.
|
||||
self.clipped_scroll_state.scroll_to(
|
||||
current_scroll_top_px + code_block_start_y_px - *SCROLL_BUFFER_OFFSET_PX,
|
||||
);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom_of_transcript(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
// This relies on the fact that the clipped scrollable will recompute
|
||||
// the scroll_top in after_layout if it exceeds the true max.
|
||||
self.clipped_scroll_state.scroll_to(f32::MAX.into_pixels());
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn previous_code_block_index(&self, ctx: &mut ViewContext<Self>) -> Option<CodeBlockIndex> {
|
||||
let transcript = self.requests_model.as_ref(ctx).transcript();
|
||||
let selected_code_block_index = self.selected_code_block?;
|
||||
let transcript_index = selected_code_block_index.transcript_index();
|
||||
|
||||
// Try to find the prev code block in the current part.
|
||||
let found = transcript
|
||||
.get(transcript_index)
|
||||
.and_then(|p| p.prev_code_block_index(selected_code_block_index));
|
||||
|
||||
// If it's not in the current part, then take the last code block from the closest
|
||||
// transcript part to this one (in reverse).
|
||||
found.or_else(|| {
|
||||
transcript
|
||||
.get(..transcript_index)?
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|part| part.last_code_block_index())
|
||||
})
|
||||
}
|
||||
|
||||
fn next_code_block_index(&self, ctx: &mut ViewContext<Self>) -> Option<CodeBlockIndex> {
|
||||
let transcript = self.requests_model.as_ref(ctx).transcript();
|
||||
let selected_code_block_index = self.selected_code_block?;
|
||||
let transcript_index = selected_code_block_index.transcript_index();
|
||||
|
||||
// Try to find the next code block in the current part.
|
||||
let found = transcript
|
||||
.get(transcript_index)
|
||||
.and_then(|p| p.next_code_block_index(selected_code_block_index));
|
||||
|
||||
// If it's not in the current part, then take the first code block from the closest
|
||||
// transcript part to this one (in sequence).
|
||||
found.or_else(|| {
|
||||
transcript
|
||||
.get(transcript_index + 1..)?
|
||||
.iter()
|
||||
.find_map(|part| part.first_code_block_index())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn select_last_code_block(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let transcript = self.requests_model.as_ref(ctx).transcript();
|
||||
// The last code block will be the last code block in the first transcript part starting from the end.
|
||||
let code_block_index = transcript
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|p| p.last_code_block_index());
|
||||
self.selected_code_block = code_block_index;
|
||||
|
||||
if let Some(new_code_block) = self.selected_code_block {
|
||||
self.scroll_to_code_block(new_code_block, ctx);
|
||||
ctx.emit(TranscriptEvent::ClickedCodeBlock);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn clear_selected_block(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.selected_code_block = None;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn code_for_index(
|
||||
&self,
|
||||
code_block_index: CodeBlockIndex,
|
||||
app: &AppContext,
|
||||
) -> Option<String> {
|
||||
let transcript = self.requests_model.as_ref(app).transcript();
|
||||
transcript
|
||||
.get(code_block_index.transcript_index())
|
||||
.and_then(|p| p.code_for_block(code_block_index).map(ToOwned::to_owned))
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.selected_code_block = None;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Rendering-related implementation.
|
||||
impl Transcript {
|
||||
fn render_code_block_actions(
|
||||
&self,
|
||||
code_block_index: CodeBlockIndex,
|
||||
appearance: &Appearance,
|
||||
code_block_info: &CodeBlockText,
|
||||
mouse_state_handles: &CodeBlockMouseStateHandles,
|
||||
) -> Box<dyn Element> {
|
||||
let mut buttons = Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::End)
|
||||
.with_main_axis_size(MainAxisSize::Max);
|
||||
|
||||
let copy_button = appearance
|
||||
.ui_builder()
|
||||
.copy_button(COPY_BUTTON_SIZE, mouse_state_handles.copy_button.clone())
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TranscriptAction::CopyCodeToClipboard {
|
||||
code_block_index,
|
||||
});
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
buttons.add_child(appearance.ui_builder().tool_tip_on_element(
|
||||
"Copy code to clipboard [Cmd + C]".to_string(),
|
||||
mouse_state_handles.copy_button_tooltip.clone(),
|
||||
copy_button,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::BottomRight,
|
||||
vec2f(0., -5.),
|
||||
));
|
||||
|
||||
if code_block_info.lang.ends_with("sh")
|
||||
|| code_block_info.lang == RUNNABLE_BLOCK_MARKDOWN_LANG
|
||||
{
|
||||
let insert_button = appearance
|
||||
.ui_builder()
|
||||
.animated_button(
|
||||
mouse_state_handles.play_button.clone(),
|
||||
TERMINAL_INPUT_SVG_PATH,
|
||||
AnimatedButtonOptions {
|
||||
size: TERMINAL_INPUT_BUTTON_SIZE,
|
||||
padding: Some(4.),
|
||||
color: None,
|
||||
with_accent_animations: true,
|
||||
circular: true,
|
||||
},
|
||||
)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TranscriptAction::PasteInTerminalInput {
|
||||
code_block_index,
|
||||
});
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
buttons.add_child(
|
||||
Container::new(appearance.ui_builder().tool_tip_on_element(
|
||||
"Insert code into terminal input [Cmd + Enter]".to_string(),
|
||||
mouse_state_handles.play_button_tooltip.clone(),
|
||||
insert_button,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::BottomRight,
|
||||
vec2f(0., -5.),
|
||||
))
|
||||
.with_margin_left(10.)
|
||||
.with_margin_bottom(-4.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let save_as_workflow_button = appearance
|
||||
.ui_builder()
|
||||
.animated_button(
|
||||
mouse_state_handles.save_as_workflow_button.clone(),
|
||||
SAVE_WORKFLOW_ICON_PATH,
|
||||
AnimatedButtonOptions {
|
||||
size: SAVE_AS_WORKFLOW_BUTTON_SIZE,
|
||||
padding: Some(4.),
|
||||
color: None,
|
||||
with_accent_animations: true,
|
||||
circular: true,
|
||||
},
|
||||
)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TranscriptAction::OpenWorkflowModal(code_block_index))
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
buttons.add_child(
|
||||
SavePosition::new(
|
||||
Container::new(appearance.ui_builder().tool_tip_on_element(
|
||||
"Save as workflow [Cmd + S]".to_string(),
|
||||
mouse_state_handles.save_as_workflow_button_tooltip.clone(),
|
||||
save_as_workflow_button,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::BottomRight,
|
||||
vec2f(0., -5.),
|
||||
))
|
||||
.with_margin_left(2.)
|
||||
.with_margin_bottom(-4.)
|
||||
.finish(),
|
||||
&save_as_workflow_position_id(code_block_index),
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
buttons.finish()
|
||||
}
|
||||
|
||||
fn render_assistant_answer(
|
||||
&self,
|
||||
transcript_part_index: usize,
|
||||
part: &AssistantTranscriptPart,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let background_color = theme.surface_2().into_solid();
|
||||
let icon = if part.is_error {
|
||||
ConstrainedBox::new(
|
||||
Icon::new(HEXAGON_ALERT_SVG_PATH, appearance.theme().ui_error_color()).finish(),
|
||||
)
|
||||
.with_height(18.)
|
||||
.with_width(18.)
|
||||
.finish()
|
||||
} else {
|
||||
ConstrainedBox::new(
|
||||
Icon::new(
|
||||
AI_ASSISTANT_SVG_PATH,
|
||||
theme.main_text_color(background_color.into()),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(16.)
|
||||
.with_width(16.)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let bottom_right_element = part.copy_all_tooltip_and_button_mouse_handles.clone().map(
|
||||
|(tooltip_handle, button_handle)| {
|
||||
let copy_button = appearance
|
||||
.ui_builder()
|
||||
.copy_button(16., button_handle)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TranscriptAction::CopyAnswerToClipboard {
|
||||
transcript_part_index,
|
||||
})
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
appearance.ui_builder().tool_tip_on_element(
|
||||
"Copy answer to clipboard".to_string(),
|
||||
tooltip_handle,
|
||||
copy_button,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::BottomRight,
|
||||
vec2f(0., -5.),
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
self.render_message(
|
||||
&part.formatted_message,
|
||||
background_color,
|
||||
icon,
|
||||
bottom_right_element,
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_user_prompt(
|
||||
&self,
|
||||
dialogue: &FormattedTranscriptMessage,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let background_color = theme.surface_1().into_solid();
|
||||
let icon = ConstrainedBox::new(
|
||||
Icon::new(
|
||||
USER_ICON_SVG_PATH,
|
||||
theme.main_text_color(background_color.into()),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(16.)
|
||||
.with_width(16.)
|
||||
.finish();
|
||||
self.render_message(dialogue, background_color, icon, None, appearance)
|
||||
}
|
||||
|
||||
/// Renders a single message (whether that be a user's prompt or assistant's answer).
|
||||
fn render_message(
|
||||
&self,
|
||||
dialogue: &FormattedTranscriptMessage,
|
||||
background_color: ColorU,
|
||||
icon: Box<dyn Element>,
|
||||
bottom_right_element: Option<Box<dyn Element>>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let inline_code_bg_color = appearance.theme().surface_3().into_solid();
|
||||
|
||||
let body = if let Some(parts) = &dialogue.markdown {
|
||||
let mut column = Flex::column();
|
||||
for part in parts {
|
||||
let column_part = match part {
|
||||
MarkdownSegment::Other {
|
||||
formatted_text,
|
||||
highlighted_hyperlink,
|
||||
} => FormattedTextElement::new(
|
||||
formatted_text.to_owned(),
|
||||
BODY_FONT_SIZE,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_family(),
|
||||
theme.main_text_color(theme.surface_2()).into_solid(),
|
||||
highlighted_hyperlink.clone(),
|
||||
)
|
||||
.with_inline_code_properties(
|
||||
Some(theme.nonactive_ui_text_color().into()),
|
||||
Some(inline_code_bg_color),
|
||||
)
|
||||
.register_default_click_handlers(move |url, ctx, _| {
|
||||
ctx.dispatch_typed_action(TranscriptAction::ClickedUrl(url));
|
||||
})
|
||||
.finish(),
|
||||
MarkdownSegment::CodeBlock {
|
||||
index,
|
||||
code,
|
||||
mouse_state_handles,
|
||||
} => {
|
||||
let actions = self.render_code_block_actions(
|
||||
*index,
|
||||
appearance,
|
||||
code,
|
||||
mouse_state_handles,
|
||||
);
|
||||
let code = code.code.clone();
|
||||
|
||||
let (border_fill, border_width, padding) =
|
||||
if self.selected_code_block == Some(*index) {
|
||||
(appearance.theme().accent(), 1.5, 11.5)
|
||||
} else {
|
||||
(appearance.theme().outline(), 1., 12.)
|
||||
};
|
||||
|
||||
let code_block_index = *index;
|
||||
|
||||
EventHandler::new(
|
||||
Container::new(
|
||||
SavePosition::new(
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_child(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(code, true)
|
||||
.with_style(UiComponentStyles {
|
||||
font_family_id: Some(
|
||||
appearance.monospace_font_family(),
|
||||
),
|
||||
font_size: Some(CODE_FONT_SIZE),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.with_margin_bottom(10.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(actions)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(padding)
|
||||
.with_border(
|
||||
Border::all(border_width).with_border_fill(border_fill),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.finish(),
|
||||
&code_block_position_id(code_block_index),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(10.)
|
||||
.with_margin_bottom(10.)
|
||||
.finish(),
|
||||
)
|
||||
.on_left_mouse_down(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TranscriptAction::ClickedCodeBlock {
|
||||
code_block_index,
|
||||
});
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
};
|
||||
|
||||
column.add_child(column_part);
|
||||
}
|
||||
|
||||
column.finish()
|
||||
} else {
|
||||
// If we don't have the markdown representation, just render it as basic text.
|
||||
appearance
|
||||
.ui_builder()
|
||||
.wrappable_text(dialogue.raw.to_owned(), true)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(BODY_FONT_SIZE),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish()
|
||||
};
|
||||
|
||||
let mut final_col = Flex::column().with_child(body);
|
||||
|
||||
if let Some(bottom_right_element) = bottom_right_element {
|
||||
final_col.add_child(
|
||||
Align::new(
|
||||
Container::new(bottom_right_element)
|
||||
.with_margin_top(16.)
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Container::new(icon)
|
||||
.with_margin_right(12.)
|
||||
.with_margin_top(3.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Shrinkable::new(1., Container::new(final_col.finish()).finish()).finish());
|
||||
|
||||
Container::new(row.finish())
|
||||
.with_background_color(background_color)
|
||||
.with_padding_left(PANEL_LEFT_MARGIN)
|
||||
.with_padding_top(16.)
|
||||
.with_padding_bottom(16.)
|
||||
.with_padding_right(20.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_prepared_responses(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Wrap::row()
|
||||
.with_run_spacing(10.)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_child(render_prepared_response_button(
|
||||
appearance,
|
||||
self.mouse_state_handles.what_to_do_next_button.clone(),
|
||||
None,
|
||||
Some(8.),
|
||||
WHAT_TO_DO_NEXT_PROMPT,
|
||||
))
|
||||
.with_child(
|
||||
Container::new(render_prepared_response_button(
|
||||
appearance,
|
||||
self.mouse_state_handles.show_examples_button.clone(),
|
||||
None,
|
||||
Some(8.),
|
||||
SHOW_EXAMPLES_PROMPT,
|
||||
))
|
||||
.with_margin_left(10.)
|
||||
.with_margin_right(10.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(render_prepared_response_button(
|
||||
appearance,
|
||||
self.mouse_state_handles.how_do_i_fix_button.clone(),
|
||||
None,
|
||||
Some(8.),
|
||||
HOW_DO_I_FIX_PROMPT,
|
||||
))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_warning_message(&self, message: String, appearance: &Appearance) -> Box<dyn Element> {
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new_inline(
|
||||
message,
|
||||
appearance.ui_font_family(),
|
||||
WARNING_MESSAGE_FONT_SIZE,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
))
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl View for Transcript {
|
||||
fn ui_name() -> &'static str {
|
||||
"AIAssistantTranscript"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let transcript = self.requests_model.as_ref(app).transcript();
|
||||
let request_status = self.requests_model.as_ref(app).request_status();
|
||||
let num_remaining_reqs = self.requests_model.as_ref(app).num_remaining_reqs();
|
||||
|
||||
let mut blocks = Flex::column();
|
||||
for (index, part) in transcript.iter().enumerate() {
|
||||
blocks.add_child(self.render_user_prompt(&part.user, appearance));
|
||||
blocks.add_child(self.render_assistant_answer(index, &part.assistant, appearance));
|
||||
}
|
||||
|
||||
if let RequestStatus::InFlight { request, .. } = request_status {
|
||||
blocks.add_child(self.render_user_prompt(request, appearance));
|
||||
|
||||
let transcript_part_index = transcript.len();
|
||||
let in_flight_request_markdown = markdown_segments_from_text(
|
||||
transcript_part_index,
|
||||
TranscriptPartSubType::Answer,
|
||||
IN_FLIGHT_REQUEST_TEXT,
|
||||
);
|
||||
blocks.add_child(self.render_assistant_answer(
|
||||
transcript_part_index,
|
||||
&AssistantTranscriptPart {
|
||||
is_error: false,
|
||||
copy_all_tooltip_and_button_mouse_handles: None,
|
||||
formatted_message: FormattedTranscriptMessage {
|
||||
markdown: in_flight_request_markdown,
|
||||
raw: IN_FLIGHT_REQUEST_TEXT.to_owned(),
|
||||
},
|
||||
},
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
|
||||
if !transcript.is_empty() && matches!(request_status, RequestStatus::NotInFlight) {
|
||||
// Only show the prepared responses if the last response wasn't an error
|
||||
// and the user still has remaining requests.
|
||||
if !transcript.last().is_none_or(|p| p.assistant.is_error) && num_remaining_reqs > 0 {
|
||||
blocks.add_child(
|
||||
Container::new(self.render_prepared_responses(appearance))
|
||||
.with_margin_top(15.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let is_custom_llm_enabled: bool = UserWorkspaces::as_ref(app)
|
||||
.current_team()
|
||||
.is_some_and(|team| team.is_custom_llm_enabled());
|
||||
|
||||
if !is_custom_llm_enabled {
|
||||
blocks.add_child(
|
||||
Container::new(render_request_limit_info(
|
||||
&self.requests_model,
|
||||
app,
|
||||
appearance,
|
||||
))
|
||||
.with_margin_top(15.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let current_transcript_summarized = self
|
||||
.requests_model
|
||||
.as_ref(app)
|
||||
.current_transcript_summarized();
|
||||
|
||||
blocks.add_child(
|
||||
Container::new(
|
||||
self.render_warning_message(ACCURACY_NOTICE_TEXT.to_string(), appearance),
|
||||
)
|
||||
.with_margin_top(DETAILS_BOTTOM_MARGIN)
|
||||
.with_margin_bottom(if current_transcript_summarized {
|
||||
DETAILS_BOTTOM_MARGIN / 2.
|
||||
} else {
|
||||
DETAILS_BOTTOM_MARGIN
|
||||
})
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if current_transcript_summarized {
|
||||
blocks.add_child(
|
||||
Container::new(self.render_warning_message(
|
||||
MISSING_CONTEXT_NOTICE_TEXT.to_string(),
|
||||
appearance,
|
||||
))
|
||||
.with_margin_bottom(DETAILS_BOTTOM_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: we don't render a scrollbar because the gutter makes the segmented transcript
|
||||
// look "broken".
|
||||
let transcript = SavePosition::new(
|
||||
ClippedScrollable::vertical(
|
||||
self.clipped_scroll_state.clone(),
|
||||
blocks.finish(),
|
||||
ScrollbarWidth::None,
|
||||
theme.disabled_text_color(theme.background()).into(),
|
||||
theme.main_text_color(theme.background()).into(),
|
||||
Fill::None,
|
||||
)
|
||||
.with_padding_end(0.)
|
||||
.with_padding_start(0.)
|
||||
.finish(),
|
||||
TRANSCRIPT_POSITION_ID,
|
||||
)
|
||||
.finish();
|
||||
|
||||
let mut navigatable_transcript =
|
||||
EventHandler::new(transcript).on_left_mouse_down(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(TranscriptAction::MouseDown);
|
||||
DispatchEventResult::StopPropagation
|
||||
});
|
||||
|
||||
// Only handle keydown events when a code block is selected and the transcript is focused.
|
||||
let is_focused = self
|
||||
.view_handle
|
||||
.upgrade(app)
|
||||
.is_some_and(|v| v.is_focused(app));
|
||||
if self.selected_code_block.is_some() && is_focused {
|
||||
navigatable_transcript = navigatable_transcript.on_keydown(|ctx, _, keystroke| {
|
||||
ctx.dispatch_typed_action(TranscriptAction::Keydown(keystroke.to_owned()));
|
||||
DispatchEventResult::StopPropagation
|
||||
});
|
||||
}
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_child(
|
||||
Container::new(navigatable_transcript.finish())
|
||||
.with_padding_top(HEADER_HEIGHT)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
stack.finish()
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
if focus_ctx.is_self_focused() {
|
||||
// Force a re-render to reflect the fact that this view is now focused.
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
|
||||
if blur_ctx.is_self_blurred() {
|
||||
// Force a re-render to reflect the fact that this view is now blurred.
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "transcript_tests.rs"]
|
||||
mod transcript_tests;
|
||||
@@ -0,0 +1,164 @@
|
||||
use crate::{
|
||||
appearance, test_util::settings::initialize_settings_for_tests,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
|
||||
use warpui::{platform::WindowStyle, App};
|
||||
|
||||
use crate::ai_assistant::{
|
||||
requests::Requests,
|
||||
test_util::{
|
||||
default_assistant_transcript_part, default_code_block_segment, default_formatted_message,
|
||||
default_other_segment,
|
||||
},
|
||||
utils::{CodeBlockIndex, TranscriptPart, TranscriptPartSubType},
|
||||
};
|
||||
|
||||
use super::Transcript;
|
||||
|
||||
// Mocked data to make it easy to test.
|
||||
lazy_static::lazy_static! {
|
||||
static ref TRANSCRIPT: Vec<TranscriptPart> = vec![
|
||||
TranscriptPart {
|
||||
user: default_formatted_message(vec![
|
||||
default_other_segment(),
|
||||
default_code_block_segment(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 0)),
|
||||
default_other_segment(),
|
||||
default_code_block_segment(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 1)),
|
||||
|
||||
]),
|
||||
assistant: default_assistant_transcript_part(default_formatted_message(vec![
|
||||
default_code_block_segment(CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 0)),
|
||||
])),
|
||||
},
|
||||
TranscriptPart {
|
||||
user: default_formatted_message(vec![
|
||||
default_code_block_segment(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 0)),
|
||||
default_code_block_segment(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1)),
|
||||
|
||||
]),
|
||||
assistant: default_assistant_transcript_part(default_formatted_message(vec![
|
||||
default_other_segment(),
|
||||
default_other_segment(),
|
||||
])),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
appearance::register(app);
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_code_block() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let requests_model = app.add_model(|_| Requests::new_with_transcript(TRANSCRIPT.clone()));
|
||||
let (_, transcript_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
Transcript::new(&requests_model, ctx)
|
||||
});
|
||||
transcript_view.update(&mut app, |view, ctx| {
|
||||
// Starting point
|
||||
view.selected_code_block =
|
||||
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 0));
|
||||
|
||||
let next_code_block = view.next_code_block_index(ctx);
|
||||
assert_eq!(
|
||||
next_code_block,
|
||||
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 1))
|
||||
);
|
||||
view.selected_code_block = next_code_block;
|
||||
|
||||
let next_code_block = view.next_code_block_index(ctx);
|
||||
assert_eq!(
|
||||
next_code_block,
|
||||
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 0))
|
||||
);
|
||||
view.selected_code_block = next_code_block;
|
||||
|
||||
let next_code_block = view.next_code_block_index(ctx);
|
||||
assert_eq!(
|
||||
next_code_block,
|
||||
Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 0))
|
||||
);
|
||||
view.selected_code_block = next_code_block;
|
||||
|
||||
let next_code_block = view.next_code_block_index(ctx);
|
||||
assert_eq!(
|
||||
next_code_block,
|
||||
Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1))
|
||||
);
|
||||
view.selected_code_block = next_code_block;
|
||||
|
||||
let next_code_block = view.next_code_block_index(ctx);
|
||||
assert_eq!(next_code_block, None)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prev_code_block() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let requests_model = app.add_model(|_| Requests::new_with_transcript(TRANSCRIPT.clone()));
|
||||
let (_, transcript_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
Transcript::new(&requests_model, ctx)
|
||||
});
|
||||
transcript_view.update(&mut app, |view, ctx| {
|
||||
// Starting point
|
||||
view.selected_code_block =
|
||||
Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1));
|
||||
|
||||
let prev_code_block = view.previous_code_block_index(ctx);
|
||||
assert_eq!(
|
||||
prev_code_block,
|
||||
Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 0))
|
||||
);
|
||||
view.selected_code_block = prev_code_block;
|
||||
|
||||
let prev_code_block = view.previous_code_block_index(ctx);
|
||||
assert_eq!(
|
||||
prev_code_block,
|
||||
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 0))
|
||||
);
|
||||
view.selected_code_block = prev_code_block;
|
||||
|
||||
let prev_code_block = view.previous_code_block_index(ctx);
|
||||
assert_eq!(
|
||||
prev_code_block,
|
||||
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 1))
|
||||
);
|
||||
view.selected_code_block = prev_code_block;
|
||||
|
||||
let prev_code_block = view.previous_code_block_index(ctx);
|
||||
assert_eq!(
|
||||
prev_code_block,
|
||||
Some(CodeBlockIndex::new(0, TranscriptPartSubType::Question, 0))
|
||||
);
|
||||
view.selected_code_block = prev_code_block;
|
||||
|
||||
let prev_code_block = view.previous_code_block_index(ctx);
|
||||
assert_eq!(prev_code_block, None);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_last_code_block() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let requests_model = app.add_model(|_| Requests::new_with_transcript(TRANSCRIPT.clone()));
|
||||
let (_, transcript_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
||||
Transcript::new(&requests_model, ctx)
|
||||
});
|
||||
transcript_view.update(&mut app, |view, ctx| {
|
||||
view.select_last_code_block(ctx);
|
||||
assert_eq!(
|
||||
view.selected_code_block,
|
||||
Some(CodeBlockIndex::new(1, TranscriptPartSubType::Question, 1))
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
/// Common functionality used across different AI Assistant components.
|
||||
use markdown_parser::{parse_markdown, CodeBlockText, FormattedText, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use warpui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, HighlightedHyperlink,
|
||||
Icon, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Text,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, ModelHandle,
|
||||
};
|
||||
|
||||
use crate::{appearance::Appearance, ui_components::blended_colors};
|
||||
|
||||
use super::{panel::AIAssistantAction, requests::Requests, transcript::CodeBlockMouseStateHandles};
|
||||
|
||||
const PREPARED_RESPONSE_FONT_SIZE: f32 = 11.;
|
||||
const REQUEST_LIMIT_INFO_FONT_SIZE: f32 = 11.;
|
||||
|
||||
const SQUARE_ALERT_SVG_PATH: &str = "bundled/svg/alert-square.svg";
|
||||
const TRIANGLE_ALERT_SVG_PATH: &str = "bundled/svg/alert-triangle.svg";
|
||||
|
||||
/// A transcript part is a question and answer _pair_. This is to enforce
|
||||
/// the invariant that every question has an answer.
|
||||
#[derive(Clone)]
|
||||
pub struct TranscriptPart {
|
||||
pub user: FormattedTranscriptMessage,
|
||||
pub assistant: AssistantTranscriptPart,
|
||||
}
|
||||
|
||||
/// The assistant part of a transcript part.
|
||||
#[derive(Clone)]
|
||||
pub struct AssistantTranscriptPart {
|
||||
pub is_error: bool,
|
||||
pub formatted_message: FormattedTranscriptMessage,
|
||||
pub copy_all_tooltip_and_button_mouse_handles: Option<(MouseStateHandle, MouseStateHandle)>,
|
||||
}
|
||||
|
||||
/// The information needed to render a single transcript message (whether it be a question or answer).
|
||||
#[derive(Clone)]
|
||||
pub struct FormattedTranscriptMessage {
|
||||
/// If we can't parse the message as markdown, we can still
|
||||
/// use the `raw` field to display it. But we should try to render as markdown.
|
||||
pub markdown: Option<Vec<MarkdownSegment>>,
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
impl FormattedTranscriptMessage {
|
||||
/// Finds the index of the first code block in the message, if there is one.
|
||||
fn first_code_block_index(&self) -> Option<CodeBlockIndex> {
|
||||
let segments = self.markdown.as_ref()?;
|
||||
segments.iter().find_map(|s| match s {
|
||||
MarkdownSegment::CodeBlock { index, .. } => Some(*index),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Finds the index of the last code block in the message, if there is one.
|
||||
fn last_code_block_index(&self) -> Option<CodeBlockIndex> {
|
||||
let segments = self.markdown.as_ref()?;
|
||||
segments.iter().rev().find_map(|s| match s {
|
||||
MarkdownSegment::CodeBlock { index, .. } => Some(*index),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Finds the index of the next code block after `code_block_index` in the message, if there is one.
|
||||
fn next_code_block_index(&self, code_block_index: usize) -> Option<CodeBlockIndex> {
|
||||
let segments = self.markdown.as_ref()?;
|
||||
segments.iter().find_map(|s| match s {
|
||||
MarkdownSegment::CodeBlock { index, .. } => {
|
||||
(index.code_block_index == code_block_index + 1).then_some(*index)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Finds the index of the previous code block before `code_block_index` in the message, if there is one.
|
||||
fn prev_code_block_index(&self, code_block_index: usize) -> Option<CodeBlockIndex> {
|
||||
if code_block_index == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let segments = self.markdown.as_ref()?;
|
||||
segments.iter().find_map(|s| match s {
|
||||
MarkdownSegment::CodeBlock { index, .. } => {
|
||||
(index.code_block_index == code_block_index - 1).then_some(*index)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the raw code block string for the given code block index.
|
||||
fn code_for_block(&self, code_block_index: usize) -> Option<&str> {
|
||||
let segments = self.markdown.as_ref()?;
|
||||
segments.iter().find_map(|s| match s {
|
||||
MarkdownSegment::CodeBlock { index, code, .. } => {
|
||||
(index.code_block_index == code_block_index).then_some(code.code.as_str())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A MarkdownSegment differs from a FormattedText in that we intentionally
|
||||
/// separate out certain markdown elements.
|
||||
/// For now, only code blocks are rendered differently.
|
||||
#[derive(Clone)]
|
||||
pub enum MarkdownSegment {
|
||||
CodeBlock {
|
||||
index: CodeBlockIndex,
|
||||
code: CodeBlockText,
|
||||
mouse_state_handles: CodeBlockMouseStateHandles,
|
||||
},
|
||||
Other {
|
||||
/// The formatted text does _not_ contain any of the other
|
||||
/// MarkdownSegment's.
|
||||
formatted_text: FormattedText,
|
||||
highlighted_hyperlink: HighlightedHyperlink,
|
||||
},
|
||||
}
|
||||
|
||||
impl TranscriptPart {
|
||||
pub fn raw_user_prompt(&self) -> &str {
|
||||
self.user.raw.as_str()
|
||||
}
|
||||
|
||||
pub fn raw_assistant_answer(&self) -> &str {
|
||||
self.assistant.formatted_message.raw.as_str()
|
||||
}
|
||||
|
||||
/// Returns the index of the first code block in this transcript part, if there is one.
|
||||
pub fn first_code_block_index(&self) -> Option<CodeBlockIndex> {
|
||||
self.user
|
||||
.first_code_block_index()
|
||||
.or_else(|| self.assistant.formatted_message.first_code_block_index())
|
||||
}
|
||||
|
||||
/// Returns the index of the last code block in this transcript part, if there is one.
|
||||
pub fn last_code_block_index(&self) -> Option<CodeBlockIndex> {
|
||||
self.assistant
|
||||
.formatted_message
|
||||
.last_code_block_index()
|
||||
.or_else(|| self.user.last_code_block_index())
|
||||
}
|
||||
|
||||
/// Returns the index of the next code block after the given code block index in this transcript part, if there is one.
|
||||
pub fn next_code_block_index(
|
||||
&self,
|
||||
code_block_index: CodeBlockIndex,
|
||||
) -> Option<CodeBlockIndex> {
|
||||
match code_block_index.transcript_part_type {
|
||||
// Since a transcript part is question -> answer, check if there's a next code block in the question part,
|
||||
// otherwise get the first code block in the answer part.
|
||||
TranscriptPartSubType::Question => self
|
||||
.user
|
||||
.next_code_block_index(code_block_index.code_block_index)
|
||||
.or_else(|| self.assistant.formatted_message.first_code_block_index()),
|
||||
TranscriptPartSubType::Answer => self
|
||||
.assistant
|
||||
.formatted_message
|
||||
.next_code_block_index(code_block_index.code_block_index),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the index of the previous code block before the given code block index in this transcript part, if there is one.
|
||||
pub fn prev_code_block_index(
|
||||
&self,
|
||||
code_block_index: CodeBlockIndex,
|
||||
) -> Option<CodeBlockIndex> {
|
||||
match code_block_index.transcript_part_type {
|
||||
TranscriptPartSubType::Question => self
|
||||
.user
|
||||
.prev_code_block_index(code_block_index.code_block_index),
|
||||
// Since a transcript part is question -> answer, check if there's a previous code block in the answer part,
|
||||
// otherwise get the last code block from the question part.
|
||||
TranscriptPartSubType::Answer => self
|
||||
.assistant
|
||||
.formatted_message
|
||||
.prev_code_block_index(code_block_index.code_block_index)
|
||||
.or_else(|| self.user.last_code_block_index()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code_for_block(&self, code_block_index: CodeBlockIndex) -> Option<&str> {
|
||||
match code_block_index.transcript_part_type {
|
||||
TranscriptPartSubType::Question => {
|
||||
self.user.code_for_block(code_block_index.code_block_index)
|
||||
}
|
||||
TranscriptPartSubType::Answer => self
|
||||
.assistant
|
||||
.formatted_message
|
||||
.code_for_block(code_block_index.code_block_index),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Since a transcript part consists of two sub parts (question and answer),
|
||||
/// this enum is used to identify which of the two we're referring to.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub enum TranscriptPartSubType {
|
||||
Question,
|
||||
Answer,
|
||||
}
|
||||
|
||||
impl TranscriptPartSubType {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Question => "question",
|
||||
Self::Answer => "answer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A CodeBlockIndex is used to uniquely identify a code block in a transcript.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct CodeBlockIndex {
|
||||
/// The index into the `trancripts` list.
|
||||
transcript_part_index: usize,
|
||||
|
||||
/// Since each transcript part consists of two sub-parts (question & answer),
|
||||
/// we need to distinguish which of these sub-parts the code block is in.
|
||||
transcript_part_type: TranscriptPartSubType,
|
||||
|
||||
/// A subpart can have > 1 code blocks, so this specifies the exact one.
|
||||
code_block_index: usize,
|
||||
}
|
||||
|
||||
impl CodeBlockIndex {
|
||||
pub fn new(
|
||||
transcript_part_index: usize,
|
||||
transcript_part_type: TranscriptPartSubType,
|
||||
code_block_index: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
transcript_part_index,
|
||||
transcript_part_type,
|
||||
code_block_index,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_id_str(&self) -> String {
|
||||
format!(
|
||||
"{}_{}_{}",
|
||||
self.transcript_part_index,
|
||||
self.transcript_part_type.as_str(),
|
||||
self.code_block_index
|
||||
)
|
||||
}
|
||||
|
||||
pub fn transcript_index(&self) -> usize {
|
||||
self.transcript_part_index
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_prepared_response_button(
|
||||
appearance: &Appearance,
|
||||
mouse_state_handle: MouseStateHandle,
|
||||
width: Option<f32>,
|
||||
right_left_padding: Option<f32>,
|
||||
prompt: &'static str,
|
||||
) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let default_button_styles = UiComponentStyles {
|
||||
width,
|
||||
font_size: Some(PREPARED_RESPONSE_FONT_SIZE),
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().background())
|
||||
.into(),
|
||||
),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
|
||||
border_color: Some(theme.accent().into()),
|
||||
border_width: Some(1.),
|
||||
padding: Some(Coords {
|
||||
top: 5.,
|
||||
bottom: 5.,
|
||||
left: right_left_padding.unwrap_or(0.),
|
||||
right: right_left_padding.unwrap_or(0.),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let hovered_and_clicked_styles = UiComponentStyles {
|
||||
background: Some(theme.accent().into()),
|
||||
font_color: Some(theme.background().into()),
|
||||
..default_button_styles
|
||||
};
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button_with_custom_styles(
|
||||
ButtonVariant::Text,
|
||||
mouse_state_handle,
|
||||
default_button_styles,
|
||||
Some(hovered_and_clicked_styles),
|
||||
Some(hovered_and_clicked_styles),
|
||||
Some(hovered_and_clicked_styles),
|
||||
)
|
||||
.with_centered_text_label(prompt.to_string())
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AIAssistantAction::PreparedPrompt(prompt))
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_request_limit_info(
|
||||
request_model: &ModelHandle<Requests>,
|
||||
app: &AppContext,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let text_color: ColorU =
|
||||
blended_colors::text_sub(appearance.theme(), appearance.theme().background());
|
||||
|
||||
let num_requests_used = request_model.as_ref(app).num_requests_used();
|
||||
let num_requests_remaining = request_model.as_ref(app).num_remaining_reqs();
|
||||
let request_limit = request_model.as_ref(app).request_limit();
|
||||
let next_refresh_time = request_model.as_ref(app).serialized_time_until_refresh();
|
||||
|
||||
// Always show the remaining requests count.
|
||||
let mut row = Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::Center)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new_inline(
|
||||
format!("Credits used: {num_requests_used} / {request_limit}.",),
|
||||
appearance.ui_font_family(),
|
||||
REQUEST_LIMIT_INFO_FONT_SIZE,
|
||||
)
|
||||
.with_color(text_color)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
// Add the warning icon if necessary.
|
||||
let icon = if num_requests_remaining == 0 {
|
||||
Some(Icon::new(
|
||||
TRIANGLE_ALERT_SVG_PATH,
|
||||
appearance.theme().ui_error_color(),
|
||||
))
|
||||
} else if num_requests_remaining <= 10 {
|
||||
Some(Icon::new(
|
||||
SQUARE_ALERT_SVG_PATH,
|
||||
appearance.theme().ui_warning_color(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(icon) = icon {
|
||||
row.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(icon.finish())
|
||||
.with_height(16.)
|
||||
.with_width(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(5.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
// Show the next refresh time if it's valid.
|
||||
if let Some(next_refresh_time) = next_refresh_time {
|
||||
row.add_child(
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
format!("{next_refresh_time} until refresh."),
|
||||
appearance.ui_font_family(),
|
||||
REQUEST_LIMIT_INFO_FONT_SIZE,
|
||||
)
|
||||
.with_color(text_color)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(5.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
row.finish()
|
||||
}
|
||||
|
||||
pub fn code_block_position_id(code_block_index: CodeBlockIndex) -> String {
|
||||
format!("code_block_id_{}", code_block_index.as_id_str(),)
|
||||
}
|
||||
|
||||
pub fn save_as_workflow_position_id(code_block_index: CodeBlockIndex) -> String {
|
||||
format!(
|
||||
"{}_save_as_workflow",
|
||||
code_block_position_id(code_block_index)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn markdown_segments_from_text(
|
||||
transcript_part_index: usize,
|
||||
transcript_part_type: TranscriptPartSubType,
|
||||
text: &str,
|
||||
) -> Option<Vec<MarkdownSegment>> {
|
||||
let parsed = parse_markdown(text).ok();
|
||||
parsed.map(|p| {
|
||||
translate_formatted_text_into_markdown_segments(
|
||||
transcript_part_index,
|
||||
transcript_part_type,
|
||||
p,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn translate_formatted_text_into_markdown_segments(
|
||||
transcript_part_index: usize,
|
||||
transcript_part_type: TranscriptPartSubType,
|
||||
formatted_text: FormattedText,
|
||||
) -> Vec<MarkdownSegment> {
|
||||
// At a high-level, we want to go through the FormattedText and extract
|
||||
// all the code-blocks separately from contiguous non-code blocks. We want
|
||||
// to do this so that we can render the code-blocks specially. The final
|
||||
// result is a set of markdown_segments.
|
||||
let mut markdown_segments = vec![];
|
||||
|
||||
// The running non-code block is a contigous sequence of FormattedTextLine's
|
||||
// that _do not_ contain any code blocks.
|
||||
let mut running_non_code_block = vec![];
|
||||
|
||||
let mut curr_code_block_index = 0;
|
||||
|
||||
for part in formatted_text.lines {
|
||||
match part {
|
||||
FormattedTextLine::CodeBlock(mut code) => {
|
||||
// If we found a code block, flush out the running non-code-block
|
||||
// contiguous sequence into a single markdown segment.
|
||||
if !running_non_code_block.is_empty() {
|
||||
markdown_segments.push(MarkdownSegment::Other {
|
||||
formatted_text: FormattedText::new_trimmed(running_non_code_block),
|
||||
highlighted_hyperlink: Default::default(),
|
||||
});
|
||||
}
|
||||
|
||||
code.code = code.code.trim().to_string();
|
||||
markdown_segments.push(MarkdownSegment::CodeBlock {
|
||||
index: CodeBlockIndex::new(
|
||||
transcript_part_index,
|
||||
transcript_part_type,
|
||||
curr_code_block_index,
|
||||
),
|
||||
code,
|
||||
mouse_state_handles: Default::default(),
|
||||
});
|
||||
curr_code_block_index += 1;
|
||||
running_non_code_block = vec![];
|
||||
}
|
||||
_ => {
|
||||
// If this is anything other than a code block, tack it onto
|
||||
// our running sequence.
|
||||
running_non_code_block.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we had a non-code block sequence that we haven't flushed yet by the end,
|
||||
// flush it now.
|
||||
if !running_non_code_block.is_empty() {
|
||||
markdown_segments.push(MarkdownSegment::Other {
|
||||
formatted_text: FormattedText::new_trimmed(running_non_code_block),
|
||||
highlighted_hyperlink: Default::default(),
|
||||
});
|
||||
}
|
||||
|
||||
markdown_segments
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "utils_tests.rs"]
|
||||
mod utils_tests;
|
||||
@@ -0,0 +1,143 @@
|
||||
use crate::ai_assistant::test_util::{
|
||||
default_assistant_transcript_part, default_code_block_segment, default_formatted_message,
|
||||
default_other_segment,
|
||||
};
|
||||
|
||||
use super::{FormattedTranscriptMessage, TranscriptPart, TranscriptPartSubType};
|
||||
|
||||
use crate::ai_assistant::utils::CodeBlockIndex;
|
||||
|
||||
// Mocked data to make it easy to test.
|
||||
lazy_static::lazy_static! {
|
||||
static ref FIRST_USER_CODE_BLOCK_INDEX: CodeBlockIndex = CodeBlockIndex::new(0, TranscriptPartSubType::Question, 0);
|
||||
static ref SECOND_USER_CODE_BLOCK_INDEX: CodeBlockIndex = CodeBlockIndex::new(0, TranscriptPartSubType::Question, 1);
|
||||
static ref USER_FORMATTED_MESSAGE: FormattedTranscriptMessage = default_formatted_message(vec![
|
||||
default_code_block_segment(*FIRST_USER_CODE_BLOCK_INDEX),
|
||||
default_other_segment(),
|
||||
default_code_block_segment(*SECOND_USER_CODE_BLOCK_INDEX),
|
||||
default_other_segment(),
|
||||
]);
|
||||
|
||||
static ref FIRST_ASSISTANT_CODE_BLOCK_INDEX: CodeBlockIndex = CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 0);
|
||||
static ref SECOND_ASSISTANT_CODE_BLOCK_INDEX: CodeBlockIndex = CodeBlockIndex::new(0, TranscriptPartSubType::Answer, 1);
|
||||
static ref ASSISTANT_FORMATTED_MESSAGE: FormattedTranscriptMessage = default_formatted_message(vec![
|
||||
default_other_segment(),
|
||||
default_other_segment(),
|
||||
default_code_block_segment(*FIRST_ASSISTANT_CODE_BLOCK_INDEX),
|
||||
default_code_block_segment(*SECOND_ASSISTANT_CODE_BLOCK_INDEX),
|
||||
]);
|
||||
|
||||
static ref TRANSCRIPT_PART: TranscriptPart =
|
||||
TranscriptPart {
|
||||
user: (*USER_FORMATTED_MESSAGE).clone(),
|
||||
assistant: default_assistant_transcript_part((*ASSISTANT_FORMATTED_MESSAGE).clone())
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_formatted_transcript_message_first_code_block() {
|
||||
assert_eq!(
|
||||
USER_FORMATTED_MESSAGE.first_code_block_index(),
|
||||
Some(*FIRST_USER_CODE_BLOCK_INDEX)
|
||||
);
|
||||
assert_eq!(
|
||||
ASSISTANT_FORMATTED_MESSAGE.first_code_block_index(),
|
||||
Some(*FIRST_ASSISTANT_CODE_BLOCK_INDEX)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_formatted_transcript_message_last_code_block() {
|
||||
assert_eq!(
|
||||
USER_FORMATTED_MESSAGE.last_code_block_index(),
|
||||
Some(*SECOND_USER_CODE_BLOCK_INDEX)
|
||||
);
|
||||
assert_eq!(
|
||||
ASSISTANT_FORMATTED_MESSAGE.last_code_block_index(),
|
||||
Some(*SECOND_ASSISTANT_CODE_BLOCK_INDEX)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_formatted_transcript_message_next_code_block() {
|
||||
assert_eq!(
|
||||
USER_FORMATTED_MESSAGE.next_code_block_index(0),
|
||||
Some(*SECOND_USER_CODE_BLOCK_INDEX)
|
||||
);
|
||||
assert_eq!(USER_FORMATTED_MESSAGE.next_code_block_index(1), None);
|
||||
assert_eq!(
|
||||
ASSISTANT_FORMATTED_MESSAGE.next_code_block_index(0),
|
||||
Some(*SECOND_ASSISTANT_CODE_BLOCK_INDEX)
|
||||
);
|
||||
assert_eq!(ASSISTANT_FORMATTED_MESSAGE.next_code_block_index(1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_formatted_transcript_message_prev_code_block() {
|
||||
assert_eq!(USER_FORMATTED_MESSAGE.prev_code_block_index(0), None);
|
||||
assert_eq!(
|
||||
USER_FORMATTED_MESSAGE.prev_code_block_index(1),
|
||||
Some(*FIRST_USER_CODE_BLOCK_INDEX)
|
||||
);
|
||||
assert_eq!(ASSISTANT_FORMATTED_MESSAGE.prev_code_block_index(0), None);
|
||||
assert_eq!(
|
||||
ASSISTANT_FORMATTED_MESSAGE.prev_code_block_index(1),
|
||||
Some(*FIRST_ASSISTANT_CODE_BLOCK_INDEX)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transcript_part_first_code_block() {
|
||||
assert_eq!(
|
||||
TRANSCRIPT_PART.first_code_block_index(),
|
||||
Some(*FIRST_USER_CODE_BLOCK_INDEX)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transcript_part_last_code_block() {
|
||||
assert_eq!(
|
||||
TRANSCRIPT_PART.last_code_block_index(),
|
||||
Some(*SECOND_ASSISTANT_CODE_BLOCK_INDEX)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transcript_part_next_code_block() {
|
||||
assert_eq!(
|
||||
TRANSCRIPT_PART.next_code_block_index(*FIRST_USER_CODE_BLOCK_INDEX),
|
||||
Some(*SECOND_USER_CODE_BLOCK_INDEX)
|
||||
);
|
||||
assert_eq!(
|
||||
TRANSCRIPT_PART.next_code_block_index(*SECOND_USER_CODE_BLOCK_INDEX),
|
||||
Some(*FIRST_ASSISTANT_CODE_BLOCK_INDEX)
|
||||
);
|
||||
assert_eq!(
|
||||
TRANSCRIPT_PART.next_code_block_index(*FIRST_ASSISTANT_CODE_BLOCK_INDEX),
|
||||
Some(*SECOND_ASSISTANT_CODE_BLOCK_INDEX)
|
||||
);
|
||||
assert_eq!(
|
||||
TRANSCRIPT_PART.next_code_block_index(*SECOND_ASSISTANT_CODE_BLOCK_INDEX),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transcript_part_prev_code_block() {
|
||||
assert_eq!(
|
||||
TRANSCRIPT_PART.prev_code_block_index(*FIRST_USER_CODE_BLOCK_INDEX),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
TRANSCRIPT_PART.prev_code_block_index(*SECOND_USER_CODE_BLOCK_INDEX),
|
||||
Some(*FIRST_USER_CODE_BLOCK_INDEX)
|
||||
);
|
||||
assert_eq!(
|
||||
TRANSCRIPT_PART.prev_code_block_index(*FIRST_ASSISTANT_CODE_BLOCK_INDEX),
|
||||
Some(*SECOND_USER_CODE_BLOCK_INDEX)
|
||||
);
|
||||
assert_eq!(
|
||||
TRANSCRIPT_PART.prev_code_block_index(*SECOND_ASSISTANT_CODE_BLOCK_INDEX),
|
||||
Some(*FIRST_ASSISTANT_CODE_BLOCK_INDEX)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user