jan/web-app/src/containers/ArtifactActionMessage.tsx
Nicholai 4e92884d51 feat: add Claude-style artifacts for persistent workspace documents
Implement a comprehensive artifacts system that allows users to create, edit,
and manage persistent documents alongside conversations, inspired by Claude's
artifacts feature.

Key Features:
- AI model integration with system prompts teaching artifact usage
- Inline preview cards in chat messages with collapsible previews
- On-demand side panel overlay (replaces split view)
- Preview/Code toggle for rendered markdown vs raw content
- Artifacts sidebar for managing multiple artifacts per thread
- Monaco editor integration for code artifacts
- Autosave with debounced writes and conflict detection
- Diff preview system for proposed updates
- Keyboard shortcuts and quick switcher
- Export/import functionality

Backend (Rust):
- New artifacts module in src-tauri/src/core/artifacts/
- 12 Tauri commands for CRUD, proposals, and export/import
- Atomic file writes with SHA256 hashing
- Sharded history storage with pruning policy
- Path traversal validation and UTF-8 enforcement

Frontend (TypeScript/React):
- ArtifactSidePanel: Claude-style overlay panel from right
- InlineArtifactCard: Preview cards embedded in chat
- ArtifactsSidebar: Floating list for artifact switching
- Enhanced ArtifactActionMessage: Parses AI metadata from content
- useArtifacts: Zustand store with autosave and conflict resolution

Types:
- Extended ThreadMessage.metadata with ArtifactAction
- ProposedContentRef supports inline and temp storage
- MIME-like content_type values for extensibility

Platform:
- Fixed platform detection to check window.__TAURI__
- Web service stubs for browser mode compatibility
- Updated assistant system prompts in both extension and web-app

This implements the complete workflow studied from Claude:
1. AI only creates artifacts when explicitly requested
2. Inline cards appear in chat with preview buttons
3. Side panel opens on demand, not automatic split
4. Users can toggle Preview/Code views and edit content
5. Autosave and version management prevent data loss
2025-11-02 12:19:36 -07:00

207 lines
7.3 KiB
TypeScript

import type { ThreadMessage, ArtifactAction, ProposedContentRef, Artifact } from '@janhq/core'
import { useArtifacts } from '@/hooks/useArtifacts'
import { useEffect, useState } from 'react'
import { getServiceHub } from '@/hooks/useServiceHub'
import { InlineArtifactCard } from './InlineArtifactCard'
type ArtifactActionMessageProps = {
message: ThreadMessage
}
/**
* Extract artifact_action from message metadata or content
* Claude-style: AI embeds JSON in message content, we parse it
*/
function extractArtifactAction(message: ThreadMessage): ArtifactAction | undefined {
// First check metadata (preferred)
if (message.metadata?.artifact_action) {
return message.metadata.artifact_action as ArtifactAction
}
// Fallback: parse from message content
if (typeof message.content === 'string') {
try {
// Look for JSON blocks in the message
const jsonMatch = message.content.match(/```json\s*(\{[\s\S]*?\})\s*```/)
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[1])
if (parsed.artifact_action) {
return parsed.artifact_action as ArtifactAction
}
}
// Also try direct JSON parsing (in case AI outputs raw JSON)
const artifactMatch = message.content.match(/\{\s*"artifact_action"[\s\S]*?\}/)
if (artifactMatch) {
const parsed = JSON.parse(artifactMatch[0])
if (parsed.artifact_action) {
return parsed.artifact_action as ArtifactAction
}
}
} catch (err) {
// Not valid JSON, ignore
}
}
return undefined
}
export function ArtifactActionMessage({ message }: ArtifactActionMessageProps) {
const artifacts = useArtifacts()
const artifactAction = extractArtifactAction(message)
const [loadingProposal, setLoadingProposal] = useState(false)
const [artifactCreated, setArtifactCreated] = useState(false)
if (!artifactAction) return null
// Auto-create artifact when message with create action is received
useEffect(() => {
if (
artifactAction.type === 'create' &&
message.thread_id &&
!artifactCreated
) {
const { name, content_type, language, preview } = artifactAction.artifact
const content = preview || '' // Use preview as initial content
artifacts.createArtifact(message.thread_id, name, content_type, language, content)
.then(() => {
setArtifactCreated(true)
})
.catch((err) => {
console.error('Failed to auto-create artifact:', err)
})
}
}, [artifactAction, message.thread_id, artifactCreated])
// Load proposal content if it exists
useEffect(() => {
if (artifactAction.type === 'update' && artifactAction.proposed_content_ref && message.thread_id) {
const ref = artifactAction.proposed_content_ref as ProposedContentRef
// Check if we already have this proposal loaded
const existingProposal = artifacts.pendingProposals[artifactAction.artifact_id]
if (existingProposal) return
setLoadingProposal(true)
// Handle inline content vs file reference
if (ref.storage === 'inline' && ref.content) {
// Content is provided inline
artifacts.proposeUpdate(message.thread_id, artifactAction.artifact_id, ref.content)
.catch((err) => {
console.error('Failed to load inline proposal:', err)
})
.finally(() => {
setLoadingProposal(false)
})
} else if (ref.storage === 'temp' && ref.path) {
// Read the proposed content from the temp file
getServiceHub()
.artifacts()
.getArtifactContent(message.thread_id, ref.path)
.then((content) => {
if (content && message.thread_id) {
// Create a proposal
return artifacts.proposeUpdate(message.thread_id, artifactAction.artifact_id, content)
}
})
.catch((err) => {
console.error('Failed to load proposal:', err)
})
.finally(() => {
setLoadingProposal(false)
})
} else {
setLoadingProposal(false)
}
}
}, [artifactAction, message.thread_id])
const handleOpenArtifact = () => {
if (artifactAction.type === 'create' && message.thread_id) {
// For create actions, the artifact should already be created
// Just open the split view
artifacts.toggleSplitView(message.thread_id)
} else if (artifactAction.type === 'update' && message.thread_id) {
// For update actions, show the diff or open the artifact
if (!artifacts.splitViewOpen[message.thread_id]) {
artifacts.toggleSplitView(message.thread_id)
}
artifacts.setActiveArtifact(message.thread_id, artifactAction.artifact_id)
}
}
if (artifactAction.type === 'create') {
// Convert ArtifactAction to Artifact for the card
const artifactForCard: Artifact & { content: string } = {
id: artifactAction.artifact_id || 'pending',
name: artifactAction.artifact.name,
slug: artifactAction.artifact.name.toLowerCase().replace(/\s+/g, '-'),
content_type: artifactAction.artifact.content_type,
language: artifactAction.artifact.language,
content: artifactAction.artifact.preview || '',
file_path: `${artifactAction.artifact.name}.${artifactAction.artifact.language || 'txt'}`,
version: 1,
hash: '',
bytes: 0,
created_at: Date.now(),
updated_at: Date.now(),
read_only: false,
extensions: {},
metadata: {
archived: false,
tags: [],
},
}
return <InlineArtifactCard artifact={artifactForCard} threadId={message.thread_id || ''} isCreating={!artifactCreated} />
}
if (artifactAction.type === 'update') {
return (
<div className="flex items-start gap-3 p-3 my-2 rounded-lg bg-blue-500/10 border border-blue-500/20">
<div className="flex-shrink-0 mt-0.5">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
className="text-blue-500"
>
<path
d="M13.5858 3.58579C14.3668 2.80474 15.6332 2.80474 16.4142 3.58579C17.1953 4.36683 17.1953 5.63316 16.4142 6.41421L6.41421 16.4142C6.03914 16.7893 5.53043 17 5 17H3C2.44772 17 2 16.5523 2 16V14C2 13.4696 2.21071 12.9609 2.58579 12.5858L12.5858 2.58579C13.3668 1.80474 14.6332 1.80474 15.4142 2.58579Z"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-main-view-fg">
Updated artifact
</div>
<div className="text-xs text-main-view-fg/60 mt-1">
{artifactAction.changes.description}
</div>
{artifactAction.changes.diff_preview && (
<div className="text-xs text-main-view-fg/50 mt-2 font-mono truncate">
{artifactAction.changes.diff_preview}
</div>
)}
</div>
<button
onClick={handleOpenArtifact}
disabled={loadingProposal}
className="flex-shrink-0 px-3 py-1.5 text-xs font-medium rounded-md bg-blue-500/20 hover:bg-blue-500/30 transition-colors disabled:opacity-50"
>
{loadingProposal ? 'Loading...' : 'View Changes'}
</button>
</div>
)
}
return null
}