From a9aa2b8229da0d54ed68eb40e4426e6983ff631e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 15 Oct 2026 18:51:11 +0200 Subject: [PATCH] feat: share AI sessions read-only by link, optionally public (#11799) * feat: share AI sessions read-only by link, optionally public Co-Authored-By: Claude Opus 5.5 * fix: render shared session snapshots from an allowlist, without mermaid or remote images Co-Authored-By: Claude Opus 5.5 * style: make the session share button icon-only Co-Authored-By: Claude Opus 5.5 * fix: safe reasoning links, artifact list and full-load exits on shared sessions Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ...42162e96499987b17a34bde7bc32d54e6260e.json | 55 +++ ...0a57fd10fc5b32f4e68ab7a217d85264623b6.json | 47 +++ ...95309a55635083a541745552c397a36ac08c4.json | 54 +++ ...a133ea859f243f518197cde803ef0c86eab33.json | 25 ++ ...3a863c3f265ea043cc653f9732bb87860b8d9.json | 65 ++++ .../20261015155831_ai_shared_session.down.sql | 1 + .../20261015155831_ai_shared_session.up.sql | 30 ++ backend/summarized_schema.txt | 1 + backend/tests/ai_shared_sessions.rs | 120 ++++++ backend/windmill-api/openapi.yaml | 187 +++++++++ backend/windmill-api/src/ai.rs | 4 + .../windmill-api/src/ai_shared_sessions.rs | 368 ++++++++++++++++++ backend/windmill-api/src/lib.rs | 5 + .../copilot/chat/AssistantMessage.svelte | 3 +- .../components/copilot/chat/chatViewHost.ts | 3 + .../chat/readOnlyChatViewHost.svelte.ts | 85 ++++ .../copilot/chat/script/CodeDisplay.svelte | 4 +- .../sessions/SessionShareButton.svelte | 231 +++++++++++ .../components/sessions/SessionWrapper.svelte | 8 + .../sessions/SharedSessionTranscript.svelte | 121 ++++++ .../sessions/sessionSharing.test.ts | 59 +++ .../lib/components/sessions/sessionSharing.ts | 188 +++++++++ .../[workspace]/[id]/+page.svelte | 157 ++++++++ 23 files changed, 1819 insertions(+), 2 deletions(-) create mode 100644 backend/.sqlx/query-7cc9d80bc4c77c996521a7c82ca42162e96499987b17a34bde7bc32d54e6260e.json create mode 100644 backend/.sqlx/query-8c35d87481de9eb1496d47e78660a57fd10fc5b32f4e68ab7a217d85264623b6.json create mode 100644 backend/.sqlx/query-c8e3e103645e0428d4b784f200595309a55635083a541745552c397a36ac08c4.json create mode 100644 backend/.sqlx/query-cb421ceb68ab798bde4192a8da9a133ea859f243f518197cde803ef0c86eab33.json create mode 100644 backend/.sqlx/query-edce6243bcdfbb6380ae2b6d8a63a863c3f265ea043cc653f9732bb87860b8d9.json create mode 100644 backend/migrations/20261015155831_ai_shared_session.down.sql create mode 100644 backend/migrations/20261015155831_ai_shared_session.up.sql create mode 100644 backend/tests/ai_shared_sessions.rs create mode 100644 backend/windmill-api/src/ai_shared_sessions.rs create mode 100644 frontend/src/lib/components/copilot/chat/readOnlyChatViewHost.svelte.ts create mode 100644 frontend/src/lib/components/sessions/SessionShareButton.svelte create mode 100644 frontend/src/lib/components/sessions/SharedSessionTranscript.svelte create mode 100644 frontend/src/lib/components/sessions/sessionSharing.test.ts create mode 100644 frontend/src/lib/components/sessions/sessionSharing.ts create mode 100644 frontend/src/routes/(styled)/shared_sessions/[workspace]/[id]/+page.svelte diff --git a/backend/.sqlx/query-7cc9d80bc4c77c996521a7c82ca42162e96499987b17a34bde7bc32d54e6260e.json b/backend/.sqlx/query-7cc9d80bc4c77c996521a7c82ca42162e96499987b17a34bde7bc32d54e6260e.json new file mode 100644 index 0000000000..6af78adde7 --- /dev/null +++ b/backend/.sqlx/query-7cc9d80bc4c77c996521a7c82ca42162e96499987b17a34bde7bc32d54e6260e.json @@ -0,0 +1,55 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE ai_shared_session SET is_public = $4\n WHERE workspace_id = $1 AND id = $2 AND email = $3\n RETURNING id, name, is_public, message_count, created_by, shared_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_public", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "message_count", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "shared_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid", + "Text", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "7cc9d80bc4c77c996521a7c82ca42162e96499987b17a34bde7bc32d54e6260e" +} diff --git a/backend/.sqlx/query-8c35d87481de9eb1496d47e78660a57fd10fc5b32f4e68ab7a217d85264623b6.json b/backend/.sqlx/query-8c35d87481de9eb1496d47e78660a57fd10fc5b32f4e68ab7a217d85264623b6.json new file mode 100644 index 0000000000..7e4de24f7e --- /dev/null +++ b/backend/.sqlx/query-8c35d87481de9eb1496d47e78660a57fd10fc5b32f4e68ab7a217d85264623b6.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ai_shared_session\n (workspace_id, session_id, email, created_by, name, is_public, message_count, snapshot)\n VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), $7, $8)\n ON CONFLICT (workspace_id, email, session_id) DO UPDATE\n SET created_by = EXCLUDED.created_by,\n name = EXCLUDED.name,\n is_public = COALESCE($6, ai_shared_session.is_public),\n message_count = EXCLUDED.message_count,\n snapshot = EXCLUDED.snapshot,\n shared_at = now()\n RETURNING id, is_public, shared_at, (xmax = 0) AS \"inserted!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "is_public", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "shared_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "inserted!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Int4", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + null + ] + }, + "hash": "8c35d87481de9eb1496d47e78660a57fd10fc5b32f4e68ab7a217d85264623b6" +} diff --git a/backend/.sqlx/query-c8e3e103645e0428d4b784f200595309a55635083a541745552c397a36ac08c4.json b/backend/.sqlx/query-c8e3e103645e0428d4b784f200595309a55635083a541745552c397a36ac08c4.json new file mode 100644 index 0000000000..e66cfa2881 --- /dev/null +++ b/backend/.sqlx/query-c8e3e103645e0428d4b784f200595309a55635083a541745552c397a36ac08c4.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, is_public, message_count, created_by, shared_at FROM ai_shared_session\n WHERE workspace_id = $1 AND email = $2 AND session_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_public", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "message_count", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "shared_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "c8e3e103645e0428d4b784f200595309a55635083a541745552c397a36ac08c4" +} diff --git a/backend/.sqlx/query-cb421ceb68ab798bde4192a8da9a133ea859f243f518197cde803ef0c86eab33.json b/backend/.sqlx/query-cb421ceb68ab798bde4192a8da9a133ea859f243f518197cde803ef0c86eab33.json new file mode 100644 index 0000000000..73fbc88289 --- /dev/null +++ b/backend/.sqlx/query-cb421ceb68ab798bde4192a8da9a133ea859f243f518197cde803ef0c86eab33.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM ai_shared_session\n WHERE workspace_id = $1 AND id = $2 AND (email = $3 OR $4::bool)\n RETURNING name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid", + "Text", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cb421ceb68ab798bde4192a8da9a133ea859f243f518197cde803ef0c86eab33" +} diff --git a/backend/.sqlx/query-edce6243bcdfbb6380ae2b6d8a63a863c3f265ea043cc653f9732bb87860b8d9.json b/backend/.sqlx/query-edce6243bcdfbb6380ae2b6d8a63a863c3f265ea043cc653f9732bb87860b8d9.json new file mode 100644 index 0000000000..94b5ba8f10 --- /dev/null +++ b/backend/.sqlx/query-edce6243bcdfbb6380ae2b6d8a63a863c3f265ea043cc653f9732bb87860b8d9.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, email, name, is_public, message_count, created_by, snapshot, shared_at\n FROM ai_shared_session\n WHERE workspace_id = $1 AND id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_public", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "message_count", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "snapshot", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "shared_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "edce6243bcdfbb6380ae2b6d8a63a863c3f265ea043cc653f9732bb87860b8d9" +} diff --git a/backend/migrations/20261015155831_ai_shared_session.down.sql b/backend/migrations/20261015155831_ai_shared_session.down.sql new file mode 100644 index 0000000000..bf6a32d4de --- /dev/null +++ b/backend/migrations/20261015155831_ai_shared_session.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ai_shared_session; diff --git a/backend/migrations/20261015155831_ai_shared_session.up.sql b/backend/migrations/20261015155831_ai_shared_session.up.sql new file mode 100644 index 0000000000..37e8939c3b --- /dev/null +++ b/backend/migrations/20261015155831_ai_shared_session.up.sql @@ -0,0 +1,30 @@ +-- A read-only snapshot of an AI session transcript that its author shared by link. Sessions +-- otherwise live only in the author's browser; this row exists only while the share does. +-- Non-public shares are readable by members of the workspace, public ones by anyone with the +-- link, which is why `id` is a random UUID and never derived from the session. +CREATE TABLE ai_shared_session ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + -- The browser-side session id. Unique per author so sharing the same session again + -- moves its one link forward rather than minting a second one. + session_id VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + created_by VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + is_public BOOLEAN NOT NULL DEFAULT false, + message_count INTEGER NOT NULL, + -- JSON kept as text: jsonb refuses the \u0000 escape a transcript can carry. + snapshot TEXT NOT NULL, + shared_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workspace_id, email, session_id) +); + +GRANT ALL ON ai_shared_session TO windmill_admin; +GRANT ALL ON ai_shared_session TO windmill_user; + +-- The handlers go through the raw pool and scope every query to the workspace themselves. +-- An admin-only policy is the backstop for a future query that reaches this table through +-- UserDB. +ALTER TABLE ai_shared_session ENABLE ROW LEVEL SECURITY; + +CREATE POLICY admin_policy ON ai_shared_session FOR ALL TO windmill_admin USING (true); diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 471a7251b3..43b3d591ea 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -41,6 +41,7 @@ ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messa ai_free_token_daily_usage: day(date), cost_nanos(bigint), updated_at(ts) ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts) ai_shared_artifact: id(uuid), workspace_id(char), artifact_id(char), email(char), created_by(char), name(char), kind(char), version(int), content(text), shared_at(ts) +ai_shared_session: id(uuid), workspace_id(char), session_id(char), email(char), created_by(char), name(char), is_public(bool), message_count(int), snapshot(text), shared_at(ts) FK: (workspace_id) -> workspace(id) ai_token_usage: workspace_id(char), day(date), email(char), provider(char), model(char), session_id(char), input_tokens(bigint), cache_read_tokens(bigint), cache_write_tokens(bigint), output_tokens(bigint), reported_cost_nano_usd(bigint), requests(bigint), updated_at(ts) FK: (workspace_id) -> workspace(id) diff --git a/backend/tests/ai_shared_sessions.rs b/backend/tests/ai_shared_sessions.rs new file mode 100644 index 0000000000..75334a5440 --- /dev/null +++ b/backend/tests/ai_shared_sessions.rs @@ -0,0 +1,120 @@ +//! Shared AI sessions: a private share is served only to members of its workspace, a public +//! one to anyone holding the link, and only its author decides which it is. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const ADMIN: &str = "Bearer SECRET_TOKEN"; +const MEMBER: &str = "Bearer SECRET_TOKEN_2"; +const OTHER_MEMBER: &str = "Bearer SECRET_TOKEN_3"; +const OUTSIDER: &str = "Bearer OUTSIDER_TOKEN"; + +#[sqlx::test(fixtures("base"))] +async fn private_share_is_for_members_and_public_share_for_anyone( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + sqlx::raw_sql( + "INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('outsider@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Outsider'); + INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('OUTSIDER_TOKEN'::bytea), 'hex'), 'OUTSIDER_T', 'OUTSIDER_TOKEN', + 'outsider@windmill.dev', 'outsider token', false);", + ) + .execute(&db) + .await?; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let authed = format!("http://localhost:{port}/api/w/test-workspace/ai/shared_sessions"); + let get = |id: &str| { + format!("http://localhost:{port}/api/w/test-workspace/ai_u/shared_sessions/get/{id}") + }; + let client = reqwest::Client::new(); + + let resp = client + .post(format!("{authed}/share")) + .header("Authorization", MEMBER) + .json(&json!({ + "session_id": "session-1", + "name": "Debugging", + "message_count": 1, + "snapshot": { "messages": [{ "role": "user", "content": "hi" }], "artifacts": [] }, + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let share: Value = resp.json().await?; + assert_eq!(share["is_public"], false, "a new share must start private"); + let id = share["id"].as_str().unwrap().to_string(); + + let status = |token: Option<&'static str>| { + let mut req = client.get(get(&id)); + if let Some(token) = token { + req = req.header("Authorization", token); + } + async move { anyhow::Ok(req.send().await?.status().as_u16()) } + }; + + assert_eq!(status(Some(OTHER_MEMBER)).await?, 200); + assert_eq!( + status(None).await?, + 401, + "a private share was served anonymously" + ); + assert_eq!( + status(Some(OUTSIDER)).await?, + 401, + "a private share was served to a non-member" + ); + + let resp = client + .post(format!("{authed}/set_public/{id}")) + .header("Authorization", OTHER_MEMBER) + .json(&json!({ "is_public": true })) + .send() + .await?; + assert_eq!( + resp.status(), + 404, + "a member who is not the author made the share public" + ); + + let resp = client + .post(format!("{authed}/set_public/{id}")) + .header("Authorization", MEMBER) + .json(&json!({ "is_public": true })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let resp = client.get(get(&id)).send().await?; + assert_eq!( + resp.status(), + 200, + "a public share was not served anonymously" + ); + let body: Value = resp.json().await?; + assert_eq!(body["snapshot"]["messages"][0]["content"], "hi"); + assert_eq!(body["can_unshare"], false); + + let delete = |token: &'static str| { + client + .delete(format!("{authed}/delete/{id}")) + .header("Authorization", token) + .send() + }; + assert_eq!( + delete(OTHER_MEMBER).await?.status(), + 404, + "a member who is neither the author nor an admin took the share down" + ); + assert_eq!( + delete(ADMIN).await?.status(), + 200, + "an admin could not take the share down" + ); + assert_eq!(status(Some(OTHER_MEMBER)).await?, 404); + + Ok(()) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d56115d9b0..cedc691eb7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -14677,6 +14677,168 @@ paths: schema: type: string + /w/{workspace}/ai/shared_sessions/share: + post: + summary: share an AI session transcript by link + description: > + Stores a read-only snapshot of the session that members of the workspace can open by + id, or anyone with the link once it is public. Sharing the same session again updates + that snapshot and keeps its id. + operationId: shareAiSession + tags: + - ai + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - session_id + - name + - message_count + - snapshot + properties: + session_id: + type: string + description: the session's id in the author's browser + name: + type: string + is_public: + type: boolean + description: left as it is on an existing share when absent; a new share starts private + message_count: + type: integer + minimum: 0 + snapshot: + type: object + additionalProperties: true + responses: + "200": + description: the shared snapshot + content: + application/json: + schema: + $ref: "#/components/schemas/SharedAiSessionInfo" + + /w/{workspace}/ai/shared_sessions/status: + get: + summary: get the calling user's share of one of their AI sessions + operationId: getAiSessionShareStatus + tags: + - ai + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: session_id + in: query + required: true + schema: + type: string + responses: + "200": + description: the share, if any + content: + application/json: + schema: + type: object + properties: + share: + $ref: "#/components/schemas/SharedAiSessionInfo" + + /w/{workspace}/ai/shared_sessions/set_public/{id}: + post: + summary: make a shared AI session public, or reserve it to workspace members + operationId: setAiSessionSharePublic + tags: + - ai + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - is_public + properties: + is_public: + type: boolean + responses: + "200": + description: the updated share + content: + application/json: + schema: + $ref: "#/components/schemas/SharedAiSessionInfo" + + /w/{workspace}/ai_u/shared_sessions/get/{id}: + get: + summary: get a shared AI session + description: > + A public share is served without authentication; any other one only to a member of + the workspace. + operationId: getSharedAiSession + tags: + - ai + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: the shared session + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/SharedAiSessionInfo" + - type: object + required: + - snapshot + - can_unshare + properties: + snapshot: + type: object + additionalProperties: true + can_unshare: + type: boolean + description: whether the caller authored the share or is a workspace admin + + /w/{workspace}/ai/shared_sessions/delete/{id}: + delete: + summary: stop sharing an AI session + operationId: unshareAiSession + tags: + - ai + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: share deleted + content: + text/plain: + schema: + type: string + /w/{workspace}/apps/get_data/v/{secretWithExtension}: get: summary: get raw app data by @@ -30256,6 +30418,31 @@ components: - shared_at - expires_at + SharedAiSessionInfo: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + is_public: + type: boolean + message_count: + type: integer + created_by: + type: string + shared_at: + type: string + format: date-time + required: + - id + - name + - is_public + - message_count + - created_by + - shared_at + InstanceAIProviderSummary: type: object properties: diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index cf7447ceba..fc7a20c57f 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -713,6 +713,10 @@ pub fn workspaced_service() -> Router { .nest( "/shared_artifacts", crate::ai_shared_artifacts::workspaced_service(), + ) + .nest( + "/shared_sessions", + crate::ai_shared_sessions::workspaced_service(), ); #[cfg(feature = "bedrock")] diff --git a/backend/windmill-api/src/ai_shared_sessions.rs b/backend/windmill-api/src/ai_shared_sessions.rs new file mode 100644 index 0000000000..c6554305db --- /dev/null +++ b/backend/windmill-api/src/ai_shared_sessions.rs @@ -0,0 +1,368 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2026 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Read-only snapshots of AI session transcripts, shared by their author. +//! +//! Sessions live in the author's browser; a row here exists only because the author asked for +//! a link. A share is readable by the members of its workspace, or by anyone holding the link +//! once its author makes it public. The snapshot is whatever the author's browser sent, so the +//! viewer treats it as untrusted data. + +use crate::db::{ApiAuthed, DB}; +use axum::{ + extract::{DefaultBodyLimit, Extension, Json, Path, Query}, + routing::{delete, get, post}, + Router, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; +use uuid::Uuid; +use windmill_api_auth::{is_effectively_unscoped, OptAuthed}; +use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::error::{Error, JsonResult, Result}; + +const MAX_SNAPSHOT_BYTES: usize = 10 * 1024 * 1024; +const MAX_NAME_CHARS: usize = 255; +const MAX_SESSION_ID_CHARS: usize = 255; +const SHARE_BODY_LIMIT: usize = MAX_SNAPSHOT_BYTES + 64 * 1024; + +pub fn workspaced_service() -> Router { + Router::new() + .route( + "/share", + post(share_session).layer(DefaultBodyLimit::max(SHARE_BODY_LIMIT)), + ) + .route("/status", get(get_share_status)) + .route("/set_public/{id}", post(set_public)) + .route("/delete/{id}", delete(unshare_session)) +} + +/// Mounted outside the authenticated router: a public share is served to anyone. +pub fn workspaced_unauthed_service() -> Router { + Router::new().route("/get/{id}", get(get_shared_session)) +} + +#[derive(Deserialize)] +struct ShareSession { + session_id: String, + name: String, + /// Left as it is on an existing share when absent; a new share starts private. + is_public: Option, + message_count: i32, + snapshot: Box, +} + +#[derive(Serialize)] +struct SharedSessionInfo { + id: Uuid, + name: String, + is_public: bool, + message_count: i32, + created_by: String, + shared_at: DateTime, +} + +#[derive(Serialize)] +struct SharedSession { + #[serde(flatten)] + info: SharedSessionInfo, + snapshot: Box, + /// Whether the caller may stop sharing it: its author, or a workspace admin. + can_unshare: bool, +} + +#[derive(Serialize)] +struct ShareStatus { + #[serde(skip_serializing_if = "Option::is_none")] + share: Option, +} + +#[derive(Deserialize)] +struct ShareStatusQuery { + session_id: String, +} + +#[derive(Deserialize)] +struct SetPublic { + is_public: bool, +} + +/// Sharing exposes the author's own browser state, so only the author's own unscoped token may +/// do it: a job token can carry an `on_behalf_of` identity, and every scoped token (guest, +/// embed, app policy, MCP) was minted for something narrower. +fn require_plain_user_token(authed: &ApiAuthed) -> Result<()> { + if authed.job_id.is_some() || !is_effectively_unscoped(authed.scopes.as_deref()) { + return Err(Error::PermissionDenied( + "Shared AI sessions are only reachable with an unscoped user token".to_string(), + )); + } + Ok(()) +} + +/// It is compared against a `VARCHAR(255)` column, and Postgres answers a NUL in a text +/// parameter with an opaque 500. +fn check_session_id(session_id: &str) -> Result<()> { + if session_id.is_empty() || session_id.chars().count() > MAX_SESSION_ID_CHARS { + return Err(Error::BadRequest(format!( + "Session id must be between 1 and {MAX_SESSION_ID_CHARS} characters" + ))); + } + if session_id.contains('\0') { + return Err(Error::BadRequest( + "Session id cannot contain NUL characters".to_string(), + )); + } + Ok(()) +} + +/// Share a session, or move the caller's existing link for it to this snapshot. +async fn share_session( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(payload): Json, +) -> JsonResult { + require_plain_user_token(&authed)?; + check_session_id(&payload.session_id)?; + let name = payload.name.trim(); + if name.is_empty() || name.chars().count() > MAX_NAME_CHARS || name.contains('\0') { + return Err(Error::BadRequest(format!( + "Session name must be between 1 and {MAX_NAME_CHARS} characters, without NUL" + ))); + } + let snapshot = payload.snapshot.get(); + if snapshot.len() > MAX_SNAPSHOT_BYTES { + return Err(Error::BadRequest(format!( + "Session snapshot is {} bytes, above the {MAX_SNAPSHOT_BYTES} byte limit", + snapshot.len() + ))); + } + if !snapshot.trim_start().starts_with('{') { + return Err(Error::BadRequest( + "Session snapshot must be a JSON object".to_string(), + )); + } + if payload.message_count < 0 { + return Err(Error::BadRequest( + "Message count cannot be negative".to_string(), + )); + } + + let mut tx = db.begin().await?; + let row = sqlx::query!( + r#"INSERT INTO ai_shared_session + (workspace_id, session_id, email, created_by, name, is_public, message_count, snapshot) + VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), $7, $8) + ON CONFLICT (workspace_id, email, session_id) DO UPDATE + SET created_by = EXCLUDED.created_by, + name = EXCLUDED.name, + is_public = COALESCE($6, ai_shared_session.is_public), + message_count = EXCLUDED.message_count, + snapshot = EXCLUDED.snapshot, + shared_at = now() + RETURNING id, is_public, shared_at, (xmax = 0) AS "inserted!""#, + &w_id, + &payload.session_id, + &authed.email, + &authed.username, + name, + payload.is_public, + payload.message_count, + snapshot, + ) + .fetch_one(&mut *tx) + .await?; + + let id = row.id.to_string(); + let public = row.is_public.to_string(); + audit_log( + &mut *tx, + &authed, + "ai.shared_sessions.share", + if row.inserted { + ActionKind::Create + } else { + ActionKind::Update + }, + &w_id, + Some(&id), + Some([("name", name), ("public", public.as_str())].into()), + ) + .await?; + tx.commit().await?; + + Ok(Json(SharedSessionInfo { + id: row.id, + name: name.to_string(), + is_public: row.is_public, + message_count: payload.message_count, + created_by: authed.username.clone(), + shared_at: row.shared_at, + })) +} + +/// The caller's own share of one of their sessions, if any. +async fn get_share_status( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Query(query): Query, +) -> JsonResult { + require_plain_user_token(&authed)?; + check_session_id(&query.session_id)?; + let share = sqlx::query_as!( + SharedSessionInfo, + "SELECT id, name, is_public, message_count, created_by, shared_at FROM ai_shared_session + WHERE workspace_id = $1 AND email = $2 AND session_id = $3", + &w_id, + &authed.email, + &query.session_id, + ) + .fetch_optional(&db) + .await?; + + Ok(Json(ShareStatus { share })) +} + +/// Only the author changes who can open their link; an admin may only take it down. +async fn set_public( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, + Json(payload): Json, +) -> JsonResult { + require_plain_user_token(&authed)?; + let mut tx = db.begin().await?; + let info = sqlx::query_as!( + SharedSessionInfo, + "UPDATE ai_shared_session SET is_public = $4 + WHERE workspace_id = $1 AND id = $2 AND email = $3 + RETURNING id, name, is_public, message_count, created_by, shared_at", + &w_id, + id, + &authed.email, + payload.is_public, + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + Error::NotFound(format!( + "Shared session {id} not found, or not shared by you" + )) + })?; + + let id_str = id.to_string(); + let public = info.is_public.to_string(); + audit_log( + &mut *tx, + &authed, + "ai.shared_sessions.set_public", + ActionKind::Update, + &w_id, + Some(&id_str), + Some([("name", info.name.as_str()), ("public", public.as_str())].into()), + ) + .await?; + tx.commit().await?; + + Ok(Json(info)) +} + +/// A public share is served to anyone with the link; any other one only to a member of its +/// workspace, which is what an `ApiAuthed` resolved on a `/w/{workspace_id}` path is. +async fn get_shared_session( + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> JsonResult { + let row = sqlx::query!( + "SELECT id, email, name, is_public, message_count, created_by, snapshot, shared_at + FROM ai_shared_session + WHERE workspace_id = $1 AND id = $2", + &w_id, + id, + ) + .fetch_optional(&db) + .await?; + + let not_found = || { + Error::NotFound(format!( + "Shared session {id} not found: it may have been unshared, or be visible only to members of {w_id}" + )) + }; + let member = opt_authed + .as_ref() + .filter(|a| a.job_id.is_none() && is_effectively_unscoped(a.scopes.as_deref())); + let row = match row { + Some(row) if row.is_public || member.is_some() => row, + // A missing share and a private one answer a stranger alike, so the link does not + // confirm that a private share exists. + _ if member.is_none() => { + return Err(Error::NotAuthorized(format!( + "Shared session {id} is not public: sign in as a member of {w_id} to open it" + ))) + } + _ => return Err(not_found()), + }; + + Ok(Json(SharedSession { + can_unshare: member.is_some_and(|a| row.email == a.email || a.is_admin), + snapshot: RawValue::from_string(row.snapshot).map_err(|e| { + Error::internal_err(format!("Stored session snapshot is not valid JSON: {e}")) + })?, + info: SharedSessionInfo { + id: row.id, + name: row.name, + is_public: row.is_public, + message_count: row.message_count, + created_by: row.created_by, + shared_at: row.shared_at, + }, + })) +} + +async fn unshare_session( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> Result { + require_plain_user_token(&authed)?; + let mut tx = db.begin().await?; + let name = sqlx::query_scalar!( + "DELETE FROM ai_shared_session + WHERE workspace_id = $1 AND id = $2 AND (email = $3 OR $4::bool) + RETURNING name", + &w_id, + id, + &authed.email, + authed.is_admin, + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + Error::NotFound(format!( + "Shared session {id} not found, or not shared by you" + )) + })?; + + let id_str = id.to_string(); + audit_log( + &mut *tx, + &authed, + "ai.shared_sessions.unshare", + ActionKind::Delete, + &w_id, + Some(&id_str), + Some([("name", name.as_str())].into()), + ) + .await?; + tx.commit().await?; + + Ok(format!("Stopped sharing {name}")) +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 9163256997..732675e78f 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -77,6 +77,7 @@ mod ai_sessions; #[cfg(feature = "parquet")] pub use ai_sessions::sweep_expired_ai_session_backups; mod ai_shared_artifacts; +mod ai_shared_sessions; mod apps; mod apps_raw_bundle; pub use apps::invalidate_app_policy_cache; @@ -924,6 +925,10 @@ pub async fn run_server( .layer(from_extractor::()) .layer(cors.clone()), ) + .nest( + "/w/{workspace_id}/ai_u/shared_sessions", + ai_shared_sessions::workspaced_unauthed_service(), + ) .layer(from_extractor::()) // Deprecated, here for backwards compatibility: user should use /mcp/w/{workspace_id}/mcp instead .nest( diff --git a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte index 3050dcedf3..ce931213b5 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte @@ -98,6 +98,7 @@ }) const candidatePaths = $derived(extractCandidatePaths(message.content)) + const reasoningPlugins = [gfmPlugin(), { renderer: { a: LinkRenderer } }] const rendererPlugin = { renderer: { pre: CodeDisplay, @@ -158,7 +159,7 @@ labelClass="truncate" contentClass="font-main text-secondary {markdownProse.xs}" > - + {/if} diff --git a/frontend/src/lib/components/copilot/chat/chatViewHost.ts b/frontend/src/lib/components/copilot/chat/chatViewHost.ts index bdacb57615..197b831e7e 100644 --- a/frontend/src/lib/components/copilot/chat/chatViewHost.ts +++ b/frontend/src/lib/components/copilot/chat/chatViewHost.ts @@ -114,6 +114,9 @@ export interface ChatViewHost { // of their own; the chrome they drive hides itself. mode?: AIMode isSessionChat: boolean + /** Someone else's transcript, replayed for a reader who did not write it: markup that + * would execute or render beyond plain text (mermaid diagrams) stays off. */ + untrustedTranscript?: boolean /** Model + reasoning picker. Off where the model is configured elsewhere. */ supportsModelSettings: boolean /** Click a user message to edit and resend it. Needs a host that can rewind diff --git a/frontend/src/lib/components/copilot/chat/readOnlyChatViewHost.svelte.ts b/frontend/src/lib/components/copilot/chat/readOnlyChatViewHost.svelte.ts new file mode 100644 index 0000000000..1e22903e9b --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/readOnlyChatViewHost.svelte.ts @@ -0,0 +1,85 @@ +import { AIAutonomyMode } from './AIChatManager.svelte' +import type { ChatViewHost } from './chatViewHost' +import type { DisplayMessage } from './shared' +import { AttachedFilesStore } from './files/attachedFiles.svelte' +import { SessionArtifactsStore } from './artifacts/artifactsState.svelte' +import type { PersistedArtifact } from './artifacts/artifactsDB' + +const noop = () => {} + +/** + * A host over a finished transcript nobody can continue: every affordance that would send, + * edit, retry or answer is off, so the chat components render it as a record. + */ +export function readOnlyChatViewHost(options: { + messages: DisplayMessage[] + workspace: string + artifacts: PersistedArtifact[] + openArtifact: (artifactId: string) => void +}): ChatViewHost { + const artifacts = new SessionArtifactsStore() + artifacts.artifacts = options.artifacts + return { + displayMessages: options.messages, + messages: [], + contextTokens: 0, + operatingWorkspace: options.workspace, + loading: false, + sendPending: false, + runHeldElsewhere: false, + loadingLabel: undefined, + compacting: false, + currentReply: '', + currentReasoning: '', + currentReasoningActive: false, + reasoningHiddenIndicatorLabel: undefined, + automaticScroll: false, + enableAutomaticScroll: noop, + disableAutomaticScroll: noop, + + instructions: '', + sendInFlight: false, + sendRequest: async () => false, + cancel: noop, + setAiChatInput: noop, + queuedMessage: '', + queuedContext: undefined, + queuedImages: [], + queuedFiles: [], + queuedBlobs: [], + queueMessage: noop, + dequeueMessage: noop, + setComposerStaged: noop, + setComposerHasDraft: noop, + clearComposerStaged: noop, + attachmentBytesExcluding: () => 0, + + storedImages: () => undefined, + retryRequest: noop, + restartGeneration: noop, + handleUserQuestionAnswer: () => false, + handleToolConfirmation: noop, + hasPendingRunForm: false, + isRunFormPending: () => false, + + isSessionChat: true, + untrustedTranscript: true, + supportsModelSettings: false, + supportsMessageEditing: false, + supportsMessageAttachments: false, + requiresMessageText: false, + supportsLinkedFolders: false, + attachmentAccept: '', + attachmentsAsBlobs: false, + tools: [], + autonomyMode: AIAutonomyMode.DEFAULT, + setAutonomyMode: noop, + autoAcceptEditsActive: false, + autoAcceptEditsAvailable: false, + autoAcceptToolConfirmationsAvailable: false, + planModeAvailable: false, + attachedFiles: new AttachedFilesStore(), + artifacts, + openArtifact: (artifactId) => options.openArtifact(artifactId) + } +} diff --git a/frontend/src/lib/components/copilot/chat/script/CodeDisplay.svelte b/frontend/src/lib/components/copilot/chat/script/CodeDisplay.svelte index 2be52a32d2..e83c163302 100644 --- a/frontend/src/lib/components/copilot/chat/script/CodeDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/script/CodeDisplay.svelte @@ -3,11 +3,13 @@ import MarkdownCodeBlock from '$lib/components/MarkdownCodeBlock.svelte' import { AIMode } from '../AIChatManager.svelte' import { getAiChatManager } from '../aiChatManagerContext' + import { getChatViewHost } from '../chatViewHost' import { Check, Play } from 'lucide-svelte' // Chat `pre` renderer: the shared MarkdownCodeBlock plus the chat-only Apply // button (wired to the script editor) and mermaid diagrams. const aiChatManager = getAiChatManager() + const chatHost = getChatViewHost() const astNode = getAstNode() let code = $derived(astNode.current.children?.[0]?.children?.[0]?.value) @@ -34,5 +36,5 @@ {showApplyButton} onApplyCode={handleApplyCode} applyButtonIcon={aiChatManager.pendingNewCode ? Check : Play} - renderMermaid + renderMermaid={!chatHost.untrustedTranscript} /> diff --git a/frontend/src/lib/components/sessions/SessionShareButton.svelte b/frontend/src/lib/components/sessions/SessionShareButton.svelte new file mode 100644 index 0000000000..59f2fd19cb --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionShareButton.svelte @@ -0,0 +1,231 @@ + + + + {#snippet trigger()} + + + +
+ +
+ {:else} +
+ +
+ {/if} + + {/snippet} +
diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index 703a18967b..05a0cdda41 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -26,6 +26,7 @@ } from 'lucide-svelte' import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' import SessionChangesBar from './SessionChangesBar.svelte' + import SessionShareButton from './SessionShareButton.svelte' import { clearSessionRecovered, isSessionRecovered } from './sessionRecoveryNotice.svelte' import { composerFocusRequest, @@ -432,6 +433,13 @@ {/snippet} + + {/snippet} {#if headerInPage} diff --git a/frontend/src/lib/components/sessions/SharedSessionTranscript.svelte b/frontend/src/lib/components/sessions/SharedSessionTranscript.svelte new file mode 100644 index 0000000000..19239fe48c --- /dev/null +++ b/frontend/src/lib/components/sessions/SharedSessionTranscript.svelte @@ -0,0 +1,121 @@ + + +{#snippet messageRow(message: DisplayMessage, messageIndex: number, inGroup: boolean)} + +{/snippet} +{#snippet groupRow(message: DisplayMessage, messageIndex: number)} + {@render messageRow(message, messageIndex, true)} +{/snippet} + +{#if snapshot.artifacts.length > 0} + +
+ Artifacts + {#each snapshot.artifacts as artifact (artifact.id)} + + {/each} +
+{/if} + +
+ {#each chatItems as item (chatItemKey(item))} + {#if item.kind === 'group'} +
+
+ +
+
+ {:else} + {@render messageRow(item.message, item.index, false)} + {/if} + {/each} +
+ + + drawer?.closeDrawer()}> + {#if openArtifact} + + {:else} + This artifact was not part of the share. + {/if} + + diff --git a/frontend/src/lib/components/sessions/sessionSharing.test.ts b/frontend/src/lib/components/sessions/sessionSharing.test.ts new file mode 100644 index 0000000000..a7720666fa --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionSharing.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { readSessionSnapshot } from './sessionSharing' + +describe('readSessionSnapshot', () => { + it('keeps only inline images and the rows and artifacts the viewer can render', () => { + const snapshot = readSessionSnapshot({ + messages: [ + { + role: 'user', + content: 'hi', + images: [ + { dataUrl: 'https://tracker.example/u.png', mediaType: 'image/png' }, + { dataUrl: 'data:image/png;base64,AAAA', mediaType: 'image/png' } + ] + }, + { role: 'tool', content: 'shot', imageUrl: 'https://tracker.example/t.png' }, + { role: 'tool', content: 'shot', imageUrl: 'data:image/png;base64,BBBB' }, + { + role: 'tool', + content: 'Run', + isLoading: true, + needsConfirmation: true, + runForm: { kind: 'test', code: 'export async function main() {}' }, + userQuestion: { question: 'ok?' }, + actions: [{ label: 'Open' }], + inspectedRun: { jobId: 'j', workspace: 'w', runId: 'j' } + }, + { role: 'system', content: 'not a chat row' }, + 'not an object' + ], + artifacts: [ + { id: 'a', name: 'Plan', kind: 'md', content: '# Plan' }, + { id: 'b', name: 'Bad', kind: 'svg', content: '' } + ] + }) + + expect(snapshot.messages.map((m) => m.role)).toEqual(['user', 'tool', 'tool', 'tool']) + expect(snapshot.messages[0]).toMatchObject({ + images: [{ dataUrl: 'data:image/png;base64,AAAA' }] + }) + expect((snapshot.messages[1] as { imageUrl?: string }).imageUrl).toBeUndefined() + expect(snapshot.messages[2]).toMatchObject({ imageUrl: 'data:image/png;base64,BBBB' }) + // Every affordance that would act as the reader is gone, pending state included. + expect(snapshot.messages[3]).toEqual({ + role: 'tool', + tool_call_id: 'shared-3', + content: 'Run', + showDetails: false, + blockedByPlanMode: false, + declinedByUser: false + }) + expect(snapshot.artifacts.map((a) => a.id)).toEqual(['a']) + }) + + it('reads a malformed snapshot as an empty one', () => { + expect(readSessionSnapshot('nope')).toEqual({ messages: [], artifacts: [] }) + }) +}) diff --git a/frontend/src/lib/components/sessions/sessionSharing.ts b/frontend/src/lib/components/sessions/sessionSharing.ts new file mode 100644 index 0000000000..3ecbe214e9 --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionSharing.ts @@ -0,0 +1,188 @@ +import type { DisplayMessage } from '$lib/components/copilot/chat/shared' +import type { ImageMediaType } from '$lib/components/copilot/chat/imageUtils' +import type { + ArtifactKind, + PersistedArtifact +} from '$lib/components/copilot/chat/artifacts/artifactsDB' + +/** The server's `MAX_SNAPSHOT_BYTES`. */ +export const MAX_SNAPSHOT_BYTES = 10 * 1024 * 1024 + +export type SharedSessionArtifact = { + id: string + name: string + kind: ArtifactKind + content: string + role?: 'plan' +} + +export type SessionSnapshot = { + messages: DisplayMessage[] + artifacts: SharedSessionArtifact[] +} + +export function sharedSessionUrl(workspaceId: string, shareId: string): string { + return `${window.location.origin}/shared_sessions/${encodeURIComponent( + workspaceId + )}/${encodeURIComponent(shareId)}` +} + +/** A row as it reads once the turn is over: the viewer can neither wait on nor answer it. */ +function settled(message: DisplayMessage, keepImages: boolean): DisplayMessage { + switch (message.role) { + case 'tool': + return { + ...message, + isLoading: false, + isQueued: false, + isStreamingArguments: false, + needsConfirmation: false, + resultStream: undefined + } + case 'assistant': + return { ...message, streaming: false } + case 'user': + return keepImages ? message : { ...message, images: undefined } + default: + return message + } +} + +/** + * The snapshot a share carries. Images go first when the whole would not fit: the + * transcript is what the link is for. + */ +export function buildSessionSnapshot( + messages: DisplayMessage[], + artifacts: PersistedArtifact[] +): SessionSnapshot { + const sharedArtifacts: SharedSessionArtifact[] = artifacts.map((a) => ({ + id: a.id, + name: a.name, + kind: a.kind, + content: a.content, + role: a.role + })) + for (const keepImages of [true, false]) { + const snapshot: SessionSnapshot = { + messages: messages.map((m) => settled(m, keepImages)), + artifacts: sharedArtifacts + } + if (new TextEncoder().encode(JSON.stringify(snapshot)).length <= MAX_SNAPSHOT_BYTES) { + return snapshot + } + } + throw new Error( + `the session is larger than the ${MAX_SNAPSHOT_BYTES / 1024 / 1024} MB a share can hold` + ) +} + +function isInlineImage(url: unknown): url is string { + return typeof url === 'string' && url.startsWith('data:image/') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function str(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function records(value: unknown): Record[] { + return Array.isArray(value) ? value.filter(isRecord) : [] +} + +/** + * A row rebuilt from the fields that only display. Everything else the live chat carries on a + * row is an affordance acting as whoever has the page open (a run form, a question to answer, + * a run to inspect, an item to open) or a pending state that mounts one, so it never survives: + * the author controls every byte of the snapshot. + */ +function readMessage(m: Record, index: number): DisplayMessage | undefined { + const content = str(m.content) ?? '' + switch (m.role) { + case 'user': + return { + role: 'user', + content, + index, + images: records(m.images) + .filter((i) => isInlineImage(i.dataUrl)) + .map((i) => ({ + dataUrl: i.dataUrl as string, + mediaType: i.mediaType as ImageMediaType, + name: str(i.name) + })), + pastes: records(m.pastes) + .filter((p) => typeof p.id === 'number' && typeof p.content === 'string') + .map((p) => ({ + id: p.id as number, + lines: typeof p.lines === 'number' ? p.lines : 0, + content: p.content as string + })), + files: records(m.files) + .filter((f) => typeof f.name === 'string' && typeof f.content === 'string') + .map((f) => ({ name: f.name as string, content: f.content as string })) + } + case 'assistant': + return { + role: 'assistant', + content, + reasoning: str(m.reasoning), + reasoningDurationMs: + typeof m.reasoningDurationMs === 'number' ? m.reasoningDurationMs : undefined, + createdAt: str(m.createdAt) + } + case 'tool': + return { + role: 'tool', + tool_call_id: str(m.tool_call_id) ?? `shared-${index}`, + content, + toolName: str(m.toolName), + parameters: m.parameters, + result: m.result, + logs: str(m.logs), + error: str(m.error), + showDetails: m.showDetails === true, + imageUrl: isInlineImage(m.imageUrl) ? m.imageUrl : undefined, + planArtifactId: str(m.planArtifactId), + planVersion: typeof m.planVersion === 'number' ? m.planVersion : undefined, + blockedByPlanMode: m.blockedByPlanMode === true, + declinedByUser: m.declinedByUser === true + } + case 'summary': + return { role: 'summary', content } + default: + return undefined + } +} + +/** + * A snapshot as the viewer may render it: rows rebuilt by {@link readMessage}, and an attached + * image only from an inline data URL, since any other source would load a third-party resource + * on behalf of whoever opens the link. Images inside rendered text are left to the viewer + * page's `img-src` policy. + */ +export function readSessionSnapshot(raw: unknown): SessionSnapshot { + const snapshot = isRecord(raw) ? raw : {} + const messages = records(snapshot.messages) + .map(readMessage) + .filter((m): m is DisplayMessage => m !== undefined) + const artifacts = records(snapshot.artifacts) + .filter( + (a) => + typeof a.id === 'string' && + typeof a.name === 'string' && + typeof a.content === 'string' && + (a.kind === 'md' || a.kind === 'html') + ) + .map((a) => ({ + id: a.id as string, + name: a.name as string, + kind: a.kind as ArtifactKind, + content: a.content as string, + role: a.role === 'plan' ? ('plan' as const) : undefined + })) + return { messages, artifacts } +} diff --git a/frontend/src/routes/(styled)/shared_sessions/[workspace]/[id]/+page.svelte b/frontend/src/routes/(styled)/shared_sessions/[workspace]/[id]/+page.svelte new file mode 100644 index 0000000000..57e0ca654b --- /dev/null +++ b/frontend/src/routes/(styled)/shared_sessions/[workspace]/[id]/+page.svelte @@ -0,0 +1,157 @@ + + + + + + + +
+
+
+ + {#if found} +
+
+

+ {found.session.name} +

+ {#if found.session.is_public} + Public + {:else} + {workspace} + {/if} +
+ + Read-only AI session shared by {found.session.created_by} ยท {displayDate( + found.session.shared_at + )} + +
+ {#if found.session.can_unshare} + + {/if} + {:else} + Shared AI session + {/if} +
+
+ +
+ {#if found} + {#if found.snapshot.messages.length > 0} + {#key found.session.id} + + {/key} + {:else} + + {/if} + {:else if shared.current?.state === 'signin'} + + + + {:else if shared.current?.state === 'gone'} + + {:else if shared.current?.state === 'error'} + + {:else} + + {/if} +
+