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
167 lines
5.6 KiB
TypeScript
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>
|
|
)
|
|
}
|
|
|