refactor: rebuild desktop app with Tauri

This commit is contained in:
leookun
2026-08-24 02:49:00 +08:00
parent a3ec2a0dfc
commit 4053a7fb20
808 changed files with 87359 additions and 181491 deletions
+806
View File
@@ -0,0 +1,806 @@
use std::collections::HashSet;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use crate::{
client::{ClientEvent, ClientPort, CommitBarrier, CommitCause, StateCommitted},
model::{
CanonicalMessage, MessageContent, Origin, PreparedRun, Role, RunAction, ToolRoundAssistant,
ToolRoundId, Usage,
},
provider::Provider,
store::{RunStatus, Store},
};
use super::{consume_model_cycle, ModelCycleFailure, RunFailure, RunOutcome};
const COMPACTION_RESERVE_TOKENS: u64 = 10_000;
const COMPACTION_OUTPUT_TOKENS: u64 = 4_096;
const COMPACTION_FALLBACK_CHARS: usize = 12_000;
const COMPACTION_INSTRUCTIONS: &str = "Summarize the conversation for the next model turn. Preserve goals, constraints, decisions, files, commands, errors, results, and unfinished work. Do not call tools. Return only the concise durable summary.";
pub struct RunEngine {
store: Store,
provider: Arc<dyn Provider>,
}
impl RunEngine {
pub fn new(store: Store, provider: Arc<dyn Provider>) -> Self {
Self { store, provider }
}
#[tracing::instrument(
skip_all,
fields(run_id = %prepared.run_id, conversation_id = %prepared.conversation_id)
)]
pub async fn run(
&self,
prepared: PreparedRun,
mut client: ClientPort,
cancellation: CancellationToken,
) -> RunOutcome {
let claimed = match self.store.claim_run(&prepared).await {
Ok(claimed) => claimed,
Err(error) => {
let outcome = RunOutcome::Failed(error.into());
let _ = client
.events
.send(ClientEvent::Ended(outcome.clone()))
.await;
tracing::info!(outcome = ?outcome, "Run claim failed");
return outcome;
}
};
let outcome = self
.run_claimed(
&prepared,
claimed.head_revision_id,
&mut client,
&cancellation,
)
.await;
let usage = outcome.1;
let outcome = outcome.0;
let (status, failure) = match &outcome {
RunOutcome::Completed => (RunStatus::Completed, None),
RunOutcome::Cancelled => (RunStatus::Cancelled, None),
RunOutcome::Failed(failure) => (
RunStatus::Failed,
Some((failure.category(), failure_message(failure))),
),
};
let failure_ref = failure
.as_ref()
.map(|(category, summary)| (*category, summary.as_str()));
if let Err(error) = self
.store
.finish_run(&prepared.run_id, status, usage, failure_ref)
.await
{
tracing::error!(run_id = %prepared.run_id, %error, "failed to persist Run outcome");
}
let _ = client
.events
.send(ClientEvent::Ended(outcome.clone()))
.await;
tracing::info!(outcome = ?outcome, usage = ?usage, "Run ended");
outcome
}
async fn run_claimed(
&self,
prepared: &PreparedRun,
mut revision: crate::model::RevisionId,
client: &mut ClientPort,
cancellation: &CancellationToken,
) -> (RunOutcome, Option<Usage>) {
let mut usage = None;
tracing::info!(
revision_id = revision.0,
"Run claimed conversation ownership"
);
if !prepared.initial_messages.is_empty() {
let mut changed = false;
for message in &prepared.initial_messages {
match self
.store
.append_message_once(
&prepared.conversation_id,
&prepared.run_id,
revision,
message,
)
.await
{
Ok((next, inserted)) => {
revision = next;
changed |= inserted;
}
Err(error) => return (RunOutcome::Failed(error.into()), usage),
}
}
if changed {
let (barrier, ready) = CommitBarrier::before_continue();
if emit(
client,
ClientEvent::StateCommitted(StateCommitted {
revision_id: revision,
tool_round_version: 0,
cause: CommitCause::InitialMessages,
barrier,
}),
)
.await
.is_err()
{
return (client_failure(), usage);
}
if let Err(outcome) = wait_for_state_ready(ready, cancellation).await {
return (outcome, usage);
}
}
}
if let RunAction::Resume {
pending_tool_round: Some(round),
} = &prepared.action
{
revision = match super::tool_round::execute(
&self.store,
prepared,
client,
cancellation,
revision,
super::tool_round::ToolRound {
id: ToolRoundId::new(format!("{}:round:resume", prepared.run_id)),
assistant: round.assistant.clone(),
calls: round.calls.clone(),
recovered_started_at_ms: Some(round.started_at_ms),
},
)
.await
{
Ok(revision) => revision,
Err(outcome) => return (outcome, usage),
};
}
let mut auto_compacted = prepared.action == RunAction::Compact;
'model: loop {
if cancellation.is_cancelled() {
return (RunOutcome::Cancelled, usage);
}
let messages = match self.store.load_revision_messages(revision).await {
Ok(messages) => messages,
Err(error) => return (RunOutcome::Failed(error.into()), usage),
};
if !auto_compacted && should_auto_compact(prepared, &messages) {
auto_compacted = true;
match self
.auto_compact(prepared, revision, &messages, client, cancellation)
.await
{
Ok((next_revision, compaction_usage)) => {
revision = next_revision;
if let Some(compaction_usage) = compaction_usage {
accumulate_usage(&mut usage, compaction_usage);
}
continue 'model;
}
Err(outcome) => return (outcome, usage),
}
}
let provider_call_index = match self.store.begin_provider_call(&prepared.run_id).await {
Ok(index) => index,
Err(error) => return (RunOutcome::Failed(error.into()), usage),
};
tracing::debug!(
provider_call_index,
revision_id = revision.0,
"starting model call"
);
let mut history = match crate::model::project_messages(&messages) {
Ok(history) => history,
Err(error) => return (RunOutcome::Failed(error.into()), usage),
};
if let Err(error) = hydrate_tool_images(&self.store, &mut history).await {
return (RunOutcome::Failed(error.into()), usage);
}
let request = crate::model::ModelRequest {
prompt: prepared.prompt.clone(),
model: prepared.model.clone(),
history,
};
let invocation = crate::model::ModelInvocation {
call_id: format!("{}:{provider_call_index}", prepared.run_id),
run_id: prepared.run_id.to_string(),
conversation_id: prepared.conversation_id.to_string(),
provider_call_index,
request,
};
let cycle_cancellation = cancellation.child_token();
let cycle_events = client.events.clone();
let cycle = consume_model_cycle(
self.provider.stream(invocation, cycle_cancellation.clone()),
&cycle_events,
&cycle_cancellation,
);
tokio::pin!(cycle);
let cycle = tokio::select! {
result = &mut cycle => result,
command = client.commands.recv() => {
let message = match command {
Some(crate::client::ClientCommand::RuntimeMessage(message)) => message,
Some(crate::client::ClientCommand::RuntimeEvent(event)) => event.into_message(),
Some(crate::client::ClientCommand::Cancel) => {
cycle_cancellation.cancel();
return (RunOutcome::Cancelled, usage);
}
Some(crate::client::ClientCommand::ClientClosed { error }) => {
cycle_cancellation.cancel();
return (RunOutcome::Failed(RunFailure::Client(error)), usage);
}
Some(crate::client::ClientCommand::ToolResult(_)) => {
cycle_cancellation.cancel();
return (
RunOutcome::Failed(RunFailure::Protocol(
"received a tool result while the model was running".into(),
)),
usage,
);
}
None => {
cycle_cancellation.cancel();
return (client_failure(), usage);
}
};
cycle_cancellation.cancel();
let interrupted = cycle.await;
match interrupted {
Ok(cycle) => {
if let Some(cycle_usage) = cycle.usage {
accumulate_usage(&mut usage, cycle_usage);
}
}
Err(failure) => {
if let Some(cycle_usage) = failure.usage {
accumulate_usage(&mut usage, cycle_usage);
}
}
}
revision = match append_runtime_message(
&self.store,
prepared,
client,
cancellation,
revision,
message,
)
.await
{
Ok(revision) => revision,
Err(outcome) => return (outcome, usage),
};
continue 'model;
}
};
let cycle = match cycle {
Ok(cycle) => cycle,
Err(ModelCycleFailure {
failure,
usage: cycle_usage,
..
}) => {
if let Some(cycle_usage) = cycle_usage {
accumulate_usage(&mut usage, cycle_usage);
}
if cancellation.is_cancelled() {
return (RunOutcome::Cancelled, usage);
}
return (RunOutcome::Failed(failure), usage);
}
};
if let Some(cycle_usage) = cycle.usage {
accumulate_usage(&mut usage, cycle_usage);
}
if prepared.action == RunAction::Compact {
if !cycle.calls.is_empty() {
return (
RunOutcome::Failed(RunFailure::Protocol(
"compaction model returned tool calls".into(),
)),
usage,
);
}
let summary = cycle.text.trim().to_string();
if summary.is_empty() {
return (
RunOutcome::Failed(RunFailure::Protocol(
"compaction model returned an empty summary".into(),
)),
usage,
);
}
let event_id = format!("summary:{}", prepared.run_id);
let summary_message = CanonicalMessage {
message_id: format!("runtime:{event_id}"),
role: Role::User,
origin: Origin::Runtime,
content: MessageContent::Parts {
parts: vec![crate::model::ContentPart::Text {
text: format!(
"<conversation_summary>\n{summary}\n</conversation_summary>"
),
}],
},
runtime_event_id: Some(event_id),
};
revision = match self
.store
.replace_revision(
&prepared.conversation_id,
&prepared.run_id,
revision,
&[summary_message],
)
.await
{
Ok(revision) => revision,
Err(error) => return (RunOutcome::Failed(error.into()), usage),
};
let (barrier, ready) = CommitBarrier::before_continue();
if emit(
client,
ClientEvent::StateCommitted(StateCommitted {
revision_id: revision,
tool_round_version: 0,
cause: CommitCause::Compaction { summary },
barrier,
}),
)
.await
.is_err()
{
return (client_failure(), usage);
}
if let Err(outcome) = wait_for_state_ready(ready, cancellation).await {
return (outcome, usage);
}
return (RunOutcome::Completed, usage);
}
if cycle.calls.is_empty() {
let assistant = CanonicalMessage {
message_id: format!("{}:assistant:{provider_call_index}", prepared.run_id),
role: Role::Assistant,
origin: Origin::Assistant,
content: MessageContent::Assistant {
text: cycle.text,
thinking: cycle.reasoning,
tool_round_id: None,
replay_state: cycle.replay_state,
tool_calls: Vec::new(),
},
runtime_event_id: None,
};
revision = match self
.store
.append_revision(
&prepared.conversation_id,
&prepared.run_id,
revision,
&[assistant],
)
.await
{
Ok(revision) => revision,
Err(error) => return (RunOutcome::Failed(error.into()), usage),
};
let (barrier, ready) = CommitBarrier::before_continue();
if emit(
client,
ClientEvent::StateCommitted(StateCommitted {
revision_id: revision,
tool_round_version: 0,
cause: CommitCause::FinalTurn,
barrier,
}),
)
.await
.is_err()
{
return (client_failure(), usage);
}
if let Err(outcome) = wait_for_state_ready(ready, cancellation).await {
return (outcome, usage);
}
return (RunOutcome::Completed, usage);
}
let round_id =
ToolRoundId::new(format!("{}:round:{provider_call_index}", prepared.run_id));
revision = match super::tool_round::execute(
&self.store,
prepared,
client,
cancellation,
revision,
super::tool_round::ToolRound {
id: round_id,
assistant: ToolRoundAssistant {
text: cycle.text,
thinking: cycle.reasoning,
model_call_id: cycle.model_call_id,
replay_state: cycle.replay_state,
},
calls: cycle.calls,
recovered_started_at_ms: None,
},
)
.await
{
Ok(revision) => revision,
Err(outcome) => return (outcome, usage),
};
}
}
async fn auto_compact(
&self,
prepared: &PreparedRun,
revision: crate::model::RevisionId,
messages: &[CanonicalMessage],
client: &mut ClientPort,
cancellation: &CancellationToken,
) -> std::result::Result<(crate::model::RevisionId, Option<Usage>), RunOutcome> {
let current_ids = prepared
.initial_messages
.iter()
.map(|message| message.message_id.as_str())
.collect::<HashSet<_>>();
let compactable = messages
.iter()
.filter(|message| !current_ids.contains(message.message_id.as_str()))
.cloned()
.collect::<Vec<_>>();
if compactable.is_empty() {
return Ok((revision, None));
}
emit(client, ClientEvent::AutoCompactionStarted)
.await
.map_err(|_| client_failure())?;
let provider_call_index = self
.store
.begin_provider_call(&prepared.run_id)
.await
.map_err(|error| RunOutcome::Failed(error.into()))?;
let history = crate::model::project_messages(&compactable)
.map_err(|error| RunOutcome::Failed(error.into()))?;
let mut model = prepared.model.clone();
model.max_output_tokens = Some(COMPACTION_OUTPUT_TOKENS);
model.reasoning.enabled = false;
model.reasoning.effort = None;
let invocation = crate::model::ModelInvocation {
call_id: format!("{}:{provider_call_index}", prepared.run_id),
run_id: prepared.run_id.to_string(),
conversation_id: prepared.conversation_id.to_string(),
provider_call_index,
request: crate::model::ModelRequest {
prompt: crate::model::PromptSpec {
instructions: COMPACTION_INSTRUCTIONS.into(),
tools: Vec::new(),
},
model,
history,
},
};
let cycle_cancellation = cancellation.child_token();
let (silent_events, mut discarded_events) = tokio::sync::mpsc::channel(256);
let drain = tokio::spawn(async move { while discarded_events.recv().await.is_some() {} });
let cycle = consume_model_cycle(
self.provider.stream(invocation, cycle_cancellation.clone()),
&silent_events,
&cycle_cancellation,
)
.await;
drop(silent_events);
let _ = drain.await;
let (summary, compaction_usage) = match cycle {
Ok(cycle) if cycle.calls.is_empty() && !cycle.text.trim().is_empty() => {
(cycle.text.trim().to_string(), cycle.usage)
}
Ok(cycle) => {
tracing::warn!("automatic compaction returned no usable summary; using fallback");
(fallback_summary(&compactable), cycle.usage)
}
Err(failure) => {
tracing::warn!(error = ?failure.failure, "automatic compaction model failed; using fallback");
(fallback_summary(&compactable), failure.usage)
}
};
let event_id = format!("summary:auto:{}", prepared.run_id);
let summary_message = CanonicalMessage {
message_id: format!("runtime:{event_id}"),
role: Role::User,
origin: Origin::Runtime,
content: MessageContent::Parts {
parts: vec![crate::model::ContentPart::Text {
text: format!("<conversation_summary>\n{summary}\n</conversation_summary>"),
}],
},
runtime_event_id: Some(event_id),
};
let mut replacement = vec![summary_message];
replacement.extend(prepared.initial_messages.iter().cloned());
let revision = self
.store
.replace_revision(
&prepared.conversation_id,
&prepared.run_id,
revision,
&replacement,
)
.await
.map_err(|error| RunOutcome::Failed(error.into()))?;
let (barrier, ready) = CommitBarrier::before_continue();
emit(
client,
ClientEvent::StateCommitted(StateCommitted {
revision_id: revision,
tool_round_version: 0,
cause: CommitCause::Compaction { summary },
barrier,
}),
)
.await
.map_err(|_| client_failure())?;
wait_for_state_ready(ready, cancellation).await?;
emit(client, ClientEvent::AutoCompactionCompleted)
.await
.map_err(|_| client_failure())?;
Ok((revision, compaction_usage))
}
}
fn should_auto_compact(prepared: &PreparedRun, messages: &[CanonicalMessage]) -> bool {
if prepared.action != RunAction::Start {
return false;
}
let Some(context_window) = prepared.model.context_window_tokens else {
return false;
};
if context_window <= COMPACTION_RESERVE_TOKENS
|| messages.len() <= prepared.initial_messages.len()
{
return false;
}
estimate_context_tokens(&prepared.prompt, messages)
> context_window.saturating_sub(COMPACTION_RESERVE_TOKENS)
}
fn estimate_context_tokens(
prompt: &crate::model::PromptSpec,
messages: &[CanonicalMessage],
) -> u64 {
let serialized = serde_json::to_string(&(prompt, messages)).unwrap_or_default();
serialized
.chars()
.fold(0_u64, |units, character| {
units.saturating_add(if character.is_ascii() { 273 } else { 550 })
})
.div_ceil(1_000)
}
fn fallback_summary(messages: &[CanonicalMessage]) -> String {
let serialized = serde_json::to_string(messages).unwrap_or_default();
let start = serialized
.char_indices()
.rev()
.nth(COMPACTION_FALLBACK_CHARS.saturating_sub(1))
.map_or(0, |(index, _)| index);
format!(
"Durable recent conversation state:\n{}",
&serialized[start..]
)
}
async fn append_runtime_message(
store: &Store,
prepared: &PreparedRun,
client: &mut ClientPort,
cancellation: &CancellationToken,
revision: crate::model::RevisionId,
message: CanonicalMessage,
) -> std::result::Result<crate::model::RevisionId, RunOutcome> {
let event_id = message.runtime_event_id.clone().ok_or_else(|| {
RunOutcome::Failed(RunFailure::Protocol(
"runtime message has no event identity".into(),
))
})?;
let (revision, inserted) = store
.append_message_once(
&prepared.conversation_id,
&prepared.run_id,
revision,
&message,
)
.await
.map_err(|error| RunOutcome::Failed(error.into()))?;
if !inserted {
return Ok(revision);
}
let (barrier, ready) = CommitBarrier::before_continue();
emit(
client,
ClientEvent::StateCommitted(StateCommitted {
revision_id: revision,
tool_round_version: 0,
cause: CommitCause::RuntimeEvent { event_id },
barrier,
}),
)
.await
.map_err(|_| client_failure())?;
wait_for_state_ready(ready, cancellation).await?;
Ok(revision)
}
async fn hydrate_tool_images(
store: &Store,
messages: &mut [crate::model::ProjectedMessage],
) -> crate::Result<()> {
use crate::{
model::{ContentPart, ProjectedContent},
store::BlobId,
Error,
};
for message in messages {
let ProjectedContent::ToolResult(result) = &mut message.content else {
continue;
};
let Some(image) = &result.image else {
continue;
};
let id = BlobId::from_base64(&image.blob_id)?;
let data = store.get_blob(&id).await?.ok_or_else(|| {
Error::Protocol(format!("Read image Blob is missing: {}", image.blob_id))
})?;
result.provider_parts = vec![
ContentPart::Text {
text: result.content.clone(),
},
ContentPart::Image {
mime_type: image.mime_type.clone(),
data,
},
];
}
Ok(())
}
fn accumulate_usage(total: &mut Option<Usage>, usage: Usage) {
match total {
Some(total) => *total += usage,
None => *total = Some(usage),
}
}
pub(super) async fn wait_for_state_ready(
ready: tokio::sync::oneshot::Receiver<std::result::Result<(), String>>,
cancellation: &CancellationToken,
) -> std::result::Result<(), RunOutcome> {
let result = tokio::select! {
biased;
result = ready => result,
_ = cancellation.cancelled() => return Err(RunOutcome::Cancelled),
};
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(error)) => Err(RunOutcome::Failed(RunFailure::Client(error))),
Err(_) => Err(client_failure()),
}
}
async fn emit(client: &ClientPort, event: ClientEvent) -> Result<(), ()> {
client.events.send(event).await.map_err(|_| ())
}
fn client_failure() -> RunOutcome {
RunOutcome::Failed(RunFailure::Client("client event channel closed".into()))
}
fn failure_message(failure: &RunFailure) -> String {
match failure {
RunFailure::Protocol(message)
| RunFailure::Provider(message)
| RunFailure::Store(message)
| RunFailure::Client(message) => message.clone(),
}
}
#[cfg(test)]
mod tests {
use super::{estimate_context_tokens, hydrate_tool_images};
use crate::{
model::{
CanonicalMessage, ContentPart, Origin, ProjectedContent, ProjectedMessage, PromptSpec,
Role, ToolImageReference, ToolResultContent,
},
store::Store,
};
#[test]
fn context_estimate_grows_with_prompt_history() {
let prompt = PromptSpec {
instructions: "system".into(),
tools: Vec::new(),
};
let short = vec![CanonicalMessage::text(
"short",
Role::User,
Origin::User,
"hello",
)];
let long = vec![CanonicalMessage::text(
"long",
Role::User,
Origin::User,
"x".repeat(100_000),
)];
assert!(estimate_context_tokens(&prompt, &long) > 25_000);
assert!(estimate_context_tokens(&prompt, &long) > estimate_context_tokens(&prompt, &short));
}
#[tokio::test]
async fn read_image_is_loaded_only_for_the_provider_projection() {
let directory = tempfile::tempdir().unwrap();
let store = Store::connect(&format!(
"sqlite://{}",
directory.path().join("test.db").display()
))
.await
.unwrap();
let data = b"\x89PNG\r\n\x1a\nimage";
let id = store.put_blob(data, &[]).await.unwrap();
let mut messages = vec![ProjectedMessage {
message_id: "result".into(),
role: Role::Tool,
content: ProjectedContent::ToolResult(ToolResultContent {
call_id: "call".into(),
name: "Read".into(),
content: "Read image file: /tmp/image.png".into(),
is_error: false,
image: Some(ToolImageReference {
blob_id: id.to_base64(),
mime_type: "image/png".into(),
path: "/tmp/image.png".into(),
}),
provider_parts: Vec::new(),
}),
}];
hydrate_tool_images(&store, &mut messages).await.unwrap();
let ProjectedContent::ToolResult(result) = &messages[0].content else {
panic!("not a tool result");
};
assert_eq!(
result.provider_parts,
vec![
ContentPart::Text {
text: "Read image file: /tmp/image.png".into()
},
ContentPart::Image {
mime_type: "image/png".into(),
data: data.to_vec()
}
]
);
let persisted = serde_json::to_value(result).unwrap();
assert!(persisted.get("provider_parts").is_none());
}
}