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
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pyjp67oR269QAx3b4yf4oH
This commit is contained in:
co-authored by
Claude Opus 5
parent
c90d1d95c2
commit
b074f73974
@@ -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);
|
||||
@@ -1882,6 +1882,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,127 @@
|
||||
//! 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"))]
|
||||
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");
|
||||
|
||||
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(())
|
||||
}
|
||||
@@ -12896,6 +12896,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
|
||||
@@ -27961,6 +28089,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")]
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -457,6 +457,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);
|
||||
|
||||
|
||||
@@ -45,6 +45,21 @@
|
||||
const previewAction = $derived(available?.type === 'open_item_preview' ? available : undefined)
|
||||
const drawerAction = $derived(available?.type === 'open_created_resource' ? available : undefined)
|
||||
|
||||
// The markdown is authored by a model, or by another member for a shared artifact, and the
|
||||
// renderer passes `javascript:` and `data:` hrefs through untouched. Relative links resolve
|
||||
// against this page, so they stay.
|
||||
const SAFE_PROTOCOLS = ['http:', 'https:', 'mailto:']
|
||||
const safeHref = $derived.by(() => {
|
||||
if (!href) return undefined
|
||||
try {
|
||||
return SAFE_PROTOCOLS.includes(new URL(href, window.location.href).protocol)
|
||||
? href
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
|
||||
const modifier = newTabModifier()
|
||||
|
||||
const hint = $derived(
|
||||
@@ -68,7 +83,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
{#if safeHref}
|
||||
{#if wmKind}
|
||||
<!-- Only a preview pill can change icon, so only it is worth tracking the modifier for. -->
|
||||
<span
|
||||
@@ -76,7 +91,7 @@
|
||||
{@attach previewAction ? modifier.attach : undefined}
|
||||
>
|
||||
<a
|
||||
{href}
|
||||
href={safeHref}
|
||||
target={previewAction ? undefined : '_blank'}
|
||||
rel={previewAction ? undefined : 'noopener noreferrer'}
|
||||
title={title || hint}
|
||||
@@ -119,8 +134,10 @@
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<a {href} target="_blank" rel="noopener noreferrer" {title}>
|
||||
<a href={safeHref} target="_blank" rel="noopener noreferrer" {title}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
||||
{:else if href}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
import { markdownProse } from '$lib/components/markdownProse'
|
||||
import CodeDisplay from '../script/CodeDisplay.svelte'
|
||||
import LinkRenderer from '../LinkRenderer.svelte'
|
||||
|
||||
interface Props {
|
||||
content: string
|
||||
/** The raw text rather than the rendered document. */
|
||||
source: boolean
|
||||
}
|
||||
|
||||
let { content, source }: Props = $props()
|
||||
|
||||
const plugins = [gfmPlugin(), { renderer: { pre: CodeDisplay, a: LinkRenderer } }]
|
||||
</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,179 @@
|
||||
<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)
|
||||
// A rename earns no version, so the name is compared too.
|
||||
const outdated = $derived(
|
||||
share !== undefined && (share.version !== version || share.name !== name.trim())
|
||||
)
|
||||
|
||||
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 outdated}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="font-normal text-secondary">
|
||||
The link shows v{share.version}{share.name !== name.trim() ? ` (${share.name})` : ''}.
|
||||
</span>
|
||||
<Button unifiedSize="sm" variant="accent" loading={saving} onClick={shareVersion}>
|
||||
Update to v{version}
|
||||
</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,13 @@
|
||||
<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 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 { currentVersion, type ArtifactVersion, type PersistedArtifact } from './artifactsDB'
|
||||
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 +104,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 +143,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 +238,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} />
|
||||
{/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,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