Skip to content
Merged
9 changes: 9 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions crates/aionui-api-types/src/chat_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Chat-message file attachments.
//!
//! A message's attachments are a tagged union discriminated by `kind`, decided
//! purely by *source* (not by any save-to-workspace setting):
//! - explorer tree selections → [`ChatFileRef::Project`] (resolved server-side
//! via `resolve_reference(op = Read)`),
//! - upload-button files → [`ChatFileRef::Upload`] (always `upload`, carrying
//! the absolute path returned by `POST /api/fs/upload`),
//! - host-filesystem picker selections → [`ChatFileRef::Local`] (an absolute
//! path the user explicitly chose in the backend-machine file browser).

use serde::{Deserialize, Serialize};

/// A single file attached to a chat message.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ChatFileRef {
/// A file inside a bound project folder, addressed by explorer identity
/// (`pe_id` + `relative_path`). The backend resolves it to an absolute path
/// via `resolve_reference` with lexical + realpath containment.
Project { pe_id: String, relative_path: String },
/// An uploaded file, carried as the absolute path returned by
/// `POST /api/fs/upload`. The backend requires it to live under the managed
/// upload directory before use.
Upload { path: String },
/// A file on the backend machine's filesystem, chosen by the user in the
/// host-file browser (`/api/fs/browse`, which already exposes the whole
/// filesystem). Carries an absolute path; the backend only checks it exists
/// and is a regular file — no managed-directory restriction, since the
/// picker that produced it already exposes this surface and the agent reads
/// the path through its own filesystem tools.
Local { path: String },
}
34 changes: 31 additions & 3 deletions crates/aionui-api-types/src/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use aionui_common::{
use serde::{Deserialize, Serialize};

use crate::acp::AcpConfigOptionDto;
use crate::chat_file::ChatFileRef;

/// Per-MCP snapshot status stored in `conversation.extra`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand Down Expand Up @@ -94,7 +95,7 @@ pub struct CloneConversationRequest {
pub struct SendMessageRequest {
pub content: String,
#[serde(default)]
pub files: Vec<String>,
pub files: Vec<ChatFileRef>,
#[serde(default)]
pub inject_skills: Vec<String>,
#[serde(default)]
Expand Down Expand Up @@ -227,6 +228,8 @@ pub struct ConversationResponse {
pub channel_chat_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assistant: Option<ConversationAssistantIdentityResponse>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
pub created_at: TimestampMs,
pub modified_at: TimestampMs,
pub extra: serde_json::Value,
Expand Down Expand Up @@ -585,6 +588,7 @@ mod tests {
pinned_at: None,
channel_chat_id: None,
assistant: None,
project_id: None,
created_at: 1712345678000,
modified_at: 1712345678000,
extra: json!({ "workspace": "/project" }),
Expand Down Expand Up @@ -623,6 +627,7 @@ mod tests {
pinned_at: None,
channel_chat_id: None,
assistant: None,
project_id: None,
created_at: 1,
modified_at: 1,
extra: json!({}),
Expand Down Expand Up @@ -655,6 +660,7 @@ mod tests {
pinned_at: Some(1712345678000),
channel_chat_id: Some("group:42".into()),
assistant: None,
project_id: None,
created_at: 1000,
modified_at: 2000,
extra: json!({}),
Expand Down Expand Up @@ -739,6 +745,7 @@ mod tests {
pinned_at: None,
channel_chat_id: None,
assistant: None,
project_id: None,
created_at: 1712345678000,
modified_at: 1712345678000,
extra: json!({}),
Expand Down Expand Up @@ -776,6 +783,7 @@ mod tests {
pinned_at: None,
channel_chat_id: None,
assistant: None,
project_id: None,
created_at: 9000,
modified_at: 9000,
extra: json!({}),
Expand All @@ -795,13 +803,31 @@ mod tests {
fn deserialize_send_message_full() {
let raw = json!({
"content": "Review this code",
"files": ["/tmp/a.rs"],
"files": [
{ "kind": "project", "pe_id": "pe1", "relative_path": "src/a.rs" },
{ "kind": "upload", "path": "/tmp/a.rs" },
{ "kind": "local", "path": "/Users/me/notes.txt" }
],
"inject_skills": ["security-review"],
"hidden": true
});
let req: SendMessageRequest = serde_json::from_value(raw).unwrap();
assert_eq!(req.content, "Review this code");
assert_eq!(req.files, vec!["/tmp/a.rs"]);
assert_eq!(
req.files,
vec![
ChatFileRef::Project {
pe_id: "pe1".into(),
relative_path: "src/a.rs".into()
},
ChatFileRef::Upload {
path: "/tmp/a.rs".into()
},
ChatFileRef::Local {
path: "/Users/me/notes.txt".into()
},
]
);
assert_eq!(req.inject_skills, vec!["security-review"]);
assert!(req.hidden);
}
Expand Down Expand Up @@ -847,6 +873,7 @@ mod tests {
pinned_at: None,
channel_chat_id: None,
assistant: None,
project_id: None,
created_at: 1000,
modified_at: 1000,
extra: json!({}),
Expand Down Expand Up @@ -899,6 +926,7 @@ mod tests {
pinned_at: None,
channel_chat_id: None,
assistant: None,
project_id: None,
created_at: 5000,
modified_at: 5000,
extra: json!({}),
Expand Down
41 changes: 33 additions & 8 deletions crates/aionui-api-types/src/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,23 @@ pub struct WriteFileRequest {
pub workspace: Option<String>,
}

/// Request body for `POST /api/fs/copy` — copy files to workspace.
/// Copy destination, addressed by explorer identity (a project folder + a
/// relative subdirectory). The backend resolves it to an absolute directory
/// via `resolve_reference`; device file paths are copied into it.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CopyTarget {
pub pe_id: String,
/// Relative directory under the pe's folder root (`""` = the root itself).
pub relative_path: String,
}

/// Request body for `POST /api/fs/copy` — copy device files into a project
/// folder (add-to-chat "paste into workspace", pe-addressed).
#[derive(Debug, Deserialize)]
pub struct CopyFilesRequest {
/// Absolute device paths of external OS files to copy in.
pub file_paths: Vec<String>,
pub workspace: String,
pub target: CopyTarget,
#[serde(default)]
pub source_root: Option<String>,
}
Expand Down Expand Up @@ -227,11 +239,19 @@ pub struct FileMetadataResponse {
pub is_directory: Option<bool>,
}

/// One file that could not be copied, with a human-readable reason
/// (e.g. name collision — the backend never silently overwrites).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CopyFailure {
pub path: String,
pub reason: String,
}

/// Result of a batch copy operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CopyFilesResponse {
pub copied_files: Vec<String>,
pub failed_files: Vec<String>,
pub failed_files: Vec<CopyFailure>,
}

/// Result of a rename operation.
Expand Down Expand Up @@ -352,20 +372,21 @@ mod tests {
fn copy_files_request_snake_case() {
let raw = json!({
"file_paths": ["/a.txt", "/b.txt"],
"workspace": "/ws",
"target": { "pe_id": "pe1", "relative_path": "sub" },
"source_root": "/src"
});
let req: CopyFilesRequest = serde_json::from_value(raw).unwrap();
assert_eq!(req.file_paths, vec!["/a.txt", "/b.txt"]);
assert_eq!(req.workspace, "/ws");
assert_eq!(req.target.pe_id, "pe1");
assert_eq!(req.target.relative_path, "sub");
assert_eq!(req.source_root.as_deref(), Some("/src"));
}

#[test]
fn copy_files_request_optional_source_root() {
let raw = json!({
"file_paths": ["/a.txt"],
"workspace": "/ws"
"target": { "pe_id": "pe1", "relative_path": "" }
});
let req: CopyFilesRequest = serde_json::from_value(raw).unwrap();
assert!(req.source_root.is_none());
Expand Down Expand Up @@ -526,11 +547,15 @@ mod tests {
fn copy_files_response_serialization() {
let resp = CopyFilesResponse {
copied_files: vec!["/ws/a.txt".into()],
failed_files: vec!["/missing.txt".into()],
failed_files: vec![CopyFailure {
path: "/missing.txt".into(),
reason: "not a file".into(),
}],
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["copied_files"][0], "/ws/a.txt");
assert_eq!(json["failed_files"][0], "/missing.txt");
assert_eq!(json["failed_files"][0]["path"], "/missing.txt");
assert_eq!(json["failed_files"][0]["reason"], "not a file");
}

#[test]
Expand Down
18 changes: 11 additions & 7 deletions crates/aionui-api-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod agent_error;
mod assistant;
mod auth;
mod channel;
mod chat_file;
mod confirmation;
mod connection_test;
mod conversation;
Expand All @@ -19,6 +20,7 @@ mod file;
mod lifecycle;
mod mcp;
mod office;
mod project;
mod provider;
mod remote_agent;
mod response;
Expand Down Expand Up @@ -72,6 +74,7 @@ pub use channel::{
PluginStatusChangedPayload, PluginStatusResponse, RejectPairingRequest, RevokeUserRequest,
SyncChannelSettingsRequest, TestPluginExtraConfig, TestPluginRequest, TestPluginResponse, UserAuthorizedPayload,
};
pub use chat_file::ChatFileRef;
pub use confirmation::{ApprovalCheckQuery, ApprovalCheckResponse, ConfirmRequest, ConfirmationListResponse};
pub use connection_test::TestBedrockConnectionRequest;
pub use conversation::{
Expand Down Expand Up @@ -100,13 +103,13 @@ pub use extension::{
InstallExtensionRequest, PermissionDetailResponse, PermissionSummaryResponse,
};
pub use file::{
BrowseDirectoryQuery, BrowseDirectoryResponse, BrowseEntry, CancelZipRequest, CopyFilesRequest, CopyFilesResponse,
CreateTempFileRequest, DirOrFileResponse, FetchRemoteImageRequest, FileChangeInfoResponse, FileMetadataResponse,
FileWatchRequest, GetFileMetadataRequest, GetFilesByDirRequest, GetImageBase64Request, ListWorkspaceFilesRequest,
ReadFileBufferRequest, ReadFileRequest, RemoveEntryRequest, RenameRequest, RenameResponse, SnapshotBaselineRequest,
SnapshotCompareResponse, SnapshotDiscardRequest, SnapshotInfoResponse, SnapshotMode, SnapshotStageRequest,
SnapshotWorkspaceRequest, WorkspaceFlatFileResponse, WorkspaceOfficeWatchRequest, WriteFileRequest, ZipFileEntry,
ZipRequest,
BrowseDirectoryQuery, BrowseDirectoryResponse, BrowseEntry, CancelZipRequest, CopyFailure, CopyFilesRequest,
CopyFilesResponse, CopyTarget, CreateTempFileRequest, DirOrFileResponse, FetchRemoteImageRequest,
FileChangeInfoResponse, FileMetadataResponse, FileWatchRequest, GetFileMetadataRequest, GetFilesByDirRequest,
GetImageBase64Request, ListWorkspaceFilesRequest, ReadFileBufferRequest, ReadFileRequest, RemoveEntryRequest,
RenameRequest, RenameResponse, SnapshotBaselineRequest, SnapshotCompareResponse, SnapshotDiscardRequest,
SnapshotInfoResponse, SnapshotMode, SnapshotStageRequest, SnapshotWorkspaceRequest, WorkspaceFlatFileResponse,
WorkspaceOfficeWatchRequest, WriteFileRequest, ZipFileEntry, ZipRequest,
};
pub use lifecycle::{GitHubReleaseAsset, SystemInfoResponse, UpdateCheckRequest, UpdateCheckResult, UpdateReleaseInfo};
pub use mcp::{
Expand All @@ -121,6 +124,7 @@ pub use office::{
PptSlideData, PreviewHistoryTargetDto, PreviewSnapshotInfoDto, PreviewState, PreviewStatusEvent,
PreviewUrlResponse, SaveSnapshotRequest, SnapshotContentResponse, StartPreviewRequest, StopPreviewRequest,
};
pub use project::{AttachFolderRequest, ProjectDetailResponse, ProjectEntry, ProjectExplorer};
pub use provider::{
BedrockAuthMethod, BedrockConfig, CreateProviderRequest, DetectProtocolRequest, DetectionSuggestion,
FetchModelsAnonymousRequest, FetchModelsRequest, FetchModelsResponse, HealthStatus, KeyTestResult, ModelCapability,
Expand Down
66 changes: 66 additions & 0 deletions crates/aionui-api-types/src/project.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//! Project Explorer control-plane HTTP DTOs.
//!
//! The wire contract the explorer frontend consumes to fetch a project's
//! roots (`GET /api/projects/{id}`) and mutate its attached folders
//! (`POST`/`DELETE .../folders`). The filesystem *content* of each root is
//! carried separately over the `fs/*` WebSocket protocol, keyed by `pe_id` —
//! these DTOs only describe the project shell and its root list.
//!
//! Deliberately excludes absolute paths / canonical URIs: the frontend
//! identifies resources purely by `{pe_id, relative_path}`. `display_path`
//! is a human-facing, read-only rendering of the folder location.

use serde::{Deserialize, Serialize};

/// Aggregated project detail — everything the explorer needs in one call,
/// so the frontend never fans out one request per root.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectDetailResponse {
pub project_id: String,
/// Project display name (explorer header).
pub name: String,
pub explorer: ProjectExplorer,
}

/// The explorer view of a project: its pinned workspace root plus every
/// attached root, in display order.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectExplorer {
/// The `pe_id` of the immutable workspace root (pinned first, not
/// removable). The frontend uses it to pin + lock that row.
pub workspace_pe_id: String,
/// Roots ordered by `order_index` ascending (backend-sorted).
pub entries: Vec<ProjectEntry>,
}

/// One explorer root. Also returned singly by `attach_folder` so the
/// frontend can splice it into the tree without re-fetching the project.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectEntry {
/// Stable root identity (= frontend `PeKey` prefix). Content subscriptions
/// key off this.
pub pe_id: String,
/// `"workspace"` (pinned, immutable) or `"attached"` (removable).
pub role: String,
/// Optional user-assigned label; `null` → frontend falls back to the
/// `display_path` basename.
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
/// Human-facing path rendering of the folder location (read-only).
pub display_path: String,
/// Position among roots (ascending).
pub order_index: i64,
/// Folder availability: `available` | `missing` | `permission_denied` |
/// `disconnected`. Drives the greyed-out / stale root indicator.
pub runtime_status: String,
}

/// `POST /api/projects/{project_id}/folders` body — attach an additional
/// (non-workspace) folder. `uri` is a `file://` URI (same form as project
/// creation).
#[derive(Debug, Clone, Deserialize)]
pub struct AttachFolderRequest {
pub uri: String,
#[serde(default)]
pub display_name: Option<String>,
}
Loading
Loading