mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 00:02:59 +00:00
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:
co-authored by
Claude Opus 5.5
parent
f827ba0566
commit
a9aa2b8229
+55
@@ -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"
|
||||
}
|
||||
+47
@@ -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"
|
||||
}
|
||||
+54
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+65
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user