mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat(ai-sessions): share session artifacts with the workspace by link (#11115)
* feat(ai-sessions): share session artifacts with the workspace by link Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * chore: cache the shared artifact queries for offline sqlx Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * fix: replace a literal NUL byte in the shared artifact body limit comment Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * test: pin that a shared artifact is confined to its workspace's path Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * fix: sanitize shared artifact markdown and validate the artifact id on every route The shared page renders another member's markdown, so ArtifactBody now runs the repo's rehype-raw + rehype-sanitize chain with the chat's link renderer on top; only the session viewer opts into the chat code block (mermaid, apply button). The link renderer keeps a link's text when its href is empty or unsafe, and the scheme check moves to a tested helper. The status route checks artifact_id like share does, so a NUL is a 400 rather than a 500. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH * fix(ai-sessions): say which way re-sharing moves an artifact link The popover offered "Update to v1" when a v2 link was open on a pinned v1, which reads as if v1 were newer. Each direction now has its own sentence and action: a newer version on screen updates the link, an older one shares that version instead, a rename updates the name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e8c02c04cd
commit
57a134e2de
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO ai_shared_artifact\n (workspace_id, artifact_id, email, created_by, name, kind, version, content)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (workspace_id, email, artifact_id) DO UPDATE\n SET created_by = EXCLUDED.created_by,\n name = EXCLUDED.name,\n kind = EXCLUDED.kind,\n version = EXCLUDED.version,\n content = EXCLUDED.content,\n shared_at = now()\n RETURNING id, shared_at, (xmax = 0) AS \"inserted!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "shared_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "inserted!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "0589cb0f96e17ecadae4923be70a6cf0148a9e10c1c4ad0f99312b8e99ec1b8a"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM ai_shared_artifact\n WHERE shared_at <= now() - ($1::bigint::text || ' s')::interval",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6774bc0ec8ca8c6c48e8e111ab074b8c5beb1c3992f6413d7bcabc8159c0f9bb"
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, email, name, kind, version, created_by, content, shared_at\n FROM ai_shared_artifact\n WHERE workspace_id = $1 AND id = $2\n AND shared_at > now() - ($3::bigint::text || ' s')::interval",
|
||||
"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": "kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "content",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "shared_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "945230149990abda67fdf4779529207306fb19756ab9c3a3d7824a15542a5b42"
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, name, kind, version, created_by, shared_at FROM ai_shared_artifact\n WHERE workspace_id = $1 AND email = $2 AND artifact_id = $3\n AND shared_at > now() - ($4::bigint::text || ' s')::interval",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "shared_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d2861932a739887785658cdf89a804306fe2083928e57458ba936d6afde53b57"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM ai_shared_artifact\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": "e50660f58274e9c135ace356ea8107739baa9ee276a99f481335fec8099f4d51"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS ai_shared_artifact;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- A copy of an AI session artifact that its author explicitly shared with the workspace.
|
||||
-- Artifacts otherwise live only in the author's browser; this row exists only while the
|
||||
-- share does, and the monitor deletes it once `shared_at` falls outside
|
||||
-- AI_SHARED_ARTIFACT_RETENTION_SECS.
|
||||
CREATE TABLE ai_shared_artifact (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
-- The browser-side artifact id. Unique per author so sharing the same artifact again
|
||||
-- moves its one link forward rather than minting a second one.
|
||||
artifact_id VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
created_by VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
kind VARCHAR(10) NOT NULL CHECK (kind IN ('md', 'html')),
|
||||
version INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
-- Reset on every re-share: retention counts from the last time the author shared it.
|
||||
shared_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, email, artifact_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ai_shared_artifact_shared_at ON ai_shared_artifact (shared_at);
|
||||
|
||||
GRANT ALL ON ai_shared_artifact TO windmill_admin;
|
||||
GRANT ALL ON ai_shared_artifact 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_artifact ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY admin_policy ON ai_shared_artifact FOR ALL TO windmill_admin USING (true);
|
||||
@@ -1912,6 +1912,17 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
tracing::info!("deleted {} expired otel trace spans", deleted_spans);
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM ai_shared_artifact
|
||||
WHERE shared_at <= now() - ($1::bigint::text || ' s')::interval",
|
||||
windmill_common::ai_shared_artifact_retention_secs(),
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting expired shared AI artifacts: {:?}", e);
|
||||
}
|
||||
|
||||
let audit_retention_days = audit_log_retention_days().await;
|
||||
let audit_retention_secs: i64 = audit_retention_days * 60 * 60 * 24;
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklis
|
||||
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
|
||||
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)
|
||||
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)
|
||||
alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(bool), workspace_id(text), acknowledged_workspace(bool), resource(text)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Shared AI session artifacts: one link per author and artifact, readable by any workspace
|
||||
//! member until its retention window passes, and removable only by its author or an admin.
|
||||
//!
|
||||
//! Expiry is enforced on read as well as by the monitor's sweep, so a share past its window must
|
||||
//! not be served in the gap before the sweep reaches it.
|
||||
|
||||
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";
|
||||
|
||||
async fn share(
|
||||
client: &reqwest::Client,
|
||||
base: &str,
|
||||
token: &str,
|
||||
content: &str,
|
||||
) -> anyhow::Result<Value> {
|
||||
let resp = client
|
||||
.post(format!("{base}/share"))
|
||||
.header("Authorization", token)
|
||||
.json(&json!({
|
||||
"artifact_id": "plan:session-1",
|
||||
"name": "Plan",
|
||||
"kind": "md",
|
||||
"version": 1,
|
||||
"content": content,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "ai_shared_artifacts"))]
|
||||
async fn shared_artifact_is_served_to_members_until_it_expires(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let base = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
|
||||
server.addr.port()
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let first = share(&client, &base, MEMBER, "draft").await?;
|
||||
let second = share(&client, &base, MEMBER, "final").await?;
|
||||
assert_eq!(first["id"], second["id"], "re-sharing minted a second link");
|
||||
let id = second["id"].as_str().unwrap();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{base}/get/{id}"))
|
||||
.header("Authorization", ADMIN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: Value = resp.json().await?;
|
||||
assert_eq!(body["content"], "final");
|
||||
|
||||
// The handlers read through the raw pool, so the workspace in the URL is the only thing
|
||||
// scoping a share: a member of another workspace must not reach it by id through theirs.
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"http://localhost:{}/api/w/test-workspace-2/ai/shared_artifacts/get/{id}",
|
||||
server.addr.port()
|
||||
))
|
||||
.header("Authorization", ADMIN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
404,
|
||||
"a share was served through another workspace's path"
|
||||
);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE ai_shared_artifact SET shared_at = now() - ($1::bigint + 60) * interval '1 second'",
|
||||
)
|
||||
.bind(windmill_common::ai_shared_artifact_retention_secs())
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = client
|
||||
.get(format!("{base}/get/{id}"))
|
||||
.header("Authorization", ADMIN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 404, "an expired share was served");
|
||||
|
||||
let status: Value = client
|
||||
.get(format!("{base}/status?artifact_id=plan:session-1"))
|
||||
.header("Authorization", MEMBER)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert!(
|
||||
status.get("share").is_none(),
|
||||
"an expired share was reported live: {status}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn only_the_author_or_an_admin_can_unshare(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let base = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
|
||||
server.addr.port()
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let shared = share(&client, &base, ADMIN, "admin's plan").await?;
|
||||
let id = shared["id"].as_str().unwrap();
|
||||
|
||||
let resp = client
|
||||
.delete(format!("{base}/delete/{id}"))
|
||||
.header("Authorization", MEMBER)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 404);
|
||||
let remaining: i64 = sqlx::query_scalar("SELECT count(*) FROM ai_shared_artifact")
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(remaining, 1, "a member deleted someone else's share");
|
||||
|
||||
let member_share = share(&client, &base, MEMBER, "member's plan").await?;
|
||||
let resp = client
|
||||
.delete(format!(
|
||||
"{base}/delete/{}",
|
||||
member_share["id"].as_str().unwrap()
|
||||
))
|
||||
.header("Authorization", ADMIN)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The id is compared against a `VARCHAR(255)` column on every route that takes one, and a
|
||||
/// NUL in it would otherwise reach Postgres and come back as a 500.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn a_malformed_artifact_id_is_refused_on_every_route(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let base = format!(
|
||||
"http://localhost:{}/api/w/test-workspace/ai/shared_artifacts",
|
||||
server.addr.port()
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
for bad_id in ["", "a\0b", &"x".repeat(256)] {
|
||||
let resp = client
|
||||
.get(format!("{base}/status"))
|
||||
.query(&[("artifact_id", bad_id)])
|
||||
.header("Authorization", MEMBER)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "status accepted {bad_id:?}");
|
||||
|
||||
let resp = client
|
||||
.post(format!("{base}/share"))
|
||||
.header("Authorization", MEMBER)
|
||||
.json(&json!({
|
||||
"artifact_id": bad_id,
|
||||
"name": "Plan",
|
||||
"kind": "md",
|
||||
"version": 1,
|
||||
"content": "x",
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 400, "share accepted {bad_id:?}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
-- Layers on `base`: a second workspace the superadmin `test-user` is also a member of, so a
|
||||
-- share can be requested through the wrong workspace's path by a caller the route accepts.
|
||||
|
||||
INSERT INTO workspace (id, name, owner) VALUES
|
||||
('test-workspace-2', 'test-workspace-2', 'test-user');
|
||||
|
||||
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
|
||||
('test-workspace-2', 'cloud', 'test-key-2');
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id) VALUES
|
||||
('test-workspace-2');
|
||||
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('test-workspace-2', 'all', 'All users', '{}');
|
||||
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||
('test-workspace-2', 'test@windmill.dev', 'test-user', true, 'Admin');
|
||||
@@ -13191,6 +13191,134 @@ paths:
|
||||
type: boolean
|
||||
description: more buckets matched than were returned, so summing them under-reports
|
||||
|
||||
/w/{workspace}/ai/shared_artifacts/share:
|
||||
post:
|
||||
summary: share an AI session artifact with the workspace
|
||||
description: >
|
||||
Stores a read-only copy that any member of the workspace can open by id. Sharing the
|
||||
same artifact again updates that copy, keeps its id, and restarts its retention window.
|
||||
operationId: shareAiArtifact
|
||||
tags:
|
||||
- ai
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- artifact_id
|
||||
- name
|
||||
- kind
|
||||
- version
|
||||
- content
|
||||
properties:
|
||||
artifact_id:
|
||||
type: string
|
||||
description: the artifact's id in the author's session
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [md, html]
|
||||
version:
|
||||
type: integer
|
||||
minimum: 1
|
||||
content:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: the shared copy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SharedAiArtifactInfo"
|
||||
|
||||
/w/{workspace}/ai/shared_artifacts/status:
|
||||
get:
|
||||
summary: get the calling user's share of one of their AI session artifacts
|
||||
operationId: getAiArtifactShareStatus
|
||||
tags:
|
||||
- ai
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: artifact_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: the live share, if any, and how long shares last
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- retention_secs
|
||||
properties:
|
||||
retention_secs:
|
||||
type: integer
|
||||
share:
|
||||
$ref: "#/components/schemas/SharedAiArtifactInfo"
|
||||
|
||||
/w/{workspace}/ai/shared_artifacts/get/{id}:
|
||||
get:
|
||||
summary: get a shared AI session artifact
|
||||
operationId: getSharedAiArtifact
|
||||
tags:
|
||||
- ai
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
"200":
|
||||
description: the shared artifact
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/SharedAiArtifactInfo"
|
||||
- type: object
|
||||
required:
|
||||
- content
|
||||
- can_unshare
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
can_unshare:
|
||||
type: boolean
|
||||
description: whether the caller authored the share or is a workspace admin
|
||||
|
||||
/w/{workspace}/ai/shared_artifacts/delete/{id}:
|
||||
delete:
|
||||
summary: stop sharing an AI session artifact
|
||||
operationId: unshareAiArtifact
|
||||
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
|
||||
@@ -28256,6 +28384,36 @@ components:
|
||||
- output_tokens
|
||||
- requests
|
||||
|
||||
SharedAiArtifactInfo:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [md, html]
|
||||
version:
|
||||
type: integer
|
||||
created_by:
|
||||
type: string
|
||||
shared_at:
|
||||
type: string
|
||||
format: date-time
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- kind
|
||||
- version
|
||||
- created_by
|
||||
- shared_at
|
||||
- expires_at
|
||||
|
||||
InstanceAIProviderSummary:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -518,6 +518,10 @@ pub fn workspaced_service() -> Router {
|
||||
// could make the server allocate and parse an arbitrarily large one.
|
||||
// Sized well above a full batch of the shape below.
|
||||
.layer(DefaultBodyLimit::max(AI_USAGE_BODY_LIMIT)),
|
||||
)
|
||||
.nest(
|
||||
"/shared_artifacts",
|
||||
crate::ai_shared_artifacts::workspaced_service(),
|
||||
);
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/*
|
||||
* 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 copies of AI session artifacts, shared with the workspace by their author.
|
||||
//!
|
||||
//! Artifacts live in the author's browser; a row here exists only because the author asked
|
||||
//! for a link. Every read filters on the retention window as well as the monitor sweeping
|
||||
//! it, so a share past its window is never served in the gap before the sweep reaches it.
|
||||
|
||||
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 uuid::Uuid;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
ai_shared_artifact_retention_secs,
|
||||
error::{Error, JsonResult, Result},
|
||||
};
|
||||
|
||||
/// The frontend's `MAX_ARTIFACT_BYTES`: no artifact the viewer holds is larger.
|
||||
const MAX_CONTENT_BYTES: usize = 256 * 1024;
|
||||
const MAX_NAME_CHARS: usize = 255;
|
||||
const MAX_ARTIFACT_ID_CHARS: usize = 255;
|
||||
/// JSON escapes a control character into six bytes, so a full-size artifact made entirely of
|
||||
/// them still fits.
|
||||
const SHARE_BODY_LIMIT: usize = MAX_CONTENT_BYTES * 6 + 64 * 1024;
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/share",
|
||||
post(share_artifact).layer(DefaultBodyLimit::max(SHARE_BODY_LIMIT)),
|
||||
)
|
||||
.route("/status", get(get_share_status))
|
||||
.route("/get/{id}", get(get_shared_artifact))
|
||||
.route("/delete/{id}", delete(unshare_artifact))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum ArtifactKind {
|
||||
Md,
|
||||
Html,
|
||||
}
|
||||
|
||||
impl ArtifactKind {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ArtifactKind::Md => "md",
|
||||
ArtifactKind::Html => "html",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ShareArtifact {
|
||||
artifact_id: String,
|
||||
name: String,
|
||||
kind: ArtifactKind,
|
||||
version: i32,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SharedArtifactInfo {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
kind: String,
|
||||
version: i32,
|
||||
created_by: String,
|
||||
shared_at: DateTime<Utc>,
|
||||
expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SharedArtifact {
|
||||
#[serde(flatten)]
|
||||
info: SharedArtifactInfo,
|
||||
content: String,
|
||||
/// Whether the caller may stop sharing it: its author, or a workspace admin.
|
||||
can_unshare: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ShareStatus {
|
||||
retention_secs: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
share: Option<SharedArtifactInfo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ShareStatusQuery {
|
||||
artifact_id: String,
|
||||
}
|
||||
|
||||
fn expires_at(shared_at: DateTime<Utc>, retention_secs: i64) -> DateTime<Utc> {
|
||||
shared_at + chrono::Duration::seconds(retention_secs)
|
||||
}
|
||||
|
||||
/// The browser-side artifact id, as every handler that takes one must check it: it is compared
|
||||
/// against a `VARCHAR(255)` column, and Postgres answers a NUL in a text parameter with an
|
||||
/// opaque 500.
|
||||
fn check_artifact_id(artifact_id: &str) -> Result<()> {
|
||||
if artifact_id.is_empty() || artifact_id.chars().count() > MAX_ARTIFACT_ID_CHARS {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Artifact id must be between 1 and {MAX_ARTIFACT_ID_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
if artifact_id.contains('\0') {
|
||||
return Err(Error::BadRequest(
|
||||
"Artifact id cannot contain NUL characters".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Share an artifact, or move the caller's existing link for it to this content. Re-sharing
|
||||
/// keeps the link and restarts its retention window.
|
||||
async fn share_artifact(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(payload): Json<ShareArtifact>,
|
||||
) -> JsonResult<SharedArtifactInfo> {
|
||||
let name = payload.name.trim();
|
||||
if name.is_empty() || name.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Artifact name must be between 1 and {MAX_NAME_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
check_artifact_id(&payload.artifact_id)?;
|
||||
if payload.content.len() > MAX_CONTENT_BYTES {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Artifact content is {} bytes, above the {MAX_CONTENT_BYTES} byte limit",
|
||||
payload.content.len()
|
||||
)));
|
||||
}
|
||||
if payload.version < 1 {
|
||||
return Err(Error::BadRequest(
|
||||
"Artifact version must be at least 1".to_string(),
|
||||
));
|
||||
}
|
||||
// Postgres rejects NUL in text columns with an opaque 500.
|
||||
if name.contains('\0') || payload.content.contains('\0') {
|
||||
return Err(Error::BadRequest(
|
||||
"Artifact name and content cannot contain NUL characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let row = sqlx::query!(
|
||||
r#"INSERT INTO ai_shared_artifact
|
||||
(workspace_id, artifact_id, email, created_by, name, kind, version, content)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (workspace_id, email, artifact_id) DO UPDATE
|
||||
SET created_by = EXCLUDED.created_by,
|
||||
name = EXCLUDED.name,
|
||||
kind = EXCLUDED.kind,
|
||||
version = EXCLUDED.version,
|
||||
content = EXCLUDED.content,
|
||||
shared_at = now()
|
||||
RETURNING id, shared_at, (xmax = 0) AS "inserted!""#,
|
||||
&w_id,
|
||||
&payload.artifact_id,
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
name,
|
||||
payload.kind.as_str(),
|
||||
payload.version,
|
||||
&payload.content,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let id = row.id.to_string();
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"ai.shared_artifacts.share",
|
||||
if row.inserted {
|
||||
ActionKind::Create
|
||||
} else {
|
||||
ActionKind::Update
|
||||
},
|
||||
&w_id,
|
||||
Some(&id),
|
||||
Some([("name", name)].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(SharedArtifactInfo {
|
||||
id: row.id,
|
||||
name: name.to_string(),
|
||||
kind: payload.kind.as_str().to_string(),
|
||||
version: payload.version,
|
||||
created_by: authed.username.clone(),
|
||||
shared_at: row.shared_at,
|
||||
expires_at: expires_at(row.shared_at, ai_shared_artifact_retention_secs()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The caller's own live share of one of their artifacts, if any, and how long a share lasts.
|
||||
async fn get_share_status(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(query): Query<ShareStatusQuery>,
|
||||
) -> JsonResult<ShareStatus> {
|
||||
check_artifact_id(&query.artifact_id)?;
|
||||
let retention_secs = ai_shared_artifact_retention_secs();
|
||||
let share = sqlx::query!(
|
||||
"SELECT id, name, kind, version, created_by, shared_at FROM ai_shared_artifact
|
||||
WHERE workspace_id = $1 AND email = $2 AND artifact_id = $3
|
||||
AND shared_at > now() - ($4::bigint::text || ' s')::interval",
|
||||
&w_id,
|
||||
&authed.email,
|
||||
&query.artifact_id,
|
||||
retention_secs,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.map(|r| SharedArtifactInfo {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
kind: r.kind,
|
||||
version: r.version,
|
||||
created_by: r.created_by,
|
||||
shared_at: r.shared_at,
|
||||
expires_at: expires_at(r.shared_at, retention_secs),
|
||||
});
|
||||
|
||||
Ok(Json(ShareStatus { retention_secs, share }))
|
||||
}
|
||||
|
||||
/// Any member of the workspace may read a live share: that is what sharing it granted.
|
||||
async fn get_shared_artifact(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
) -> JsonResult<SharedArtifact> {
|
||||
let retention_secs = ai_shared_artifact_retention_secs();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, name, kind, version, created_by, content, shared_at
|
||||
FROM ai_shared_artifact
|
||||
WHERE workspace_id = $1 AND id = $2
|
||||
AND shared_at > now() - ($3::bigint::text || ' s')::interval",
|
||||
&w_id,
|
||||
id,
|
||||
retention_secs,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::NotFound(format!(
|
||||
"Shared artifact {id} not found: it may have expired or been unshared"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Json(SharedArtifact {
|
||||
can_unshare: row.email == authed.email || authed.is_admin,
|
||||
content: row.content,
|
||||
info: SharedArtifactInfo {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
version: row.version,
|
||||
created_by: row.created_by,
|
||||
shared_at: row.shared_at,
|
||||
expires_at: expires_at(row.shared_at, retention_secs),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn unshare_artifact(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
) -> Result<String> {
|
||||
let mut tx = db.begin().await?;
|
||||
let name = sqlx::query_scalar!(
|
||||
"DELETE FROM ai_shared_artifact
|
||||
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 artifact {id} not found, or not shared by you"
|
||||
))
|
||||
})?;
|
||||
|
||||
let id_str = id.to_string();
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"ai.shared_artifacts.unshare",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(&id_str),
|
||||
Some([("name", name.as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Stopped sharing {name}"))
|
||||
}
|
||||
@@ -69,6 +69,7 @@ mod ai;
|
||||
#[cfg(feature = "private")]
|
||||
mod ai_free_tier_ee;
|
||||
mod ai_free_tier_oss;
|
||||
mod ai_shared_artifacts;
|
||||
mod apps;
|
||||
mod apps_raw_bundle;
|
||||
pub use apps::invalidate_app_policy_cache;
|
||||
|
||||
@@ -577,6 +577,7 @@ pub const ENV_SETTINGS: &[&str] = &[
|
||||
"OTEL_RESOURCE_ATTRIBUTES",
|
||||
"OTEL_JOB_LOGS",
|
||||
"OTEL_TRACES_RETENTION_SECS",
|
||||
"AI_SHARED_ARTIFACT_RETENTION_SECS",
|
||||
"DISABLE_S3_STORE",
|
||||
"PG_SCHEMA",
|
||||
"PG_LISTENER_REFRESH_PERIOD_SECS",
|
||||
|
||||
@@ -151,6 +151,7 @@ pub const DEFAULT_HUB_BASE_URL: &str = "https://hub.windmill.dev";
|
||||
pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000;
|
||||
pub const DEFAULT_SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs
|
||||
pub const DEFAULT_OTEL_TRACES_RETENTION_SECS: i64 = 60 * 60 * 24 * 7; // 1 week retention period for HTTP request spans
|
||||
pub const DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS: i64 = 60 * 60 * 24 * 30;
|
||||
pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers";
|
||||
|
||||
/// A century. Every consumer has to survive `now - retention`, and the ceilings are much lower
|
||||
@@ -229,6 +230,13 @@ pub fn service_log_retention_secs() -> i64 {
|
||||
SERVICE_LOG_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// How long a shared AI session artifact stays viewable, in seconds, counted from the last time
|
||||
/// its author shared it. Read by both the API, which stops serving an expired share, and the
|
||||
/// monitor, which deletes it — so both must agree, which is why they share this one reader.
|
||||
pub fn ai_shared_artifact_retention_secs() -> i64 {
|
||||
*AI_SHARED_ARTIFACT_RETENTION_SECS
|
||||
}
|
||||
|
||||
/// Canonical form of a base URL, used as one of the inputs to the offline-license
|
||||
/// instance hash (`compute_instance_hash`).
|
||||
///
|
||||
@@ -476,6 +484,15 @@ lazy_static::lazy_static! {
|
||||
/// [`set_otel_traces_retention_secs`] is the only writer, [`otel_traces_retention_secs`] the
|
||||
/// only reader.
|
||||
static ref OTEL_TRACES_RETENTION_SECS: AtomicI64 = AtomicI64::new(DEFAULT_OTEL_TRACES_RETENTION_SECS);
|
||||
/// Read it with [`ai_shared_artifact_retention_secs`].
|
||||
static ref AI_SHARED_ARTIFACT_RETENTION_SECS: i64 = clamp_retention_secs(
|
||||
std::env::var("AI_SHARED_ARTIFACT_RETENTION_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS),
|
||||
DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS,
|
||||
"AI shared artifact",
|
||||
);
|
||||
|
||||
pub static ref MONITOR_LOGS_ON_OBJECT_STORE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
type WindmillItemKind,
|
||||
type WorkspaceItemTargetKind
|
||||
} from './workspaceItems.svelte'
|
||||
import { safeHref } from './safeHref'
|
||||
|
||||
type Props = {
|
||||
href?: string
|
||||
@@ -45,6 +46,8 @@
|
||||
const previewAction = $derived(available?.type === 'open_item_preview' ? available : undefined)
|
||||
const drawerAction = $derived(available?.type === 'open_created_resource' ? available : undefined)
|
||||
|
||||
const allowedHref = $derived(safeHref(href, window.location.href))
|
||||
|
||||
const modifier = newTabModifier()
|
||||
|
||||
const hint = $derived(
|
||||
@@ -68,7 +71,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
{#if allowedHref}
|
||||
{#if wmKind}
|
||||
<!-- Only a preview pill can change icon, so only it is worth tracking the modifier for. -->
|
||||
<span
|
||||
@@ -76,7 +79,7 @@
|
||||
{@attach previewAction ? modifier.attach : undefined}
|
||||
>
|
||||
<a
|
||||
{href}
|
||||
href={allowedHref}
|
||||
target={previewAction ? undefined : '_blank'}
|
||||
rel={previewAction ? undefined : 'noopener noreferrer'}
|
||||
title={title || hint}
|
||||
@@ -119,8 +122,11 @@
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<a {href} target="_blank" rel="noopener noreferrer" {title}>
|
||||
<a href={allowedHref} target="_blank" rel="noopener noreferrer" {title}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- An empty or unsafe href still has text; drop only the link. -->
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from 'svelte'
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import { markdownProse } from '$lib/components/markdownProse'
|
||||
import { markdownPlugins } from '$lib/components/markdownPlugins'
|
||||
import LinkRenderer from '../LinkRenderer.svelte'
|
||||
|
||||
interface Props {
|
||||
content: string
|
||||
/** The raw text rather than the rendered document. */
|
||||
source: boolean
|
||||
/**
|
||||
* The fenced-code renderer. Defaults to the shared chain's, which keeps mermaid off:
|
||||
* a shared artifact is another member's text. The session viewer passes the chat's,
|
||||
* which draws diagrams and needs the chat context the shared page does not have.
|
||||
*/
|
||||
pre?: Component<any>
|
||||
}
|
||||
|
||||
let { content, source, pre }: Props = $props()
|
||||
|
||||
// The shared chain (raw HTML re-parsed, then sanitized) rather than a chat-only one, because
|
||||
// this body also renders what another member wrote. Renderers merge in order, so the chat's
|
||||
// link pill and, when given, its code block sit on top of it.
|
||||
const plugins = $derived([
|
||||
...markdownPlugins,
|
||||
{ renderer: { a: LinkRenderer, ...(pre ? { pre } : {}) } }
|
||||
])
|
||||
</script>
|
||||
|
||||
<!-- Rendered inside the caller's scroll container: the fade below is sticky to it. -->
|
||||
{#if source}
|
||||
<!-- key: SimpleEditor reads `code` only on init. -->
|
||||
{#key content}
|
||||
<SimpleEditor lang="markdown" code={content} readOnly class="h-full" />
|
||||
{/key}
|
||||
{:else}
|
||||
<!-- Pinned under the header, fades scrolled-under content instead of hard-clipping it.
|
||||
The negative margin cancels its flow height so it overlays instead of pushing. -->
|
||||
<div class="sticky top-0 z-10 h-4 -mb-4 bg-gradient-to-b from-surface-tertiary to-transparent"
|
||||
></div>
|
||||
<div class="pb-4 pt-2 {markdownProse.doc}">
|
||||
<Markdown md={content} {plugins} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { Check, Copy, Download } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { copyToClipboard, download } from '$lib/utils'
|
||||
import { artifactFilename, artifactMimeType, type ArtifactKind } from './artifactsDB'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
kind: ArtifactKind
|
||||
content: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
let { name, kind, content, disabled = false }: Props = $props()
|
||||
|
||||
let copied = $state(false)
|
||||
async function copyRaw() {
|
||||
if (!(await copyToClipboard(content))) return
|
||||
copied = true
|
||||
setTimeout(() => (copied = false), 1500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- A Button's `disabled` does not reach its dropdown items, so the item carries its own. -->
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
{disabled}
|
||||
startIcon={{ icon: copied ? Check : Copy }}
|
||||
onClick={copyRaw}
|
||||
title="Copy raw markdown"
|
||||
dropdownItems={[
|
||||
{
|
||||
label: 'Download as .md',
|
||||
icon: Download,
|
||||
onClick: () => download(artifactFilename({ name, kind }), content, artifactMimeType(kind)),
|
||||
disabled
|
||||
}
|
||||
]}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { 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 { AiService, type SharedAiArtifactInfo } from '$lib/gen'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { copyToClipboard, displayDate } from '$lib/utils'
|
||||
import type { ArtifactKind } from './artifactsDB'
|
||||
import { formatRetention, sharedArtifactUrl, shareWorkspaceId } from './artifactSharing'
|
||||
|
||||
interface Props {
|
||||
artifactId: string
|
||||
name: string
|
||||
kind: ArtifactKind
|
||||
content: string
|
||||
/** The version on screen, which is the one a share copies. */
|
||||
version: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
let { artifactId, name, kind, content, version, disabled = false }: Props = $props()
|
||||
|
||||
const workspace = $derived(
|
||||
$workspaceStore ? shareWorkspaceId($workspaceStore, $userWorkspaces) : undefined
|
||||
)
|
||||
|
||||
const status = resource(
|
||||
() => ({ workspace, artifactId }),
|
||||
async ({ workspace, artifactId }) => {
|
||||
if (!workspace) return undefined
|
||||
try {
|
||||
return await AiService.getAiArtifactShareStatus({ workspace, artifactId })
|
||||
} catch (err) {
|
||||
// The button still works without it: sharing reports its own failure.
|
||||
console.error('Could not read the artifact share status', err)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const share = $derived(status.current?.share)
|
||||
const url = $derived(share && workspace ? sharedArtifactUrl(workspace, share.id) : undefined)
|
||||
// Which way re-sharing would move the link: a newer version on screen is an update, an
|
||||
// older one (a pinned approved plan, say) is a deliberate switch back, and the same version
|
||||
// under another name is a rename, which earns no version of its own.
|
||||
const change = $derived.by(() => {
|
||||
if (!share) return undefined
|
||||
if (share.version < version) return 'newer'
|
||||
if (share.version > version) return 'older'
|
||||
return share.name !== name.trim() ? 'renamed' : undefined
|
||||
})
|
||||
|
||||
let saving = $state(false)
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
const body = (err as { body?: unknown })?.body
|
||||
return typeof body === 'string' && body ? body : String(err)
|
||||
}
|
||||
|
||||
function reflect(next: SharedAiArtifactInfo | undefined) {
|
||||
if (!status.current) return
|
||||
status.mutate({ ...status.current, share: next })
|
||||
}
|
||||
|
||||
async function shareVersion() {
|
||||
if (!workspace) return
|
||||
const updating = share !== undefined
|
||||
saving = true
|
||||
try {
|
||||
const shared = await AiService.shareAiArtifact({
|
||||
workspace,
|
||||
requestBody: { artifact_id: artifactId, name, kind, version, content }
|
||||
})
|
||||
if (status.current) reflect(shared)
|
||||
else await status.refetch()
|
||||
if (updating) {
|
||||
sendUserToast(`The link now shows v${shared.version}`)
|
||||
} else if (await copyToClipboard(sharedArtifactUrl(workspace, shared.id), false)) {
|
||||
sendUserToast('Link copied to clipboard')
|
||||
}
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not share the artifact: ${errorMessage(err)}`, true)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSharing() {
|
||||
if (!workspace || !share) return
|
||||
saving = true
|
||||
try {
|
||||
await AiService.unshareAiArtifact({ 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}
|
||||
triggerAttrs={{ 'aria-label': 'Share artifact', 'aria-haspopup': 'dialog' }}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
nonCaptureEvent
|
||||
disabled={disabled || !workspace}
|
||||
startIcon={{ icon: share ? Link : Share2 }}
|
||||
title={share ? 'Shared with the workspace' : 'Share with the workspace'}
|
||||
>
|
||||
{share ? 'Shared' : 'Share'}
|
||||
</Button>
|
||||
{/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 with workspace</span>
|
||||
<span class="font-normal text-secondary">
|
||||
{#if share}
|
||||
Members of {workspace} can open a read-only copy of v{share.version} with this link.
|
||||
{:else}
|
||||
Members of {workspace} will be able to open a read-only copy of v{version} with a link.
|
||||
{/if}
|
||||
{#if status.current}
|
||||
The copy is deleted {formatRetention(status.current.retention_secs)} after it is shared.
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if share && url}
|
||||
<div class="flex flex-col gap-1">
|
||||
<ClipboardPanel content={url} size="sm" />
|
||||
<span class="text-2xs font-normal text-hint">
|
||||
Expires {displayDate(share.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
{#if change}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-normal text-secondary">
|
||||
{#if change === 'newer'}
|
||||
The link shows v{share.version}; v{version} is on screen.
|
||||
{:else if change === 'older'}
|
||||
The link shows v{share.version}, newer than the v{version} on screen.
|
||||
{:else}
|
||||
The link still shows the old name, “{share.name}”.
|
||||
{/if}
|
||||
</span>
|
||||
<Button unifiedSize="sm" variant="accent" loading={saving} onClick={shareVersion}>
|
||||
{#if change === 'newer'}
|
||||
Update link to v{version}
|
||||
{:else if change === 'older'}
|
||||
Share v{version} instead
|
||||
{:else}
|
||||
Update name
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<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}
|
||||
onClick={shareVersion}
|
||||
>
|
||||
Create link
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -1,23 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte'
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import { Code, Eye, FileText, Copy, Check, Download, ClipboardList } from 'lucide-svelte'
|
||||
import { Code, Eye, FileText, ClipboardList } from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import { copyToClipboard, download } from '$lib/utils'
|
||||
import { currentVersion, type ArtifactVersion, type PersistedArtifact } from './artifactsDB'
|
||||
import CodeDisplay from '../script/CodeDisplay.svelte'
|
||||
import LinkRenderer from '../LinkRenderer.svelte'
|
||||
import {
|
||||
artifactFilename,
|
||||
artifactMimeType,
|
||||
currentVersion,
|
||||
type ArtifactVersion,
|
||||
type PersistedArtifact
|
||||
} from './artifactsDB'
|
||||
import { markdownProse } from '$lib/components/markdownProse'
|
||||
import ArtifactBody from './ArtifactBody.svelte'
|
||||
import ArtifactExportButton from './ArtifactExportButton.svelte'
|
||||
import ArtifactShareButton from './ArtifactShareButton.svelte'
|
||||
import ArtifactVersionPicker from './ArtifactVersionPicker.svelte'
|
||||
import type { SessionArtifactsStore } from './artifactsState.svelte'
|
||||
import { History } from 'lucide-svelte'
|
||||
@@ -114,22 +105,6 @@
|
||||
const backTo = $derived(view.backToPlan)
|
||||
let showSource = $state(false)
|
||||
const source = $derived(!canPreview || showSource)
|
||||
|
||||
let copied = $state(false)
|
||||
async function copyRaw() {
|
||||
if (!(await copyToClipboard(shown.content))) return
|
||||
copied = true
|
||||
setTimeout(() => (copied = false), 1500)
|
||||
}
|
||||
function downloadFile() {
|
||||
download(
|
||||
artifactFilename({ name: shown.name, kind: artifact.kind }),
|
||||
shown.content,
|
||||
artifactMimeType(artifact.kind)
|
||||
)
|
||||
}
|
||||
|
||||
const plugins = [gfmPlugin(), { renderer: { pre: CodeDisplay, a: LinkRenderer } }]
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full bg-surface-tertiary">
|
||||
@@ -169,27 +144,22 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<!-- Copy raw markdown, with a dropdown for the download-as-file variant. Both export
|
||||
`shown`, which is still the current document while a pin is restoring — so both
|
||||
are disabled, the item explicitly: a Button's `disabled` does not reach it. -->
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
<!-- Both export and share `shown`, which is still the current document while a pin is
|
||||
restoring, so both wait for it. -->
|
||||
<ArtifactShareButton
|
||||
artifactId={artifact.id}
|
||||
name={shown.name}
|
||||
kind={artifact.kind}
|
||||
content={shown.content}
|
||||
version={shownVersion ?? latest}
|
||||
disabled={restoringPin}
|
||||
startIcon={{ icon: copied ? Check : Copy }}
|
||||
onClick={copyRaw}
|
||||
title="Copy raw markdown"
|
||||
dropdownItems={[
|
||||
{
|
||||
label: 'Download as .md',
|
||||
icon: Download,
|
||||
onClick: downloadFile,
|
||||
disabled: restoringPin
|
||||
}
|
||||
]}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
/>
|
||||
<ArtifactExportButton
|
||||
name={shown.name}
|
||||
kind={artifact.kind}
|
||||
content={shown.content}
|
||||
disabled={restoringPin}
|
||||
/>
|
||||
{#if canPreview}
|
||||
<ToggleButtonGroup
|
||||
noWFull
|
||||
@@ -269,19 +239,8 @@
|
||||
<div class="flex-1 min-h-0 overflow-auto px-8">
|
||||
{#if restoringPin}
|
||||
<!-- Deliberately blank until the pinned snapshot lands; see restoringPin. -->
|
||||
{:else if source}
|
||||
<!-- key: SimpleEditor reads `code` only on init; remount on id or content change. -->
|
||||
{#key `${artifact.id}:${pinnedContent ? `v${pinnedContent.version}` : artifact.updatedAt}`}
|
||||
<SimpleEditor lang="markdown" code={shown.content} readOnly class="h-full" />
|
||||
{/key}
|
||||
{:else}
|
||||
<!-- Pinned under the header, fades scrolled-under content instead of hard-clipping it.
|
||||
The negative margin cancels its flow height so it overlays instead of pushing. -->
|
||||
<div class="sticky top-0 z-10 h-4 -mb-4 bg-gradient-to-b from-surface-tertiary to-transparent"
|
||||
></div>
|
||||
<div class="pb-4 pt-2 {markdownProse.doc}">
|
||||
<Markdown md={shown.content} {plugins} />
|
||||
</div>
|
||||
<ArtifactBody content={shown.content} {source} pre={CodeDisplay} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('$lib/base', () => ({ base: '' }))
|
||||
|
||||
import { shareWorkspaceId } from './artifactSharing'
|
||||
|
||||
describe('shareWorkspaceId', () => {
|
||||
it('shares a fork session into the topmost workspace the user still belongs to', () => {
|
||||
const workspaces = [
|
||||
{ id: 'prod' },
|
||||
{ id: 'wm-fork-a', parent_workspace_id: 'prod' },
|
||||
{ id: 'wm-fork-b', parent_workspace_id: 'wm-fork-a' }
|
||||
]
|
||||
expect(shareWorkspaceId('wm-fork-b', workspaces)).toBe('prod')
|
||||
})
|
||||
|
||||
it('stops below a parent the user is not a member of', () => {
|
||||
const workspaces = [{ id: 'wm-fork-a', parent_workspace_id: 'prod' }]
|
||||
expect(shareWorkspaceId('wm-fork-a', workspaces)).toBe('wm-fork-a')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { base } from '$lib/base'
|
||||
|
||||
/**
|
||||
* The workspace a share from `workspaceId` lands in: the topmost ancestor the user still
|
||||
* belongs to. A session often runs in a fork, whose members are its creator alone, so a link
|
||||
* minted there would reach nobody — and it would be deleted with the fork.
|
||||
*/
|
||||
export function shareWorkspaceId(
|
||||
workspaceId: string,
|
||||
workspaces: { id: string; parent_workspace_id?: string | null }[]
|
||||
): string {
|
||||
let current = workspaceId
|
||||
const seen = new Set([current])
|
||||
for (;;) {
|
||||
const parent = workspaces.find((w) => w.id === current)?.parent_workspace_id
|
||||
if (!parent || seen.has(parent) || !workspaces.some((w) => w.id === parent)) return current
|
||||
seen.add(parent)
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
export function sharedArtifactUrl(workspaceId: string, shareId: string): string {
|
||||
return `${window.location.origin}${base}/shared_artifacts/${encodeURIComponent(
|
||||
shareId
|
||||
)}?workspace=${encodeURIComponent(workspaceId)}`
|
||||
}
|
||||
|
||||
/** "30 days", "12 hours": the retention window in the largest whole unit it fills. */
|
||||
export function formatRetention(secs: number): string {
|
||||
const units: [string, number][] = [
|
||||
['day', 86400],
|
||||
['hour', 3600],
|
||||
['minute', 60]
|
||||
]
|
||||
for (const [unit, size] of units) {
|
||||
if (secs >= size) {
|
||||
const n = Math.floor(secs / size)
|
||||
return `${n} ${unit}${n === 1 ? '' : 's'}`
|
||||
}
|
||||
}
|
||||
return `${secs} second${secs === 1 ? '' : 's'}`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { safeHref } from './safeHref'
|
||||
|
||||
const BASE = 'https://app.example.com/sessions?workspace=demo'
|
||||
|
||||
describe('safeHref', () => {
|
||||
it.each([
|
||||
'https://windmill.dev/docs',
|
||||
'http://localhost:3000/',
|
||||
'mailto:someone@example.com',
|
||||
'/runs/abc',
|
||||
'#anchor',
|
||||
'docs/page'
|
||||
])('keeps %s', (href) => {
|
||||
expect(safeHref(href, BASE)).toBe(href)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'JavaScript:alert(1)',
|
||||
' javascript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox',
|
||||
'file:///etc/passwd'
|
||||
])('drops %s', (href) => {
|
||||
expect(safeHref(href, BASE)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a missing or empty href', () => {
|
||||
expect(safeHref(undefined, BASE)).toBeUndefined()
|
||||
expect(safeHref('', BASE)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
const SAFE_PROTOCOLS = ['http:', 'https:', 'mailto:']
|
||||
|
||||
/**
|
||||
* The href a rendered markdown link may carry, or undefined for one that must be dropped.
|
||||
*
|
||||
* Markdown reaches the chat renderers from a model, and from another member for a shared
|
||||
* artifact, and `svelte-exmarkdown` passes `javascript:` and `data:` hrefs through untouched.
|
||||
* Relative links resolve against `base` (the page), so they stay.
|
||||
*/
|
||||
export function safeHref(href: string | undefined, base: string): string | undefined {
|
||||
if (!href) return undefined
|
||||
try {
|
||||
return SAFE_PROTOCOLS.includes(new URL(href, base).protocol) ? href : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state'
|
||||
import { resource } from 'runed'
|
||||
import { Code, Eye, FileText, Link2Off } from 'lucide-svelte'
|
||||
import { Button, EmptyState, Skeleton } from '$lib/components/common'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ArtifactBody from '$lib/components/copilot/chat/artifacts/ArtifactBody.svelte'
|
||||
import ArtifactExportButton from '$lib/components/copilot/chat/artifacts/ArtifactExportButton.svelte'
|
||||
import { AiService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { displayDate } from '$lib/utils'
|
||||
|
||||
const id = $derived(page.params.id ?? '')
|
||||
// The link carries its workspace; the store only catches up once the layout applies it.
|
||||
const workspace = $derived(page.url.searchParams.get('workspace') ?? $workspaceStore)
|
||||
|
||||
type Loaded =
|
||||
| { state: 'found'; artifact: Awaited<ReturnType<typeof AiService.getSharedAiArtifact>> }
|
||||
| { state: 'gone' }
|
||||
| { state: 'error'; message: string }
|
||||
|
||||
const shared = resource(
|
||||
() => ({ workspace, id }),
|
||||
async ({ workspace, id }): Promise<Loaded | undefined> => {
|
||||
if (!workspace || !id) return undefined
|
||||
try {
|
||||
return {
|
||||
state: 'found',
|
||||
artifact: await AiService.getSharedAiArtifact({ workspace, id })
|
||||
}
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number })?.status
|
||||
// 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 artifact = $derived(shared.current?.state === 'found' ? shared.current.artifact : undefined)
|
||||
|
||||
let showSource = $state(false)
|
||||
const source = $derived(artifact?.kind !== 'md' || showSource)
|
||||
|
||||
let removing = $state(false)
|
||||
async function stopSharing() {
|
||||
if (!workspace || !artifact) return
|
||||
removing = true
|
||||
try {
|
||||
await AiService.unshareAiArtifact({ workspace, id: artifact.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>
|
||||
|
||||
<div class="flex h-full flex-col px-4 py-6 sm:px-8">
|
||||
<div class="mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col gap-3">
|
||||
{#if artifact}
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<FileText size={16} class="shrink-0 text-secondary" />
|
||||
<h1 class="truncate text-lg font-semibold text-emphasis" title={artifact.name}>
|
||||
{artifact.name}
|
||||
</h1>
|
||||
</div>
|
||||
<span class="text-xs font-normal text-secondary">
|
||||
Shared by {artifact.created_by} · v{artifact.version} · {displayDate(
|
||||
artifact.shared_at
|
||||
)} · expires {displayDate(artifact.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
{#if artifact.can_unshare}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
destructive
|
||||
loading={removing}
|
||||
onClick={stopSharing}
|
||||
>
|
||||
Stop sharing
|
||||
</Button>
|
||||
{/if}
|
||||
<ArtifactExportButton
|
||||
name={artifact.name}
|
||||
kind={artifact.kind}
|
||||
content={artifact.content}
|
||||
/>
|
||||
{#if artifact.kind === 'md'}
|
||||
<ToggleButtonGroup
|
||||
noWFull
|
||||
selected={showSource ? 'source' : 'preview'}
|
||||
onSelected={(v) => (showSource = v === 'source')}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
{item}
|
||||
value="preview"
|
||||
icon={Eye}
|
||||
iconOnly
|
||||
tooltip="Preview"
|
||||
size="sm"
|
||||
/>
|
||||
<ToggleButton
|
||||
{item}
|
||||
value="source"
|
||||
icon={Code}
|
||||
iconOnly
|
||||
tooltip="View source"
|
||||
size="sm"
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-surface-tertiary px-8">
|
||||
<ArtifactBody content={artifact.content} {source} />
|
||||
</div>
|
||||
{:else if shared.current?.state === 'gone'}
|
||||
<EmptyState
|
||||
icon={Link2Off}
|
||||
title="This shared artifact is no longer available"
|
||||
description="Its link expired, or its author stopped sharing it."
|
||||
/>
|
||||
{:else if shared.current?.state === 'error'}
|
||||
<EmptyState
|
||||
icon={Link2Off}
|
||||
title="Could not load this shared artifact"
|
||||
description={shared.current.message}
|
||||
/>
|
||||
{:else}
|
||||
<Skeleton layout={[[3], 1, [30]]} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
export function load() {
|
||||
return {
|
||||
stuff: { title: 'Shared artifact' }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user