完成 Editor Agent Mock Agent P1 收尾
接入 Web Project 契约、SpacetimeDB 表与 api-server 控制面 新增 Mock Agent、静态构建 runner 与独立预览网关 补齐 /editor/agent 前端页面、服务客户端和 SSE 订阅 修复 sandbox 预览资源跨域加载并补充并发保护 接入本地 dev 预览端口漂移与服务身份初始化 更新 P1 技术方案、验收清单和 Hermes 共享记忆
This commit is contained in:
@@ -56,6 +56,7 @@ shared-kernel = { workspace = true }
|
||||
shared-logging = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
spacetime-client = { workspace = true }
|
||||
web-project-runner = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal", "process"] }
|
||||
tokio-stream = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
|
||||
@@ -44,6 +44,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(modules::profile::router(state.clone()))
|
||||
.merge(modules::assets::router(state.clone()))
|
||||
.merge(modules::editor_project::router(state.clone()))
|
||||
.merge(modules::web_project::router(state.clone()))
|
||||
.merge(modules::platform::router(state.clone()))
|
||||
.merge(modules::external_generation::router(state.clone()))
|
||||
.merge(modules::play_flow::router(state.clone()))
|
||||
|
||||
@@ -138,6 +138,13 @@ pub struct AppConfig {
|
||||
pub spacetime_pool_size: u32,
|
||||
pub spacetime_procedure_timeout: Duration,
|
||||
pub spacetime_health_check_timeout: Duration,
|
||||
pub web_project_runner_bin: Option<PathBuf>,
|
||||
pub web_project_artifact_root: PathBuf,
|
||||
pub web_project_preview_host: String,
|
||||
pub web_project_preview_port: u16,
|
||||
pub web_project_preview_public_base_url: String,
|
||||
pub web_project_preview_frame_ancestors: String,
|
||||
pub web_project_preview_build_max_concurrent_tasks: usize,
|
||||
pub llm_provider: LlmProvider,
|
||||
pub llm_base_url: String,
|
||||
pub llm_api_key: Option<String>,
|
||||
@@ -368,6 +375,14 @@ impl Default for AppConfig {
|
||||
spacetime_health_check_timeout: Duration::from_secs(
|
||||
DEFAULT_SPACETIME_HEALTH_CHECK_TIMEOUT_SECONDS,
|
||||
),
|
||||
web_project_runner_bin: None,
|
||||
web_project_artifact_root: PathBuf::from("server-rs/.data/web-project-artifacts"),
|
||||
web_project_preview_host: "127.0.0.1".to_string(),
|
||||
web_project_preview_port: 3104,
|
||||
web_project_preview_public_base_url: "http://127.0.0.1:3104".to_string(),
|
||||
web_project_preview_frame_ancestors: "http://127.0.0.1:3000 http://localhost:3000"
|
||||
.to_string(),
|
||||
web_project_preview_build_max_concurrent_tasks: 2,
|
||||
llm_provider: LlmProvider::Ark,
|
||||
llm_base_url: String::new(),
|
||||
llm_api_key: None,
|
||||
@@ -895,6 +910,43 @@ impl AppConfig {
|
||||
config.spacetime_health_check_timeout =
|
||||
Duration::from_secs(spacetime_health_check_timeout_seconds);
|
||||
}
|
||||
config.web_project_runner_bin =
|
||||
read_first_non_empty_env(&["GENARRATIVE_WEB_PROJECT_RUNNER_BIN"]).map(PathBuf::from);
|
||||
if let Some(artifact_root) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_WEB_PROJECT_ARTIFACT_ROOT"])
|
||||
{
|
||||
config.web_project_artifact_root = PathBuf::from(artifact_root);
|
||||
}
|
||||
if let Some(preview_host) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_WEB_PROJECT_PREVIEW_HOST"])
|
||||
{
|
||||
config.web_project_preview_host = preview_host;
|
||||
}
|
||||
if let Ok(preview_port) = env::var("GENARRATIVE_WEB_PROJECT_PREVIEW_PORT")
|
||||
&& let Ok(parsed_port) = preview_port.parse::<u16>()
|
||||
{
|
||||
config.web_project_preview_port = parsed_port;
|
||||
}
|
||||
if let Some(preview_base_url) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_WEB_PROJECT_PREVIEW_PUBLIC_BASE_URL"])
|
||||
{
|
||||
config.web_project_preview_public_base_url = preview_base_url;
|
||||
} else {
|
||||
config.web_project_preview_public_base_url = format!(
|
||||
"http://{}:{}",
|
||||
config.web_project_preview_host, config.web_project_preview_port
|
||||
);
|
||||
}
|
||||
if let Some(frame_ancestors) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_WEB_PROJECT_PREVIEW_FRAME_ANCESTORS"])
|
||||
{
|
||||
config.web_project_preview_frame_ancestors = frame_ancestors;
|
||||
}
|
||||
if let Some(max_concurrent_tasks) =
|
||||
read_first_usize_env(&["GENARRATIVE_WEB_PROJECT_PREVIEW_BUILD_MAX_CONCURRENT_TASKS"])
|
||||
{
|
||||
config.web_project_preview_build_max_concurrent_tasks = max_concurrent_tasks;
|
||||
}
|
||||
|
||||
if let Some(llm_provider) =
|
||||
read_first_llm_provider_env(&["GENARRATIVE_LLM_PROVIDER", "LLM_PROVIDER"])
|
||||
@@ -1699,6 +1751,7 @@ mod tests {
|
||||
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_WEB_PROJECT_PREVIEW_BUILD_MAX_CONCURRENT_TASKS");
|
||||
std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
|
||||
@@ -1717,6 +1770,10 @@ mod tests {
|
||||
std::env::set_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS", "64");
|
||||
std::env::set_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS", "32");
|
||||
std::env::set_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS", "16");
|
||||
std::env::set_var(
|
||||
"GENARRATIVE_WEB_PROJECT_PREVIEW_BUILD_MAX_CONCURRENT_TASKS",
|
||||
"3",
|
||||
);
|
||||
std::env::set_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS", "3000");
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false");
|
||||
std::env::set_var(
|
||||
@@ -1744,6 +1801,7 @@ mod tests {
|
||||
assert_eq!(config.gallery_max_concurrent_requests, Some(64));
|
||||
assert_eq!(config.detail_max_concurrent_requests, Some(32));
|
||||
assert_eq!(config.admin_max_concurrent_requests, Some(16));
|
||||
assert_eq!(config.web_project_preview_build_max_concurrent_tasks, 3);
|
||||
assert_eq!(
|
||||
config.shutdown_outbox_flush_timeout,
|
||||
std::time::Duration::from_millis(3_000)
|
||||
@@ -1779,6 +1837,7 @@ mod tests {
|
||||
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_WEB_PROJECT_PREVIEW_BUILD_MAX_CONCURRENT_TASKS");
|
||||
std::env::remove_var("GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
|
||||
|
||||
@@ -9,10 +9,10 @@ use serde_json::{Value, json};
|
||||
use shared_kernel::build_prefixed_uuid_id;
|
||||
use spacetime_client::{
|
||||
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
|
||||
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput,
|
||||
EditorAssetLibraryRecord, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord,
|
||||
EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
|
||||
EditorProjectGetRecordInput,
|
||||
EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord,
|
||||
EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetRecord,
|
||||
EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord,
|
||||
EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
|
||||
EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||
EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError,
|
||||
};
|
||||
@@ -1023,12 +1023,11 @@ fn map_editor_project_error(error: SpacetimeClientError) -> AppError {
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
SpacetimeClientError::Runtime(message) => {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
SpacetimeClientError::Runtime(message) => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_details(json!({
|
||||
"provider": "editor-project",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
})),
|
||||
other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": other.to_string(),
|
||||
|
||||
@@ -744,6 +744,7 @@ mod tests {
|
||||
started_at: Some("2026-06-03T00:00:00Z".to_string()),
|
||||
completed_at: None,
|
||||
updated_at: "2026-06-03T00:00:00Z".to_string(),
|
||||
updated_at_micros: 1_749_165_600_000_000,
|
||||
lease_token: lease_token.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ mod vector_engine_audio_generation;
|
||||
mod visual_novel;
|
||||
mod volcengine_speech;
|
||||
mod wallet_refund_outbox;
|
||||
mod web_project;
|
||||
mod web_project_mock_agent;
|
||||
mod web_project_preview_gateway;
|
||||
mod wechat;
|
||||
mod wooden_fish;
|
||||
mod work_author;
|
||||
@@ -219,6 +222,8 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
|
||||
{
|
||||
Ok(state) => {
|
||||
spawn_app_state_background_workers(&state);
|
||||
let preview_shutdown = shutdown_signal_for_preview(state.clone());
|
||||
spawn_web_project_preview_gateway(&state, preview_shutdown).await?;
|
||||
let tracking_outbox = state.tracking_outbox();
|
||||
let wallet_refund_outbox = state.wallet_refund_outbox();
|
||||
let worker_state = process_role
|
||||
@@ -275,6 +280,40 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
|
||||
result
|
||||
}
|
||||
|
||||
async fn spawn_web_project_preview_gateway(
|
||||
state: &AppState,
|
||||
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
|
||||
) -> Result<(), io::Error> {
|
||||
let bind_address = format!(
|
||||
"{}:{}",
|
||||
state.config.web_project_preview_host, state.config.web_project_preview_port
|
||||
)
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|error| io::Error::other(format!("Web Project preview 监听地址非法:{error}")))?;
|
||||
let listener = build_tcp_listener(bind_address, state.config.listen_backlog)?;
|
||||
let router = web_project_preview_gateway::router(
|
||||
state.clone(),
|
||||
state.config.web_project_artifact_root.clone(),
|
||||
state.config.web_project_preview_frame_ancestors.clone(),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
info!(%bind_address, "Web Project preview gateway 已启动");
|
||||
if let Err(error) = axum::serve(listener, router)
|
||||
.with_graceful_shutdown(shutdown)
|
||||
.await
|
||||
{
|
||||
error!(error = %error, "Web Project preview gateway 已退出");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal_for_preview(state: AppState) {
|
||||
let signal = wait_for_shutdown_signal().await;
|
||||
state.mark_not_ready();
|
||||
info!(signal, "Web Project preview gateway 收到退出信号");
|
||||
}
|
||||
|
||||
async fn shutdown_signal(context: ShutdownContext) {
|
||||
let signal = wait_for_shutdown_signal().await;
|
||||
if let Some(state) = context.app_state.as_ref() {
|
||||
|
||||
@@ -20,4 +20,5 @@ pub mod puzzle_clear;
|
||||
pub mod square_hole;
|
||||
pub mod story;
|
||||
pub mod visual_novel;
|
||||
pub mod web_project;
|
||||
pub mod wooden_fish;
|
||||
|
||||
@@ -8,10 +8,10 @@ use crate::{
|
||||
editor_project::{
|
||||
create_editor_asset, create_editor_asset_folder, create_editor_project,
|
||||
create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder,
|
||||
delete_editor_project, edit_editor_image, generate_editor_image,
|
||||
get_editor_asset_library, get_editor_project, list_editor_projects,
|
||||
load_recent_editor_project, rename_editor_project, save_editor_project_layout,
|
||||
update_editor_asset, update_editor_asset_folder,
|
||||
delete_editor_project, edit_editor_image, generate_editor_image, get_editor_asset_library,
|
||||
get_editor_project, list_editor_projects, load_recent_editor_project,
|
||||
rename_editor_project, save_editor_project_layout, update_editor_asset,
|
||||
update_editor_asset_folder,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
72
server-rs/crates/api-server/src/modules/web_project.rs
Normal file
72
server-rs/crates/api-server/src/modules/web_project.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, patch, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
state::AppState,
|
||||
web_project::{
|
||||
create_mock_agent_turn, create_web_project, create_web_project_preview_build,
|
||||
get_web_project, get_web_project_preview_build, get_web_project_snapshot,
|
||||
patch_web_project_files, stream_web_project_preview_build_events,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/creation/web-project/projects",
|
||||
post(create_web_project).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/web-project/projects/{project_id}",
|
||||
get(get_web_project).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/web-project/projects/{project_id}/snapshot",
|
||||
get(get_web_project_snapshot).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/web-project/projects/{project_id}/files",
|
||||
patch(patch_web_project_files).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/web-project/projects/{project_id}/mock-agent-turns",
|
||||
post(create_mock_agent_turn).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/web-project/projects/{project_id}/preview-builds",
|
||||
post(create_web_project_preview_build).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/web-project/preview-builds/{job_id}",
|
||||
get(get_web_project_preview_build).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/web-project/preview-builds/{job_id}/events",
|
||||
get(stream_web_project_preview_build_events)
|
||||
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
error::Error,
|
||||
fmt,
|
||||
sync::{
|
||||
@@ -35,7 +35,7 @@ use spacetime_client::{
|
||||
SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError, SpacetimeClientHealthSnapshot,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::{Semaphore, broadcast};
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
@@ -272,6 +272,9 @@ pub struct AppStateInner {
|
||||
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
|
||||
creative_agent_sessions: Arc<Mutex<HashMap<String, CreativeAgentSessionRuntimeRecord>>>,
|
||||
profile_recharge_order_updates: broadcast::Sender<String>,
|
||||
web_project_build_updates: broadcast::Sender<String>,
|
||||
web_project_preview_build_limiter: Arc<Semaphore>,
|
||||
web_project_preview_active_projects: Arc<Mutex<HashSet<String>>>,
|
||||
#[cfg(test)]
|
||||
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
|
||||
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
|
||||
@@ -283,6 +286,28 @@ struct CreativeAgentSessionRuntimeRecord {
|
||||
snapshot: CreativeAgentSessionSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WebProjectPreviewBuildSlotError {
|
||||
GlobalLimit,
|
||||
ProjectAlreadyRunning,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WebProjectPreviewBuildSlot {
|
||||
project_key: String,
|
||||
active_projects: Arc<Mutex<HashSet<String>>>,
|
||||
_permit: OwnedSemaphorePermit,
|
||||
}
|
||||
|
||||
impl Drop for WebProjectPreviewBuildSlot {
|
||||
fn drop(&mut self) {
|
||||
self.active_projects
|
||||
.lock()
|
||||
.expect("web project preview active project set should lock")
|
||||
.remove(&self.project_key);
|
||||
}
|
||||
}
|
||||
|
||||
// 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdminRuntime {
|
||||
@@ -414,6 +439,10 @@ impl AppState {
|
||||
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
|
||||
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
|
||||
let (profile_recharge_order_updates, _) = broadcast::channel(128);
|
||||
let (web_project_build_updates, _) = broadcast::channel(256);
|
||||
let web_project_preview_build_limiter = Arc::new(Semaphore::new(
|
||||
config.web_project_preview_build_max_concurrent_tasks.max(1),
|
||||
));
|
||||
|
||||
Ok(Self(Arc::new(AppStateInner {
|
||||
config,
|
||||
@@ -451,6 +480,9 @@ impl AppState {
|
||||
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
|
||||
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
profile_recharge_order_updates,
|
||||
web_project_build_updates,
|
||||
web_project_preview_build_limiter,
|
||||
web_project_preview_active_projects: Arc::new(Mutex::new(HashSet::new())),
|
||||
#[cfg(test)]
|
||||
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
|
||||
})))
|
||||
@@ -919,6 +951,35 @@ impl AppState {
|
||||
&self.spacetime_client
|
||||
}
|
||||
|
||||
pub fn web_project_build_updates(&self) -> broadcast::Sender<String> {
|
||||
self.web_project_build_updates.clone()
|
||||
}
|
||||
|
||||
pub fn try_acquire_web_project_preview_build_slot(
|
||||
&self,
|
||||
project_id: &str,
|
||||
) -> Result<WebProjectPreviewBuildSlot, WebProjectPreviewBuildSlotError> {
|
||||
let project_key = project_id.to_string();
|
||||
let mut active_projects = self
|
||||
.web_project_preview_active_projects
|
||||
.lock()
|
||||
.expect("web project preview active project set should lock");
|
||||
if active_projects.contains(&project_key) {
|
||||
return Err(WebProjectPreviewBuildSlotError::ProjectAlreadyRunning);
|
||||
}
|
||||
let permit = self
|
||||
.web_project_preview_build_limiter
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| WebProjectPreviewBuildSlotError::GlobalLimit)?;
|
||||
active_projects.insert(project_key.clone());
|
||||
Ok(WebProjectPreviewBuildSlot {
|
||||
project_key,
|
||||
active_projects: self.web_project_preview_active_projects.clone(),
|
||||
_permit: permit,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
|
||||
&self.puzzle_gallery_cache
|
||||
}
|
||||
@@ -1625,6 +1686,33 @@ mod tests {
|
||||
assert_eq!(created.stages.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_project_preview_build_slot_enforces_global_and_project_limits() {
|
||||
let mut config = AppConfig::default();
|
||||
config.web_project_preview_build_max_concurrent_tasks = 1;
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
|
||||
let slot = state
|
||||
.try_acquire_web_project_preview_build_slot("web-project-1")
|
||||
.expect("first build should acquire slot");
|
||||
assert!(matches!(
|
||||
state.try_acquire_web_project_preview_build_slot("web-project-1"),
|
||||
Err(WebProjectPreviewBuildSlotError::ProjectAlreadyRunning)
|
||||
));
|
||||
assert!(matches!(
|
||||
state.try_acquire_web_project_preview_build_slot("web-project-2"),
|
||||
Err(WebProjectPreviewBuildSlotError::GlobalLimit)
|
||||
));
|
||||
|
||||
drop(slot);
|
||||
|
||||
assert!(
|
||||
state
|
||||
.try_acquire_web_project_preview_build_slot("web-project-2")
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_skips_llm_client_when_api_key_missing() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
1091
server-rs/crates/api-server/src/web_project.rs
Normal file
1091
server-rs/crates/api-server/src/web_project.rs
Normal file
File diff suppressed because it is too large
Load Diff
248
server-rs/crates/api-server/src/web_project_mock_agent.rs
Normal file
248
server-rs/crates/api-server/src/web_project_mock_agent.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
use shared_contracts::web_project::{WebProjectPatch, WebProjectPatchOperation};
|
||||
|
||||
pub fn build_mock_agent_patch(prompt: &str) -> (WebProjectPatch, String) {
|
||||
let normalized = prompt.trim();
|
||||
let theme = if normalized.contains("绿色") {
|
||||
WebProjectTheme::Green
|
||||
} else if normalized.contains("粉色") {
|
||||
WebProjectTheme::Pink
|
||||
} else {
|
||||
WebProjectTheme::Blue
|
||||
};
|
||||
|
||||
if normalized.contains("破坏构建") {
|
||||
return (
|
||||
WebProjectPatch {
|
||||
operations: vec![WebProjectPatchOperation::UpdateFile {
|
||||
path: "src/App.tsx".to_string(),
|
||||
content: broken_app_source(),
|
||||
}],
|
||||
},
|
||||
"生成一个构建语法错误用于验证失败保留上一版预览".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if normalized.contains("卡片") || normalized.contains("列表") {
|
||||
return (
|
||||
WebProjectPatch {
|
||||
operations: vec![
|
||||
WebProjectPatchOperation::UpdateFile {
|
||||
path: "src/App.tsx".to_string(),
|
||||
content: card_list_app_source(),
|
||||
},
|
||||
WebProjectPatchOperation::UpdateFile {
|
||||
path: "src/App.css".to_string(),
|
||||
content: themed_css(theme),
|
||||
},
|
||||
],
|
||||
},
|
||||
"更新为卡片列表页面".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if normalized.contains("计数") || normalized.contains("按钮") {
|
||||
return (
|
||||
WebProjectPatch {
|
||||
operations: vec![
|
||||
WebProjectPatchOperation::UpdateFile {
|
||||
path: "src/App.tsx".to_string(),
|
||||
content: counter_app_source(),
|
||||
},
|
||||
WebProjectPatchOperation::UpdateFile {
|
||||
path: "src/App.css".to_string(),
|
||||
content: themed_css(theme),
|
||||
},
|
||||
],
|
||||
},
|
||||
"更新为计数按钮页面".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
(
|
||||
WebProjectPatch {
|
||||
operations: vec![
|
||||
WebProjectPatchOperation::UpdateFile {
|
||||
path: "src/App.tsx".to_string(),
|
||||
content: default_app_source(normalized),
|
||||
},
|
||||
WebProjectPatchOperation::UpdateFile {
|
||||
path: "src/App.css".to_string(),
|
||||
content: themed_css(theme),
|
||||
},
|
||||
],
|
||||
},
|
||||
"更新页面标题和主题色".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
enum WebProjectTheme {
|
||||
Blue,
|
||||
Green,
|
||||
Pink,
|
||||
}
|
||||
|
||||
fn counter_app_source() -> String {
|
||||
r#"import { useState } from 'react';
|
||||
import './App.css';
|
||||
|
||||
export default function App() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero-panel">
|
||||
<p className="eyebrow">Mock Agent</p>
|
||||
<h1>蓝色计数按钮</h1>
|
||||
<button className="primary-action" onClick={() => setCount((value) => value + 1)}>
|
||||
已点击 {count} 次
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn card_list_app_source() -> String {
|
||||
r#"import './App.css';
|
||||
|
||||
const cards = ['构思', '搭建', '预览'];
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero-panel">
|
||||
<p className="eyebrow">Mock Agent</p>
|
||||
<h1>项目卡片列表</h1>
|
||||
<div className="card-grid">
|
||||
{cards.map((item) => (
|
||||
<article className="feature-card" key={item}>{item}</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn broken_app_source() -> String {
|
||||
r#"import './App.css';
|
||||
|
||||
export default function App() {
|
||||
return <main className="app-shell">破坏构建</main
|
||||
}
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn default_app_source(prompt: &str) -> String {
|
||||
let title = prompt
|
||||
.chars()
|
||||
.take(30)
|
||||
.collect::<String>()
|
||||
.replace(['<', '>', '{', '}'], "");
|
||||
format!(
|
||||
r#"import './App.css';
|
||||
|
||||
export default function App() {{
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero-panel">
|
||||
<p className="eyebrow">Mock Agent</p>
|
||||
<h1>{}</h1>
|
||||
<p className="summary">页面已根据本轮指令更新。</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}}
|
||||
"#,
|
||||
if title.trim().is_empty() {
|
||||
"Web 工程预览"
|
||||
} else {
|
||||
title.trim()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn themed_css(theme: WebProjectTheme) -> String {
|
||||
let (accent, surface) = match theme {
|
||||
WebProjectTheme::Blue => ("#2563eb", "#eff6ff"),
|
||||
WebProjectTheme::Green => ("#059669", "#ecfdf5"),
|
||||
WebProjectTheme::Pink => ("#db2777", "#fdf2f8"),
|
||||
};
|
||||
format!(
|
||||
r#":root {{
|
||||
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #111827;
|
||||
background: {surface};
|
||||
}}
|
||||
|
||||
body {{
|
||||
margin: 0;
|
||||
}}
|
||||
|
||||
.app-shell {{
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, {surface}, #ffffff);
|
||||
}}
|
||||
|
||||
.hero-panel {{
|
||||
width: min(520px, 100%);
|
||||
border: 1px solid rgba(17, 24, 39, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 28px;
|
||||
box-shadow: 0 16px 40px rgba(17, 24, 39, 0.12);
|
||||
}}
|
||||
|
||||
.eyebrow {{
|
||||
margin: 0 0 8px;
|
||||
color: {accent};
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
|
||||
h1 {{
|
||||
margin: 0 0 18px;
|
||||
font-size: 32px;
|
||||
line-height: 1.15;
|
||||
}}
|
||||
|
||||
.summary {{
|
||||
margin: 0;
|
||||
color: #4b5563;
|
||||
}}
|
||||
|
||||
.primary-action {{
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: {accent};
|
||||
color: white;
|
||||
padding: 12px 18px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}}
|
||||
|
||||
.card-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}}
|
||||
|
||||
.feature-card {{
|
||||
border-radius: 8px;
|
||||
background: {surface};
|
||||
border: 1px solid rgba(17, 24, 39, 0.08);
|
||||
padding: 16px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
283
server-rs/crates/api-server/src/web_project_preview_gateway.rs
Normal file
283
server-rs/crates/api-server/src/web_project_preview_gateway.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
use std::{
|
||||
path::{Component, Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::{Path as AxumPath, State},
|
||||
http::{
|
||||
HeaderValue, StatusCode,
|
||||
header::{
|
||||
ACCESS_CONTROL_ALLOW_ORIGIN, CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE,
|
||||
},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
const PREVIEW_TOKEN_MAX_AGE_MICROS: i64 = 24 * 60 * 60 * 1_000_000;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PreviewGatewayState {
|
||||
app_state: AppState,
|
||||
artifact_root: Arc<PathBuf>,
|
||||
frame_ancestors: Arc<String>,
|
||||
}
|
||||
|
||||
pub fn router(app_state: AppState, artifact_root: PathBuf, frame_ancestors: String) -> Router {
|
||||
Router::new()
|
||||
.route("/p/{preview_token_id}", get(serve_preview_index))
|
||||
.route("/p/{preview_token_id}/", get(serve_preview_index))
|
||||
.route(
|
||||
"/p/{preview_token_id}/{*asset_path}",
|
||||
get(serve_preview_asset),
|
||||
)
|
||||
.with_state(PreviewGatewayState {
|
||||
app_state,
|
||||
artifact_root: Arc::new(artifact_root),
|
||||
frame_ancestors: Arc::new(normalize_frame_ancestors(&frame_ancestors)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn serve_preview_index(
|
||||
State(state): State<PreviewGatewayState>,
|
||||
AxumPath(preview_token_id): AxumPath<String>,
|
||||
) -> Response {
|
||||
serve_preview_file(state, preview_token_id, "index.html".to_string()).await
|
||||
}
|
||||
|
||||
async fn serve_preview_asset(
|
||||
State(state): State<PreviewGatewayState>,
|
||||
AxumPath((preview_token_id, asset_path)): AxumPath<(String, String)>,
|
||||
) -> Response {
|
||||
let asset_path = if asset_path.trim().is_empty() {
|
||||
"index.html".to_string()
|
||||
} else {
|
||||
asset_path
|
||||
};
|
||||
serve_preview_file(state, preview_token_id, asset_path).await
|
||||
}
|
||||
|
||||
async fn serve_preview_file(
|
||||
state: PreviewGatewayState,
|
||||
preview_token_id: String,
|
||||
asset_path: String,
|
||||
) -> Response {
|
||||
if !preview_token_is_fresh(&preview_token_id) {
|
||||
return gateway_error(StatusCode::GONE, "预览令牌已失效");
|
||||
}
|
||||
let build = match state
|
||||
.app_state
|
||||
.spacetime_client()
|
||||
.get_web_project_preview_build_by_token(
|
||||
spacetime_client::WebProjectPreviewBuildTokenGetRecordInput {
|
||||
preview_token_id: preview_token_id.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(record) => record.build,
|
||||
Err(_) => return gateway_error(StatusCode::GONE, "预览令牌已失效"),
|
||||
};
|
||||
let build = if build.preview_token_id.as_deref() == Some(preview_token_id.as_str())
|
||||
&& build.status == "succeeded"
|
||||
{
|
||||
build
|
||||
} else {
|
||||
return gateway_error(StatusCode::GONE, "预览令牌已失效");
|
||||
};
|
||||
let Some(artifact_id) = build.artifact_id else {
|
||||
return gateway_error(StatusCode::GONE, "预览产物不存在");
|
||||
};
|
||||
let artifact_root = match fs::canonicalize(state.artifact_root.as_ref()).await {
|
||||
Ok(path) => path,
|
||||
Err(_) => return gateway_error(StatusCode::GONE, "预览产物不存在"),
|
||||
};
|
||||
let artifact_dir = match fs::canonicalize(artifact_root.join(artifact_id)).await {
|
||||
Ok(path) if path.starts_with(&artifact_root) => path,
|
||||
_ => return gateway_error(StatusCode::GONE, "预览产物不存在"),
|
||||
};
|
||||
let requested_file = match resolve_artifact_path(&artifact_dir, &asset_path) {
|
||||
Ok(path) => path,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let file_path = if fs::metadata(&requested_file)
|
||||
.await
|
||||
.map(|metadata| metadata.is_file())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
requested_file
|
||||
} else {
|
||||
artifact_dir.join("index.html")
|
||||
};
|
||||
let file_path = match fs::canonicalize(file_path).await {
|
||||
Ok(path) if path.starts_with(&artifact_dir) && path.starts_with(&artifact_root) => path,
|
||||
_ => return gateway_error(StatusCode::FORBIDDEN, "预览路径不合法"),
|
||||
};
|
||||
let Some(mime_type) = mime_type_for_path(&file_path) else {
|
||||
return gateway_error(StatusCode::UNSUPPORTED_MEDIA_TYPE, "预览文件类型不支持");
|
||||
};
|
||||
match fs::read(&file_path).await {
|
||||
Ok(bytes) => preview_response(bytes, mime_type, state.frame_ancestors.as_str()),
|
||||
Err(_) => gateway_error(StatusCode::NOT_FOUND, "预览文件不存在"),
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_token_is_fresh(preview_token_id: &str) -> bool {
|
||||
let Some(issued_at_micros) = parse_preview_token_issued_at(preview_token_id) else {
|
||||
return false;
|
||||
};
|
||||
let now = current_utc_micros();
|
||||
issued_at_micros <= now && now.saturating_sub(issued_at_micros) <= PREVIEW_TOKEN_MAX_AGE_MICROS
|
||||
}
|
||||
|
||||
fn parse_preview_token_issued_at(preview_token_id: &str) -> Option<i64> {
|
||||
let mut parts = preview_token_id.split('_');
|
||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some("wpt"), Some(issued_at), Some(secret), None) if secret.len() >= 32 => {
|
||||
issued_at.parse::<i64>().ok()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
fn resolve_artifact_path(artifact_dir: &Path, asset_path: &str) -> Result<PathBuf, Response> {
|
||||
let mut relative = PathBuf::new();
|
||||
for component in Path::new(asset_path).components() {
|
||||
match component {
|
||||
Component::Normal(segment) => relative.push(segment),
|
||||
Component::CurDir => {}
|
||||
_ => return Err(gateway_error(StatusCode::FORBIDDEN, "预览路径不合法")),
|
||||
}
|
||||
}
|
||||
Ok(artifact_dir.join(relative))
|
||||
}
|
||||
|
||||
fn preview_response(bytes: Vec<u8>, mime_type: &'static str, frame_ancestors: &str) -> Response {
|
||||
let mut response = Response::new(Body::from(bytes));
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static(mime_type));
|
||||
let csp = format!(
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; worker-src 'none'; frame-ancestors {}; object-src 'none'; base-uri 'none'",
|
||||
normalize_frame_ancestors(frame_ancestors)
|
||||
);
|
||||
if let Ok(value) = HeaderValue::from_str(&csp) {
|
||||
headers.insert(CONTENT_SECURITY_POLICY, value);
|
||||
}
|
||||
headers.insert(
|
||||
CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-store, max-age=0, must-revalidate"),
|
||||
);
|
||||
headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
|
||||
response
|
||||
}
|
||||
|
||||
fn normalize_frame_ancestors(input: &str) -> String {
|
||||
let values = input
|
||||
.split_whitespace()
|
||||
.filter(|value| {
|
||||
*value == "'self'"
|
||||
|| (value.starts_with("http://") || value.starts_with("https://"))
|
||||
&& !value.contains(';')
|
||||
&& !value.contains(',')
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if values.is_empty() {
|
||||
"'self'".to_string()
|
||||
} else {
|
||||
values.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
fn gateway_error(status: StatusCode, message: &'static str) -> Response {
|
||||
(status, message).into_response()
|
||||
}
|
||||
|
||||
fn mime_type_for_path(path: &Path) -> Option<&'static str> {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"html" => Some("text/html; charset=utf-8"),
|
||||
"js" | "mjs" => Some("text/javascript; charset=utf-8"),
|
||||
"css" => Some("text/css; charset=utf-8"),
|
||||
"json" => Some("application/json; charset=utf-8"),
|
||||
"svg" => Some("image/svg+xml"),
|
||||
"png" => Some("image/png"),
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
"webp" => Some("image/webp"),
|
||||
"ico" => Some("image/x-icon"),
|
||||
"woff" => Some("font/woff"),
|
||||
"woff2" => Some("font/woff2"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preview_csp_allows_configured_editor_origin() {
|
||||
let response = preview_response(
|
||||
b"<html></html>".to_vec(),
|
||||
"text/html; charset=utf-8",
|
||||
"http://127.0.0.1:3000",
|
||||
);
|
||||
let csp = response
|
||||
.headers()
|
||||
.get(CONTENT_SECURITY_POLICY)
|
||||
.expect("preview response should include csp")
|
||||
.to_str()
|
||||
.expect("csp should be ascii");
|
||||
assert!(csp.contains("frame-ancestors http://127.0.0.1:3000"));
|
||||
assert!(!csp.contains("frame-ancestors 'none'"));
|
||||
assert!(csp.contains("connect-src 'none'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_response_allows_sandboxed_module_assets() {
|
||||
let response = preview_response(
|
||||
b"export default 1;".to_vec(),
|
||||
"text/javascript; charset=utf-8",
|
||||
"http://127.0.0.1:3000",
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers().get(ACCESS_CONTROL_ALLOW_ORIGIN),
|
||||
Some(&HeaderValue::from_static("*"))
|
||||
);
|
||||
let csp = response
|
||||
.headers()
|
||||
.get(CONTENT_SECURITY_POLICY)
|
||||
.expect("preview response should include csp")
|
||||
.to_str()
|
||||
.expect("csp should be ascii");
|
||||
assert!(csp.contains("connect-src 'none'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_ancestors_falls_back_to_self_when_env_is_invalid() {
|
||||
assert_eq!(
|
||||
normalize_frame_ancestors("bad; https://ok.example"),
|
||||
"https://ok.example"
|
||||
);
|
||||
assert_eq!(normalize_frame_ancestors("bad;"), "'self'");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user