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
500 lines
15 KiB
TypeScript
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 }
|
|
})
|
|
},
|
|
}))
|
|
|