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
207 lines
7.3 KiB
TypeScript
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
|
|
}
|
|
|