1
0
forked from boos/BoosAPI

feat: add workspace and claude messages support

This commit is contained in:
boos
2026-05-22 05:57:37 +08:00
parent c1074ddf71
commit 7cdb02ef1d
24 changed files with 3282 additions and 479 deletions
+1
View File
@@ -11,3 +11,4 @@ pub mod oauth;
pub mod route_rebuild;
pub mod shared;
pub mod update_center;
pub mod workspace;
+330
View File
@@ -0,0 +1,330 @@
use crate::{error::AppError, AppState};
use base64::{engine::general_purpose, Engine as _};
use chrono::Utc;
use reqwest::header::CONTENT_TYPE;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct WorkspaceAssetInput {
pub user_id: Option<i64>,
pub source: String,
pub kind: String,
pub title: Option<String>,
pub prompt: Option<String>,
pub model: Option<String>,
pub remote_url: Option<String>,
pub metadata: Value,
}
#[derive(Debug, Clone)]
pub struct StoredWorkspaceAsset {
pub public_id: String,
pub file_path: Option<String>,
pub remote_url: Option<String>,
pub status: String,
}
fn workspace_root() -> PathBuf {
std::env::var("BOOSAPI_WORKSPACE_DATA_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("/data/boosapi-workspace"))
}
fn safe_segment(value: &str) -> String {
let mut out = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'-'
}
})
.collect::<String>();
while out.contains("--") {
out = out.replace("--", "-");
}
let trimmed = out.trim_matches('-').to_string();
if trimmed.is_empty() {
"asset".to_string()
} else {
trimmed.chars().take(48).collect()
}
}
fn extension_for_mime(mime: &str) -> &'static str {
let mime = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
match mime.as_str() {
"image/jpeg" | "image/jpg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"image/svg+xml" => "svg",
"video/mp4" => "mp4",
"video/webm" => "webm",
"audio/mpeg" | "audio/mp3" => "mp3",
"audio/wav" => "wav",
"application/json" => "json",
_ if mime.starts_with("image/") => "png",
_ if mime.starts_with("video/") => "mp4",
_ if mime.starts_with("audio/") => "mp3",
_ => "bin",
}
}
fn extension_from_url(url: &str) -> Option<String> {
let without_query = url.split(['?', '#']).next().unwrap_or(url);
let ext = Path::new(without_query)
.extension()
.and_then(|value| value.to_str())?
.trim()
.to_ascii_lowercase();
if ext.len() <= 8 && ext.chars().all(|ch| ch.is_ascii_alphanumeric()) {
Some(ext)
} else {
None
}
}
async fn write_asset_file(
user_id: Option<i64>,
source: &str,
public_id: &str,
ext: &str,
bytes: &[u8],
) -> Result<PathBuf, AppError> {
let now = Utc::now();
let user_segment = user_id
.map(|id| id.to_string())
.unwrap_or_else(|| "admin".to_string());
let dir = workspace_root()
.join(user_segment)
.join(safe_segment(source))
.join(now.format("%Y-%m").to_string());
tokio::fs::create_dir_all(&dir)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!("create workspace dir: {err}")))?;
let path = dir.join(format!("{public_id}.{ext}"));
let mut file = tokio::fs::File::create(&path)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!("create workspace asset: {err}")))?;
file.write_all(bytes)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!("write workspace asset: {err}")))?;
Ok(path)
}
async fn insert_asset(
state: &AppState,
input: &WorkspaceAssetInput,
public_id: &str,
status: &str,
mime_type: Option<&str>,
file_path: Option<&Path>,
size_bytes: i64,
metadata: Value,
) -> Result<StoredWorkspaceAsset, AppError> {
let file_name = file_path
.and_then(|path| path.file_name())
.and_then(|value| value.to_str())
.map(str::to_string);
let file_path_string = file_path.map(|path| path.to_string_lossy().to_string());
sqlx::query(
"INSERT INTO workspace_assets
(public_id, user_id, source, kind, title, prompt, model, status,
mime_type, file_name, file_path, remote_url, size_bytes, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(public_id)
.bind(input.user_id)
.bind(&input.source)
.bind(&input.kind)
.bind(&input.title)
.bind(&input.prompt)
.bind(&input.model)
.bind(status)
.bind(mime_type)
.bind(&file_name)
.bind(&file_path_string)
.bind(&input.remote_url)
.bind(size_bytes)
.bind(metadata.to_string())
.execute(&*state.db)
.await?;
Ok(StoredWorkspaceAsset {
public_id: public_id.to_string(),
file_path: file_path_string,
remote_url: input.remote_url.clone(),
status: status.to_string(),
})
}
pub async fn record_remote_asset(
state: &AppState,
input: WorkspaceAssetInput,
) -> Result<StoredWorkspaceAsset, AppError> {
let public_id = format!("wa_{}", Uuid::new_v4().simple());
let Some(url) = input.remote_url.as_deref() else {
return insert_asset(
state,
&input,
&public_id,
"failed",
None,
None,
0,
json!({ "error": "remote_url is required", "metadata": input.metadata }),
)
.await;
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
match client.get(url).send().await {
Ok(response) if response.status().is_success() => {
let mime_type = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_string)
.unwrap_or_else(|| "application/octet-stream".to_string());
let bytes = response
.bytes()
.await
.map_err(|err| AppError::UpstreamError(format!("download workspace asset: {err}")))?;
let ext = extension_from_url(url).unwrap_or_else(|| extension_for_mime(&mime_type).to_string());
let path = write_asset_file(input.user_id, &input.source, &public_id, &ext, &bytes).await?;
insert_asset(
state,
&input,
&public_id,
"completed",
Some(&mime_type),
Some(&path),
bytes.len() as i64,
input.metadata.clone(),
)
.await
}
Ok(response) => {
let status = response.status().as_u16();
insert_asset(
state,
&input,
&public_id,
"failed",
None,
None,
0,
json!({ "error": format!("download returned HTTP {status}"), "metadata": input.metadata }),
)
.await
}
Err(err) => {
insert_asset(
state,
&input,
&public_id,
"failed",
None,
None,
0,
json!({ "error": err.to_string(), "metadata": input.metadata }),
)
.await
}
}
}
pub async fn record_base64_asset(
state: &AppState,
mut input: WorkspaceAssetInput,
value: &str,
) -> Result<StoredWorkspaceAsset, AppError> {
let public_id = format!("wa_{}", Uuid::new_v4().simple());
let trimmed = value.trim();
let (mime_type, encoded) = if let Some(rest) = trimmed.strip_prefix("data:") {
let (meta, data) = rest
.split_once(',')
.ok_or_else(|| AppError::BadRequest("invalid data URL".into()))?;
let mime = meta.split(';').next().unwrap_or("image/png").trim();
(mime.to_string(), data)
} else {
("image/png".to_string(), trimmed)
};
input.remote_url = None;
let bytes = general_purpose::STANDARD
.decode(encoded)
.map_err(|err| AppError::BadRequest(format!("invalid base64 asset: {err}")))?;
let ext = extension_for_mime(&mime_type);
let path = write_asset_file(input.user_id, &input.source, &public_id, ext, &bytes).await?;
insert_asset(
state,
&input,
&public_id,
"completed",
Some(&mime_type),
Some(&path),
bytes.len() as i64,
input.metadata.clone(),
)
.await
}
pub async fn record_image_assets_from_response(
state: &AppState,
user_id: Option<i64>,
source: &str,
prompt: Option<String>,
model: Option<String>,
response: &Value,
) {
let Some(items) = response.get("data").and_then(Value::as_array) else {
return;
};
for (index, item) in items.iter().enumerate() {
let title = prompt
.as_deref()
.map(|value| value.chars().take(36).collect::<String>())
.filter(|value| !value.trim().is_empty())
.or_else(|| Some(format!("图片产出 #{}", index + 1)));
if let Some(url) = item.get("url").and_then(Value::as_str).filter(|value| !value.trim().is_empty()) {
let input = WorkspaceAssetInput {
user_id,
source: source.to_string(),
kind: "image".to_string(),
title,
prompt: prompt.clone(),
model: model.clone(),
remote_url: Some(url.trim().to_string()),
metadata: json!({ "responseItem": item }),
};
if let Err(err) = record_remote_asset(state, input).await {
tracing::warn!(%err, "failed to archive remote image asset");
}
} else if let Some(b64) = item
.get("b64_json")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
{
let input = WorkspaceAssetInput {
user_id,
source: source.to_string(),
kind: "image".to_string(),
title,
prompt: prompt.clone(),
model: model.clone(),
remote_url: None,
metadata: json!({ "responseItem": item }),
};
if let Err(err) = record_base64_asset(state, input, b64).await {
tracing::warn!(%err, "failed to archive base64 image asset");
}
}
}
}