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
123 lines
4.2 KiB
TypeScript
123 lines
4.2 KiB
TypeScript
/**
|
|
* ArtifactsSidebar - Floating list of all artifacts in current conversation
|
|
*
|
|
* Appears in bottom-right of the side panel.
|
|
* Lists all artifacts with download buttons and click-to-switch functionality.
|
|
*/
|
|
|
|
import { useArtifacts } from '@/hooks/useArtifacts'
|
|
import { cn } from '@/lib/utils'
|
|
import { IconDownload, IconFileText, IconCode } from '@tabler/icons-react'
|
|
|
|
type ArtifactsSidebarProps = {
|
|
threadId: string
|
|
}
|
|
|
|
export function ArtifactsSidebar({ threadId }: ArtifactsSidebarProps) {
|
|
const artifacts = useArtifacts()
|
|
const threadArtifacts = artifacts.threadArtifacts[threadId] || []
|
|
const activeArtifactId = artifacts.threadIndex[threadId]?.active_artifact_id
|
|
|
|
const handleDownloadAll = () => {
|
|
// Download all artifacts as individual files
|
|
threadArtifacts.forEach((artifact: any) => {
|
|
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 handleSelectArtifact = (artifactId: string) => {
|
|
artifacts.setActiveArtifact(threadId, artifactId)
|
|
}
|
|
|
|
const handleDownloadSingle = (artifact: any, e: React.MouseEvent) => {
|
|
e.stopPropagation() // Prevent selecting the artifact
|
|
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 getArtifactIcon = (artifact: any) => {
|
|
if (artifact.content_type.startsWith('text/x-')) {
|
|
return <IconCode size={14} />
|
|
}
|
|
return <IconFileText size={14} />
|
|
}
|
|
|
|
const getFileType = (artifact: any) => {
|
|
const ext = artifact.file_path.split('.').pop()
|
|
return ext?.toUpperCase() || 'TXT'
|
|
}
|
|
|
|
if (threadArtifacts.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<div className="bg-background border border-main-view-fg/10 rounded-lg shadow-lg overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-3 border-b border-main-view-fg/10 bg-main-view-fg/5">
|
|
<h3 className="text-xs font-semibold text-main-view-fg">Artifacts</h3>
|
|
{threadArtifacts.length > 1 && (
|
|
<button
|
|
onClick={handleDownloadAll}
|
|
className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-main-view-fg/70 hover:text-main-view-fg hover:bg-main-view-fg/10 rounded transition-colors"
|
|
>
|
|
<IconDownload size={12} />
|
|
Download all
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Artifact List */}
|
|
<div className="max-h-64 overflow-y-auto">
|
|
{threadArtifacts.map((artifact) => (
|
|
<button
|
|
key={artifact.id}
|
|
onClick={() => handleSelectArtifact(artifact.id)}
|
|
className={cn(
|
|
"w-full flex items-center gap-2 p-3 text-left transition-colors border-b border-main-view-fg/5 last:border-b-0",
|
|
artifact.id === activeArtifactId
|
|
? "bg-main-view-fg/10"
|
|
: "hover:bg-main-view-fg/5"
|
|
)}
|
|
>
|
|
<div className="flex-shrink-0 text-main-view-fg/60">
|
|
{getArtifactIcon(artifact)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium text-main-view-fg truncate">
|
|
{artifact.name}
|
|
</div>
|
|
<div className="text-xs text-main-view-fg/50 mt-0.5">
|
|
{artifact.content_type === 'text/markdown' ? 'Document' : 'Code'} · {getFileType(artifact)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={(e) => handleDownloadSingle(artifact, e)}
|
|
className="flex-shrink-0 p-1.5 hover:bg-main-view-fg/10 rounded transition-colors"
|
|
title="Download"
|
|
>
|
|
<IconDownload size={14} />
|
|
</button>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|