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 <noreply@anthropic.com>

* fix: render shared session snapshots from an allowlist, without mermaid or remote images

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: make the session share button icon-only

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: safe reasoning links, artifact list and full-load exits on shared sessions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-15 18:51:11 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f827ba0566
commit a9aa2b8229
23 changed files with 1819 additions and 2 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ai_shared_session;
@@ -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);
+1
View File
@@ -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)
+120
View File
@@ -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<Postgres>,
) -> 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(())
}
+187
View File
@@ -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:
+4
View File
@@ -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")]
@@ -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<bool>,
message_count: i32,
snapshot: Box<RawValue>,
}
#[derive(Serialize)]
struct SharedSessionInfo {
id: Uuid,
name: String,
is_public: bool,
message_count: i32,
created_by: String,
shared_at: DateTime<Utc>,
}
#[derive(Serialize)]
struct SharedSession {
#[serde(flatten)]
info: SharedSessionInfo,
snapshot: Box<RawValue>,
/// 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<SharedSessionInfo>,
}
#[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<DB>,
Path(w_id): Path<String>,
Json(payload): Json<ShareSession>,
) -> JsonResult<SharedSessionInfo> {
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<DB>,
Path(w_id): Path<String>,
Query(query): Query<ShareStatusQuery>,
) -> JsonResult<ShareStatus> {
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<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
Json(payload): Json<SetPublic>,
) -> JsonResult<SharedSessionInfo> {
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<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> JsonResult<SharedSession> {
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<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> Result<String> {
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}"))
}
+5
View File
@@ -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::<OptAuthed>())
.layer(cors.clone()),
)
.nest(
"/w/{workspace_id}/ai_u/shared_sessions",
ai_shared_sessions::workspaced_unauthed_service(),
)
.layer(from_extractor::<OptAuthed>())
// Deprecated, here for backwards compatibility: user should use /mcp/w/{workspace_id}/mcp instead
.nest(
@@ -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}"
>
<Markdown md={reasoning} plugins={[gfmPlugin()]} />
<Markdown md={reasoning} plugins={reasoningPlugins} />
</ChatCollapsibleCard>
{/if}
@@ -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
@@ -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)
}
}
@@ -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}
/>
@@ -0,0 +1,231 @@
<script lang="ts">
import { Globe, Link, Share2 } from 'lucide-svelte'
import { resource } from 'runed'
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { AiService, type SharedAiSessionInfo } from '$lib/gen'
import { userWorkspaces } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard, displayDate } from '$lib/utils'
import { shareWorkspaceId } from '$lib/components/copilot/chat/artifacts/artifactSharing'
import type { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { buildSessionSnapshot, sharedSessionUrl } from './sessionSharing'
interface Props {
sessionId: string
name: string
/** The workspace the session acts on; undefined until its first message is sent. */
workspaceId: string | undefined
manager: AIChatManager
}
let { sessionId, name, workspaceId, manager }: Props = $props()
const workspace = $derived(
workspaceId ? shareWorkspaceId(workspaceId, $userWorkspaces) : undefined
)
const messageCount = $derived(manager.displayMessages.length)
const shareName = $derived(name.trim() || 'Untitled session')
const status = resource(
() => ({ workspace, sessionId }),
async ({ workspace, sessionId }) => {
if (!workspace) return undefined
try {
return await AiService.getAiSessionShareStatus({ workspace, sessionId })
} catch (err) {
// The button still works without it: sharing reports its own failure.
console.error('Could not read the session share status', err)
return undefined
}
}
)
const share = $derived(status.current?.share)
const url = $derived(share && workspace ? sharedSessionUrl(workspace, share.id) : undefined)
// A hint only: an edited and regenerated turn keeps both, so updating stays offered either way.
const outdated = $derived(
share !== undefined && (share.message_count !== messageCount || share.name !== shareName)
)
let saving = $state(false)
function errorMessage(err: unknown): string {
const body = (err as { body?: unknown })?.body
return typeof body === 'string' && body ? body : String((err as Error)?.message ?? err)
}
function reflect(next: SharedAiSessionInfo | undefined) {
if (status.current) status.mutate({ ...status.current, share: next })
}
async function shareSnapshot() {
if (!workspace) return
const updating = share !== undefined
saving = true
try {
const snapshot = buildSessionSnapshot(
$state.snapshot(manager.displayMessages),
$state.snapshot(manager.artifacts.artifacts)
)
const shared = await AiService.shareAiSession({
workspace,
requestBody: {
session_id: sessionId,
name: shareName,
message_count: messageCount,
snapshot
}
})
if (status.current) reflect(shared)
else await status.refetch()
if (updating) {
sendUserToast('The link now shows the latest messages')
} else if (await copyToClipboard(sharedSessionUrl(workspace, shared.id), false)) {
sendUserToast('Link copied to clipboard')
}
} catch (err) {
sendUserToast(`Could not share the session: ${errorMessage(err)}`, true)
} finally {
saving = false
}
}
async function setPublic(isPublic: boolean) {
if (!workspace || !share) return
saving = true
try {
reflect(
await AiService.setAiSessionSharePublic({
workspace,
id: share.id,
requestBody: { is_public: isPublic }
})
)
} catch (err) {
sendUserToast(`Could not change who can open the link: ${errorMessage(err)}`, true)
} finally {
saving = false
}
}
async function stopSharing() {
if (!workspace || !share) return
saving = true
try {
await AiService.unshareAiSession({ workspace, id: share.id })
reflect(undefined)
sendUserToast('Stopped sharing: the link no longer opens')
} catch (err) {
sendUserToast(`Could not stop sharing: ${errorMessage(err)}`, true)
} finally {
saving = false
}
}
</script>
<Popover
placement="bottom-end"
contentClasses="!bg-surface"
disabled={!workspace || messageCount === 0}
triggerAttrs={{ 'aria-label': 'Share session', 'aria-haspopup': 'dialog' }}
>
{#snippet trigger()}
<!-- Icon only, like the session menu beside it: tinted once a link exists. -->
<Button
unifiedSize="xs"
variant="subtle"
iconOnly
nonCaptureEvent
disabled={!workspace || messageCount === 0}
btnClasses={share ? '!text-accent' : 'text-tertiary'}
startIcon={{ icon: share ? (share.is_public ? Globe : Link) : Share2 }}
title={!workspace || messageCount === 0
? 'Send a first message to share this session'
: share
? share.is_public
? 'Shared publicly'
: 'Shared with the workspace'
: 'Share session'}
/>
{/snippet}
{#snippet content()}
<div class="flex w-80 flex-col gap-3 p-3 text-xs">
<div class="flex flex-col gap-1">
<span class="font-semibold text-emphasis">Share session</span>
<span class="font-normal text-secondary">
{#if share?.is_public}
Anyone with the link can read a copy of this conversation. Nobody can continue it.
{:else}
Members of {workspace} can read a copy of this conversation with the link. Nobody can continue
it.
{/if}
</span>
</div>
{#if share && url}
<Toggle
size="xs"
checked={share.is_public}
disabled={saving}
options={{
right: 'Public',
rightTooltip: 'Anyone with the link can open it, without signing in'
}}
on:change={(e) => setPublic(e.detail)}
/>
<div class="flex flex-col gap-1">
<ClipboardPanel content={url} size="sm" />
<span class="text-2xs font-normal text-hint">
Shows {share.message_count} message{share.message_count === 1 ? '' : 's'} as of {displayDate(
share.shared_at
)}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="font-normal text-secondary">
{outdated
? 'The session changed since it was shared.'
: 'The link shows the session as it was when shared.'}
</span>
<Button
unifiedSize="sm"
variant={outdated ? 'accent' : 'default'}
loading={saving}
disabled={manager.loading}
onClick={shareSnapshot}
>
Update link
</Button>
</div>
<div class="flex justify-end">
<Button
unifiedSize="sm"
variant="default"
destructive
disabled={saving}
onClick={stopSharing}
>
Stop sharing
</Button>
</div>
{:else}
<div class="flex justify-end">
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Link }}
loading={saving}
disabled={status.loading || manager.loading}
title={manager.loading ? 'Wait for the current turn to finish' : undefined}
onClick={shareSnapshot}
>
Create link
</Button>
</div>
{/if}
</div>
{/snippet}
</Popover>
@@ -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 @@
</span>
{/snippet}
</DropdownV2>
<SessionShareButton
sessionId={session.id}
name={session.summary ?? ''}
workspaceId={session.workspace_id}
manager={runtime.manager}
/>
{/snippet}
{#if headerInPage}
@@ -0,0 +1,121 @@
<script lang="ts">
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import { FileText } from 'lucide-svelte'
import AIChatMessage from '$lib/components/copilot/chat/AIChatMessage.svelte'
import ToolGroupDisplay from '$lib/components/copilot/chat/ToolGroupDisplay.svelte'
import ArtifactBody from '$lib/components/copilot/chat/artifacts/ArtifactBody.svelte'
import { setChatViewHost } from '$lib/components/copilot/chat/chatViewHost'
import { readOnlyChatViewHost } from '$lib/components/copilot/chat/readOnlyChatViewHost.svelte'
import { chatItemKey, groupToolRuns } from '$lib/components/copilot/chat/toolGroups'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { SessionSnapshot } from './sessionSharing'
import { untrack } from 'svelte'
interface Props {
snapshot: SessionSnapshot
workspace: string
}
let { snapshot, workspace }: Props = $props()
let openArtifactId: string | undefined = $state(undefined)
let drawer: Drawer | undefined = $state(undefined)
// Built once: the page remounts this component for another share.
const initial = untrack(() => ({ snapshot, workspace }))
setChatViewHost(
readOnlyChatViewHost({
messages: initial.snapshot.messages,
workspace: initial.workspace,
artifacts: initial.snapshot.artifacts.map((a) => ({
...a,
sessionId: '',
createdAt: 0,
updatedAt: 0
})),
openArtifact: (id) => showArtifact(id)
})
)
function showArtifact(id: string) {
openArtifactId = id
drawer?.openDrawer()
}
const messages = $derived(snapshot.messages)
const chatItems = $derived(groupToolRuns(messages))
const openArtifact = $derived(snapshot.artifacts.find((a) => a.id === openArtifactId))
// An answer's copy row shows once per turn, on its last answer, as in the live chat.
const showsAnswerActions = $derived.by(() => {
const shows: boolean[] = new Array(messages.length).fill(false)
const answeredLater = new Set<string | undefined>()
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role === 'user' || message.role === 'summary') {
answeredLater.clear()
} else if (message.role === 'assistant' && message.content) {
const run = message.jobId ?? message.stepName
shows[i] = !answeredLater.has(run)
answeredLater.add(run)
}
}
return shows
})
</script>
{#snippet messageRow(message: DisplayMessage, messageIndex: number, inGroup: boolean)}
<AIChatMessage
{message}
{messageIndex}
availableContext={[]}
editingMessageIndex={null}
isLast={messageIndex === messages.length - 1}
{inGroup}
showAnswerActions={showsAnswerActions[messageIndex]}
/>
{/snippet}
{#snippet groupRow(message: DisplayMessage, messageIndex: number)}
{@render messageRow(message, messageIndex, true)}
{/snippet}
{#if snapshot.artifacts.length > 0}
<!-- The transcript only links plan documents; every other artifact is reached from here. -->
<div class="mb-4 flex flex-wrap items-center gap-2 px-2">
<span class="text-xs font-semibold text-emphasis">Artifacts</span>
{#each snapshot.artifacts as artifact (artifact.id)}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: FileText }}
onClick={() => showArtifact(artifact.id)}
>
{artifact.name}
</Button>
{/each}
</div>
{/if}
<div class="flex flex-col pb-2">
{#each chatItems as item (chatItemKey(item))}
{#if item.kind === 'group'}
<div class="mb-1 min-w-0 text-sm px-2 text-primary">
<div class="px-[1px]">
<ToolGroupDisplay group={item} entry={groupRow} />
</div>
</div>
{:else}
{@render messageRow(item.message, item.index, false)}
{/if}
{/each}
</div>
<Drawer bind:this={drawer} size="900px">
<DrawerContent title={openArtifact?.name ?? 'Artifact'} on:close={() => drawer?.closeDrawer()}>
{#if openArtifact}
<ArtifactBody content={openArtifact.content} source={openArtifact.kind !== 'md'} />
{:else}
<span class="text-xs text-secondary">This artifact was not part of the share.</span>
{/if}
</DrawerContent>
</Drawer>
@@ -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: '<svg/>' }
]
})
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: [] })
})
})
@@ -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<string, unknown> {
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<string, unknown>[] {
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<string, unknown>, 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 }
}
@@ -0,0 +1,157 @@
<script lang="ts">
import { page } from '$app/state'
import { resource } from 'runed'
import { Globe, Link2Off, LogIn, MessagesSquare, Users } from 'lucide-svelte'
import { Badge, Button, EmptyState, Skeleton } from '$lib/components/common'
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
import SharedSessionTranscript from '$lib/components/sessions/SharedSessionTranscript.svelte'
import { readSessionSnapshot } from '$lib/components/sessions/sessionSharing'
import { applyDarkModeVariant } from '$lib/darkModeVariant'
import { AiService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { displayDate } from '$lib/utils'
// Reached without signing in when the share is public, so this page sets up what the
// logged-in layout otherwise would.
const darkMode =
window.localStorage.getItem('dark-mode') ??
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
document.documentElement.classList.toggle('dark', darkMode === 'dark')
applyDarkModeVariant()
const workspace = $derived(page.params.workspace ?? '')
const id = $derived(page.params.id ?? '')
$effect(() => {
$workspaceStore = workspace
})
type Loaded =
| {
state: 'found'
session: Awaited<ReturnType<typeof AiService.getSharedAiSession>>
snapshot: ReturnType<typeof readSessionSnapshot>
}
| { state: 'gone' }
| { state: 'signin' }
| { state: 'error'; message: string }
const shared = resource(
() => ({ workspace, id }),
async ({ workspace, id }): Promise<Loaded | undefined> => {
if (!workspace || !id) return undefined
try {
const session = await AiService.getSharedAiSession({ workspace, id })
return { state: 'found', session, snapshot: readSessionSnapshot(session.snapshot) }
} catch (err) {
const status = (err as { status?: number })?.status
if (status === 401) return { state: 'signin' }
// A malformed id is a 400 from the path extractor, and means the same to the reader.
if (status === 404 || status === 400) return { state: 'gone' }
return { state: 'error', message: String((err as { body?: unknown })?.body ?? err) }
}
}
)
const found = $derived(shared.current?.state === 'found' ? shared.current : undefined)
const loginHref = $derived(
`/user/login?rd=${encodeURIComponent(page.url.pathname + page.url.search)}`
)
let removing = $state(false)
async function stopSharing() {
if (!found) return
removing = true
try {
await AiService.unshareAiSession({ workspace, id: found.session.id })
shared.mutate({ state: 'gone' })
sendUserToast('Stopped sharing: the link no longer opens')
} catch (err) {
sendUserToast(`Could not stop sharing: ${(err as { body?: unknown })?.body ?? err}`, true)
} finally {
removing = false
}
}
</script>
<!-- The transcript is its author's text, rendered to whoever holds the link: an image in it
that points elsewhere would report every reader to that host. A policy stays with the
document once set, so every link out of this page is a full load (`data-sveltekit-reload`). -->
<svelte:head>
<meta http-equiv="content-security-policy" content="img-src 'self' data: blob:" />
</svelte:head>
<div class="flex min-h-screen flex-col bg-surface" data-sveltekit-reload>
<div class="border-b bg-surface-secondary">
<div class="mx-auto flex w-full max-w-3xl flex-wrap items-center gap-3 px-4 py-3">
<WindmillIcon height="20px" width="20px" />
{#if found}
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-w-0 items-center gap-2">
<h1 class="truncate text-sm font-semibold text-emphasis" title={found.session.name}>
{found.session.name}
</h1>
{#if found.session.is_public}
<Badge color="blue" icon={{ icon: Globe, position: 'left' }}>Public</Badge>
{:else}
<Badge color="gray" icon={{ icon: Users, position: 'left' }}>{workspace}</Badge>
{/if}
</div>
<span class="text-xs font-normal text-secondary">
Read-only AI session shared by {found.session.created_by} · {displayDate(
found.session.shared_at
)}
</span>
</div>
{#if found.session.can_unshare}
<Button
unifiedSize="sm"
variant="default"
destructive
loading={removing}
onClick={stopSharing}
>
Stop sharing
</Button>
{/if}
{:else}
<span class="text-sm font-semibold text-emphasis">Shared AI session</span>
{/if}
</div>
</div>
<div class="mx-auto w-full max-w-3xl flex-1 px-4 py-6">
{#if found}
{#if found.snapshot.messages.length > 0}
{#key found.session.id}
<SharedSessionTranscript snapshot={found.snapshot} {workspace} />
{/key}
{:else}
<EmptyState icon={MessagesSquare} title="This session has no messages" />
{/if}
{:else if shared.current?.state === 'signin'}
<EmptyState
icon={LogIn}
title="Sign in to read this session"
description="It is shared with the members of {workspace} only."
>
<Button variant="accent" unifiedSize="md" href={loginHref}>Sign in</Button>
</EmptyState>
{:else if shared.current?.state === 'gone'}
<EmptyState
icon={Link2Off}
title="This shared session is not available"
description="Its author stopped sharing it, or it is shared with another workspace's members only."
/>
{:else if shared.current?.state === 'error'}
<EmptyState
icon={Link2Off}
title="Could not load this shared session"
description={shared.current.message}
/>
{:else}
<Skeleton layout={[[3], 1, [30]]} />
{/if}
</div>
</div>