jan/web-app/src/hooks/useArtifacts.ts
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

500 lines
15 KiB
TypeScript

import { create } from 'zustand'
import type { Artifact, ArtifactIndex, DiffPreview } from '@janhq/core'
import { getServiceHub } from '@/hooks/useServiceHub'
/**
* Debounce delay for autosave (ms)
*/
const AUTOSAVE_DEBOUNCE = 1200
/**
* Artifact store state
*/
type ArtifactsState = {
// Data
threadArtifacts: Record<string, Artifact[]>
threadIndex: Record<string, ArtifactIndex>
artifactContent: Record<string, string> // artifact_id → content
dirtyArtifacts: Set<string> // artifact_ids with unsaved changes
// Conflict tracking
editorVersions: Record<string, { version: number; hash: string }>
// UI state
splitViewOpen: Record<string, boolean>
splitRatio: Record<string, number>
// Proposals
pendingProposals: Record<string, DiffPreview>
// Loading states
loadingArtifacts: Record<string, boolean>
savingArtifacts: Set<string>
// Debounce timer
saveTimers: Record<string, NodeJS.Timeout>
// Conflict state
conflicts: Record<string, { localContent: string; serverContent: string }>
// Actions
loadArtifacts: (threadId: string) => Promise<void>
createArtifact: (
threadId: string,
name: string,
contentType: string,
language: string | undefined,
initialContent: string
) => Promise<Artifact | null>
getArtifactContent: (threadId: string, artifactId: string) => Promise<string | null>
updateArtifactContent: (artifactId: string, content: string) => void
saveArtifact: (threadId: string, artifactId: string) => Promise<void>
deleteArtifact: (threadId: string, artifactId: string) => Promise<void>
renameArtifact: (threadId: string, artifactId: string, newName: string) => Promise<void>
setActiveArtifact: (threadId: string, artifactId: string | null) => void
toggleSplitView: (threadId: string) => void
setSplitRatio: (threadId: string, ratio: number) => void
proposeUpdate: (threadId: string, artifactId: string, content: string) => Promise<DiffPreview | null>
applyProposal: (threadId: string, artifactId: string, proposalId: string, selectedHunks?: number[]) => Promise<void>
discardProposal: (threadId: string, artifactId: string, proposalId: string) => void
clearPendingProposal: (artifactId: string) => void
forceSaveAll: (threadId: string) => Promise<void>
resolveConflict: (threadId: string, artifactId: string, resolution: 'keep-mine' | 'take-theirs') => Promise<void>
clearConflict: (artifactId: string) => void
}
/**
* Load split view state from localStorage
*/
function loadSplitState(threadId: string): { open: boolean; ratio: number } {
const open = localStorage.getItem(`artifacts_split_open_${threadId}`) === 'true'
const ratio = parseInt(localStorage.getItem(`artifacts_split_ratio_${threadId}`) || '60', 10)
return { open, ratio: Math.max(40, Math.min(80, ratio)) }
}
/**
* Save split view state to localStorage
*/
function saveSplitState(threadId: string, open: boolean, ratio: number) {
localStorage.setItem(`artifacts_split_open_${threadId}`, open.toString())
localStorage.setItem(`artifacts_split_ratio_${threadId}`, ratio.toString())
}
export const useArtifacts = create<ArtifactsState>()((set, get) => ({
// Initial state
threadArtifacts: {},
threadIndex: {},
artifactContent: {},
dirtyArtifacts: new Set(),
editorVersions: {},
splitViewOpen: {},
splitRatio: {},
pendingProposals: {},
loadingArtifacts: {},
savingArtifacts: new Set(),
saveTimers: {},
conflicts: {},
// Load artifacts for a thread
loadArtifacts: async (threadId: string) => {
set((state) => ({
loadingArtifacts: { ...state.loadingArtifacts, [threadId]: true },
}))
try {
const index = await getServiceHub().artifacts().listArtifacts(threadId)
// Load split view state from localStorage
const { open, ratio } = loadSplitState(threadId)
set((state) => ({
threadArtifacts: {
...state.threadArtifacts,
[threadId]: index.artifacts,
},
threadIndex: {
...state.threadIndex,
[threadId]: index,
},
splitViewOpen: {
...state.splitViewOpen,
[threadId]: open,
},
splitRatio: {
...state.splitRatio,
[threadId]: ratio,
},
loadingArtifacts: { ...state.loadingArtifacts, [threadId]: false },
}))
} catch (error) {
console.error('Error loading artifacts:', error)
set((state) => ({
loadingArtifacts: { ...state.loadingArtifacts, [threadId]: false },
}))
}
},
// Create a new artifact
createArtifact: async (threadId, name, contentType, language, initialContent) => {
try {
const artifact = await getServiceHub().artifacts().createArtifact(threadId, {
name,
content_type: contentType,
language,
content: initialContent,
})
// Reload artifacts to get updated index
await get().loadArtifacts(threadId)
// Set as active and open split view
set((state) => {
const newSplitOpen = { ...state.splitViewOpen, [threadId]: true }
saveSplitState(threadId, true, state.splitRatio[threadId] || 60)
return {
splitViewOpen: newSplitOpen,
artifactContent: { ...state.artifactContent, [artifact.id]: initialContent },
editorVersions: {
...state.editorVersions,
[artifact.id]: { version: artifact.version, hash: artifact.hash },
},
}
})
// Set as active
await getServiceHub().artifacts().setActiveArtifact(threadId, artifact.id)
return artifact
} catch (error) {
console.error('Error creating artifact:', error)
return null
}
},
// Get artifact content
getArtifactContent: async (threadId, artifactId) => {
const state = get()
// Check if already loaded
if (state.artifactContent[artifactId]) {
return state.artifactContent[artifactId]
}
try {
const content = await getServiceHub().artifacts().getArtifactContent(threadId, artifactId)
// Find artifact to get version/hash
const artifacts = state.threadArtifacts[threadId] || []
const artifact = artifacts.find((a) => a.id === artifactId)
set((state) => ({
artifactContent: { ...state.artifactContent, [artifactId]: content },
editorVersions: artifact
? {
...state.editorVersions,
[artifactId]: { version: artifact.version, hash: artifact.hash },
}
: state.editorVersions,
}))
return content
} catch (error) {
console.error('Error getting artifact content:', error)
return null
}
},
// Update artifact content in memory (triggers debounced save)
updateArtifactContent: (artifactId, content) => {
const state = get()
set((state) => ({
artifactContent: { ...state.artifactContent, [artifactId]: content },
dirtyArtifacts: new Set([...state.dirtyArtifacts, artifactId]),
}))
// Clear existing timer
if (state.saveTimers[artifactId]) {
clearTimeout(state.saveTimers[artifactId])
}
// Set debounced save timer
const timer = setTimeout(() => {
// Find the thread for this artifact
const threadId = Object.keys(state.threadArtifacts).find((tid) =>
state.threadArtifacts[tid]?.some((a) => a.id === artifactId)
)
if (threadId) {
get().saveArtifact(threadId, artifactId)
}
}, AUTOSAVE_DEBOUNCE)
set((state) => ({
saveTimers: { ...state.saveTimers, [artifactId]: timer },
}))
},
// Save artifact to backend
saveArtifact: async (threadId, artifactId) => {
const state = get()
const content = state.artifactContent[artifactId]
const version = state.editorVersions[artifactId]
if (!content || !version) {
console.warn('No content or version info for artifact:', artifactId)
return
}
// Mark as saving
set((state) => ({
savingArtifacts: new Set([...state.savingArtifacts, artifactId]),
}))
try {
const updatedArtifact = await getServiceHub().artifacts().updateArtifact(
threadId,
artifactId,
content,
version.version,
version.hash
)
// Update version/hash and clear dirty flag
set((state) => {
const newDirty = new Set(state.dirtyArtifacts)
newDirty.delete(artifactId)
const newSaving = new Set(state.savingArtifacts)
newSaving.delete(artifactId)
return {
editorVersions: {
...state.editorVersions,
[artifactId]: { version: updatedArtifact.version, hash: updatedArtifact.hash },
},
dirtyArtifacts: newDirty,
savingArtifacts: newSaving,
}
})
// Reload artifacts to update metadata
await get().loadArtifacts(threadId)
} catch (error: unknown) {
console.error('Error saving artifact:', error)
// Check for conflict error
if (error instanceof Error && (error.message.includes('conflict') || error.message.includes('Version') || error.message.includes('Hash'))) {
// Load server content
try {
const serverContent = await getServiceHub().artifacts().getArtifactContent(threadId, artifactId)
if (serverContent) {
set((state) => ({
conflicts: {
...state.conflicts,
[artifactId]: {
localContent: content,
serverContent,
},
},
}))
}
} catch (err) {
console.error('Failed to load server content for conflict:', err)
}
}
// Remove from saving set
set((state) => {
const newSaving = new Set(state.savingArtifacts)
newSaving.delete(artifactId)
return { savingArtifacts: newSaving }
})
}
},
// Delete an artifact
deleteArtifact: async (threadId, artifactId) => {
try {
await getServiceHub().artifacts().deleteArtifact(threadId, artifactId)
// Clear from state
set((state) => {
const newContent = { ...state.artifactContent }
delete newContent[artifactId]
const newVersions = { ...state.editorVersions }
delete newVersions[artifactId]
const newDirty = new Set(state.dirtyArtifacts)
newDirty.delete(artifactId)
return {
artifactContent: newContent,
editorVersions: newVersions,
dirtyArtifacts: newDirty,
}
})
// Reload artifacts
await get().loadArtifacts(threadId)
} catch (error) {
console.error('Error deleting artifact:', error)
}
},
// Rename an artifact
renameArtifact: async (threadId, artifactId, newName) => {
try {
await getServiceHub().artifacts().renameArtifact(threadId, artifactId, newName)
// Reload artifacts to get updated metadata
await get().loadArtifacts(threadId)
} catch (error) {
console.error('Error renaming artifact:', error)
}
},
// Set active artifact
setActiveArtifact: (threadId, artifactId) => {
getServiceHub().artifacts().setActiveArtifact(threadId, artifactId)
set((state) => {
const index = state.threadIndex[threadId]
if (!index) return state
return {
threadIndex: {
...state.threadIndex,
[threadId]: { ...index, active_artifact_id: artifactId },
},
}
})
},
// Toggle split view
toggleSplitView: (threadId) => {
set((state) => {
const newOpen = !state.splitViewOpen[threadId]
saveSplitState(threadId, newOpen, state.splitRatio[threadId] || 60)
return {
splitViewOpen: { ...state.splitViewOpen, [threadId]: newOpen },
}
})
},
// Set split ratio
setSplitRatio: (threadId, ratio) => {
const clampedRatio = Math.max(40, Math.min(80, ratio))
set((state) => {
saveSplitState(threadId, state.splitViewOpen[threadId] ?? false, clampedRatio)
return {
splitRatio: { ...state.splitRatio, [threadId]: clampedRatio },
}
})
},
// Propose an update
proposeUpdate: async (threadId, artifactId, content) => {
try {
const diffPreview = await getServiceHub().artifacts().proposeUpdate(threadId, artifactId, content)
set((state) => ({
pendingProposals: { ...state.pendingProposals, [artifactId]: diffPreview },
}))
return diffPreview
} catch (error) {
console.error('Error proposing update:', error)
return null
}
},
// Apply a proposal
applyProposal: async (threadId, artifactId, proposalId, selectedHunks) => {
try {
await getServiceHub().artifacts().applyProposal(threadId, artifactId, proposalId, selectedHunks)
// Clear proposal and reload artifact content
get().clearPendingProposal(artifactId)
await get().getArtifactContent(threadId, artifactId)
await get().loadArtifacts(threadId)
} catch (error) {
console.error('Error applying proposal:', error)
}
},
// Discard a proposal
discardProposal: (threadId, artifactId, proposalId) => {
getServiceHub().artifacts().discardProposal(threadId, artifactId, proposalId)
get().clearPendingProposal(artifactId)
},
// Clear pending proposal from state
clearPendingProposal: (artifactId) => {
set((state) => {
const newProposals = { ...state.pendingProposals }
delete newProposals[artifactId]
return { pendingProposals: newProposals }
})
},
// Force save all dirty artifacts (e.g., on window unload)
forceSaveAll: async (threadId) => {
const state = get()
const artifacts = state.threadArtifacts[threadId] || []
const savePromises = Array.from(state.dirtyArtifacts)
.filter((artifactId) => artifacts.some((a) => a.id === artifactId))
.map((artifactId) => get().saveArtifact(threadId, artifactId))
await Promise.all(savePromises)
},
// Resolve a conflict
resolveConflict: async (threadId, artifactId, resolution) => {
const state = get()
const conflict = state.conflicts[artifactId]
if (!conflict) return
const contentToSave = resolution === 'keep-mine' ? conflict.localContent : conflict.serverContent
// Get current server version
await get().loadArtifacts(threadId)
const artifacts = state.threadArtifacts[threadId] || []
const artifact = artifacts.find((a) => a.id === artifactId)
if (!artifact) return
// Update with server's current version
set((state) => ({
artifactContent: { ...state.artifactContent, [artifactId]: contentToSave },
editorVersions: {
...state.editorVersions,
[artifactId]: { version: artifact.version, hash: artifact.hash },
},
}))
// Save with correct version
await get().saveArtifact(threadId, artifactId)
// Clear conflict
get().clearConflict(artifactId)
},
// Clear conflict state
clearConflict: (artifactId) => {
set((state) => {
const newConflicts = { ...state.conflicts }
delete newConflicts[artifactId]
return { conflicts: newConflicts }
})
},
}))