Add Web Push, guided start screen, SVG icons, chunked uploads, rename
- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished notifications while no app window is visible; settings with test button - Guided new-session flow: task, craft target (website/app/game/old software/fresh), clean room or deep, description, model, folder (new, existing or cloned git repository); originals upload to original/ - Craft phase bar (craft_phase tool) for study/specify/build/check/ready - Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB) - All UI glyphs and emojis replaced by themeable SVG icons - Rename dialog with manual name or model-generated suggestion Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
013695722a
commit
a0d0e707f2
48 files changed
+2724
-228
No files matched your search
+83
-2
@@ -72,6 +72,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions", get(list_sessions).post(create_session))
|
||||
.route("/sessions/search", get(search_sessions))
|
||||
.route("/sessions/{id}/fork", post(fork_session))
|
||||
.route("/sessions/{id}/title", post(suggest_title))
|
||||
.route("/sessions/{id}/export", get(export_session))
|
||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||
.route("/sessions/{id}/prompt", post(prompt))
|
||||
@@ -120,6 +121,9 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/images/config", get(images_config))
|
||||
.route("/images/{id}", delete(delete_image))
|
||||
.route("/images/{id}/file", get(image_file))
|
||||
.route("/push/key", get(push_key))
|
||||
.route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe))
|
||||
.route("/push/test", post(push_test))
|
||||
.route("/ws", get(ws))
|
||||
.layer(middleware::from_fn_with_state(app.clone(), auth::require_user))
|
||||
// Prompts may carry images (base64); the default limit is 2 MB.
|
||||
@@ -163,12 +167,13 @@ async fn get_config(Extension(user): Extension<User>, State(app): State<AppState
|
||||
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
|
||||
if !user.is_admin() {
|
||||
// Users only need defaults, not the configuration.
|
||||
return Ok(Json(json!({"default_model": default_model, "currency": cfg.server.currency})));
|
||||
return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency})));
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"config": config::redacted_json(&cfg),
|
||||
"workspace": app.workspace(),
|
||||
"default_model": default_model,
|
||||
"small_model": cfg.server.small_model,
|
||||
"currency": cfg.server.currency,
|
||||
})))
|
||||
}
|
||||
@@ -320,6 +325,7 @@ struct CreateSession {
|
||||
model: Option<String>,
|
||||
agent: Option<String>,
|
||||
permission_mode: Option<String>,
|
||||
clean_room: Option<bool>,
|
||||
}
|
||||
|
||||
/// Resolve a directory and check it lies inside one of the user's roots.
|
||||
@@ -364,7 +370,8 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
updated_at: now,
|
||||
todos: Value::Null,
|
||||
last_usage: None,
|
||||
clean_room: None,
|
||||
clean_room: body.clean_room,
|
||||
phase: None,
|
||||
};
|
||||
let s = app.save_session(s)?;
|
||||
Ok(Json(json!(s)))
|
||||
@@ -389,6 +396,19 @@ async fn search_sessions(Extension(user): Extension<User>, State(app): State<App
|
||||
Ok(Json(json!(hits)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct TitleBody {
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
/// Generate a title suggestion with a chosen model (not saved).
|
||||
async fn suggest_title(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, body: Option<Json<TitleBody>>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
let model = body.and_then(|b| b.0.model);
|
||||
let title = crate::runner::suggest_title(app.clone(), &id, model).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("{e:#}")))?;
|
||||
Ok(Json(json!({"title": title})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct ForkBody {
|
||||
/// Last message to keep (inclusive); all messages when missing.
|
||||
@@ -414,6 +434,7 @@ async fn fork_session(Extension(user): Extension<User>, State(app): State<AppSta
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_usage: None,
|
||||
phase: None,
|
||||
..src.clone()
|
||||
};
|
||||
let fork = app.save_session(fork)?;
|
||||
@@ -988,6 +1009,50 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
|
||||
|
||||
// --- websocket --------------------------------------------------------------
|
||||
|
||||
// --- web push -------------------------------------------------------------------
|
||||
|
||||
async fn push_key(State(app): State<AppState>) -> ApiResult {
|
||||
Ok(Json(json!({"key": app.push.public_key()})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PushKeys {
|
||||
p256dh: String,
|
||||
auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PushSubscription {
|
||||
endpoint: String,
|
||||
keys: PushKeys,
|
||||
}
|
||||
|
||||
async fn push_subscribe(Extension(user): Extension<User>, State(app): State<AppState>, Json(sub): Json<PushSubscription>) -> ApiResult {
|
||||
if !sub.endpoint.starts_with("https://") {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid push endpoint".into()));
|
||||
}
|
||||
app.store.push_add(&user.id, &sub.endpoint, &sub.keys.p256dh, &sub.keys.auth)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PushEndpoint {
|
||||
endpoint: String,
|
||||
}
|
||||
|
||||
async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<PushEndpoint>) -> ApiResult {
|
||||
// Only the owner's own subscription.
|
||||
if app.store.push_subscriptions(&user.id)?.iter().any(|s| s.endpoint == b.endpoint) {
|
||||
app.store.push_remove(&b.endpoint)?;
|
||||
}
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn push_test(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let sent = crate::push::send_to_user(&app, &user.id, &json!({"kind": "test", "title": "CodeCraft", "body": "Notifications work.", "tag": "test", "url": "/"})).await;
|
||||
Ok(Json(json!({"sent": sent})))
|
||||
}
|
||||
|
||||
async fn ws(Extension(user): Extension<User>, State(app): State<AppState>, upgrade: WebSocketUpgrade) -> Response {
|
||||
upgrade.on_upgrade(move |socket| ws_loop(app, user, socket))
|
||||
}
|
||||
@@ -1002,6 +1067,7 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) {
|
||||
let mut ping = tokio::time::interval(std::time::Duration::from_secs(25));
|
||||
// Re-check the login regularly so disabled users lose their live connection.
|
||||
let mut recheck = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
let mut visible = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
ev = rx.recv() => match ev {
|
||||
@@ -1021,6 +1087,18 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) {
|
||||
},
|
||||
msg = socket.recv() => match msg {
|
||||
Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break,
|
||||
Some(Ok(WsMessage::Text(t))) => {
|
||||
// The client reports whether its window is visible (push only when none is).
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&t) {
|
||||
if v["type"] == "visibility" {
|
||||
let now = v["visible"].as_bool().unwrap_or(false);
|
||||
if now != visible {
|
||||
app.push.set_visible(&user.id, if now { 1 } else { -1 });
|
||||
visible = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ = ping.tick() => {
|
||||
@@ -1035,6 +1113,9 @@ async fn ws_loop(app: AppState, user: User, mut socket: WebSocket) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if visible {
|
||||
app.push.set_visible(&user.id, -1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> {
|
||||
|
||||
Reference in new issue
Block a user