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

218 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 users 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)
}
}
},
}))