story-kit: done 240_story_btw_side_question_slash_command
Implement /btw side question slash command — lets users ask quick questions from conversation context without disrupting the main chat. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,14 @@ enum WsRequest {
|
||||
/// Heartbeat ping from the client. The server responds with `Pong` so the
|
||||
/// client can detect stale (half-closed) connections.
|
||||
Ping,
|
||||
/// A quick side question answered from current conversation context.
|
||||
/// The question and response are NOT added to the conversation history
|
||||
/// and no tool calls are made.
|
||||
SideQuestion {
|
||||
question: String,
|
||||
context_messages: Vec<Message>,
|
||||
config: chat::ProviderConfig,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -116,6 +124,14 @@ enum WsResponse {
|
||||
OnboardingStatus {
|
||||
needs_onboarding: bool,
|
||||
},
|
||||
/// Streaming token from a `/btw` side question response.
|
||||
SideQuestionToken {
|
||||
content: String,
|
||||
},
|
||||
/// Final signal that the `/btw` side question has been fully answered.
|
||||
SideQuestionDone {
|
||||
response: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<WatcherEvent> for Option<WsResponse> {
|
||||
@@ -344,6 +360,33 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
||||
Ok(WsRequest::Ping) => {
|
||||
let _ = tx.send(WsResponse::Pong);
|
||||
}
|
||||
Ok(WsRequest::SideQuestion { question, context_messages, config }) => {
|
||||
let tx_side = tx.clone();
|
||||
let store = ctx.store.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = chat::side_question(
|
||||
context_messages,
|
||||
question,
|
||||
config,
|
||||
store.as_ref(),
|
||||
|token| {
|
||||
let _ = tx_side.send(WsResponse::SideQuestionToken {
|
||||
content: token.to_string(),
|
||||
});
|
||||
},
|
||||
).await;
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let _ = tx_side.send(WsResponse::SideQuestionDone { response });
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx_side.send(WsResponse::SideQuestionDone {
|
||||
response: format!("Error: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -370,6 +413,39 @@ pub async fn ws_handler(ws: WebSocket, ctx: Data<&Arc<AppContext>>) -> impl poem
|
||||
Ok(WsRequest::PermissionResponse { .. }) => {
|
||||
// Permission responses outside an active chat are ignored.
|
||||
}
|
||||
Ok(WsRequest::SideQuestion {
|
||||
question,
|
||||
context_messages,
|
||||
config,
|
||||
}) => {
|
||||
let tx_side = tx.clone();
|
||||
let store = ctx.store.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = chat::side_question(
|
||||
context_messages,
|
||||
question,
|
||||
config,
|
||||
store.as_ref(),
|
||||
|token| {
|
||||
let _ = tx_side.send(WsResponse::SideQuestionToken {
|
||||
content: token.to_string(),
|
||||
});
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let _ = tx_side
|
||||
.send(WsResponse::SideQuestionDone { response });
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx_side.send(WsResponse::SideQuestionDone {
|
||||
response: format!("Error: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.send(WsResponse::Error {
|
||||
message: format!("Invalid request: {err}"),
|
||||
|
||||
@@ -409,6 +409,83 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
/// Answer a one-off side question using the existing conversation as context.
|
||||
///
|
||||
/// Unlike `chat`, this function:
|
||||
/// - Does NOT perform tool calls.
|
||||
/// - Does NOT modify the main conversation history.
|
||||
/// - Does NOT touch the shared cancel signal.
|
||||
/// - Performs a single LLM call and returns the response text.
|
||||
pub async fn side_question<U>(
|
||||
context_messages: Vec<Message>,
|
||||
question: String,
|
||||
config: ProviderConfig,
|
||||
store: &dyn StoreOps,
|
||||
mut on_token: U,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
U: FnMut(&str) + Send,
|
||||
{
|
||||
use crate::llm::providers::anthropic::AnthropicProvider;
|
||||
use crate::llm::providers::ollama::OllamaProvider;
|
||||
|
||||
// Use a local cancel channel that is never cancelled, so the side question
|
||||
// runs to completion independently of any main chat cancel signal.
|
||||
// Keep `_cancel_tx` alive for the duration of the function so the channel
|
||||
// stays open and `changed()` inside the providers does not spuriously fire.
|
||||
let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||
let mut cancel_rx = cancel_rx;
|
||||
cancel_rx.borrow_and_update();
|
||||
|
||||
let base_url = config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| "http://localhost:11434".to_string());
|
||||
|
||||
let is_claude_code = config.provider == "claude-code";
|
||||
let is_claude = !is_claude_code && config.model.starts_with("claude-");
|
||||
|
||||
// Build a minimal history: existing context + the side question.
|
||||
let mut history = context_messages;
|
||||
history.push(Message {
|
||||
role: Role::User,
|
||||
content: question,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
|
||||
// No tools for side questions.
|
||||
let tools: &[ToolDefinition] = &[];
|
||||
|
||||
let response = if is_claude {
|
||||
let api_key = get_anthropic_api_key_impl(store)?;
|
||||
let provider = AnthropicProvider::new(api_key);
|
||||
provider
|
||||
.chat_stream(
|
||||
&config.model,
|
||||
&history,
|
||||
tools,
|
||||
&mut cancel_rx,
|
||||
|token| on_token(token),
|
||||
|_tool_name| {},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Anthropic Error: {e}"))?
|
||||
} else if is_claude_code {
|
||||
return Err("Claude Code provider does not support side questions".to_string());
|
||||
} else {
|
||||
let provider = OllamaProvider::new(base_url);
|
||||
provider
|
||||
.chat_stream(&config.model, &history, tools, &mut cancel_rx, |token| {
|
||||
on_token(token)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Ollama Error: {e}"))?
|
||||
};
|
||||
|
||||
Ok(response.content.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn execute_tool(call: &ToolCall, state: &SessionState) -> String {
|
||||
use crate::io::{fs, search, shell};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user