use crate::entities::attachment::{AttachMode, Attachment, format_bytes};
use crate::entities::chat::Chat;
use crate::entities::message::{Message, MessageRole, ToolCallRecord};
use crate::entities::message_image::MessageImage;
use crate::entities::sampling::SamplingConfig;
use crate::shared::api::{ApiImage, ApiMessage, ApiToolCall, ChatRequest};
use crate::shared::config::{AttachmentSettings, CompactionSettings};
use crate::shared::i18n::Locale;
fn message_to_api(message: &Message, loc: &Locale) -> Option<ApiMessage> {
match message.role {
MessageRole::System => None,
MessageRole::User => {
Some(ApiMessage::user(&message.text).with_images(images_to_api(&message.images, loc)))
}
MessageRole::Assistant => {
if message.tool_calls.is_empty() {
Some(ApiMessage::assistant(&message.text))
} else {
let calls = message.tool_calls.iter().map(record_to_api).collect();
Some(ApiMessage::assistant_tool_calls(&message.text, calls))
}
}
MessageRole::Tool => message.tool_call_id.as_ref().map(|id| {
ApiMessage::tool(id, &message.text).with_images(images_to_api(&message.images, loc))
}),
}
}
fn api_messages(messages: &[Message], loc: &Locale) -> Vec<ApiMessage> {
const SEP: &str = "\n\n";
let mut out = Vec::with_capacity(messages.len());
let mut pending: Vec<&str> = Vec::new();
for m in messages {
if m.is_notification() {
pending.push(&m.text);
continue;
}
if m.role == MessageRole::User && !pending.is_empty() {
let mut parts = std::mem::take(&mut pending);
parts.push(&m.text);
out.push(ApiMessage::user(parts.join(SEP)).with_images(images_to_api(&m.images, loc)));
continue;
}
if !pending.is_empty() {
out.push(ApiMessage::user(std::mem::take(&mut pending).join(SEP)));
}
if let Some(api) = message_to_api(m, loc) {
out.push(api);
}
}
if !pending.is_empty() {
out.push(ApiMessage::user(pending.join(SEP)));
}
out
}
pub(super) fn withhold_images(messages: &mut [ApiMessage], loc: &Locale) -> usize {
let mut withheld = 0;
for message in messages.iter_mut().filter(|m| !m.images.is_empty()) {
let markers: Vec<String> = message
.images
.drain(..)
.map(|image| {
let name = match image.label.as_deref() {
Some(label) => label.trim_end().trim_end_matches(':').to_string(),
None => loc.t("prompt.images.withheld_unnamed").to_string(),
};
loc.tf("prompt.images.withheld", &[("image", &name)])
})
.collect();
withheld += markers.len();
let markers = markers.join("\n");
message.content = if message.content.is_empty() {
markers
} else {
format!("{}\n\n{markers}", message.content)
};
}
withheld
}
fn images_to_api(images: &[MessageImage], loc: &Locale) -> Vec<ApiImage> {
images
.iter()
.enumerate()
.map(|(i, image)| {
ApiImage::new(
image.mime.clone(),
&image.data,
Some(loc.tf(
"prompt.images.label",
&[("n", &(i + 1).to_string()), ("name", &image.name)],
)),
)
})
.collect()
}
fn record_to_api(rec: &ToolCallRecord) -> ApiToolCall {
ApiToolCall {
id: rec.id.clone(),
name: rec.name.clone(),
arguments: rec.arguments.to_string(),
thought_signature: rec.thought_signature.clone(),
}
}
pub(super) fn last_user_message_at(chat: &Chat) -> Option<chrono::DateTime<chrono::Utc>> {
chat.messages
.iter()
.rev()
.find(|m| m.role == MessageRole::User)
.map(|m| m.timestamp)
}
pub(super) struct PromptContext<'a> {
pub attachments: &'a AttachmentSettings,
pub compaction: &'a CompactionSettings,
pub indexed: &'a [uuid::Uuid],
pub history_tools: bool,
pub offered_tools: &'a [crate::entities::profile::ToolId],
pub files: &'a [crate::features::chat_inputs::ChatInput],
pub python_dirs: (&'static str, &'static str),
pub loc: &'a Locale,
}
pub(super) struct RequestEnv<'a> {
pub attachments: &'a [Attachment],
pub workspace: Option<&'a crate::entities::workspace::Workspace>,
pub compaction: Option<(&'a str, usize)>,
}
impl<'a> RequestEnv<'a> {
pub(super) fn of(chat: &'a Chat, compaction_enabled: bool) -> Self {
Self {
attachments: &chat.attachments,
workspace: chat.workspace.as_ref(),
compaction: chat.compaction_view(compaction_enabled),
}
}
}
pub(super) fn build_request(
chat: &Chat,
sampling: SamplingConfig,
tools: Vec<crate::shared::api::ToolSchema>,
cx: &PromptContext<'_>,
) -> ChatRequest {
build_request_in(
&chat.system_message,
&chat.messages,
&RequestEnv::of(chat, cx.compaction.enabled),
sampling,
tools,
cx,
)
}
pub(super) fn build_request_in(
system_message: &str,
messages: &[Message],
env: &RequestEnv<'_>,
sampling: SamplingConfig,
tools: Vec<crate::shared::api::ToolSchema>,
cx: &PromptContext<'_>,
) -> ChatRequest {
let system = if system_message.trim().is_empty() {
None
} else {
Some(system_message.to_string())
};
let (summary, upto) = match env.compaction {
Some((s, i)) => (Some(s), i),
None => (None, 0),
};
let system = inject_compaction(system, summary, cx.history_tools, cx.loc);
let system = inject_attachments(system, env.attachments, cx.attachments, cx.indexed, cx.loc);
let system = inject_files(system, cx.files, cx.python_dirs, cx.loc);
ChatRequest {
continue_final: false,
system: inject_workspace(system, env.workspace, cx.offered_tools, cx.loc),
messages: api_messages(&messages[upto..], cx.loc),
sampling,
tools,
}
}
pub(super) fn inject_workspace(
system: Option<String>,
workspace: Option<&crate::entities::workspace::Workspace>,
offered: &[crate::entities::profile::ToolId],
loc: &Locale,
) -> Option<String> {
use crate::features::tools::code::CodeTool;
let Some(ws) = workspace else {
return system;
};
let has = |tool: CodeTool| offered.iter().any(|id| id.as_str() == tool.id());
let available: Vec<CodeTool> = crate::features::tools::code::ALL
.into_iter()
.filter(|&t| has(t))
.collect();
let reach = if available.is_empty() {
loc.t("prompt.workspace.no_tools").to_string()
} else {
let mut reach = loc.t("prompt.workspace.tools").to_string();
for tool in &available {
reach.push('\n');
match tool.slot() {
Some(slot) => reach.push_str(&loc.tf(
tool.gloss_key(),
&[("line", ws.command(slot).unwrap_or_default())],
)),
None => reach.push_str(loc.t(tool.gloss_key())),
}
}
reach.push_str("\n\n");
reach.push_str(loc.t("prompt.workspace.rules"));
reach
};
let block = loc.tf(
"prompt.workspace.block",
&[("root", &ws.root), ("name", ws.name()), ("reach", &reach)],
);
Some(match system {
Some(s) if !s.trim().is_empty() => format!("{s}\n\n{block}"),
_ => block,
})
}
pub(super) fn inject_compaction(
system: Option<String>,
summary: Option<&str>,
tools: bool,
loc: &Locale,
) -> Option<String> {
let Some(summary) = summary else {
return system;
};
let reach = if tools {
"compaction.block.tools"
} else {
"compaction.block.no_tools"
};
let block = format!(
"{}{}\n\n{}",
loc.t("compaction.block.header"),
loc.t(reach),
summary.trim()
);
Some(match system {
Some(s) if !s.trim().is_empty() => format!("{s}\n\n{block}"),
_ => block,
})
}
pub(super) fn inject_attachments(
system: Option<String>,
attachments: &[Attachment],
cfg: &AttachmentSettings,
indexed: &[uuid::Uuid],
loc: &Locale,
) -> Option<String> {
if attachments.is_empty() {
return system;
}
let mut block = String::from(loc.t("prompt.attachments.header"));
for a in attachments {
let width = fence_width(&a.text);
let open = "<".repeat(width);
let close = ">".repeat(width);
let size = format_bytes(a.bytes);
let (head, body, tail) = match a.mode {
AttachMode::Inline => (
loc.tf(
"prompt.attachments.begin_full",
&[
("open", &open),
("name", &a.name),
("size", &size),
("close", &close),
],
),
a.text.as_str(),
loc.tf(
"prompt.attachments.end",
&[("open", &open), ("name", &a.name), ("close", &close)],
),
),
AttachMode::ByReference => {
let pages = a.page_count(cfg.page_tokens).to_string();
let tail = if indexed.contains(&a.id) {
"prompt.attachments.end_excerpt_search"
} else {
"prompt.attachments.end_excerpt"
};
(
loc.tf(
"prompt.attachments.begin_excerpt",
&[
("open", &open),
("name", &a.name),
("size", &size),
("tokens", &a.est_tokens.to_string()),
("pages", &pages),
("close", &close),
],
),
a.excerpt(cfg.excerpt_tokens),
loc.tf(
tail,
&[
("open", &open),
("name", &a.name),
("pages", &pages),
("close", &close),
],
),
)
}
};
block.push_str("\n\n");
block.push_str(&head);
block.push('\n');
block.push_str(body);
block.push('\n');
block.push_str(&tail);
}
Some(match system {
Some(s) if !s.is_empty() => format!("{s}\n\n{block}"),
_ => block,
})
}
pub(super) fn inject_files(
system: Option<String>,
inputs: &[crate::features::chat_inputs::ChatInput],
dirs: (&str, &str),
loc: &Locale,
) -> Option<String> {
if inputs.is_empty() {
return system;
}
let (in_dir, out_dir) = dirs;
let mut block = loc.tf("prompt.files.header", &[("in", in_dir), ("out", out_dir)]);
for item in inputs {
block.push_str(&loc.tf(
"prompt.files.item",
&[
("i", &item.handle.to_string()),
("in", in_dir),
("name", &item.name),
("staged", &item.staged),
("size", &format_bytes(item.bytes as usize)),
("mime", &item.mime),
],
));
}
Some(match system {
Some(s) if !s.is_empty() => format!("{s}\n\n{block}"),
_ => block,
})
}
fn fence_width(text: &str) -> usize {
let mut width = 3;
while text.contains(&">".repeat(width)) {
width += 1;
}
width
}