支持在会话输入框直接进行文件/图片的拖拽和粘贴
This commit is contained in:
Generated
+1
@@ -751,6 +751,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
|||||||
name = "codeg"
|
name = "codeg"
|
||||||
version = "0.0.15"
|
version = "0.0.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"bzip2",
|
"bzip2",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ sea-orm = { version = "1.1", features = ["sqlx-sqlite", "runtime-tokio-rustls",
|
|||||||
sea-orm-migration = { version = "1.1", features = ["sqlx-sqlite", "runtime-tokio-rustls"] }
|
sea-orm-migration = { version = "1.1", features = ["sqlx-sqlite", "runtime-tokio-rustls"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
notify = "6"
|
notify = "6"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-window-state = "2"
|
tauri-plugin-window-state = "2"
|
||||||
|
|||||||
@@ -4,19 +4,21 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use sacp::schema::McpServerStdio;
|
use sacp::schema::McpServerStdio;
|
||||||
use sacp::schema::{
|
use sacp::schema::{
|
||||||
CancelNotification, ClientCapabilities, ContentBlock, ContentChunk, CreateTerminalRequest,
|
BlobResourceContents, CancelNotification, ClientCapabilities, ContentBlock, ContentChunk,
|
||||||
CreateTerminalResponse, FileSystemCapability, InitializeRequest, KillTerminalCommandRequest,
|
CreateTerminalRequest, CreateTerminalResponse, EmbeddedResource, EmbeddedResourceResource,
|
||||||
|
FileSystemCapability, ImageContent, InitializeRequest, KillTerminalCommandRequest,
|
||||||
KillTerminalCommandResponse, LoadSessionRequest, NewSessionRequest, NewSessionResponse,
|
KillTerminalCommandResponse, LoadSessionRequest, NewSessionRequest, NewSessionResponse,
|
||||||
PermissionOptionKind, Plan, PlanEntryPriority, PlanEntryStatus, PromptRequest, ProtocolVersion,
|
PermissionOptionKind, Plan, PlanEntryPriority, PlanEntryStatus, PromptRequest,
|
||||||
ReadTextFileRequest, ReadTextFileResponse, ReleaseTerminalRequest, ReleaseTerminalResponse,
|
ProtocolVersion, ReadTextFileRequest, ReadTextFileResponse, ReleaseTerminalRequest,
|
||||||
RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, ResourceLink,
|
ReleaseTerminalResponse, RequestPermissionOutcome, RequestPermissionRequest,
|
||||||
SelectedPermissionOutcome, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory,
|
RequestPermissionResponse, ResourceLink, SelectedPermissionOutcome, SessionConfigKind,
|
||||||
SessionConfigSelectGroup, SessionConfigSelectOption, SessionConfigSelectOptions, SessionId,
|
SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectGroup,
|
||||||
SessionModeState, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
|
SessionConfigSelectOption, SessionConfigSelectOptions, SessionId, SessionModeState,
|
||||||
|
SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
|
||||||
SetSessionConfigOptionResponse, SetSessionModeRequest, StopReason, TerminalExitStatus,
|
SetSessionConfigOptionResponse, SetSessionModeRequest, StopReason, TerminalExitStatus,
|
||||||
TerminalOutputRequest, TerminalOutputResponse, TextContent, ToolCallContent,
|
TerminalOutputRequest, TerminalOutputResponse, TextContent, TextResourceContents,
|
||||||
WaitForTerminalExitRequest, WaitForTerminalExitResponse, WriteTextFileRequest,
|
ToolCallContent, WaitForTerminalExitRequest, WaitForTerminalExitResponse,
|
||||||
WriteTextFileResponse,
|
WriteTextFileRequest, WriteTextFileResponse,
|
||||||
};
|
};
|
||||||
use sacp::util::MatchDispatch;
|
use sacp::util::MatchDispatch;
|
||||||
use sacp::{
|
use sacp::{
|
||||||
@@ -32,9 +34,9 @@ use crate::acp::registry::{self, AgentDistribution};
|
|||||||
use crate::acp::terminal_runtime::{TerminalRuntime, TerminalRuntimeError};
|
use crate::acp::terminal_runtime::{TerminalRuntime, TerminalRuntimeError};
|
||||||
use crate::acp::types::{
|
use crate::acp::types::{
|
||||||
AcpEvent, AvailableCommandInfo, ConnectionInfo, ConnectionStatus, PermissionOptionInfo,
|
AcpEvent, AvailableCommandInfo, ConnectionInfo, ConnectionStatus, PermissionOptionInfo,
|
||||||
PlanEntryInfo, PromptInputBlock, SessionConfigKindInfo, SessionConfigOptionInfo,
|
PlanEntryInfo, PromptCapabilitiesInfo, PromptInputBlock, SessionConfigKindInfo,
|
||||||
SessionConfigSelectGroupInfo, SessionConfigSelectInfo, SessionConfigSelectOptionInfo,
|
SessionConfigOptionInfo, SessionConfigSelectGroupInfo, SessionConfigSelectInfo,
|
||||||
SessionModeInfo, SessionModeStateInfo,
|
SessionConfigSelectOptionInfo, SessionModeInfo, SessionModeStateInfo,
|
||||||
};
|
};
|
||||||
use crate::models::agent::AgentType;
|
use crate::models::agent::AgentType;
|
||||||
use crate::network::proxy;
|
use crate::network::proxy;
|
||||||
@@ -448,6 +450,24 @@ fn emit_selectors_ready(connection_id: &str, app_handle: &tauri::AppHandle) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_prompt_capabilities(
|
||||||
|
connection_id: &str,
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
capabilities: &sacp::schema::PromptCapabilities,
|
||||||
|
) {
|
||||||
|
let _ = app_handle.emit(
|
||||||
|
"acp://event",
|
||||||
|
AcpEvent::PromptCapabilities {
|
||||||
|
connection_id: connection_id.into(),
|
||||||
|
prompt_capabilities: PromptCapabilitiesInfo {
|
||||||
|
image: capabilities.image,
|
||||||
|
audio: capabilities.audio,
|
||||||
|
embedded_context: capabilities.embedded_context,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_working_dir(working_dir: Option<&str>) -> PathBuf {
|
fn resolve_working_dir(working_dir: Option<&str>) -> PathBuf {
|
||||||
match working_dir {
|
match working_dir {
|
||||||
Some(dir) => {
|
Some(dir) => {
|
||||||
@@ -591,7 +611,12 @@ async fn run_connection(
|
|||||||
.read_text_file(true)
|
.read_text_file(true)
|
||||||
.write_text_file(true)),
|
.write_text_file(true)),
|
||||||
);
|
);
|
||||||
let _init_resp = cx.send_request_to(Agent, init_request).block_task().await?;
|
let init_resp = cx.send_request_to(Agent, init_request).block_task().await?;
|
||||||
|
emit_prompt_capabilities(
|
||||||
|
&conn_id,
|
||||||
|
&handle,
|
||||||
|
&init_resp.agent_capabilities.prompt_capabilities,
|
||||||
|
);
|
||||||
|
|
||||||
// Emit connected status
|
// Emit connected status
|
||||||
let _ = handle.emit(
|
let _ = handle.emit(
|
||||||
@@ -1128,6 +1153,35 @@ fn map_prompt_blocks(blocks: Vec<PromptInputBlock>) -> Vec<ContentBlock> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|block| match block {
|
.map(|block| match block {
|
||||||
PromptInputBlock::Text { text } => ContentBlock::Text(TextContent::new(text)),
|
PromptInputBlock::Text { text } => ContentBlock::Text(TextContent::new(text)),
|
||||||
|
PromptInputBlock::Image {
|
||||||
|
data,
|
||||||
|
mime_type,
|
||||||
|
uri,
|
||||||
|
} => ContentBlock::Image(ImageContent::new(data, mime_type).uri(uri)),
|
||||||
|
PromptInputBlock::Resource {
|
||||||
|
uri,
|
||||||
|
mime_type,
|
||||||
|
text,
|
||||||
|
blob,
|
||||||
|
} => {
|
||||||
|
let resource = match (text, blob) {
|
||||||
|
(Some(text_value), _) => {
|
||||||
|
let content =
|
||||||
|
TextResourceContents::new(text_value, uri.clone()).mime_type(mime_type);
|
||||||
|
EmbeddedResourceResource::TextResourceContents(content)
|
||||||
|
}
|
||||||
|
(None, Some(blob_value)) => {
|
||||||
|
let content =
|
||||||
|
BlobResourceContents::new(blob_value, uri.clone()).mime_type(mime_type);
|
||||||
|
EmbeddedResourceResource::BlobResourceContents(content)
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
let content = TextResourceContents::new("", uri.clone()).mime_type(mime_type);
|
||||||
|
EmbeddedResourceResource::TextResourceContents(content)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ContentBlock::Resource(EmbeddedResource::new(resource))
|
||||||
|
}
|
||||||
PromptInputBlock::ResourceLink {
|
PromptInputBlock::ResourceLink {
|
||||||
uri,
|
uri,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -7,6 +7,21 @@ pub enum PromptInputBlock {
|
|||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
Image {
|
||||||
|
data: String,
|
||||||
|
mime_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
uri: Option<String>,
|
||||||
|
},
|
||||||
|
Resource {
|
||||||
|
uri: String,
|
||||||
|
#[serde(default)]
|
||||||
|
mime_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
text: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
blob: Option<String>,
|
||||||
|
},
|
||||||
ResourceLink {
|
ResourceLink {
|
||||||
uri: String,
|
uri: String,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -17,6 +32,13 @@ pub enum PromptInputBlock {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct PromptCapabilitiesInfo {
|
||||||
|
pub image: bool,
|
||||||
|
pub audio: bool,
|
||||||
|
pub embedded_context: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// Events pushed from Rust backend to frontend via Tauri event system.
|
/// Events pushed from Rust backend to frontend via Tauri event system.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
@@ -77,6 +99,11 @@ pub enum AcpEvent {
|
|||||||
},
|
},
|
||||||
/// Initial selector payloads (modes/config options) have been emitted
|
/// Initial selector payloads (modes/config options) have been emitted
|
||||||
SelectorsReady { connection_id: String },
|
SelectorsReady { connection_id: String },
|
||||||
|
/// Prompt capabilities for this connection
|
||||||
|
PromptCapabilities {
|
||||||
|
connection_id: String,
|
||||||
|
prompt_capabilities: PromptCapabilitiesInfo,
|
||||||
|
},
|
||||||
/// Current session mode changed
|
/// Current session mode changed
|
||||||
ModeChanged {
|
ModeChanged {
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use std::process::{Command, Stdio};
|
|||||||
use std::sync::{mpsc, LazyLock, Mutex};
|
use std::sync::{mpsc, LazyLock, Mutex};
|
||||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
@@ -1161,6 +1162,8 @@ const FILE_PREVIEW_MIN_BYTES: usize = 4_096;
|
|||||||
const FILE_PREVIEW_MAX_BYTES: usize = 2_000_000;
|
const FILE_PREVIEW_MAX_BYTES: usize = 2_000_000;
|
||||||
const FILE_EDIT_DEFAULT_MAX_BYTES: usize = 400_000;
|
const FILE_EDIT_DEFAULT_MAX_BYTES: usize = 400_000;
|
||||||
const FILE_EDIT_MAX_BYTES: usize = 2_000_000;
|
const FILE_EDIT_MAX_BYTES: usize = 2_000_000;
|
||||||
|
const FILE_BASE64_DEFAULT_MAX_BYTES: usize = 20_000_000;
|
||||||
|
const FILE_BASE64_MAX_BYTES: usize = 100_000_000;
|
||||||
const FILE_IO_MAX_CONCURRENT_OPS: usize = 8;
|
const FILE_IO_MAX_CONCURRENT_OPS: usize = 8;
|
||||||
|
|
||||||
static FILE_IO_SEMAPHORE: LazyLock<Semaphore> =
|
static FILE_IO_SEMAPHORE: LazyLock<Semaphore> =
|
||||||
@@ -2028,6 +2031,47 @@ pub async fn get_file_tree(
|
|||||||
Ok(dir_children.remove(&root).unwrap_or_default())
|
Ok(dir_children.remove(&root).unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn read_file_base64(
|
||||||
|
path: String,
|
||||||
|
max_bytes: Option<usize>,
|
||||||
|
) -> Result<String, AppCommandError> {
|
||||||
|
let trimmed = path.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(AppCommandError::invalid_input("Path cannot be empty"));
|
||||||
|
}
|
||||||
|
let target = PathBuf::from(trimmed);
|
||||||
|
if !target.exists() {
|
||||||
|
return Err(AppCommandError::not_found("File does not exist"));
|
||||||
|
}
|
||||||
|
if !target.is_file() {
|
||||||
|
return Err(AppCommandError::invalid_input("Path is not a file"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = max_bytes
|
||||||
|
.unwrap_or(FILE_BASE64_DEFAULT_MAX_BYTES)
|
||||||
|
.clamp(FILE_PREVIEW_MIN_BYTES, FILE_BASE64_MAX_BYTES);
|
||||||
|
|
||||||
|
run_file_io(move || {
|
||||||
|
let metadata = std::fs::metadata(&target).map_err(AppCommandError::io)?;
|
||||||
|
if metadata.len() > limit as u64 {
|
||||||
|
return Err(
|
||||||
|
AppCommandError::invalid_input("File is too large to attach")
|
||||||
|
.with_detail(format!("max_bytes={limit}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(&target).map_err(AppCommandError::io)?;
|
||||||
|
if bytes.len() > limit {
|
||||||
|
return Err(
|
||||||
|
AppCommandError::invalid_input("File is too large to attach")
|
||||||
|
.with_detail(format!("max_bytes={limit}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn read_file_preview(
|
pub async fn read_file_preview(
|
||||||
root_path: String,
|
root_path: String,
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ pub fn run() {
|
|||||||
folders::start_file_tree_watch,
|
folders::start_file_tree_watch,
|
||||||
folders::stop_file_tree_watch,
|
folders::stop_file_tree_watch,
|
||||||
folders::get_file_tree,
|
folders::get_file_tree,
|
||||||
|
folders::read_file_base64,
|
||||||
folders::read_file_preview,
|
folders::read_file_preview,
|
||||||
folders::read_file_for_edit,
|
folders::read_file_for_edit,
|
||||||
folders::save_file_content,
|
folders::save_file_content,
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ pub enum ContentBlock {
|
|||||||
Text {
|
Text {
|
||||||
text: String,
|
text: String,
|
||||||
},
|
},
|
||||||
|
Image {
|
||||||
|
data: String,
|
||||||
|
mime_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
uri: Option<String>,
|
||||||
|
},
|
||||||
ToolUse {
|
ToolUse {
|
||||||
tool_use_id: Option<String>,
|
tool_use_id: Option<String>,
|
||||||
tool_name: String,
|
tool_name: String,
|
||||||
|
|||||||
@@ -501,20 +501,49 @@ impl CodexParser {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
let normalized = strip_blocked_resource_mentions(&text);
|
let normalized = strip_blocked_resource_mentions(&text);
|
||||||
let message_text = if normalized.is_empty() {
|
let mut blocks: Vec<ContentBlock> = Vec::new();
|
||||||
"Attached resources".to_string()
|
if !normalized.is_empty() {
|
||||||
} else {
|
blocks.push(ContentBlock::Text { text: normalized });
|
||||||
normalized
|
}
|
||||||
|
|
||||||
|
if let Some(images) =
|
||||||
|
payload.get("images").and_then(|v| v.as_array())
|
||||||
|
{
|
||||||
|
for image in images {
|
||||||
|
let Some(raw) = image.as_str() else {
|
||||||
|
continue;
|
||||||
};
|
};
|
||||||
|
let Some((mime_type, data)) = parse_data_uri_image(raw)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
blocks.push(ContentBlock::Image {
|
||||||
|
data,
|
||||||
|
mime_type,
|
||||||
|
uri: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if blocks.is_empty() {
|
||||||
|
blocks.push(ContentBlock::Text {
|
||||||
|
text: "Attached resources".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if title.is_none() {
|
if title.is_none() {
|
||||||
title = extract_codex_title_candidate(&text, true);
|
title = extract_codex_title_candidate(&text, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if should_skip_duplicate_user_message(&messages, &blocks, timestamp)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
messages.push(UnifiedMessage {
|
messages.push(UnifiedMessage {
|
||||||
id: format!("user-{}", messages.len()),
|
id: format!("user-{}", messages.len()),
|
||||||
role: MessageRole::User,
|
role: MessageRole::User,
|
||||||
content: vec![ContentBlock::Text { text: message_text }],
|
content: blocks,
|
||||||
timestamp,
|
timestamp,
|
||||||
usage: None,
|
usage: None,
|
||||||
duration_ms: None,
|
duration_ms: None,
|
||||||
@@ -690,6 +719,40 @@ impl CodexParser {
|
|||||||
model: None,
|
model: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
"message" => {
|
||||||
|
let role =
|
||||||
|
payload.get("role").and_then(|r| r.as_str()).unwrap_or("");
|
||||||
|
if role == "user" {
|
||||||
|
if let Some(blocks) =
|
||||||
|
extract_response_item_user_image_blocks(payload)
|
||||||
|
{
|
||||||
|
if should_skip_duplicate_user_message(
|
||||||
|
&messages, &blocks, timestamp,
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if title.is_none() {
|
||||||
|
if let Some(text) = first_text_block(&blocks) {
|
||||||
|
title = extract_codex_title_candidate(
|
||||||
|
text.as_str(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push(UnifiedMessage {
|
||||||
|
id: format!("user-{}", messages.len()),
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: blocks,
|
||||||
|
timestamp,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -946,10 +1009,138 @@ fn extract_codex_text_content(payload: &serde_json::Value) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_data_uri_image(raw: &str) -> Option<(String, String)> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if !trimmed.starts_with("data:") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let marker = ";base64,";
|
||||||
|
let marker_idx = trimmed.find(marker)?;
|
||||||
|
let mime_type = trimmed.get(5..marker_idx)?.trim();
|
||||||
|
if !mime_type.starts_with("image/") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let data = trimmed.get(marker_idx + marker.len()..)?.trim();
|
||||||
|
if data.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((mime_type.to_string(), data.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_input_image_data_uri(item: &serde_json::Value) -> Option<(String, String)> {
|
||||||
|
let data_uri = item
|
||||||
|
.get("image_url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.or_else(|| {
|
||||||
|
item.get("image_url")
|
||||||
|
.and_then(|v| v.get("url"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
})
|
||||||
|
.or_else(|| item.get("url").and_then(|v| v.as_str()))?;
|
||||||
|
parse_data_uri_image(data_uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_text_block(blocks: &[ContentBlock]) -> Option<String> {
|
||||||
|
blocks.iter().find_map(|block| match block {
|
||||||
|
ContentBlock::Text { text } => Some(text.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocks_equal(a: &[ContentBlock], b: &[ContentBlock]) -> bool {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
serde_json::to_value(a).ok() == serde_json::to_value(b).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_skip_duplicate_user_message(
|
||||||
|
messages: &[UnifiedMessage],
|
||||||
|
blocks: &[ContentBlock],
|
||||||
|
timestamp: DateTime<Utc>,
|
||||||
|
) -> bool {
|
||||||
|
// Some Codex logs emit the same user message through both `response_item`
|
||||||
|
// and `event_msg`, sometimes with a non-trivial delay. Deduplicate by
|
||||||
|
// content in a bounded recent time window.
|
||||||
|
const DUP_WINDOW_MS: i64 = 120_000;
|
||||||
|
|
||||||
|
for msg in messages.iter().rev() {
|
||||||
|
if !matches!(msg.role, MessageRole::User) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let delta_ms = (timestamp - msg.timestamp).num_milliseconds().abs();
|
||||||
|
if delta_ms > DUP_WINDOW_MS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if blocks_equal(&msg.content, blocks) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_response_item_user_image_blocks(
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
) -> Option<Vec<ContentBlock>> {
|
||||||
|
let content = payload.get("content")?.as_array()?;
|
||||||
|
let mut blocks: Vec<ContentBlock> = Vec::new();
|
||||||
|
let mut text_parts: Vec<String> = Vec::new();
|
||||||
|
let mut has_input_image = false;
|
||||||
|
|
||||||
|
for item in content {
|
||||||
|
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
match item_type {
|
||||||
|
"input_text" => {
|
||||||
|
let Some(text) = item.get("text").and_then(|v| v.as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if text.trim() == "<image>" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !text.is_empty() {
|
||||||
|
text_parts.push(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"input_image" => {
|
||||||
|
has_input_image = true;
|
||||||
|
let Some((mime_type, data)) = parse_input_image_data_uri(item) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
blocks.push(ContentBlock::Image {
|
||||||
|
data,
|
||||||
|
mime_type,
|
||||||
|
uri: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has_input_image {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = strip_blocked_resource_mentions(&text_parts.join("\n"));
|
||||||
|
if !text.is_empty() {
|
||||||
|
blocks.insert(0, ContentBlock::Text { text });
|
||||||
|
}
|
||||||
|
|
||||||
|
if blocks.is_empty() {
|
||||||
|
blocks.push(ContentBlock::Text {
|
||||||
|
text: "Attached resources".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(blocks)
|
||||||
|
}
|
||||||
|
|
||||||
fn strip_blocked_resource_mentions(input: &str) -> String {
|
fn strip_blocked_resource_mentions(input: &str) -> String {
|
||||||
let blocked_re = Regex::new(r"@([^\s@]+)\s*\[blocked[^\]]*\]").expect("valid blocked regex");
|
let blocked_re = Regex::new(r"@([^\s@]+)\s*\[blocked[^\]]*\]").expect("valid blocked regex");
|
||||||
|
let image_tag_re = Regex::new(r"(?i)</?image\s*/?>").expect("valid image tag regex");
|
||||||
let collapsed_ws_re = Regex::new(r"[ \t]{2,}").expect("valid whitespace regex");
|
let collapsed_ws_re = Regex::new(r"[ \t]{2,}").expect("valid whitespace regex");
|
||||||
let text = blocked_re.replace_all(input, "").to_string();
|
let text = blocked_re.replace_all(input, "").to_string();
|
||||||
|
let text = image_tag_re.replace_all(&text, "").to_string();
|
||||||
let text = collapsed_ws_re.replace_all(&text, " ").to_string();
|
let text = collapsed_ws_re.replace_all(&text, " ").to_string();
|
||||||
text.trim().to_string()
|
text.trim().to_string()
|
||||||
}
|
}
|
||||||
@@ -1030,12 +1221,16 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::extract_codex_title_candidate;
|
use super::extract_codex_title_candidate;
|
||||||
use super::extract_context_window_used_tokens_from_token_count_info;
|
use super::extract_context_window_used_tokens_from_token_count_info;
|
||||||
|
use super::extract_response_item_user_image_blocks;
|
||||||
use super::extract_turn_usage_from_codex_usage;
|
use super::extract_turn_usage_from_codex_usage;
|
||||||
use super::merge_codex_context_window_stats;
|
use super::merge_codex_context_window_stats;
|
||||||
use super::merge_codex_total_usage_stats;
|
use super::merge_codex_total_usage_stats;
|
||||||
use super::resolve_codex_home_dir_from;
|
use super::resolve_codex_home_dir_from;
|
||||||
|
use super::should_skip_duplicate_user_message;
|
||||||
|
use super::strip_blocked_resource_mentions;
|
||||||
use super::CodexParser;
|
use super::CodexParser;
|
||||||
use crate::models::{SessionStats, TurnUsage};
|
use crate::models::{ContentBlock, MessageRole, SessionStats, TurnUsage, UnifiedMessage};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -1063,6 +1258,75 @@ mod tests {
|
|||||||
assert_eq!(got.as_deref(), Some("修复 codex 会话标题"));
|
assert_eq!(got.as_deref(), Some("修复 codex 会话标题"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_image_placeholders_from_user_text() {
|
||||||
|
let input = "这个图片里面是什么\n</image>\n<image>\n";
|
||||||
|
let got = strip_blocked_resource_mentions(input);
|
||||||
|
assert_eq!(got, "这个图片里面是什么");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_response_item_input_image_blocks() {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"content": [
|
||||||
|
{"type": "input_text", "text": "这是什么东西"},
|
||||||
|
{"type": "input_text", "text": "<image>"},
|
||||||
|
{"type": "input_image", "image_url": "data:image/png;base64,QUJD"}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let blocks = extract_response_item_user_image_blocks(&payload).expect("blocks");
|
||||||
|
assert_eq!(blocks.len(), 2);
|
||||||
|
match &blocks[0] {
|
||||||
|
ContentBlock::Text { text } => assert_eq!(text, "这是什么东西"),
|
||||||
|
_ => panic!("expected text block"),
|
||||||
|
}
|
||||||
|
match &blocks[1] {
|
||||||
|
ContentBlock::Image {
|
||||||
|
data, mime_type, ..
|
||||||
|
} => {
|
||||||
|
assert_eq!(mime_type, "image/png");
|
||||||
|
assert_eq!(data, "QUJD");
|
||||||
|
}
|
||||||
|
_ => panic!("expected image block"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_duplicate_user_message_within_short_window() {
|
||||||
|
let now = Utc::now();
|
||||||
|
let blocks = vec![
|
||||||
|
ContentBlock::Text {
|
||||||
|
text: "hello".to_string(),
|
||||||
|
},
|
||||||
|
ContentBlock::Image {
|
||||||
|
data: "QUJD".to_string(),
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
uri: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let messages = vec![UnifiedMessage {
|
||||||
|
id: "user-0".to_string(),
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: blocks.clone(),
|
||||||
|
timestamp: now,
|
||||||
|
usage: None,
|
||||||
|
duration_ms: None,
|
||||||
|
model: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
assert!(should_skip_duplicate_user_message(
|
||||||
|
&messages,
|
||||||
|
&blocks,
|
||||||
|
now + Duration::milliseconds(1200),
|
||||||
|
));
|
||||||
|
assert!(!should_skip_duplicate_user_message(
|
||||||
|
&messages,
|
||||||
|
&blocks,
|
||||||
|
now + Duration::seconds(180),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn summary_title_skips_injected_messages_and_uses_real_prompt() {
|
fn summary_title_skips_injected_messages_and_uses_real_prompt() {
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import type {
|
import type {
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
|
PromptCapabilitiesInfo,
|
||||||
PromptDraft,
|
PromptDraft,
|
||||||
SessionConfigOptionInfo,
|
SessionConfigOptionInfo,
|
||||||
SessionModeInfo,
|
SessionModeInfo,
|
||||||
@@ -12,6 +13,7 @@ import { MessageInput } from "@/components/chat/message-input"
|
|||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
status: ConnectionStatus | null
|
status: ConnectionStatus | null
|
||||||
|
promptCapabilities: PromptCapabilitiesInfo
|
||||||
defaultPath?: string
|
defaultPath?: string
|
||||||
onFocus?: () => void
|
onFocus?: () => void
|
||||||
onSend: (draft: PromptDraft, modeId?: string | null) => void
|
onSend: (draft: PromptDraft, modeId?: string | null) => void
|
||||||
@@ -30,6 +32,7 @@ interface ChatInputProps {
|
|||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
status,
|
status,
|
||||||
|
promptCapabilities,
|
||||||
defaultPath,
|
defaultPath,
|
||||||
onFocus,
|
onFocus,
|
||||||
onSend,
|
onSend,
|
||||||
@@ -54,6 +57,7 @@ export function ChatInput({
|
|||||||
<div className="p-4 pt-0">
|
<div className="p-4 pt-0">
|
||||||
<MessageInput
|
<MessageInput
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
|
promptCapabilities={promptCapabilities}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
defaultPath={defaultPath}
|
defaultPath={defaultPath}
|
||||||
disabled={!isConnected}
|
disabled={!isConnected}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import type {
|
import type {
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
|
PromptCapabilitiesInfo,
|
||||||
PromptDraft,
|
PromptDraft,
|
||||||
SessionConfigOptionInfo,
|
SessionConfigOptionInfo,
|
||||||
SessionModeInfo,
|
SessionModeInfo,
|
||||||
@@ -12,6 +13,7 @@ import { PermissionDialog } from "@/components/chat/permission-dialog"
|
|||||||
|
|
||||||
interface ConversationShellProps {
|
interface ConversationShellProps {
|
||||||
status: ConnectionStatus | null
|
status: ConnectionStatus | null
|
||||||
|
promptCapabilities: PromptCapabilitiesInfo
|
||||||
defaultPath?: string
|
defaultPath?: string
|
||||||
error: string | null
|
error: string | null
|
||||||
pendingPermission: PendingPermission | null
|
pendingPermission: PendingPermission | null
|
||||||
@@ -34,6 +36,7 @@ interface ConversationShellProps {
|
|||||||
|
|
||||||
export function ConversationShell({
|
export function ConversationShell({
|
||||||
status,
|
status,
|
||||||
|
promptCapabilities,
|
||||||
defaultPath,
|
defaultPath,
|
||||||
error,
|
error,
|
||||||
pendingPermission,
|
pendingPermission,
|
||||||
@@ -64,6 +67,7 @@ export function ConversationShell({
|
|||||||
|
|
||||||
<ChatInput
|
<ChatInput
|
||||||
status={status}
|
status={status}
|
||||||
|
promptCapabilities={promptCapabilities}
|
||||||
defaultPath={defaultPath}
|
defaultPath={defaultPath}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { getCurrentWebview } from "@tauri-apps/api/webview"
|
||||||
import { open } from "@tauri-apps/plugin-dialog"
|
import { open } from "@tauri-apps/plugin-dialog"
|
||||||
|
import Image from "next/image"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { FileSearch, Plus, Send, Square, X } from "lucide-react"
|
import { FileSearch, Plus, Send, Square, X } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { readFileBase64 } from "@/lib/tauri"
|
||||||
import type {
|
import type {
|
||||||
AvailableCommandInfo,
|
AvailableCommandInfo,
|
||||||
|
PromptCapabilitiesInfo,
|
||||||
PromptDraft,
|
PromptDraft,
|
||||||
PromptInputBlock,
|
PromptInputBlock,
|
||||||
SessionConfigOptionInfo,
|
SessionConfigOptionInfo,
|
||||||
@@ -45,17 +49,33 @@ interface MessageInputProps {
|
|||||||
onModeChange?: (modeId: string) => void
|
onModeChange?: (modeId: string) => void
|
||||||
onConfigOptionChange?: (configId: string, valueId: string) => void
|
onConfigOptionChange?: (configId: string, valueId: string) => void
|
||||||
availableCommands?: AvailableCommandInfo[] | null
|
availableCommands?: AvailableCommandInfo[] | null
|
||||||
|
promptCapabilities: PromptCapabilitiesInfo
|
||||||
attachmentTabId?: string | null
|
attachmentTabId?: string | null
|
||||||
draftStorageKey?: string | null
|
draftStorageKey?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InputAttachment {
|
interface ResourceInputAttachment {
|
||||||
path: string
|
id: string
|
||||||
|
type: "resource"
|
||||||
|
kind: "link" | "embedded"
|
||||||
uri: string
|
uri: string
|
||||||
name: string
|
name: string
|
||||||
mimeType: string | null
|
mimeType: string | null
|
||||||
|
text?: string | null
|
||||||
|
blob?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImageInputAttachment {
|
||||||
|
id: string
|
||||||
|
type: "image"
|
||||||
|
data: string
|
||||||
|
uri: string | null
|
||||||
|
name: string
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputAttachment = ResourceInputAttachment | ImageInputAttachment
|
||||||
|
|
||||||
const MIME_BY_EXT: Record<string, string> = {
|
const MIME_BY_EXT: Record<string, string> = {
|
||||||
txt: "text/plain",
|
txt: "text/plain",
|
||||||
md: "text/markdown",
|
md: "text/markdown",
|
||||||
@@ -104,6 +124,102 @@ function toFileUri(path: string): string {
|
|||||||
return `file:///${encoded}`
|
return `file:///${encoded}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDragFiles(dataTransfer: DataTransfer | null): boolean {
|
||||||
|
if (!dataTransfer?.types) return false
|
||||||
|
return Array.from(dataTransfer.types).includes("Files")
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointWithinElement(
|
||||||
|
position: { x: number; y: number },
|
||||||
|
element: HTMLElement
|
||||||
|
): boolean {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
const candidates = [
|
||||||
|
{ x: position.x, y: position.y },
|
||||||
|
{ x: position.x / dpr, y: position.y / dpr },
|
||||||
|
]
|
||||||
|
return candidates.some(
|
||||||
|
(point) =>
|
||||||
|
point.x >= rect.left &&
|
||||||
|
point.x <= rect.right &&
|
||||||
|
point.y >= rect.top &&
|
||||||
|
point.y <= rect.bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(reader.error ?? new Error("Failed to read blob"))
|
||||||
|
}
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result !== "string") {
|
||||||
|
reject(new Error("Unexpected non-string blob reader result"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const markerIndex = reader.result.indexOf(",")
|
||||||
|
resolve(
|
||||||
|
markerIndex >= 0 ? reader.result.slice(markerIndex + 1) : reader.result
|
||||||
|
)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilePath(file: File): string | null {
|
||||||
|
const withPath = file as File & { path?: string; webkitRelativePath?: string }
|
||||||
|
if (typeof withPath.path === "string" && withPath.path.trim().length > 0) {
|
||||||
|
return withPath.path
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof withPath.webkitRelativePath === "string" &&
|
||||||
|
withPath.webkitRelativePath.trim().length > 0
|
||||||
|
) {
|
||||||
|
return withPath.webkitRelativePath
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_LIKE_MIME_PREFIXES = [
|
||||||
|
"text/",
|
||||||
|
"application/json",
|
||||||
|
"application/xml",
|
||||||
|
"application/yaml",
|
||||||
|
"application/x-yaml",
|
||||||
|
"application/toml",
|
||||||
|
"application/javascript",
|
||||||
|
"application/typescript",
|
||||||
|
]
|
||||||
|
const DRAG_DROP_IMAGE_MAX_BYTES = 20_000_000
|
||||||
|
|
||||||
|
function isTextLikeFile(file: File): boolean {
|
||||||
|
const mime = file.type.toLowerCase()
|
||||||
|
if (mime) {
|
||||||
|
if (TEXT_LIKE_MIME_PREFIXES.some((prefix) => mime.startsWith(prefix))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||||
|
if (!ext) return false
|
||||||
|
return Boolean(
|
||||||
|
MIME_BY_EXT[ext]?.startsWith("text/") ||
|
||||||
|
["json", "yaml", "yml", "xml", "toml", "md", "csv"].includes(ext)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClipboardResourceUri(name: string): string {
|
||||||
|
const normalizedName = name.trim() || "clipboard-resource"
|
||||||
|
return `clipboard://${encodeURIComponent(normalizedName)}-${crypto.randomUUID()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDataUri(base64Data: string, mimeType: string | null): string {
|
||||||
|
const safeMime =
|
||||||
|
mimeType && mimeType.trim() ? mimeType : "application/octet-stream"
|
||||||
|
return `data:${safeMime};base64,${base64Data}`
|
||||||
|
}
|
||||||
|
|
||||||
function SelectorLoadingChip({ label }: { label: string }) {
|
function SelectorLoadingChip({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground">
|
<div className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground">
|
||||||
@@ -131,6 +247,7 @@ export function MessageInput({
|
|||||||
onModeChange,
|
onModeChange,
|
||||||
onConfigOptionChange,
|
onConfigOptionChange,
|
||||||
availableCommands,
|
availableCommands,
|
||||||
|
promptCapabilities,
|
||||||
attachmentTabId,
|
attachmentTabId,
|
||||||
draftStorageKey,
|
draftStorageKey,
|
||||||
}: MessageInputProps) {
|
}: MessageInputProps) {
|
||||||
@@ -142,8 +259,12 @@ export function MessageInput({
|
|||||||
return loadMessageInputDraft(effectiveDraftStorageKey) ?? ""
|
return loadMessageInputDraft(effectiveDraftStorageKey) ?? ""
|
||||||
})
|
})
|
||||||
const [attachments, setAttachments] = useState<InputAttachment[]>([])
|
const [attachments, setAttachments] = useState<InputAttachment[]>([])
|
||||||
|
const [isDragActive, setIsDragActive] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const lastDomDropAtRef = useRef(0)
|
||||||
const composingRef = useRef(false)
|
const composingRef = useRef(false)
|
||||||
const textRef = useRef(text)
|
const textRef = useRef(text)
|
||||||
|
const canAttachImages = promptCapabilities.image
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
textRef.current = text
|
textRef.current = text
|
||||||
@@ -176,6 +297,22 @@ export function MessageInput({
|
|||||||
hasModes && Boolean(effectiveModeId) && !hasConfigOptions
|
hasModes && Boolean(effectiveModeId) && !hasConfigOptions
|
||||||
const showModeLoading = modeLoading && !hasConfigOptions && !showModeSelector
|
const showModeLoading = modeLoading && !hasConfigOptions && !showModeSelector
|
||||||
const showConfigLoading = configOptionsLoading && !hasConfigOptions
|
const showConfigLoading = configOptionsLoading && !hasConfigOptions
|
||||||
|
const imageAttachments = useMemo(
|
||||||
|
() =>
|
||||||
|
attachments.filter(
|
||||||
|
(attachment): attachment is ImageInputAttachment =>
|
||||||
|
attachment.type === "image"
|
||||||
|
),
|
||||||
|
[attachments]
|
||||||
|
)
|
||||||
|
const resourceAttachments = useMemo(
|
||||||
|
() =>
|
||||||
|
attachments.filter(
|
||||||
|
(attachment): attachment is ResourceInputAttachment =>
|
||||||
|
attachment.type === "resource"
|
||||||
|
),
|
||||||
|
[attachments]
|
||||||
|
)
|
||||||
const hasAttachments = attachments.length > 0
|
const hasAttachments = attachments.length > 0
|
||||||
const hasSendableContent = text.trim().length > 0 || hasAttachments
|
const hasSendableContent = text.trim().length > 0 || hasAttachments
|
||||||
|
|
||||||
@@ -196,24 +333,292 @@ export function MessageInput({
|
|||||||
)
|
)
|
||||||
}, [slashMenuOpen, slashCommands, text])
|
}, [slashMenuOpen, slashCommands, text])
|
||||||
|
|
||||||
const appendAttachments = useCallback((paths: string[]) => {
|
const appendResourceLinks = useCallback(
|
||||||
|
(
|
||||||
|
links: Array<{
|
||||||
|
uri: string
|
||||||
|
name: string
|
||||||
|
mimeType: string | null
|
||||||
|
dedupeKey: string
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
if (links.length === 0) return
|
||||||
setAttachments((prev) => {
|
setAttachments((prev) => {
|
||||||
const seen = new Set(prev.map((item) => item.path))
|
const seen = new Set(
|
||||||
|
prev.flatMap((item) =>
|
||||||
|
item.type === "resource" && item.kind === "link" ? [item.uri] : []
|
||||||
|
)
|
||||||
|
)
|
||||||
const next = [...prev]
|
const next = [...prev]
|
||||||
for (const path of paths) {
|
for (const link of links) {
|
||||||
if (typeof path !== "string" || !path || seen.has(path)) continue
|
if (!link.uri || seen.has(link.dedupeKey)) continue
|
||||||
seen.add(path)
|
seen.add(link.dedupeKey)
|
||||||
next.push({
|
next.push({
|
||||||
path,
|
id: `resource-link:${link.dedupeKey}`,
|
||||||
uri: toFileUri(path),
|
type: "resource",
|
||||||
name: fileNameFromPath(path),
|
kind: "link",
|
||||||
mimeType: mimeTypeFromPath(path),
|
uri: link.uri,
|
||||||
|
name: link.name,
|
||||||
|
mimeType: link.mimeType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const appendResourceAttachments = useCallback(
|
||||||
|
(paths: string[]) => {
|
||||||
|
const normalized = paths
|
||||||
|
.filter(
|
||||||
|
(path): path is string => typeof path === "string" && path.length > 0
|
||||||
|
)
|
||||||
|
.map((path) => {
|
||||||
|
const uri = toFileUri(path)
|
||||||
|
return {
|
||||||
|
uri,
|
||||||
|
name: fileNameFromPath(path),
|
||||||
|
mimeType: mimeTypeFromPath(path),
|
||||||
|
dedupeKey: uri,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
appendResourceLinks(normalized)
|
||||||
|
},
|
||||||
|
[appendResourceLinks]
|
||||||
|
)
|
||||||
|
|
||||||
|
const appendEmbeddedResources = useCallback(
|
||||||
|
(
|
||||||
|
resources: Array<{
|
||||||
|
uri: string
|
||||||
|
name: string
|
||||||
|
mimeType: string | null
|
||||||
|
text?: string | null
|
||||||
|
blob?: string | null
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
if (resources.length === 0) return
|
||||||
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
...resources.map((resource) => ({
|
||||||
|
id: `resource-embedded:${crypto.randomUUID()}`,
|
||||||
|
type: "resource" as const,
|
||||||
|
kind: "embedded" as const,
|
||||||
|
uri: resource.uri,
|
||||||
|
name: resource.name,
|
||||||
|
mimeType: resource.mimeType,
|
||||||
|
text: resource.text ?? null,
|
||||||
|
blob: resource.blob ?? null,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const appendFilesAsResources = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
if (files.length === 0) return
|
||||||
|
const pathLinks: Array<{
|
||||||
|
uri: string
|
||||||
|
name: string
|
||||||
|
mimeType: string | null
|
||||||
|
dedupeKey: string
|
||||||
|
}> = []
|
||||||
|
const fallbackDataLinks: Array<{
|
||||||
|
uri: string
|
||||||
|
name: string
|
||||||
|
mimeType: string | null
|
||||||
|
dedupeKey: string
|
||||||
|
}> = []
|
||||||
|
const embeddedResources: Array<{
|
||||||
|
uri: string
|
||||||
|
name: string
|
||||||
|
mimeType: string | null
|
||||||
|
text?: string | null
|
||||||
|
blob?: string | null
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const path = getFilePath(file)
|
||||||
|
const name = file.name || `resource-${crypto.randomUUID()}`
|
||||||
|
const mimeType = file.type || mimeTypeFromPath(name)
|
||||||
|
if (path) {
|
||||||
|
const uri = toFileUri(path)
|
||||||
|
pathLinks.push({
|
||||||
|
uri,
|
||||||
|
name: fileNameFromPath(path),
|
||||||
|
mimeType: mimeTypeFromPath(path) ?? mimeType ?? null,
|
||||||
|
dedupeKey: uri,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!promptCapabilities.embedded_context) {
|
||||||
|
const base64 = await blobToBase64(file)
|
||||||
|
const dataUri = buildDataUri(base64, mimeType ?? null)
|
||||||
|
fallbackDataLinks.push({
|
||||||
|
uri: dataUri,
|
||||||
|
name,
|
||||||
|
mimeType: mimeType ?? null,
|
||||||
|
dedupeKey: `${name}:${file.size}:${file.lastModified}`,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const uri = buildClipboardResourceUri(name)
|
||||||
|
if (isTextLikeFile(file)) {
|
||||||
|
const textContent = await file.text()
|
||||||
|
embeddedResources.push({
|
||||||
|
uri,
|
||||||
|
name,
|
||||||
|
mimeType: mimeType ?? null,
|
||||||
|
text: textContent,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const blobContent = await blobToBase64(file)
|
||||||
|
embeddedResources.push({
|
||||||
|
uri,
|
||||||
|
name,
|
||||||
|
mimeType: mimeType ?? null,
|
||||||
|
blob: blobContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendResourceLinks(pathLinks)
|
||||||
|
appendResourceLinks(fallbackDataLinks)
|
||||||
|
appendEmbeddedResources(embeddedResources)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
appendEmbeddedResources,
|
||||||
|
appendResourceLinks,
|
||||||
|
promptCapabilities.embedded_context,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const appendImageAttachments = useCallback(async (files: File[]) => {
|
||||||
|
if (files.length === 0) return
|
||||||
|
const parsed = await Promise.all(
|
||||||
|
files.map(async (file, index) => {
|
||||||
|
const mimeType =
|
||||||
|
file.type && file.type.startsWith("image/")
|
||||||
|
? file.type
|
||||||
|
: (mimeTypeFromPath(file.name) ?? "image/png")
|
||||||
|
const base64Data = await blobToBase64(file)
|
||||||
|
return {
|
||||||
|
id: `image:${Date.now()}:${index}:${crypto.randomUUID()}`,
|
||||||
|
type: "image" as const,
|
||||||
|
data: base64Data,
|
||||||
|
uri: null,
|
||||||
|
name: file.name || `image-${Date.now()}-${index + 1}`,
|
||||||
|
mimeType,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setAttachments((prev) => [...prev, ...parsed])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const appendImagePathAttachments = useCallback(
|
||||||
|
async (paths: string[]) => {
|
||||||
|
if (paths.length === 0 || !canAttachImages) return
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
paths.map(async (path, index) => {
|
||||||
|
const data = await readFileBase64(path, DRAG_DROP_IMAGE_MAX_BYTES)
|
||||||
|
return {
|
||||||
|
id: `image:${Date.now()}:${index}:${crypto.randomUUID()}`,
|
||||||
|
type: "image" as const,
|
||||||
|
data,
|
||||||
|
uri: toFileUri(path),
|
||||||
|
name: fileNameFromPath(path),
|
||||||
|
mimeType: mimeTypeFromPath(path) ?? "image/png",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed: ImageInputAttachment[] = []
|
||||||
|
settled.forEach((result, index) => {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
parsed.push(result.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
`[MessageInput] drop image path failed (${paths[index]}):`,
|
||||||
|
result.reason
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (parsed.length === 0) return
|
||||||
|
setAttachments((prev) => [...prev, ...parsed])
|
||||||
|
},
|
||||||
|
[canAttachImages]
|
||||||
|
)
|
||||||
|
|
||||||
|
const appendPathsFromDrop = useCallback(
|
||||||
|
async (paths: string[]) => {
|
||||||
|
if (paths.length === 0) return
|
||||||
|
const normalized = paths.filter(
|
||||||
|
(path): path is string => typeof path === "string" && path.length > 0
|
||||||
|
)
|
||||||
|
if (normalized.length === 0) return
|
||||||
|
|
||||||
|
const imagePaths: string[] = []
|
||||||
|
const resourcePaths: string[] = []
|
||||||
|
for (const path of normalized) {
|
||||||
|
const mimeType = mimeTypeFromPath(path) ?? ""
|
||||||
|
if (canAttachImages && mimeType.startsWith("image/")) {
|
||||||
|
imagePaths.push(path)
|
||||||
|
} else {
|
||||||
|
resourcePaths.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imagePaths.length > 0) {
|
||||||
|
await appendImagePathAttachments(imagePaths)
|
||||||
|
}
|
||||||
|
if (resourcePaths.length > 0) {
|
||||||
|
appendResourceAttachments(resourcePaths)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appendImagePathAttachments, appendResourceAttachments, canAttachImages]
|
||||||
|
)
|
||||||
|
|
||||||
|
const appendFilesFromInput = useCallback(
|
||||||
|
async (files: File[]) => {
|
||||||
|
if (files.length === 0) return
|
||||||
|
const imageFiles: File[] = []
|
||||||
|
const resourceFiles: File[] = []
|
||||||
|
for (const file of files) {
|
||||||
|
const mimeType = file.type || mimeTypeFromPath(file.name) || ""
|
||||||
|
if (canAttachImages && mimeType.startsWith("image/")) {
|
||||||
|
imageFiles.push(file)
|
||||||
|
} else {
|
||||||
|
resourceFiles.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
|
await appendImageAttachments(imageFiles)
|
||||||
|
}
|
||||||
|
if (resourceFiles.length > 0) {
|
||||||
|
await appendFilesAsResources(resourceFiles)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appendFilesAsResources, appendImageAttachments, canAttachImages]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
(event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (disabled || isPrompting) return
|
||||||
|
const files = Array.from(event.clipboardData?.files ?? [])
|
||||||
|
if (files.length === 0) return
|
||||||
|
event.preventDefault()
|
||||||
|
void appendFilesFromInput(files).catch((error) => {
|
||||||
|
console.error("[MessageInput] paste files failed:", error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[appendFilesFromInput, disabled, isPrompting]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showModeSelector) return
|
if (!showModeSelector) return
|
||||||
if (!effectiveModeId || !onModeChange) return
|
if (!effectiveModeId || !onModeChange) return
|
||||||
@@ -258,11 +663,11 @@ export function MessageInput({
|
|||||||
})
|
})
|
||||||
if (!selected) return
|
if (!selected) return
|
||||||
const picked = Array.isArray(selected) ? selected : [selected]
|
const picked = Array.isArray(selected) ? selected : [selected]
|
||||||
appendAttachments(picked.filter((item): item is string => !!item))
|
appendResourceAttachments(picked.filter((item): item is string => !!item))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[MessageInput] pick files failed:", error)
|
console.error("[MessageInput] pick files failed:", error)
|
||||||
}
|
}
|
||||||
}, [appendAttachments, defaultPath, disabled])
|
}, [appendResourceAttachments, defaultPath, disabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!attachmentTabId) return
|
if (!attachmentTabId) return
|
||||||
@@ -271,17 +676,61 @@ export function MessageInput({
|
|||||||
const customEvent = event as CustomEvent<AttachFileToSessionDetail>
|
const customEvent = event as CustomEvent<AttachFileToSessionDetail>
|
||||||
if (!customEvent.detail) return
|
if (!customEvent.detail) return
|
||||||
if (customEvent.detail.tabId !== attachmentTabId) return
|
if (customEvent.detail.tabId !== attachmentTabId) return
|
||||||
appendAttachments([customEvent.detail.path])
|
appendResourceAttachments([customEvent.detail.path])
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
|
window.addEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
|
window.removeEventListener(ATTACH_FILE_TO_SESSION_EVENT, handleAttachFile)
|
||||||
}
|
}
|
||||||
}, [appendAttachments, attachmentTabId])
|
}, [appendResourceAttachments, attachmentTabId])
|
||||||
|
|
||||||
const removeAttachment = useCallback((path: string) => {
|
useEffect(() => {
|
||||||
setAttachments((prev) => prev.filter((item) => item.path !== path))
|
let unlisten: (() => void) | null = null
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
getCurrentWebview()
|
||||||
|
.onDragDropEvent((event) => {
|
||||||
|
const host = containerRef.current
|
||||||
|
if (!host) return
|
||||||
|
const payload = event.payload
|
||||||
|
if (payload.type === "leave") {
|
||||||
|
setIsDragActive(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const inside = pointWithinElement(payload.position, host)
|
||||||
|
if (payload.type === "drop") {
|
||||||
|
setIsDragActive(false)
|
||||||
|
if (Date.now() - lastDomDropAtRef.current < 250) return
|
||||||
|
if (!inside || disabled || isPrompting) return
|
||||||
|
void appendPathsFromDrop(payload.paths).catch((error) => {
|
||||||
|
console.error("[MessageInput] drag drop paths failed:", error)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsDragActive(inside && !disabled && !isPrompting)
|
||||||
|
})
|
||||||
|
.then((fn) => {
|
||||||
|
if (cancelled) {
|
||||||
|
fn()
|
||||||
|
} else {
|
||||||
|
unlisten = fn
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Ignore non-Tauri environments.
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (unlisten) {
|
||||||
|
unlisten()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [appendPathsFromDrop, disabled, isPrompting])
|
||||||
|
|
||||||
|
const removeAttachment = useCallback((id: string) => {
|
||||||
|
setAttachments((prev) => prev.filter((item) => item.id !== id))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
@@ -293,6 +742,8 @@ export function MessageInput({
|
|||||||
blocks.push({ type: "text", text: trimmed })
|
blocks.push({ type: "text", text: trimmed })
|
||||||
}
|
}
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
|
if (attachment.type === "resource") {
|
||||||
|
if (attachment.kind === "link") {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "resource_link",
|
type: "resource_link",
|
||||||
uri: attachment.uri,
|
uri: attachment.uri,
|
||||||
@@ -300,11 +751,28 @@ export function MessageInput({
|
|||||||
mime_type: attachment.mimeType,
|
mime_type: attachment.mimeType,
|
||||||
description: null,
|
description: null,
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
blocks.push({
|
||||||
|
type: "resource",
|
||||||
|
uri: attachment.uri,
|
||||||
|
mime_type: attachment.mimeType,
|
||||||
|
text: attachment.text ?? null,
|
||||||
|
blob: attachment.blob ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
blocks.push({
|
||||||
|
type: "image",
|
||||||
|
data: attachment.data,
|
||||||
|
mime_type: attachment.mimeType,
|
||||||
|
uri: attachment.uri,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayText =
|
const displayText =
|
||||||
trimmed ||
|
trimmed ||
|
||||||
`Attached ${attachments.length} resource${attachments.length > 1 ? "s" : ""}`
|
`Attached ${attachments.length} attachment${attachments.length > 1 ? "s" : ""}`
|
||||||
onSend({ blocks, displayText }, showModeSelector ? effectiveModeId : null)
|
onSend({ blocks, displayText }, showModeSelector ? effectiveModeId : null)
|
||||||
if (effectiveDraftStorageKey) {
|
if (effectiveDraftStorageKey) {
|
||||||
clearMessageInputDraft(effectiveDraftStorageKey)
|
clearMessageInputDraft(effectiveDraftStorageKey)
|
||||||
@@ -372,11 +840,70 @@ export function MessageInput({
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleContainerDragOver = useCallback(
|
||||||
|
(event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!hasDragFiles(event.dataTransfer)) return
|
||||||
|
event.preventDefault()
|
||||||
|
if (!disabled && !isPrompting) {
|
||||||
|
setIsDragActive(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, isPrompting]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleContainerDragLeave = useCallback(
|
||||||
|
(event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
const related = event.relatedTarget
|
||||||
|
if (
|
||||||
|
related &&
|
||||||
|
related instanceof Node &&
|
||||||
|
event.currentTarget.contains(related)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsDragActive(false)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleContainerDrop = useCallback(
|
||||||
|
(event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
if (!hasDragFiles(event.dataTransfer)) return
|
||||||
|
event.preventDefault()
|
||||||
|
lastDomDropAtRef.current = Date.now()
|
||||||
|
setIsDragActive(false)
|
||||||
|
if (disabled || isPrompting) return
|
||||||
|
const files = Array.from(event.dataTransfer.files ?? [])
|
||||||
|
if (files.length > 0) {
|
||||||
|
void appendFilesFromInput(files).catch((error) => {
|
||||||
|
console.error("[MessageInput] drop files failed:", error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appendFilesFromInput, disabled, isPrompting]
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasImageAttachments = imageAttachments.length > 0
|
||||||
|
const hasResourceAttachments = resourceAttachments.length > 0
|
||||||
|
const topPaddingClass =
|
||||||
|
hasImageAttachments && hasResourceAttachments
|
||||||
|
? "pt-24"
|
||||||
|
: hasImageAttachments
|
||||||
|
? "pt-16"
|
||||||
|
: hasResourceAttachments
|
||||||
|
? "pt-10"
|
||||||
|
: "pt-3"
|
||||||
const bottomPaddingClass = "pb-10"
|
const bottomPaddingClass = "pb-10"
|
||||||
const topPaddingClass = hasAttachments ? "pt-10" : ""
|
const showDragActive = isDragActive && !disabled && !isPrompting
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative"
|
||||||
|
onDragOver={handleContainerDragOver}
|
||||||
|
onDragLeave={handleContainerDragLeave}
|
||||||
|
onDrop={handleContainerDrop}
|
||||||
|
>
|
||||||
{slashMenuOpen && filteredSlashCommands.length > 0 && (
|
{slashMenuOpen && filteredSlashCommands.length > 0 && (
|
||||||
<SlashCommandMenu
|
<SlashCommandMenu
|
||||||
commands={filteredSlashCommands}
|
commands={filteredSlashCommands}
|
||||||
@@ -390,29 +917,61 @@ export function MessageInput({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onCompositionStart={() => (composingRef.current = true)}
|
onCompositionStart={() => (composingRef.current = true)}
|
||||||
onCompositionEnd={() => (composingRef.current = false)}
|
onCompositionEnd={() => (composingRef.current = false)}
|
||||||
|
onPaste={handlePaste}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
placeholder={resolvedPlaceholder}
|
placeholder={resolvedPlaceholder}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm pr-12 resize-none bg-transparent",
|
"text-sm pr-12 resize-none bg-transparent",
|
||||||
|
showDragActive && "ring-1 ring-primary/40",
|
||||||
topPaddingClass,
|
topPaddingClass,
|
||||||
bottomPaddingClass,
|
bottomPaddingClass,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
/>
|
/>
|
||||||
{hasAttachments && (
|
{(hasImageAttachments || hasResourceAttachments) && (
|
||||||
<div className="absolute left-2 right-2 top-2">
|
<div className="absolute left-2 right-12 top-2 z-10 flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-1 overflow-x-auto">
|
{hasImageAttachments && (
|
||||||
{attachments.map((attachment) => (
|
<div className="flex items-center gap-1 overflow-x-auto pb-0.5">
|
||||||
|
{imageAttachments.map((attachment) => (
|
||||||
<div
|
<div
|
||||||
key={attachment.path}
|
key={attachment.id}
|
||||||
|
className="relative shrink-0 overflow-hidden rounded-md border border-border/70 bg-muted/30"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={`data:${attachment.mimeType};base64,${attachment.data}`}
|
||||||
|
alt={attachment.name}
|
||||||
|
width={56}
|
||||||
|
height={56}
|
||||||
|
unoptimized
|
||||||
|
className="h-14 w-14 object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeAttachment(attachment.id)}
|
||||||
|
className="absolute right-1 top-1 rounded-sm bg-background/70 p-0.5 hover:bg-background"
|
||||||
|
aria-label={t("removeAttachmentAria", {
|
||||||
|
name: attachment.name,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasResourceAttachments && (
|
||||||
|
<div className="flex items-center gap-1 overflow-x-auto">
|
||||||
|
{resourceAttachments.map((attachment) => (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground"
|
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-2 text-[11px] text-muted-foreground"
|
||||||
>
|
>
|
||||||
<FileSearch className="h-3 w-3" />
|
<FileSearch className="h-3 w-3" />
|
||||||
<span className="max-w-40 truncate">{attachment.name}</span>
|
<span className="max-w-40 truncate">{attachment.name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeAttachment(attachment.path)}
|
onClick={() => removeAttachment(attachment.id)}
|
||||||
className="rounded-sm p-0.5 hover:bg-muted-foreground/15"
|
className="rounded-sm p-0.5 hover:bg-muted-foreground/15"
|
||||||
aria-label={t("removeAttachmentAria", {
|
aria-label={t("removeAttachmentAria", {
|
||||||
name: attachment.name,
|
name: attachment.name,
|
||||||
@@ -423,6 +982,12 @@ export function MessageInput({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showDragActive && (
|
||||||
|
<div className="pointer-events-none absolute inset-1 z-20 flex items-center justify-center rounded-md border border-dashed border-primary/50 bg-background/80 text-xs text-muted-foreground">
|
||||||
|
{t("dropFilesToAttach")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute left-2 right-24 bottom-2 flex flex-col gap-1">
|
<div className="absolute left-2 right-24 bottom-2 flex flex-col gap-1">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@/lib/adapters/ai-elements-adapter"
|
} from "@/lib/adapters/ai-elements-adapter"
|
||||||
import {
|
import {
|
||||||
buildUserMessageTextPartsFromDraft,
|
buildUserMessageTextPartsFromDraft,
|
||||||
|
extractUserImagesFromDraft,
|
||||||
extractUserResourcesFromDraft,
|
extractUserResourcesFromDraft,
|
||||||
getPromptDraftDisplayText,
|
getPromptDraftDisplayText,
|
||||||
} from "@/lib/prompt-draft"
|
} from "@/lib/prompt-draft"
|
||||||
@@ -41,6 +42,7 @@ import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
|||||||
import { LiveTurnStats } from "@/components/message/live-turn-stats"
|
import { LiveTurnStats } from "@/components/message/live-turn-stats"
|
||||||
import { TurnStats } from "@/components/message/turn-stats"
|
import { TurnStats } from "@/components/message/turn-stats"
|
||||||
import { UserResourceLinks } from "@/components/message/user-resource-links"
|
import { UserResourceLinks } from "@/components/message/user-resource-links"
|
||||||
|
import { UserImageAttachments } from "@/components/message/user-image-attachments"
|
||||||
import { ConversationShell } from "@/components/chat/conversation-shell"
|
import { ConversationShell } from "@/components/chat/conversation-shell"
|
||||||
import {
|
import {
|
||||||
MessageThread,
|
MessageThread,
|
||||||
@@ -604,6 +606,7 @@ export function WelcomeInputPanel({
|
|||||||
draft,
|
draft,
|
||||||
sharedT("attachedResources")
|
sharedT("attachedResources")
|
||||||
),
|
),
|
||||||
|
userImages: extractUserImagesFromDraft(draft),
|
||||||
userResources: extractUserResourcesFromDraft(draft),
|
userResources: extractUserResourcesFromDraft(draft),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
@@ -683,6 +686,7 @@ export function WelcomeInputPanel({
|
|||||||
draft,
|
draft,
|
||||||
sharedT("attachedResources")
|
sharedT("attachedResources")
|
||||||
),
|
),
|
||||||
|
userImages: extractUserImagesFromDraft(draft),
|
||||||
userResources: extractUserResourcesFromDraft(draft),
|
userResources: extractUserResourcesFromDraft(draft),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
@@ -791,6 +795,7 @@ export function WelcomeInputPanel({
|
|||||||
<MessageInput
|
<MessageInput
|
||||||
key={newConversationDraftStorageKey}
|
key={newConversationDraftStorageKey}
|
||||||
onSend={handleWelcomeSend}
|
onSend={handleWelcomeSend}
|
||||||
|
promptCapabilities={conn.promptCapabilities}
|
||||||
defaultPath={workingDir}
|
defaultPath={workingDir}
|
||||||
placeholder={
|
placeholder={
|
||||||
agentsLoaded && usableAgentCount === 0
|
agentsLoaded && usableAgentCount === 0
|
||||||
@@ -827,6 +832,7 @@ export function WelcomeInputPanel({
|
|||||||
return (
|
return (
|
||||||
<ConversationShell
|
<ConversationShell
|
||||||
status={connStatus}
|
status={connStatus}
|
||||||
|
promptCapabilities={conn.promptCapabilities}
|
||||||
defaultPath={workingDir}
|
defaultPath={workingDir}
|
||||||
error={conn.error}
|
error={conn.error}
|
||||||
pendingPermission={conn.pendingPermission}
|
pendingPermission={conn.pendingPermission}
|
||||||
@@ -854,6 +860,12 @@ export function WelcomeInputPanel({
|
|||||||
<MessageContent>
|
<MessageContent>
|
||||||
<ContentPartsRenderer parts={msg.content} role={msg.role} />
|
<ContentPartsRenderer parts={msg.content} role={msg.role} />
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
|
{msg.role === "user" && msg.userImages?.length ? (
|
||||||
|
<UserImageAttachments
|
||||||
|
images={msg.userImages}
|
||||||
|
className="self-end"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{msg.role === "user" && msg.userResources?.length ? (
|
{msg.role === "user" && msg.userResources?.length ? (
|
||||||
<UserResourceLinks
|
<UserResourceLinks
|
||||||
resources={msg.userResources}
|
resources={msg.userResources}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { AgentType, PromptDraft } from "@/lib/types"
|
|||||||
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
import type { AdaptedMessage } from "@/lib/adapters/ai-elements-adapter"
|
||||||
import {
|
import {
|
||||||
buildUserMessageTextPartsFromDraft,
|
buildUserMessageTextPartsFromDraft,
|
||||||
|
extractUserImagesFromDraft,
|
||||||
extractUserResourcesFromDraft,
|
extractUserResourcesFromDraft,
|
||||||
} from "@/lib/prompt-draft"
|
} from "@/lib/prompt-draft"
|
||||||
import { buildConversationDraftStorageKey } from "@/lib/message-input-draft"
|
import { buildConversationDraftStorageKey } from "@/lib/message-input-draft"
|
||||||
@@ -119,6 +120,7 @@ const ExistingConversationView = memo(function ExistingConversationView({
|
|||||||
draft,
|
draft,
|
||||||
sharedT("attachedResources")
|
sharedT("attachedResources")
|
||||||
),
|
),
|
||||||
|
userImages: extractUserImagesFromDraft(draft),
|
||||||
userResources: extractUserResourcesFromDraft(draft),
|
userResources: extractUserResourcesFromDraft(draft),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
@@ -209,6 +211,7 @@ const ExistingConversationView = memo(function ExistingConversationView({
|
|||||||
return (
|
return (
|
||||||
<ConversationShell
|
<ConversationShell
|
||||||
status={conn.status}
|
status={conn.status}
|
||||||
|
promptCapabilities={conn.promptCapabilities}
|
||||||
defaultPath={folder?.path}
|
defaultPath={folder?.path}
|
||||||
error={conn.error}
|
error={conn.error}
|
||||||
pendingPermission={conn.pendingPermission}
|
pendingPermission={conn.pendingPermission}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type AdaptedMessage,
|
type AdaptedMessage,
|
||||||
type AdaptedContentPart,
|
type AdaptedContentPart,
|
||||||
type MessageGroup,
|
type MessageGroup,
|
||||||
|
type UserImageDisplay,
|
||||||
type UserResourceDisplay,
|
type UserResourceDisplay,
|
||||||
groupAdaptedMessages,
|
groupAdaptedMessages,
|
||||||
extractUserResourcesFromText,
|
extractUserResourcesFromText,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
import { TurnStats } from "./turn-stats"
|
import { TurnStats } from "./turn-stats"
|
||||||
import { LiveTurnStats } from "./live-turn-stats"
|
import { LiveTurnStats } from "./live-turn-stats"
|
||||||
import { UserResourceLinks } from "./user-resource-links"
|
import { UserResourceLinks } from "./user-resource-links"
|
||||||
|
import { UserImageAttachments } from "./user-image-attachments"
|
||||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||||
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
import { LiveMessageBlock } from "@/components/chat/live-message-block"
|
||||||
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
import { AgentPlanOverlay } from "@/components/chat/agent-plan-overlay"
|
||||||
@@ -45,6 +47,7 @@ interface MessageListViewProps {
|
|||||||
interface ResolvedMessageGroup extends MessageGroup {
|
interface ResolvedMessageGroup extends MessageGroup {
|
||||||
parts: AdaptedContentPart[]
|
parts: AdaptedContentPart[]
|
||||||
resources: UserResourceDisplay[]
|
resources: UserResourceDisplay[]
|
||||||
|
images: UserImageDisplay[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackExtractUserResources(
|
function fallbackExtractUserResources(
|
||||||
@@ -53,11 +56,13 @@ function fallbackExtractUserResources(
|
|||||||
): {
|
): {
|
||||||
parts: AdaptedContentPart[]
|
parts: AdaptedContentPart[]
|
||||||
resources: UserResourceDisplay[]
|
resources: UserResourceDisplay[]
|
||||||
|
images: UserImageDisplay[]
|
||||||
} {
|
} {
|
||||||
if (group.role !== "user") {
|
if (group.role !== "user") {
|
||||||
return {
|
return {
|
||||||
parts: group.parts,
|
parts: group.parts,
|
||||||
resources: group.userResources ?? [],
|
resources: group.userResources ?? [],
|
||||||
|
images: group.userImages ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +99,11 @@ function fallbackExtractUserResources(
|
|||||||
parsedParts.push({ type: "text", text: attachedResourcesText })
|
parsedParts.push({ type: "text", text: attachedResourcesText })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { parts: parsedParts, resources: dedupedResources }
|
return {
|
||||||
|
parts: parsedParts,
|
||||||
|
resources: dedupedResources,
|
||||||
|
images: group.userImages ?? [],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMessageGroup(
|
function resolveMessageGroup(
|
||||||
@@ -106,6 +115,7 @@ function resolveMessageGroup(
|
|||||||
...group,
|
...group,
|
||||||
parts: resolved.parts,
|
parts: resolved.parts,
|
||||||
resources: resolved.resources,
|
resources: resolved.resources,
|
||||||
|
images: resolved.images,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +135,9 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
|
|||||||
<MessageContent>
|
<MessageContent>
|
||||||
<ContentPartsRenderer parts={group.parts} role={group.role} />
|
<ContentPartsRenderer parts={group.parts} role={group.role} />
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
|
{group.role === "user" && group.images.length > 0 ? (
|
||||||
|
<UserImageAttachments images={group.images} className="self-end" />
|
||||||
|
) : null}
|
||||||
{group.role === "user" && group.resources.length > 0 ? (
|
{group.role === "user" && group.resources.length > 0 ? (
|
||||||
<UserResourceLinks resources={group.resources} className="self-end" />
|
<UserResourceLinks resources={group.resources} className="self-end" />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -152,6 +165,9 @@ const PendingMessageGroup = memo(function PendingMessageGroup({
|
|||||||
<MessageContent>
|
<MessageContent>
|
||||||
<ContentPartsRenderer parts={group.parts} role={group.role} />
|
<ContentPartsRenderer parts={group.parts} role={group.role} />
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
|
{group.role === "user" && group.images.length > 0 ? (
|
||||||
|
<UserImageAttachments images={group.images} className="self-end" />
|
||||||
|
) : null}
|
||||||
{group.role === "user" && group.resources.length > 0 ? (
|
{group.role === "user" && group.resources.length > 0 ? (
|
||||||
<UserResourceLinks resources={group.resources} className="self-end" />
|
<UserResourceLinks resources={group.resources} className="self-end" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import Image from "next/image"
|
||||||
|
import type { UserImageDisplay } from "@/lib/adapters/ai-elements-adapter"
|
||||||
|
|
||||||
|
interface UserImageAttachmentsProps {
|
||||||
|
images: UserImageDisplay[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserImageAttachments({
|
||||||
|
images,
|
||||||
|
className,
|
||||||
|
}: UserImageAttachmentsProps) {
|
||||||
|
if (images.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<div
|
||||||
|
key={`${image.uri ?? image.name}-${index}`}
|
||||||
|
className="overflow-hidden rounded-md border border-border/70 bg-muted/30"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={`data:${image.mime_type};base64,${image.data}`}
|
||||||
|
alt={image.name}
|
||||||
|
width={56}
|
||||||
|
height={56}
|
||||||
|
unoptimized
|
||||||
|
className="h-14 w-14 object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
SessionConfigOptionInfo,
|
SessionConfigOptionInfo,
|
||||||
SessionModeStateInfo,
|
SessionModeStateInfo,
|
||||||
FixAction,
|
FixAction,
|
||||||
|
PromptCapabilitiesInfo,
|
||||||
PromptInputBlock,
|
PromptInputBlock,
|
||||||
} from "@/lib/types"
|
} from "@/lib/types"
|
||||||
import { AGENT_LABELS } from "@/lib/types"
|
import { AGENT_LABELS } from "@/lib/types"
|
||||||
@@ -80,6 +81,7 @@ export interface ConnectionState {
|
|||||||
contextKey: string
|
contextKey: string
|
||||||
agentType: AgentType
|
agentType: AgentType
|
||||||
status: ConnectionStatus
|
status: ConnectionStatus
|
||||||
|
promptCapabilities: PromptCapabilitiesInfo
|
||||||
selectorsReady: boolean
|
selectorsReady: boolean
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
modes: SessionModeStateInfo | null
|
modes: SessionModeStateInfo | null
|
||||||
@@ -157,6 +159,11 @@ type Action =
|
|||||||
type: "SELECTORS_READY"
|
type: "SELECTORS_READY"
|
||||||
contextKey: string
|
contextKey: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "PROMPT_CAPABILITIES"
|
||||||
|
contextKey: string
|
||||||
|
promptCapabilities: PromptCapabilitiesInfo
|
||||||
|
}
|
||||||
| { type: "MODE_CHANGED"; contextKey: string; modeId: string }
|
| { type: "MODE_CHANGED"; contextKey: string; modeId: string }
|
||||||
| {
|
| {
|
||||||
type: "PLAN_UPDATE"
|
type: "PLAN_UPDATE"
|
||||||
@@ -266,6 +273,17 @@ function sameModes(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function samePromptCapabilities(
|
||||||
|
a: PromptCapabilitiesInfo,
|
||||||
|
b: PromptCapabilitiesInfo
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
a.image === b.image &&
|
||||||
|
a.audio === b.audio &&
|
||||||
|
a.embedded_context === b.embedded_context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function samePlanEntries(a: PlanEntryInfo[], b: PlanEntryInfo[]): boolean {
|
function samePlanEntries(a: PlanEntryInfo[], b: PlanEntryInfo[]): boolean {
|
||||||
if (a === b) return true
|
if (a === b) return true
|
||||||
if (a.length !== b.length) return false
|
if (a.length !== b.length) return false
|
||||||
@@ -412,6 +430,11 @@ function connectionsReducer(
|
|||||||
contextKey: action.contextKey,
|
contextKey: action.contextKey,
|
||||||
agentType: action.agentType,
|
agentType: action.agentType,
|
||||||
status: "connecting",
|
status: "connecting",
|
||||||
|
promptCapabilities: {
|
||||||
|
image: false,
|
||||||
|
audio: false,
|
||||||
|
embedded_context: false,
|
||||||
|
},
|
||||||
selectorsReady: false,
|
selectorsReady: false,
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
modes: null,
|
modes: null,
|
||||||
@@ -757,6 +780,25 @@ function connectionsReducer(
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "PROMPT_CAPABILITIES": {
|
||||||
|
const conn = state.get(action.contextKey)
|
||||||
|
if (!conn) return state
|
||||||
|
if (
|
||||||
|
samePromptCapabilities(
|
||||||
|
conn.promptCapabilities,
|
||||||
|
action.promptCapabilities
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
const next = new Map(state)
|
||||||
|
next.set(action.contextKey, {
|
||||||
|
...conn,
|
||||||
|
promptCapabilities: action.promptCapabilities,
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
case "MODE_CHANGED": {
|
case "MODE_CHANGED": {
|
||||||
const conn = state.get(action.contextKey)
|
const conn = state.get(action.contextKey)
|
||||||
if (!conn?.modes) return state
|
if (!conn?.modes) return state
|
||||||
@@ -1307,6 +1349,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
contextKey,
|
contextKey,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
case "prompt_capabilities":
|
||||||
|
flushStreamingQueue()
|
||||||
|
dispatch({
|
||||||
|
type: "PROMPT_CAPABILITIES",
|
||||||
|
contextKey,
|
||||||
|
promptCapabilities: e.prompt_capabilities,
|
||||||
|
})
|
||||||
|
break
|
||||||
case "mode_changed":
|
case "mode_changed":
|
||||||
flushStreamingQueue()
|
flushStreamingQueue()
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|||||||
@@ -13,14 +13,22 @@ import type {
|
|||||||
AgentType,
|
AgentType,
|
||||||
AvailableCommandInfo,
|
AvailableCommandInfo,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
|
PromptCapabilitiesInfo,
|
||||||
SessionConfigOptionInfo,
|
SessionConfigOptionInfo,
|
||||||
SessionModeStateInfo,
|
SessionModeStateInfo,
|
||||||
PromptInputBlock,
|
PromptInputBlock,
|
||||||
} from "@/lib/types"
|
} from "@/lib/types"
|
||||||
|
|
||||||
|
const DEFAULT_PROMPT_CAPABILITIES: PromptCapabilitiesInfo = {
|
||||||
|
image: false,
|
||||||
|
audio: false,
|
||||||
|
embedded_context: false,
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseConnectionReturn {
|
export interface UseConnectionReturn {
|
||||||
connectionId: string | null
|
connectionId: string | null
|
||||||
status: ConnectionStatus | null
|
status: ConnectionStatus | null
|
||||||
|
promptCapabilities: PromptCapabilitiesInfo
|
||||||
selectorsReady: boolean
|
selectorsReady: boolean
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
modes: SessionModeStateInfo | null
|
modes: SessionModeStateInfo | null
|
||||||
@@ -64,6 +72,8 @@ export function useConnection(contextKey: string): UseConnectionReturn {
|
|||||||
|
|
||||||
const connectionId = connection?.connectionId ?? null
|
const connectionId = connection?.connectionId ?? null
|
||||||
const status = connection?.status ?? null
|
const status = connection?.status ?? null
|
||||||
|
const promptCapabilities =
|
||||||
|
connection?.promptCapabilities ?? DEFAULT_PROMPT_CAPABILITIES
|
||||||
const selectorsReady = connection?.selectorsReady ?? false
|
const selectorsReady = connection?.selectorsReady ?? false
|
||||||
const sessionId = connection?.sessionId ?? null
|
const sessionId = connection?.sessionId ?? null
|
||||||
const modes = connection?.modes ?? null
|
const modes = connection?.modes ?? null
|
||||||
@@ -119,6 +129,7 @@ export function useConnection(contextKey: string): UseConnectionReturn {
|
|||||||
() => ({
|
() => ({
|
||||||
connectionId,
|
connectionId,
|
||||||
status,
|
status,
|
||||||
|
promptCapabilities,
|
||||||
selectorsReady,
|
selectorsReady,
|
||||||
sessionId,
|
sessionId,
|
||||||
modes,
|
modes,
|
||||||
@@ -138,6 +149,7 @@ export function useConnection(contextKey: string): UseConnectionReturn {
|
|||||||
[
|
[
|
||||||
connectionId,
|
connectionId,
|
||||||
status,
|
status,
|
||||||
|
promptCapabilities,
|
||||||
selectorsReady,
|
selectorsReady,
|
||||||
sessionId,
|
sessionId,
|
||||||
modes,
|
modes,
|
||||||
|
|||||||
@@ -1123,6 +1123,7 @@
|
|||||||
"askAnything": "Ask anything...",
|
"askAnything": "Ask anything...",
|
||||||
"removeAttachmentAria": "Remove {name}",
|
"removeAttachmentAria": "Remove {name}",
|
||||||
"attachFiles": "Attach files",
|
"attachFiles": "Attach files",
|
||||||
|
"dropFilesToAttach": "Drop files to attach",
|
||||||
"loadingSettings": "Loading settings...",
|
"loadingSettings": "Loading settings...",
|
||||||
"loadingMode": "Loading mode...",
|
"loadingMode": "Loading mode...",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
@@ -1123,6 +1123,7 @@
|
|||||||
"askAnything": "请开始输入...",
|
"askAnything": "请开始输入...",
|
||||||
"removeAttachmentAria": "移除 {name}",
|
"removeAttachmentAria": "移除 {name}",
|
||||||
"attachFiles": "附加文件",
|
"attachFiles": "附加文件",
|
||||||
|
"dropFilesToAttach": "拖拽文件到此处附加",
|
||||||
"loadingSettings": "正在加载设置...",
|
"loadingSettings": "正在加载设置...",
|
||||||
"loadingMode": "正在加载模式...",
|
"loadingMode": "正在加载模式...",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
|||||||
@@ -1123,6 +1123,7 @@
|
|||||||
"askAnything": "請開始輸入...",
|
"askAnything": "請開始輸入...",
|
||||||
"removeAttachmentAria": "移除 {name}",
|
"removeAttachmentAria": "移除 {name}",
|
||||||
"attachFiles": "附加檔案",
|
"attachFiles": "附加檔案",
|
||||||
|
"dropFilesToAttach": "拖曳檔案到此處附加",
|
||||||
"loadingSettings": "正在載入設定...",
|
"loadingSettings": "正在載入設定...",
|
||||||
"loadingMode": "正在載入模式...",
|
"loadingMode": "正在載入模式...",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ export interface UserResourceDisplay {
|
|||||||
mime_type?: string | null
|
mime_type?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserImageDisplay {
|
||||||
|
name: string
|
||||||
|
data: string
|
||||||
|
mime_type: string
|
||||||
|
uri?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
const BLOCKED_RESOURCE_MENTION_RE = /@([^\s@]+)\s*\[blocked[^\]]*\]/gi
|
const BLOCKED_RESOURCE_MENTION_RE = /@([^\s@]+)\s*\[blocked[^\]]*\]/gi
|
||||||
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g
|
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g
|
||||||
|
|
||||||
@@ -54,6 +61,7 @@ export interface AdaptedMessage {
|
|||||||
role: MessageRole
|
role: MessageRole
|
||||||
content: AdaptedContentPart[]
|
content: AdaptedContentPart[]
|
||||||
userResources?: UserResourceDisplay[]
|
userResources?: UserResourceDisplay[]
|
||||||
|
userImages?: UserImageDisplay[]
|
||||||
timestamp: string
|
timestamp: string
|
||||||
usage?: TurnUsage | null
|
usage?: TurnUsage | null
|
||||||
duration_ms?: number | null
|
duration_ms?: number | null
|
||||||
@@ -398,6 +406,20 @@ function addResource(
|
|||||||
resources.push(resource)
|
resources.push(resource)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addImage(images: UserImageDisplay[], image: UserImageDisplay) {
|
||||||
|
const key = `${image.mime_type}:${image.data.length}:${image.data.slice(0, 64)}`
|
||||||
|
if (
|
||||||
|
images.some(
|
||||||
|
(item) =>
|
||||||
|
`${item.mime_type}:${item.data.length}:${item.data.slice(0, 64)}` ===
|
||||||
|
key
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
images.push(image)
|
||||||
|
}
|
||||||
|
|
||||||
export function extractUserResourcesFromText(text: string): {
|
export function extractUserResourcesFromText(text: string): {
|
||||||
text: string
|
text: string
|
||||||
resources: UserResourceDisplay[]
|
resources: UserResourceDisplay[]
|
||||||
@@ -480,6 +502,33 @@ function splitUserTextAndResources(
|
|||||||
return { parts: nextParts, resources }
|
return { parts: nextParts, resources }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveImageNameFromBlock(
|
||||||
|
block: Extract<ContentBlock, { type: "image" }>
|
||||||
|
): string {
|
||||||
|
if (block.uri && block.uri.trim().length > 0) {
|
||||||
|
return fileNameFromUri(block.uri)
|
||||||
|
}
|
||||||
|
const ext = block.mime_type.split("/")[1]?.split("+")[0] ?? "image"
|
||||||
|
return `image.${ext}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUserImagesFromBlocks(
|
||||||
|
blocks: ContentBlock[]
|
||||||
|
): UserImageDisplay[] {
|
||||||
|
const images: UserImageDisplay[] = []
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type !== "image") continue
|
||||||
|
if (!block.data || !block.mime_type) continue
|
||||||
|
addImage(images, {
|
||||||
|
name: deriveImageNameFromBlock(block),
|
||||||
|
data: block.data,
|
||||||
|
mime_type: block.mime_type,
|
||||||
|
uri: block.uri ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a stable tool call ID based on message ID and block index
|
* Generate a stable tool call ID based on message ID and block index
|
||||||
*/
|
*/
|
||||||
@@ -661,6 +710,8 @@ export function adaptMessageTurn(
|
|||||||
turn.role === "user"
|
turn.role === "user"
|
||||||
? splitUserTextAndResources(adaptedContent, text.attachedResources)
|
? splitUserTextAndResources(adaptedContent, text.attachedResources)
|
||||||
: { parts: adaptedContent, resources: [] as UserResourceDisplay[] }
|
: { parts: adaptedContent, resources: [] as UserResourceDisplay[] }
|
||||||
|
const userImages =
|
||||||
|
turn.role === "user" ? extractUserImagesFromBlocks(turn.blocks) : []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: turn.id,
|
id: turn.id,
|
||||||
@@ -668,6 +719,7 @@ export function adaptMessageTurn(
|
|||||||
content: userSplit.parts,
|
content: userSplit.parts,
|
||||||
userResources:
|
userResources:
|
||||||
userSplit.resources.length > 0 ? userSplit.resources : undefined,
|
userSplit.resources.length > 0 ? userSplit.resources : undefined,
|
||||||
|
userImages: userImages.length > 0 ? userImages : undefined,
|
||||||
timestamp: turn.timestamp,
|
timestamp: turn.timestamp,
|
||||||
usage: turn.usage,
|
usage: turn.usage,
|
||||||
duration_ms: turn.duration_ms,
|
duration_ms: turn.duration_ms,
|
||||||
@@ -695,6 +747,7 @@ export interface MessageGroup {
|
|||||||
role: "user" | "assistant" | "system"
|
role: "user" | "assistant" | "system"
|
||||||
parts: AdaptedContentPart[]
|
parts: AdaptedContentPart[]
|
||||||
userResources?: UserResourceDisplay[]
|
userResources?: UserResourceDisplay[]
|
||||||
|
userImages?: UserImageDisplay[]
|
||||||
usage?: TurnUsage | null
|
usage?: TurnUsage | null
|
||||||
duration_ms?: number | null
|
duration_ms?: number | null
|
||||||
model?: string | null
|
model?: string | null
|
||||||
@@ -738,6 +791,7 @@ export function groupAdaptedMessages(
|
|||||||
role: effectiveRole,
|
role: effectiveRole,
|
||||||
parts: [...msg.content],
|
parts: [...msg.content],
|
||||||
userResources: msg.userResources,
|
userResources: msg.userResources,
|
||||||
|
userImages: msg.userImages,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (currentGroup && currentGroup.role === "assistant") {
|
if (currentGroup && currentGroup.role === "assistant") {
|
||||||
|
|||||||
+62
-1
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AdaptedContentPart,
|
AdaptedContentPart,
|
||||||
|
UserImageDisplay,
|
||||||
UserResourceDisplay,
|
UserResourceDisplay,
|
||||||
} from "@/lib/adapters/ai-elements-adapter"
|
} from "@/lib/adapters/ai-elements-adapter"
|
||||||
import type { PromptDraft, PromptInputBlock } from "@/lib/types"
|
import type { PromptDraft, PromptInputBlock } from "@/lib/types"
|
||||||
@@ -10,6 +11,35 @@ function isResourceLinkBlock(
|
|||||||
return block.type === "resource_link"
|
return block.type === "resource_link"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEmbeddedResourceBlock(
|
||||||
|
block: PromptInputBlock
|
||||||
|
): block is Extract<PromptInputBlock, { type: "resource" }> {
|
||||||
|
return block.type === "resource"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageBlock(
|
||||||
|
block: PromptInputBlock
|
||||||
|
): block is Extract<PromptInputBlock, { type: "image" }> {
|
||||||
|
return block.type === "image"
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveResourceNameFromUri(uri: string): string {
|
||||||
|
const fallback = "resource"
|
||||||
|
const normalized = uri.trim()
|
||||||
|
if (!normalized) return fallback
|
||||||
|
const withoutQuery = normalized.split(/[?#]/, 1)[0]
|
||||||
|
const candidate = withoutQuery.split(/[\\/]/).pop() ?? ""
|
||||||
|
let decoded = ""
|
||||||
|
if (candidate) {
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(candidate)
|
||||||
|
} catch {
|
||||||
|
decoded = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decoded || fallback
|
||||||
|
}
|
||||||
|
|
||||||
export function getPromptDraftDisplayText(
|
export function getPromptDraftDisplayText(
|
||||||
draft: PromptDraft,
|
draft: PromptDraft,
|
||||||
attachedResourcesFallback: string
|
attachedResourcesFallback: string
|
||||||
@@ -33,9 +63,40 @@ export function buildUserMessageTextPartsFromDraft(
|
|||||||
export function extractUserResourcesFromDraft(
|
export function extractUserResourcesFromDraft(
|
||||||
draft: PromptDraft
|
draft: PromptDraft
|
||||||
): UserResourceDisplay[] {
|
): UserResourceDisplay[] {
|
||||||
return draft.blocks.filter(isResourceLinkBlock).map((resource) => ({
|
const linked = draft.blocks.filter(isResourceLinkBlock).map((resource) => ({
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
uri: resource.uri,
|
uri: resource.uri,
|
||||||
mime_type: resource.mime_type ?? null,
|
mime_type: resource.mime_type ?? null,
|
||||||
}))
|
}))
|
||||||
|
const embedded = draft.blocks
|
||||||
|
.filter(isEmbeddedResourceBlock)
|
||||||
|
.map((resource) => ({
|
||||||
|
name: deriveResourceNameFromUri(resource.uri),
|
||||||
|
uri: resource.uri,
|
||||||
|
mime_type: resource.mime_type ?? null,
|
||||||
|
}))
|
||||||
|
return [...linked, ...embedded]
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveImageName(
|
||||||
|
uri: string | null | undefined,
|
||||||
|
mimeType: string
|
||||||
|
): string {
|
||||||
|
if (uri && uri.trim().length > 0) {
|
||||||
|
const name = deriveResourceNameFromUri(uri)
|
||||||
|
if (name !== "resource") return name
|
||||||
|
}
|
||||||
|
const ext = mimeType.split("/")[1]?.split("+")[0] ?? "image"
|
||||||
|
return `image.${ext}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractUserImagesFromDraft(
|
||||||
|
draft: PromptDraft
|
||||||
|
): UserImageDisplay[] {
|
||||||
|
return draft.blocks.filter(isImageBlock).map((image) => ({
|
||||||
|
name: deriveImageName(image.uri, image.mime_type),
|
||||||
|
data: image.data,
|
||||||
|
mime_type: image.mime_type,
|
||||||
|
uri: image.uri ?? null,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -743,6 +743,13 @@ export async function stopFileTreeWatch(rootPath: string): Promise<void> {
|
|||||||
return invoke("stop_file_tree_watch", { rootPath })
|
return invoke("stop_file_tree_watch", { rootPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readFileBase64(
|
||||||
|
path: string,
|
||||||
|
maxBytes?: number
|
||||||
|
): Promise<string> {
|
||||||
|
return invoke("read_file_base64", { path, maxBytes: maxBytes ?? null })
|
||||||
|
}
|
||||||
|
|
||||||
export async function readFilePreview(
|
export async function readFilePreview(
|
||||||
rootPath: string,
|
rootPath: string,
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export type MessageRole = "user" | "assistant" | "system" | "tool"
|
|||||||
|
|
||||||
export type ContentBlock =
|
export type ContentBlock =
|
||||||
| { type: "text"; text: string }
|
| { type: "text"; text: string }
|
||||||
|
| {
|
||||||
|
type: "image"
|
||||||
|
data: string
|
||||||
|
mime_type: string
|
||||||
|
uri?: string | null
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "tool_use"
|
type: "tool_use"
|
||||||
tool_use_id: string | null
|
tool_use_id: string | null
|
||||||
@@ -304,8 +310,27 @@ export type ConnectionStatus =
|
|||||||
| "disconnected"
|
| "disconnected"
|
||||||
| "error"
|
| "error"
|
||||||
|
|
||||||
|
export interface PromptCapabilitiesInfo {
|
||||||
|
image: boolean
|
||||||
|
audio: boolean
|
||||||
|
embedded_context: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type PromptInputBlock =
|
export type PromptInputBlock =
|
||||||
| { type: "text"; text: string }
|
| { type: "text"; text: string }
|
||||||
|
| {
|
||||||
|
type: "image"
|
||||||
|
data: string
|
||||||
|
mime_type: string
|
||||||
|
uri?: string | null
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "resource"
|
||||||
|
uri: string
|
||||||
|
mime_type?: string | null
|
||||||
|
text?: string | null
|
||||||
|
blob?: string | null
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "resource_link"
|
type: "resource_link"
|
||||||
uri: string
|
uri: string
|
||||||
@@ -430,6 +455,11 @@ export type AcpEvent =
|
|||||||
type: "selectors_ready"
|
type: "selectors_ready"
|
||||||
connection_id: string
|
connection_id: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "prompt_capabilities"
|
||||||
|
connection_id: string
|
||||||
|
prompt_capabilities: PromptCapabilitiesInfo
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "mode_changed"
|
type: "mode_changed"
|
||||||
connection_id: string
|
connection_id: string
|
||||||
|
|||||||
Reference in New Issue
Block a user