jan/web-app/src/containers/InlineArtifactCard.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

167 lines
5.6 KiB
TypeScript

/**
* InlineArtifactCard - Claude-style inline artifact preview card
*
* Displays a collapsed preview of an artifact within the chat message flow.
* Clicking "Preview contents" opens the artifact in the split view/side panel.
*/
import type { Artifact } from '@janhq/core'
import { useArtifacts } from '@/hooks/useArtifacts'
import { useState } from 'react'
import { cn } from '@/lib/utils'
type InlineArtifactCardProps = {
artifact: Artifact & { content: string } // Artifact with loaded content
threadId: string
isCreating?: boolean
}
export function InlineArtifactCard({ artifact, threadId, isCreating }: InlineArtifactCardProps) {
const artifacts = useArtifacts()
const [isExpanded, setIsExpanded] = useState(false)
const handlePreviewClick = () => {
// Open split view and set this as active artifact
artifacts.setActiveArtifact(threadId, artifact.id)
if (!artifacts.splitViewOpen[threadId]) {
artifacts.toggleSplitView(threadId)
}
}
const handleDownload = () => {
// Create blob and download
const blob = new Blob([artifact.content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = artifact.file_path.split('/').pop() || 'artifact.txt'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const getFileExtension = () => {
const ext = artifact.file_path.split('.').pop()
return ext?.toUpperCase() || 'TXT'
}
const getArtifactType = () => {
if (artifact.content_type.startsWith('text/x-')) {
return 'Code'
}
if (artifact.content_type === 'text/markdown') {
return 'Document'
}
return 'File'
}
const getContentPreview = () => {
// First ~200 chars for preview
const preview = artifact.content.substring(0, 200)
return preview.length < artifact.content.length ? preview + '...' : preview
}
return (
<div className="my-3 border border-main-view-fg/10 rounded-lg overflow-hidden bg-main-view-fg/5">
{/* Header */}
<button
onClick={handlePreviewClick}
disabled={isCreating}
className={cn(
"w-full flex items-center gap-3 p-3 text-left transition-colors",
"hover:bg-main-view-fg/10",
isCreating && "opacity-60 cursor-not-allowed"
)}
>
<div className="flex-shrink-0">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
className="text-main-view-fg/60"
>
<path
d="M6 2C5.44772 2 5 2.44772 5 3V17C5 17.5523 5.44772 18 6 18H14C14.5523 18 15 17.5523 15 17V6.41421C15 6.149 14.8946 5.89464 14.7071 5.70711L11.2929 2.29289C11.1054 2.10536 10.851 2 10.5858 2H6Z"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
/>
<path d="M11 2V5C11 5.55228 11.4477 6 12 6H15" 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">
{isCreating ? 'Creating' : artifact.name}
</div>
<div className="text-xs text-main-view-fg/60 mt-0.5">
{getArtifactType()} · {getFileExtension()}
</div>
</div>
{isCreating && (
<div className="flex-shrink-0">
<svg
className="animate-spin h-4 w-4 text-main-view-fg/60"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
</button>
{/* Collapsible Preview */}
{isExpanded && !isCreating && (
<div className="border-t border-main-view-fg/10 p-4 bg-main-view-fg/5">
<pre className="text-xs text-main-view-fg/70 whitespace-pre-wrap font-mono overflow-auto max-h-40">
{getContentPreview()}
</pre>
</div>
)}
{/* Footer Actions */}
<div className="border-t border-main-view-fg/10 p-3 flex items-center justify-between bg-main-view-fg/5">
<button
onClick={() => setIsExpanded(!isExpanded)}
disabled={isCreating}
className="text-xs text-main-view-fg/60 hover:text-main-view-fg transition-colors disabled:opacity-50"
>
{isExpanded ? 'Hide preview' : 'Show preview'}
</button>
<div className="flex items-center gap-2">
<button
onClick={handleDownload}
disabled={isCreating}
className="px-2 py-1 text-xs font-medium rounded-md hover:bg-main-view-fg/10 transition-colors disabled:opacity-50"
>
Download
</button>
<button
onClick={handlePreviewClick}
disabled={isCreating}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-main-view-fg/10 hover:bg-main-view-fg/20 transition-colors disabled:opacity-50"
>
{isCreating ? 'Creating...' : 'Preview contents'}
</button>
</div>
</div>
</div>
)
}