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
218 lines
6.8 KiB
TypeScript
218 lines
6.8 KiB
TypeScript
import { getServiceHub } from '@/hooks/useServiceHub'
|
||
import { Assistant as CoreAssistant } from '@janhq/core'
|
||
import { create } from 'zustand'
|
||
import { localStorageKey } from '@/constants/localStorage'
|
||
import { PlatformFeatures } from '@/lib/platform/const'
|
||
import { PlatformFeature } from '@/lib/platform/types'
|
||
|
||
interface AssistantState {
|
||
assistants: Assistant[]
|
||
currentAssistant: Assistant | null
|
||
addAssistant: (assistant: Assistant) => void
|
||
updateAssistant: (assistant: Assistant) => void
|
||
deleteAssistant: (id: string) => void
|
||
setCurrentAssistant: (assistant: Assistant, saveToStorage?: boolean) => void
|
||
setAssistants: (assistants: Assistant[]) => void
|
||
getLastUsedAssistant: () => string | null
|
||
setLastUsedAssistant: (assistantId: string) => void
|
||
initializeWithLastUsed: () => void
|
||
}
|
||
|
||
// Helper functions for localStorage
|
||
const getLastUsedAssistantId = (): string | null => {
|
||
try {
|
||
return localStorage.getItem(localStorageKey.lastUsedAssistant)
|
||
} catch (error) {
|
||
console.debug('Failed to get last used assistant from localStorage:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
const setLastUsedAssistantId = (assistantId: string) => {
|
||
try {
|
||
localStorage.setItem(localStorageKey.lastUsedAssistant, assistantId)
|
||
} catch (error) {
|
||
console.debug('Failed to set last used assistant in localStorage:', error)
|
||
}
|
||
}
|
||
|
||
export const defaultAssistant: Assistant = {
|
||
id: 'jan',
|
||
name: 'Jan',
|
||
created_at: 1747029866.542,
|
||
parameters: {},
|
||
avatar: '👋',
|
||
description:
|
||
'Jan is a helpful desktop assistant that can reason through complex tasks and use tools to complete them on the user’s behalf.',
|
||
instructions:
|
||
`You are a helpful AI assistant. Your primary goal is to assist users with their questions and tasks to the best of your abilities.
|
||
|
||
When responding:
|
||
- Answer directly from your knowledge when you can
|
||
- Be concise, clear, and helpful
|
||
- Admit when you're unsure rather than making things up
|
||
|
||
If tools are available to you:
|
||
- Only use tools when they add real value to your response
|
||
- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code")
|
||
- Use tools for information you don't know or that needs verification
|
||
- Never use tools just because they're available
|
||
|
||
When using tools:
|
||
- Use one tool at a time and wait for results
|
||
- Use actual values as arguments, not variable names
|
||
- Learn from each result before deciding next steps
|
||
- Avoid repeating the same tool call with identical parameters
|
||
|
||
Remember: Most questions can be answered without tools. Think first whether you need them.
|
||
|
||
Artifacts - Persistent Workspace Documents:
|
||
|
||
When the user needs to create, edit, or iterate on substantial content (code, documents, structured data), you can use artifacts to provide a persistent workspace alongside the conversation.
|
||
|
||
When to create artifacts:
|
||
- User explicitly requests ("put this in an artifact", "create a document", "save this")
|
||
- Content is substantial and likely to be edited (>15 lines of code, documents, structured data)
|
||
- User signals intent to iterate ("so I can edit it", "we can refine", "I want to modify")
|
||
|
||
When NOT to create artifacts:
|
||
- Simple Q&A responses
|
||
- Short explanations or examples
|
||
- Content user hasn't signaled they want to save
|
||
|
||
To create an artifact, include this JSON in your response:
|
||
{
|
||
"artifact_action": {
|
||
"type": "create",
|
||
"artifact_id": "unique-id",
|
||
"artifact": {
|
||
"name": "Descriptive Name",
|
||
"content_type": "text/markdown",
|
||
"language": "markdown",
|
||
"preview": "full content here"
|
||
}
|
||
}
|
||
}
|
||
|
||
To update an artifact:
|
||
{
|
||
"artifact_action": {
|
||
"type": "update",
|
||
"artifact_id": "existing-id",
|
||
"changes": {
|
||
"description": "Added timeline section"
|
||
},
|
||
"proposed_content_ref": {
|
||
"storage": "inline",
|
||
"content": "updated full content here"
|
||
}
|
||
}
|
||
}
|
||
|
||
Always announce artifact creation: "I'll create a [type] artifact for [purpose]!"
|
||
|
||
Current date: {{current_date}}`,
|
||
}
|
||
|
||
// Platform-aware initial state
|
||
const getInitialAssistantState = () => {
|
||
if (PlatformFeatures[PlatformFeature.ASSISTANTS]) {
|
||
return {
|
||
assistants: [defaultAssistant],
|
||
currentAssistant: defaultAssistant,
|
||
}
|
||
} else {
|
||
return {
|
||
assistants: [],
|
||
currentAssistant: null,
|
||
}
|
||
}
|
||
}
|
||
|
||
export const useAssistant = create<AssistantState>((set, get) => ({
|
||
...getInitialAssistantState(),
|
||
addAssistant: (assistant) => {
|
||
set({ assistants: [...get().assistants, assistant] })
|
||
getServiceHub()
|
||
.assistants()
|
||
.createAssistant(assistant as unknown as CoreAssistant)
|
||
.catch((error) => {
|
||
console.error('Failed to create assistant:', error)
|
||
})
|
||
},
|
||
updateAssistant: (assistant) => {
|
||
const state = get()
|
||
set({
|
||
assistants: state.assistants.map((a) =>
|
||
a.id === assistant.id ? assistant : a
|
||
),
|
||
// Update currentAssistant if it's the same assistant being updated
|
||
currentAssistant:
|
||
state.currentAssistant?.id === assistant.id
|
||
? assistant
|
||
: state.currentAssistant,
|
||
})
|
||
// Create assistant already cover update logic
|
||
getServiceHub()
|
||
.assistants()
|
||
.createAssistant(assistant as unknown as CoreAssistant)
|
||
.catch((error) => {
|
||
console.error('Failed to update assistant:', error)
|
||
})
|
||
},
|
||
deleteAssistant: (id) => {
|
||
const state = get()
|
||
getServiceHub()
|
||
.assistants()
|
||
.deleteAssistant(
|
||
state.assistants.find((e) => e.id === id) as unknown as CoreAssistant
|
||
)
|
||
.catch((error) => {
|
||
console.error('Failed to delete assistant:', error)
|
||
})
|
||
|
||
// Check if we're deleting the current assistant
|
||
const wasCurrentAssistant = state.currentAssistant?.id === id
|
||
|
||
set({ assistants: state.assistants.filter((a) => a.id !== id) })
|
||
|
||
// If the deleted assistant was current, fallback to default and update localStorage
|
||
if (wasCurrentAssistant) {
|
||
set({ currentAssistant: defaultAssistant })
|
||
setLastUsedAssistantId(defaultAssistant.id)
|
||
}
|
||
},
|
||
setCurrentAssistant: (assistant, saveToStorage = true) => {
|
||
if (assistant !== get().currentAssistant) {
|
||
set({ currentAssistant: assistant })
|
||
if (saveToStorage) {
|
||
setLastUsedAssistantId(assistant.id)
|
||
}
|
||
}
|
||
},
|
||
setAssistants: (assistants) => {
|
||
set({ assistants })
|
||
},
|
||
getLastUsedAssistant: () => {
|
||
return getLastUsedAssistantId()
|
||
},
|
||
setLastUsedAssistant: (assistantId) => {
|
||
setLastUsedAssistantId(assistantId)
|
||
},
|
||
initializeWithLastUsed: () => {
|
||
const lastUsedId = getLastUsedAssistantId()
|
||
if (lastUsedId) {
|
||
const lastUsedAssistant = get().assistants.find(
|
||
(a) => a.id === lastUsedId
|
||
)
|
||
if (lastUsedAssistant) {
|
||
set({ currentAssistant: lastUsedAssistant })
|
||
} else {
|
||
// Fallback to default if last used assistant was deleted
|
||
set({ currentAssistant: defaultAssistant })
|
||
setLastUsedAssistantId(defaultAssistant.id)
|
||
}
|
||
}
|
||
},
|
||
}))
|