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
This commit is contained in:
parent
154301b3ad
commit
4e92884d51
249
core/src/types/artifact/artifactEntity.ts
Normal file
249
core/src/types/artifact/artifactEntity.ts
Normal file
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Artifact types for workspace/document management alongside conversations
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* History retention policy for artifacts
|
||||
*/
|
||||
export type HistoryKeepPolicy = {
|
||||
max_entries: number
|
||||
max_days: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact metadata
|
||||
*/
|
||||
export type ArtifactMetadata = {
|
||||
source_message_id?: string
|
||||
archived: boolean
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual artifact entry
|
||||
* @stored
|
||||
*/
|
||||
export type Artifact = {
|
||||
/** Unique identifier (ULID) */
|
||||
id: string
|
||||
/** User-visible name */
|
||||
name: string
|
||||
/** URL-safe slug */
|
||||
slug: string
|
||||
/** MIME-like content type (e.g., "text/markdown", "text/x-typescript") */
|
||||
content_type: string
|
||||
/** Programming language for syntax highlighting (optional) */
|
||||
language?: string
|
||||
/** Creation timestamp (Unix epoch seconds) */
|
||||
created_at: number
|
||||
/** Last update timestamp (Unix epoch seconds) */
|
||||
updated_at: number
|
||||
/** Version number (increments on each update) */
|
||||
version: number
|
||||
/** File size in bytes */
|
||||
bytes: number
|
||||
/** SHA256 hash of content (format: "sha256:...") */
|
||||
hash: string
|
||||
/** Relative file path within artifacts directory */
|
||||
file_path: string
|
||||
/** Whether artifact is read-only */
|
||||
read_only: boolean
|
||||
/** Extension data for future features (e.g., diagram config) */
|
||||
extensions: Record<string, unknown>
|
||||
/** Additional metadata */
|
||||
metadata: ArtifactMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Root artifact index structure
|
||||
* @stored
|
||||
*/
|
||||
export type ArtifactIndex = {
|
||||
/** Schema version for migrations */
|
||||
schema_version: number
|
||||
/** History retention policy */
|
||||
history_keep: HistoryKeepPolicy
|
||||
/** List of all artifacts in this thread */
|
||||
artifacts: Artifact[]
|
||||
/** Currently active/selected artifact ID */
|
||||
active_artifact_id: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Data for creating a new artifact
|
||||
* @data_transfer_object
|
||||
*/
|
||||
export type ArtifactCreate = {
|
||||
/** Artifact name */
|
||||
name: string
|
||||
/** MIME-like content type */
|
||||
content_type: string
|
||||
/** Programming language (optional) */
|
||||
language?: string
|
||||
/** Initial content */
|
||||
content: string
|
||||
/** Source message ID that created this artifact */
|
||||
source_message_id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff hunk for selective application
|
||||
* @data_transfer_object
|
||||
*/
|
||||
export type DiffHunk = {
|
||||
/** Starting line number */
|
||||
start_line: number
|
||||
/** Ending line number */
|
||||
end_line: number
|
||||
/** Diff content */
|
||||
content: string
|
||||
/** Type of change: "add", "remove", or "modify" */
|
||||
change_type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff preview returned from propose_update
|
||||
* @data_transfer_object
|
||||
*/
|
||||
export type DiffPreview = {
|
||||
/** Unique proposal ID */
|
||||
proposal_id: string
|
||||
/** Artifact being updated */
|
||||
artifact_id: string
|
||||
/** Current artifact version */
|
||||
current_version: number
|
||||
/** Current artifact hash */
|
||||
current_hash: string
|
||||
/** Proposed content hash */
|
||||
proposed_hash: string
|
||||
/** List of diff hunks */
|
||||
hunks: DiffHunk[]
|
||||
/** Full unified diff string */
|
||||
full_diff: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of import operation
|
||||
* @data_transfer_object
|
||||
*/
|
||||
export type ImportResult = {
|
||||
/** Number of artifacts imported */
|
||||
imported_count: number
|
||||
/** Number of artifacts skipped */
|
||||
skipped_count: number
|
||||
/** List of error messages */
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposed content reference in message metadata
|
||||
* Used to avoid embedding large diffs in message payloads
|
||||
* @data_transfer_object
|
||||
*/
|
||||
export type ProposedContentRef = {
|
||||
/** Storage type: "temp" for file storage, "inline" for direct content */
|
||||
storage: 'temp' | 'inline'
|
||||
/** Relative path to proposed content file (required if storage is "temp") */
|
||||
path?: string
|
||||
/** Inline content (required if storage is "inline") */
|
||||
content?: string
|
||||
/** File size in bytes */
|
||||
bytes: number
|
||||
/** SHA256 hash of proposed content */
|
||||
hash: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifact action metadata in messages
|
||||
* Embedded in ThreadMessage.metadata to signal artifact operations
|
||||
* @data_transfer_object
|
||||
*/
|
||||
export type ArtifactAction =
|
||||
| ArtifactCreateAction
|
||||
| ArtifactUpdateAction
|
||||
|
||||
/**
|
||||
* Create artifact action
|
||||
*/
|
||||
export type ArtifactCreateAction = {
|
||||
type: 'create'
|
||||
artifact_id?: string
|
||||
artifact: {
|
||||
name: string
|
||||
content_type: string
|
||||
language?: string
|
||||
preview?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update artifact action
|
||||
*/
|
||||
export type ArtifactUpdateAction = {
|
||||
type: 'update'
|
||||
artifact_id: string
|
||||
changes: {
|
||||
description: string
|
||||
diff_preview?: string
|
||||
}
|
||||
proposed_content_ref?: ProposedContentRef
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported MIME-like content types
|
||||
*/
|
||||
export const CONTENT_TYPES = {
|
||||
MARKDOWN: 'text/markdown',
|
||||
TYPESCRIPT: 'text/x-typescript',
|
||||
JAVASCRIPT: 'text/x-javascript',
|
||||
PYTHON: 'text/x-python',
|
||||
RUST: 'text/x-rust',
|
||||
GO: 'text/x-go',
|
||||
JAVA: 'text/x-java',
|
||||
C: 'text/x-c',
|
||||
CPP: 'text/x-cpp',
|
||||
HTML: 'text/html',
|
||||
CSS: 'text/css',
|
||||
JSON: 'application/json',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Map content types to Monaco editor language IDs
|
||||
*/
|
||||
export const CONTENT_TYPE_TO_LANGUAGE: Record<string, string> = {
|
||||
'text/markdown': 'markdown',
|
||||
'text/x-typescript': 'typescript',
|
||||
'text/x-javascript': 'javascript',
|
||||
'text/x-python': 'python',
|
||||
'text/x-rust': 'rust',
|
||||
'text/x-go': 'go',
|
||||
'text/x-java': 'java',
|
||||
'text/x-c': 'c',
|
||||
'text/x-cpp': 'cpp',
|
||||
'text/html': 'html',
|
||||
'text/css': 'css',
|
||||
'application/json': 'json',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension for content type
|
||||
*/
|
||||
export function getExtensionForContentType(contentType: string): string {
|
||||
const extensionMap: Record<string, string> = {
|
||||
'text/markdown': '.md',
|
||||
'text/x-typescript': '.ts',
|
||||
'text/x-javascript': '.js',
|
||||
'text/x-python': '.py',
|
||||
'text/x-rust': '.rs',
|
||||
'text/x-go': '.go',
|
||||
'text/x-java': '.java',
|
||||
'text/x-c': '.c',
|
||||
'text/x-cpp': '.cpp',
|
||||
'text/html': '.html',
|
||||
'text/css': '.css',
|
||||
'application/json': '.json',
|
||||
}
|
||||
return extensionMap[contentType] || '.txt'
|
||||
}
|
||||
|
||||
2
core/src/types/artifact/index.ts
Normal file
2
core/src/types/artifact/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './artifactEntity'
|
||||
|
||||
@ -11,3 +11,4 @@ export * from './setting'
|
||||
export * from './engine'
|
||||
export * from './hardware'
|
||||
export * from './mcp'
|
||||
export * from './artifact'
|
||||
|
||||
@ -75,7 +75,73 @@ export default class JanAssistantExtension extends AssistantExtension {
|
||||
'Jan is a helpful desktop assistant that can reason through complex tasks and use tools to complete them on the user’s behalf.',
|
||||
model: '*',
|
||||
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.\n\nWhen responding:\n- Answer directly from your knowledge when you can\n- Be concise, clear, and helpful\n- Admit when you’re unsure rather than making things up\n\nIf tools are available to you:\n- Only use tools when they add real value to your response\n- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code")\n- Use tools for information you don’t know or that needs verification\n- Never use tools just because they’re available\n\nWhen using tools:\n- Use one tool at a time and wait for results\n- Use actual values as arguments, not variable names\n- Learn from each result before deciding next steps\n- Avoid repeating the same tool call with identical parameters\n\nRemember: Most questions can be answered without tools. Think first whether you need them.\n\nCurrent date: {{current_date}}',
|
||||
`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}}`,
|
||||
tools: [
|
||||
{
|
||||
type: 'retrieval',
|
||||
|
||||
@ -342,41 +342,73 @@ __metadata:
|
||||
|
||||
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension":
|
||||
version: 0.1.10
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension"
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension"
|
||||
dependencies:
|
||||
rxjs: "npm:^7.8.1"
|
||||
ulidx: "npm:^2.3.0"
|
||||
checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c
|
||||
peerDependencies:
|
||||
react: 19.0.0
|
||||
checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension":
|
||||
version: 0.1.10
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension"
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension"
|
||||
dependencies:
|
||||
rxjs: "npm:^7.8.1"
|
||||
ulidx: "npm:^2.3.0"
|
||||
checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c
|
||||
peerDependencies:
|
||||
react: 19.0.0
|
||||
checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension":
|
||||
version: 0.1.10
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension"
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension"
|
||||
dependencies:
|
||||
rxjs: "npm:^7.8.1"
|
||||
ulidx: "npm:^2.3.0"
|
||||
checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c
|
||||
peerDependencies:
|
||||
react: 19.0.0
|
||||
checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension":
|
||||
version: 0.1.10
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension"
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension"
|
||||
dependencies:
|
||||
rxjs: "npm:^7.8.1"
|
||||
ulidx: "npm:^2.3.0"
|
||||
checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c
|
||||
peerDependencies:
|
||||
react: 19.0.0
|
||||
checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Frag-extension%40workspace%3Arag-extension":
|
||||
version: 0.1.10
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Frag-extension%40workspace%3Arag-extension"
|
||||
dependencies:
|
||||
rxjs: "npm:^7.8.1"
|
||||
ulidx: "npm:^2.3.0"
|
||||
peerDependencies:
|
||||
react: 19.0.0
|
||||
checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension":
|
||||
version: 0.1.10
|
||||
resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension"
|
||||
dependencies:
|
||||
rxjs: "npm:^7.8.1"
|
||||
ulidx: "npm:^2.3.0"
|
||||
peerDependencies:
|
||||
react: 19.0.0
|
||||
checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -418,6 +450,20 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@janhq/rag-extension@workspace:rag-extension":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@janhq/rag-extension@workspace:rag-extension"
|
||||
dependencies:
|
||||
"@janhq/core": ../../core/package.tgz
|
||||
"@janhq/tauri-plugin-rag-api": "link:../../src-tauri/plugins/tauri-plugin-rag"
|
||||
"@janhq/tauri-plugin-vector-db-api": "link:../../src-tauri/plugins/tauri-plugin-vector-db"
|
||||
cpx: "npm:1.5.0"
|
||||
rimraf: "npm:6.0.1"
|
||||
rolldown: "npm:1.0.0-beta.1"
|
||||
typescript: "npm:5.9.2"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@janhq/tauri-plugin-hardware-api@link:../../src-tauri/plugins/tauri-plugin-hardware::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@janhq/tauri-plugin-hardware-api@link:../../src-tauri/plugins/tauri-plugin-hardware::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension"
|
||||
@ -430,6 +476,44 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: soft
|
||||
|
||||
"@janhq/tauri-plugin-rag-api@link:../../src-tauri/plugins/tauri-plugin-rag::locator=%40janhq%2Frag-extension%40workspace%3Arag-extension":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@janhq/tauri-plugin-rag-api@link:../../src-tauri/plugins/tauri-plugin-rag::locator=%40janhq%2Frag-extension%40workspace%3Arag-extension"
|
||||
languageName: node
|
||||
linkType: soft
|
||||
|
||||
"@janhq/tauri-plugin-rag-api@link:../../src-tauri/plugins/tauri-plugin-rag::locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@janhq/tauri-plugin-rag-api@link:../../src-tauri/plugins/tauri-plugin-rag::locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension"
|
||||
languageName: node
|
||||
linkType: soft
|
||||
|
||||
"@janhq/tauri-plugin-vector-db-api@link:../../src-tauri/plugins/tauri-plugin-vector-db::locator=%40janhq%2Frag-extension%40workspace%3Arag-extension":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@janhq/tauri-plugin-vector-db-api@link:../../src-tauri/plugins/tauri-plugin-vector-db::locator=%40janhq%2Frag-extension%40workspace%3Arag-extension"
|
||||
languageName: node
|
||||
linkType: soft
|
||||
|
||||
"@janhq/tauri-plugin-vector-db-api@link:../../src-tauri/plugins/tauri-plugin-vector-db::locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@janhq/tauri-plugin-vector-db-api@link:../../src-tauri/plugins/tauri-plugin-vector-db::locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension"
|
||||
languageName: node
|
||||
linkType: soft
|
||||
|
||||
"@janhq/vector-db-extension@workspace:vector-db-extension":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@janhq/vector-db-extension@workspace:vector-db-extension"
|
||||
dependencies:
|
||||
"@janhq/core": ../../core/package.tgz
|
||||
"@janhq/tauri-plugin-rag-api": "link:../../src-tauri/plugins/tauri-plugin-rag"
|
||||
"@janhq/tauri-plugin-vector-db-api": "link:../../src-tauri/plugins/tauri-plugin-vector-db"
|
||||
cpx: "npm:1.5.0"
|
||||
rimraf: "npm:6.0.1"
|
||||
rolldown: "npm:1.0.0-beta.1"
|
||||
typescript: "npm:5.9.2"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@jridgewell/sourcemap-codec@npm:^1.5.0":
|
||||
version: 1.5.0
|
||||
resolution: "@jridgewell/sourcemap-codec@npm:1.5.0"
|
||||
|
||||
14
src-tauri/Cargo.lock
generated
14
src-tauri/Cargo.lock
generated
@ -6,11 +6,13 @@ version = 3
|
||||
name = "Jan"
|
||||
version = "0.6.599"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs",
|
||||
"env",
|
||||
"fix-path-env",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hyper 0.14.32",
|
||||
"jan-utils",
|
||||
"libc",
|
||||
@ -23,6 +25,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tar",
|
||||
"tauri",
|
||||
@ -45,6 +48,7 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"ulid",
|
||||
"url",
|
||||
"uuid",
|
||||
"windows-sys 0.60.2",
|
||||
@ -7005,6 +7009,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ulid"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
|
||||
dependencies = [
|
||||
"rand 0.9.2",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
|
||||
@ -93,6 +93,10 @@ tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = "0.7.14"
|
||||
url = "2.5"
|
||||
uuid = { version = "1.7", features = ["v4"] }
|
||||
ulid = "1.1"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
chrono = "0.4"
|
||||
|
||||
[dependencies.tauri]
|
||||
version = "2.8.5"
|
||||
|
||||
@ -52,6 +52,30 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@janhq/tauri-plugin-rag-api@workspace:tauri-plugin-rag":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@janhq/tauri-plugin-rag-api@workspace:tauri-plugin-rag"
|
||||
dependencies:
|
||||
"@rollup/plugin-typescript": "npm:^12.0.0"
|
||||
"@tauri-apps/api": "npm:>=2.0.0-beta.6"
|
||||
rollup: "npm:^4.9.6"
|
||||
tslib: "npm:^2.6.2"
|
||||
typescript: "npm:^5.3.3"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@janhq/tauri-plugin-vector-db-api@workspace:tauri-plugin-vector-db":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@janhq/tauri-plugin-vector-db-api@workspace:tauri-plugin-vector-db"
|
||||
dependencies:
|
||||
"@rollup/plugin-typescript": "npm:^12.0.0"
|
||||
"@tauri-apps/api": "npm:>=2.0.0-beta.6"
|
||||
rollup: "npm:^4.9.6"
|
||||
tslib: "npm:^2.6.2"
|
||||
typescript: "npm:^5.3.3"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@npmcli/agent@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "@npmcli/agent@npm:3.0.0"
|
||||
|
||||
689
src-tauri/src/core/artifacts/commands.rs
Normal file
689
src-tauri/src/core/artifacts/commands.rs
Normal file
@ -0,0 +1,689 @@
|
||||
use tauri::Runtime;
|
||||
use ulid::Ulid;
|
||||
|
||||
use super::helpers::{
|
||||
compute_hash, create_history_snapshot, generate_slug, normalize_content, prune_history,
|
||||
read_artifact_content, read_index, validate_artifact_path, write_artifact_content, write_index,
|
||||
};
|
||||
use super::models::{
|
||||
Artifact, ArtifactCreate, ArtifactIndex, ArtifactMetadata, DiffHunk, DiffPreview, ImportResult,
|
||||
};
|
||||
use super::utils::{ensure_artifacts_dirs, get_artifact_path, get_proposals_dir};
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Get file extension for content type
|
||||
fn get_extension_for_content_type(content_type: &str) -> &str {
|
||||
match content_type {
|
||||
"text/markdown" => ".md",
|
||||
"text/x-typescript" => ".ts",
|
||||
"text/x-javascript" => ".js",
|
||||
"text/x-python" => ".py",
|
||||
"application/json" => ".json",
|
||||
"text/x-rust" => ".rs",
|
||||
"text/x-go" => ".go",
|
||||
"text/x-java" => ".java",
|
||||
"text/x-c" => ".c",
|
||||
"text/x-cpp" => ".cpp",
|
||||
"text/html" => ".html",
|
||||
"text/css" => ".css",
|
||||
_ => ".txt",
|
||||
}
|
||||
}
|
||||
|
||||
/// List all artifacts for a thread
|
||||
#[tauri::command]
|
||||
pub async fn list_artifacts<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
) -> Result<ArtifactIndex, String> {
|
||||
read_index(app_handle, &thread_id)
|
||||
}
|
||||
|
||||
/// Create a new artifact
|
||||
#[tauri::command]
|
||||
pub async fn create_artifact<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
artifact: ArtifactCreate,
|
||||
) -> Result<Artifact, String> {
|
||||
// Ensure artifacts directory exists
|
||||
ensure_artifacts_dirs(app_handle.clone(), &thread_id)?;
|
||||
|
||||
// Read existing index
|
||||
let mut index = read_index(app_handle.clone(), &thread_id)?;
|
||||
|
||||
// Generate artifact ID
|
||||
let artifact_id = Ulid::new().to_string();
|
||||
let slug = generate_slug(&artifact.name);
|
||||
let ext = get_extension_for_content_type(&artifact.content_type);
|
||||
let file_path = format!("{}-{}{}", artifact_id, slug, ext);
|
||||
|
||||
// Validate path
|
||||
validate_artifact_path(&artifact_id, &file_path)?;
|
||||
|
||||
// Normalize and hash content
|
||||
let normalized_content = normalize_content(&artifact.content)?;
|
||||
let hash = compute_hash(&normalized_content);
|
||||
let bytes = normalized_content.len() as u64;
|
||||
|
||||
// Write content to file
|
||||
write_artifact_content(
|
||||
app_handle.clone(),
|
||||
&thread_id,
|
||||
&artifact_id,
|
||||
&file_path,
|
||||
&normalized_content,
|
||||
)?;
|
||||
|
||||
// Create artifact entry
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let new_artifact = Artifact {
|
||||
id: artifact_id,
|
||||
name: artifact.name,
|
||||
slug,
|
||||
content_type: artifact.content_type,
|
||||
language: artifact.language,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
version: 1,
|
||||
bytes,
|
||||
hash,
|
||||
file_path,
|
||||
read_only: false,
|
||||
extensions: std::collections::HashMap::new(),
|
||||
metadata: ArtifactMetadata {
|
||||
source_message_id: artifact.source_message_id,
|
||||
archived: false,
|
||||
tags: Vec::new(),
|
||||
},
|
||||
};
|
||||
|
||||
// Add to index
|
||||
index.artifacts.push(new_artifact.clone());
|
||||
|
||||
// Save index
|
||||
write_index(app_handle, &thread_id, &index)?;
|
||||
|
||||
Ok(new_artifact)
|
||||
}
|
||||
|
||||
/// Get artifact content
|
||||
#[tauri::command]
|
||||
pub async fn get_artifact_content<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
artifact_id: String,
|
||||
) -> Result<String, String> {
|
||||
let index = read_index(app_handle.clone(), &thread_id)?;
|
||||
|
||||
let artifact = index
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|a| a.id == artifact_id)
|
||||
.ok_or_else(|| "Artifact not found".to_string())?;
|
||||
|
||||
read_artifact_content(app_handle, &thread_id, &artifact.id, &artifact.file_path)
|
||||
}
|
||||
|
||||
/// Update artifact content
|
||||
#[tauri::command]
|
||||
pub async fn update_artifact<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
artifact_id: String,
|
||||
content: String,
|
||||
version: u32,
|
||||
hash: String,
|
||||
) -> Result<Artifact, String> {
|
||||
let mut index = read_index(app_handle.clone(), &thread_id)?;
|
||||
|
||||
// Find artifact
|
||||
let artifact = index
|
||||
.artifacts
|
||||
.iter_mut()
|
||||
.find(|a| a.id == artifact_id)
|
||||
.ok_or_else(|| "Artifact not found".to_string())?;
|
||||
|
||||
// Check if read-only
|
||||
if artifact.read_only {
|
||||
return Err("Artifact is read-only".to_string());
|
||||
}
|
||||
|
||||
// Conflict detection: verify version and hash match
|
||||
if artifact.version != version {
|
||||
return Err(format!(
|
||||
"Version conflict: expected {}, got {}",
|
||||
artifact.version, version
|
||||
));
|
||||
}
|
||||
|
||||
if artifact.hash != hash {
|
||||
return Err("Hash conflict: artifact has been modified".to_string());
|
||||
}
|
||||
|
||||
// Read current content for history snapshot
|
||||
let current_content = read_artifact_content(
|
||||
app_handle.clone(),
|
||||
&thread_id,
|
||||
&artifact.id,
|
||||
&artifact.file_path,
|
||||
)?;
|
||||
|
||||
// Create history snapshot before updating
|
||||
create_history_snapshot(app_handle.clone(), &thread_id, artifact, ¤t_content)?;
|
||||
|
||||
// Normalize and hash new content
|
||||
let normalized_content = normalize_content(&content)?;
|
||||
let new_hash = compute_hash(&normalized_content);
|
||||
let new_bytes = normalized_content.len() as u64;
|
||||
|
||||
// Write new content
|
||||
write_artifact_content(
|
||||
app_handle.clone(),
|
||||
&thread_id,
|
||||
&artifact.id,
|
||||
&artifact.file_path,
|
||||
&normalized_content,
|
||||
)?;
|
||||
|
||||
// Update artifact metadata
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
artifact.updated_at = now;
|
||||
artifact.version += 1;
|
||||
artifact.bytes = new_bytes;
|
||||
artifact.hash = new_hash;
|
||||
|
||||
// Prune history based on policy
|
||||
prune_history(
|
||||
app_handle.clone(),
|
||||
&thread_id,
|
||||
artifact,
|
||||
index.history_keep.max_entries,
|
||||
index.history_keep.max_days,
|
||||
)?;
|
||||
|
||||
let updated_artifact = artifact.clone();
|
||||
|
||||
// Save index
|
||||
write_index(app_handle, &thread_id, &index)?;
|
||||
|
||||
Ok(updated_artifact)
|
||||
}
|
||||
|
||||
/// Delete an artifact
|
||||
#[tauri::command]
|
||||
pub async fn delete_artifact<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
artifact_id: String,
|
||||
) -> Result<(), String> {
|
||||
let mut index = read_index(app_handle.clone(), &thread_id)?;
|
||||
|
||||
// Find artifact index
|
||||
let artifact_idx = index
|
||||
.artifacts
|
||||
.iter()
|
||||
.position(|a| a.id == artifact_id)
|
||||
.ok_or_else(|| "Artifact not found".to_string())?;
|
||||
|
||||
let artifact = &index.artifacts[artifact_idx];
|
||||
|
||||
// Delete file
|
||||
let artifact_path = get_artifact_path(app_handle.clone(), &thread_id, &artifact.file_path);
|
||||
if artifact_path.exists() {
|
||||
fs::remove_file(&artifact_path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Delete history directory
|
||||
let history_dir = super::utils::get_artifact_history_dir(app_handle.clone(), &thread_id, &artifact_id);
|
||||
if history_dir.exists() {
|
||||
fs::remove_dir_all(&history_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Remove from index
|
||||
index.artifacts.remove(artifact_idx);
|
||||
|
||||
// Clear active if it was the deleted artifact
|
||||
if index.active_artifact_id.as_deref() == Some(&artifact_id) {
|
||||
index.active_artifact_id = None;
|
||||
}
|
||||
|
||||
// Save index
|
||||
write_index(app_handle, &thread_id, &index)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename an artifact
|
||||
#[tauri::command]
|
||||
pub async fn rename_artifact<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
artifact_id: String,
|
||||
new_name: String,
|
||||
) -> Result<Artifact, String> {
|
||||
let mut index = read_index(app_handle.clone(), &thread_id)?;
|
||||
|
||||
// Find artifact
|
||||
let artifact = index
|
||||
.artifacts
|
||||
.iter_mut()
|
||||
.find(|a| a.id == artifact_id)
|
||||
.ok_or_else(|| "Artifact not found".to_string())?;
|
||||
|
||||
// Update name and slug (but keep file_path stable)
|
||||
artifact.name = new_name;
|
||||
artifact.slug = generate_slug(&artifact.name);
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
artifact.updated_at = now;
|
||||
|
||||
let updated_artifact = artifact.clone();
|
||||
|
||||
// Save index
|
||||
write_index(app_handle, &thread_id, &index)?;
|
||||
|
||||
Ok(updated_artifact)
|
||||
}
|
||||
|
||||
/// Set active artifact
|
||||
#[tauri::command]
|
||||
pub async fn set_active_artifact<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
artifact_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut index = read_index(app_handle.clone(), &thread_id)?;
|
||||
|
||||
// Verify artifact exists if provided
|
||||
if let Some(id) = &artifact_id {
|
||||
if !index.artifacts.iter().any(|a| &a.id == id) {
|
||||
return Err("Artifact not found".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
index.active_artifact_id = artifact_id;
|
||||
|
||||
write_index(app_handle, &thread_id, &index)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Propose an update to an artifact (stores in .proposals/)
|
||||
#[tauri::command]
|
||||
pub async fn propose_update<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
artifact_id: String,
|
||||
content: String,
|
||||
) -> Result<DiffPreview, String> {
|
||||
ensure_artifacts_dirs(app_handle.clone(), &thread_id)?;
|
||||
|
||||
let index = read_index(app_handle.clone(), &thread_id)?;
|
||||
|
||||
// Find artifact
|
||||
let artifact = index
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|a| a.id == artifact_id)
|
||||
.ok_or_else(|| "Artifact not found".to_string())?;
|
||||
|
||||
// Read current content
|
||||
let current_content = read_artifact_content(
|
||||
app_handle.clone(),
|
||||
&thread_id,
|
||||
&artifact.id,
|
||||
&artifact.file_path,
|
||||
)?;
|
||||
|
||||
// Normalize proposed content
|
||||
let normalized_content = normalize_content(&content)?;
|
||||
let proposed_hash = compute_hash(&normalized_content);
|
||||
|
||||
// Generate proposal ID
|
||||
let proposal_id = Ulid::new().to_string();
|
||||
let ext = Path::new(&artifact.file_path)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("txt");
|
||||
let proposal_filename = format!("{}.{}", proposal_id, ext);
|
||||
|
||||
// Write proposal to .proposals/
|
||||
let proposals_dir = get_proposals_dir(app_handle.clone(), &thread_id);
|
||||
fs::create_dir_all(&proposals_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let proposal_path = proposals_dir.join(&proposal_filename);
|
||||
fs::write(&proposal_path, normalized_content.as_bytes()).map_err(|e| e.to_string())?;
|
||||
|
||||
// Generate diff (simple line-by-line for now)
|
||||
let hunks = generate_diff_hunks(¤t_content, &normalized_content);
|
||||
let full_diff = format!(
|
||||
"--- Current\n+++ Proposed\n{}",
|
||||
hunks
|
||||
.iter()
|
||||
.map(|h| format!("@@ {} @@\n{}", h.change_type, h.content))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
|
||||
Ok(DiffPreview {
|
||||
proposal_id,
|
||||
artifact_id: artifact.id.clone(),
|
||||
current_version: artifact.version,
|
||||
current_hash: artifact.hash.clone(),
|
||||
proposed_hash,
|
||||
hunks,
|
||||
full_diff,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a proposal to an artifact
|
||||
#[tauri::command]
|
||||
pub async fn apply_proposal<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
artifact_id: String,
|
||||
proposal_id: String,
|
||||
selected_hunks: Option<Vec<usize>>,
|
||||
) -> Result<Artifact, String> {
|
||||
// Read proposal
|
||||
let proposals_dir = get_proposals_dir(app_handle.clone(), &thread_id);
|
||||
|
||||
// Find proposal file (we need to match by proposal_id prefix)
|
||||
let entries = fs::read_dir(&proposals_dir).map_err(|e| e.to_string())?;
|
||||
let mut proposal_path = None;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let filename = entry.file_name();
|
||||
let filename_str = filename.to_string_lossy();
|
||||
if filename_str.starts_with(&proposal_id) {
|
||||
proposal_path = Some(entry.path());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let proposal_path = proposal_path.ok_or_else(|| "Proposal not found".to_string())?;
|
||||
|
||||
// Read proposed content
|
||||
let proposed_content = fs::read_to_string(&proposal_path).map_err(|e| e.to_string())?;
|
||||
|
||||
// If selected_hunks is provided, we'd apply only those hunks
|
||||
// For simplicity now, we apply the full proposal
|
||||
let final_content = if selected_hunks.is_some() {
|
||||
// TODO: Implement selective hunk application
|
||||
proposed_content
|
||||
} else {
|
||||
proposed_content
|
||||
};
|
||||
|
||||
// Get current artifact to get version and hash
|
||||
let index = read_index(app_handle.clone(), &thread_id)?;
|
||||
let artifact = index
|
||||
.artifacts
|
||||
.iter()
|
||||
.find(|a| a.id == artifact_id)
|
||||
.ok_or_else(|| "Artifact not found".to_string())?;
|
||||
|
||||
// Update artifact using the update command
|
||||
let result = update_artifact(
|
||||
app_handle.clone(),
|
||||
thread_id.clone(),
|
||||
artifact_id,
|
||||
final_content,
|
||||
artifact.version,
|
||||
artifact.hash.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Delete proposal file
|
||||
fs::remove_file(&proposal_path).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Discard a proposal
|
||||
#[tauri::command]
|
||||
pub async fn discard_proposal<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
_artifact_id: String,
|
||||
proposal_id: String,
|
||||
) -> Result<(), String> {
|
||||
let proposals_dir = get_proposals_dir(app_handle, &thread_id);
|
||||
|
||||
// Find and delete proposal file
|
||||
let entries = fs::read_dir(&proposals_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let filename = entry.file_name();
|
||||
let filename_str = filename.to_string_lossy();
|
||||
if filename_str.starts_with(&proposal_id) {
|
||||
fs::remove_file(entry.path()).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Proposal not found".to_string())
|
||||
}
|
||||
|
||||
/// Export artifacts to a ZIP file
|
||||
#[tauri::command]
|
||||
pub async fn export_artifacts<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
output_path: String,
|
||||
) -> Result<(), String> {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use zip::write::{FileOptions, ZipWriter};
|
||||
use zip::CompressionMethod;
|
||||
|
||||
let artifacts_dir = super::utils::get_artifacts_dir(app_handle.clone(), &thread_id);
|
||||
|
||||
if !artifacts_dir.exists() {
|
||||
return Err("No artifacts directory found".to_string());
|
||||
}
|
||||
|
||||
// Create ZIP file
|
||||
let zip_file = File::create(&output_path).map_err(|e| e.to_string())?;
|
||||
let mut zip = ZipWriter::new(zip_file);
|
||||
|
||||
let options = FileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
|
||||
// Add artifacts.json
|
||||
let index_path = super::utils::get_index_path(app_handle.clone(), &thread_id);
|
||||
if index_path.exists() {
|
||||
let content = fs::read(&index_path).map_err(|e| e.to_string())?;
|
||||
zip.start_file("artifacts.json", options).map_err(|e| e.to_string())?;
|
||||
zip.write_all(&content).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Add all artifact files
|
||||
let index = read_index(app_handle.clone(), &thread_id)?;
|
||||
for artifact in &index.artifacts {
|
||||
let artifact_path = super::utils::get_artifact_path(app_handle.clone(), &thread_id, &artifact.file_path);
|
||||
if artifact_path.exists() {
|
||||
let content = fs::read(&artifact_path).map_err(|e| e.to_string())?;
|
||||
zip.start_file(&artifact.file_path, options).map_err(|e| e.to_string())?;
|
||||
zip.write_all(&content).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
// Add README
|
||||
let readme = format!(
|
||||
"Jan Artifacts Export\n\nSchema Version: {}\nThread ID: {}\nExported: {}\n\nTo restore: Import this ZIP file using Jan's artifact import feature.",
|
||||
index.schema_version,
|
||||
thread_id,
|
||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
|
||||
);
|
||||
zip.start_file("README.txt", options).map_err(|e| e.to_string())?;
|
||||
zip.write_all(readme.as_bytes()).map_err(|e| e.to_string())?;
|
||||
|
||||
zip.finish().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import artifacts from a ZIP file
|
||||
#[tauri::command]
|
||||
pub async fn import_artifacts<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: String,
|
||||
archive_path: String,
|
||||
) -> Result<ImportResult, String> {
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use zip::ZipArchive;
|
||||
|
||||
ensure_artifacts_dirs(app_handle.clone(), &thread_id)?;
|
||||
|
||||
let file = File::open(&archive_path).map_err(|e| e.to_string())?;
|
||||
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut imported_count = 0;
|
||||
let mut skipped_count = 0;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Read the imported index
|
||||
let mut imported_index: Option<ArtifactIndex> = None;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
|
||||
let filename = file.name().to_string();
|
||||
|
||||
if filename == "artifacts.json" {
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content).map_err(|e| e.to_string())?;
|
||||
imported_index = serde_json::from_str(&content).ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let imported_index = imported_index.ok_or_else(|| "No artifacts.json found in archive".to_string())?;
|
||||
|
||||
// Load existing index
|
||||
let mut existing_index = read_index(app_handle.clone(), &thread_id)?;
|
||||
|
||||
// Merge artifacts
|
||||
for imported_artifact in imported_index.artifacts {
|
||||
// Check if artifact already exists
|
||||
if existing_index.artifacts.iter().any(|a| a.id == imported_artifact.id) {
|
||||
skipped_count += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for slug collision
|
||||
let mut final_slug = imported_artifact.slug.clone();
|
||||
let mut counter = 1;
|
||||
while existing_index.artifacts.iter().any(|a| a.slug == final_slug) {
|
||||
final_slug = format!("{}-{}", imported_artifact.slug, counter);
|
||||
counter += 1;
|
||||
}
|
||||
|
||||
// Extract artifact file from ZIP
|
||||
let mut found_file = false;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
|
||||
if file.name() == imported_artifact.file_path {
|
||||
let mut content = Vec::new();
|
||||
file.read_to_end(&mut content).map_err(|e| e.to_string())?;
|
||||
|
||||
// Write to disk
|
||||
let artifact_path = super::utils::get_artifact_path(app_handle.clone(), &thread_id, &imported_artifact.file_path);
|
||||
fs::write(&artifact_path, content).map_err(|e| e.to_string())?;
|
||||
|
||||
found_file = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found_file {
|
||||
errors.push(format!("File not found in archive: {}", imported_artifact.file_path));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add to index with updated slug if needed
|
||||
let mut new_artifact = imported_artifact.clone();
|
||||
new_artifact.slug = final_slug;
|
||||
existing_index.artifacts.push(new_artifact);
|
||||
imported_count += 1;
|
||||
}
|
||||
|
||||
// Save updated index
|
||||
write_index(app_handle, &thread_id, &existing_index)?;
|
||||
|
||||
Ok(ImportResult {
|
||||
imported_count,
|
||||
skipped_count,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
/// Simple diff hunk generation (line-by-line)
|
||||
fn generate_diff_hunks(current: &str, proposed: &str) -> Vec<DiffHunk> {
|
||||
let current_lines: Vec<&str> = current.lines().collect();
|
||||
let proposed_lines: Vec<&str> = proposed.lines().collect();
|
||||
|
||||
let mut hunks = Vec::new();
|
||||
let mut line_num = 1;
|
||||
|
||||
// Simple implementation: compare line by line
|
||||
let max_len = current_lines.len().max(proposed_lines.len());
|
||||
|
||||
for i in 0..max_len {
|
||||
let current_line = current_lines.get(i).copied();
|
||||
let proposed_line = proposed_lines.get(i).copied();
|
||||
|
||||
match (current_line, proposed_line) {
|
||||
(Some(c), Some(p)) if c != p => {
|
||||
// Modified line
|
||||
hunks.push(DiffHunk {
|
||||
start_line: line_num,
|
||||
end_line: line_num,
|
||||
content: format!("-{}\n+{}", c, p),
|
||||
change_type: "modify".to_string(),
|
||||
});
|
||||
}
|
||||
(None, Some(p)) => {
|
||||
// Added line
|
||||
hunks.push(DiffHunk {
|
||||
start_line: line_num,
|
||||
end_line: line_num,
|
||||
content: format!("+{}", p),
|
||||
change_type: "add".to_string(),
|
||||
});
|
||||
}
|
||||
(Some(c), None) => {
|
||||
// Removed line
|
||||
hunks.push(DiffHunk {
|
||||
start_line: line_num,
|
||||
end_line: line_num,
|
||||
content: format!("-{}", c),
|
||||
change_type: "remove".to_string(),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// No change
|
||||
}
|
||||
}
|
||||
|
||||
line_num += 1;
|
||||
}
|
||||
|
||||
hunks
|
||||
}
|
||||
|
||||
37
src-tauri/src/core/artifacts/constants.rs
Normal file
37
src-tauri/src/core/artifacts/constants.rs
Normal file
@ -0,0 +1,37 @@
|
||||
/// Artifact storage constants
|
||||
|
||||
/// Directory name for artifacts within a thread
|
||||
pub const ARTIFACTS_DIR: &str = "artifacts";
|
||||
|
||||
/// Index file containing all artifact metadata
|
||||
pub const ARTIFACTS_INDEX: &str = "artifacts.json";
|
||||
|
||||
/// Search index file for quick switcher
|
||||
#[allow(dead_code)]
|
||||
pub const SEARCH_INDEX: &str = "artifacts.search.json";
|
||||
|
||||
/// Directory for version history (sharded by artifact ID)
|
||||
pub const HISTORY_DIR: &str = ".history";
|
||||
|
||||
/// Directory for staged proposals from assistant
|
||||
pub const PROPOSALS_DIR: &str = ".proposals";
|
||||
|
||||
/// Current schema version for artifacts.json
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Default history retention policy
|
||||
pub const DEFAULT_MAX_HISTORY_ENTRIES: u32 = 50;
|
||||
pub const DEFAULT_MAX_HISTORY_DAYS: u32 = 30;
|
||||
|
||||
/// File extension for various content types (reserved for future use)
|
||||
#[allow(dead_code)]
|
||||
pub const MARKDOWN_EXT: &str = ".md";
|
||||
#[allow(dead_code)]
|
||||
pub const TYPESCRIPT_EXT: &str = ".ts";
|
||||
#[allow(dead_code)]
|
||||
pub const JAVASCRIPT_EXT: &str = ".js";
|
||||
#[allow(dead_code)]
|
||||
pub const PYTHON_EXT: &str = ".py";
|
||||
#[allow(dead_code)]
|
||||
pub const JSON_EXT: &str = ".json";
|
||||
|
||||
348
src-tauri/src/core/artifacts/helpers.rs
Normal file
348
src-tauri/src/core/artifacts/helpers.rs
Normal file
@ -0,0 +1,348 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::SystemTime;
|
||||
use tauri::Runtime;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::models::{Artifact, ArtifactIndex};
|
||||
use super::utils::{get_index_path, get_artifact_path};
|
||||
|
||||
/// Global cache for artifact indices with mtime tracking
|
||||
static INDEX_CACHE: OnceLock<Mutex<HashMap<String, (ArtifactIndex, SystemTime)>>> =
|
||||
OnceLock::new();
|
||||
|
||||
/// Validate artifact ID and file path to prevent path traversal attacks
|
||||
///
|
||||
/// # Security
|
||||
/// This function ensures that:
|
||||
/// - IDs contain only alphanumeric characters, hyphens, and underscores
|
||||
/// - File paths do not contain parent directory references (..)
|
||||
/// - File paths do not start with / or contain absolute path indicators
|
||||
pub fn validate_artifact_path(artifact_id: &str, file_path: &str) -> Result<(), String> {
|
||||
// Validate artifact ID format
|
||||
if !artifact_id
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Err("Invalid artifact ID: must contain only alphanumeric characters, hyphens, and underscores".to_string());
|
||||
}
|
||||
|
||||
// Validate file path doesn't contain dangerous patterns
|
||||
if file_path.contains("..") {
|
||||
return Err("Invalid file path: parent directory references not allowed".to_string());
|
||||
}
|
||||
|
||||
if file_path.starts_with('/') || file_path.starts_with('\\') {
|
||||
return Err("Invalid file path: absolute paths not allowed".to_string());
|
||||
}
|
||||
|
||||
// Check for Windows absolute paths (C:\ etc)
|
||||
if file_path.len() >= 2 && file_path.chars().nth(1) == Some(':') {
|
||||
return Err("Invalid file path: absolute paths not allowed".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute SHA256 hash of content
|
||||
pub fn compute_hash(content: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
format!("sha256:{}", hex::encode(result))
|
||||
}
|
||||
|
||||
/// Normalize line endings to LF and enforce UTF-8
|
||||
pub fn normalize_content(content: &str) -> Result<String, String> {
|
||||
// Content is already a &str, so it's valid UTF-8
|
||||
// Just normalize line endings
|
||||
Ok(content.replace("\r\n", "\n").replace('\r', "\n"))
|
||||
}
|
||||
|
||||
/// Read and parse the artifact index with caching
|
||||
pub fn read_index<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
) -> Result<ArtifactIndex, String> {
|
||||
let index_path = get_index_path(app_handle.clone(), thread_id);
|
||||
|
||||
// If index doesn't exist, return default
|
||||
if !index_path.exists() {
|
||||
return Ok(ArtifactIndex::default());
|
||||
}
|
||||
|
||||
// Get file metadata for mtime checking
|
||||
let metadata = fs::metadata(&index_path).map_err(|e| e.to_string())?;
|
||||
let mtime = metadata
|
||||
.modified()
|
||||
.map_err(|e| format!("Failed to get modification time: {}", e))?;
|
||||
|
||||
// Check cache
|
||||
let cache = INDEX_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let cache_guard = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(cache.lock())
|
||||
});
|
||||
|
||||
if let Some((cached_index, cached_mtime)) = cache_guard.get(thread_id) {
|
||||
// If mtime matches, return cached version
|
||||
if *cached_mtime == mtime {
|
||||
return Ok(cached_index.clone());
|
||||
}
|
||||
}
|
||||
|
||||
drop(cache_guard); // Release lock before reading file
|
||||
|
||||
// Read and parse index
|
||||
let content = fs::read_to_string(&index_path).map_err(|e| e.to_string())?;
|
||||
let index: ArtifactIndex = serde_json::from_str(&content).map_err(|e| {
|
||||
format!("Failed to parse artifact index: {}", e)
|
||||
})?;
|
||||
|
||||
// Update cache
|
||||
let mut cache_guard = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(cache.lock())
|
||||
});
|
||||
cache_guard.insert(thread_id.to_string(), (index.clone(), mtime));
|
||||
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Write artifact index atomically (temp file + rename)
|
||||
pub fn write_index<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
index: &ArtifactIndex,
|
||||
) -> Result<(), String> {
|
||||
let index_path = get_index_path(app_handle, thread_id);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = index_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Write to temporary file
|
||||
let temp_path = index_path.with_extension("json.tmp");
|
||||
let content = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
|
||||
fs::write(&temp_path, content).map_err(|e| e.to_string())?;
|
||||
|
||||
// Atomic rename
|
||||
fs::rename(&temp_path, &index_path).map_err(|e| e.to_string())?;
|
||||
|
||||
// Invalidate cache
|
||||
let cache = INDEX_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache_guard = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(cache.lock())
|
||||
});
|
||||
cache_guard.remove(thread_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read artifact content from file
|
||||
pub fn read_artifact_content<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
artifact_id: &str,
|
||||
file_path: &str,
|
||||
) -> Result<String, String> {
|
||||
// Validate paths
|
||||
validate_artifact_path(artifact_id, file_path)?;
|
||||
|
||||
let path = get_artifact_path(app_handle, thread_id, file_path);
|
||||
|
||||
if !path.exists() {
|
||||
return Err(format!("Artifact file not found: {}", file_path));
|
||||
}
|
||||
|
||||
// Read file ensuring UTF-8
|
||||
let content = fs::read_to_string(&path).map_err(|e| {
|
||||
format!("Failed to read artifact content: {}", e)
|
||||
})?;
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// Write artifact content to file with normalization
|
||||
pub fn write_artifact_content<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
artifact_id: &str,
|
||||
file_path: &str,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
// Validate paths
|
||||
validate_artifact_path(artifact_id, file_path)?;
|
||||
|
||||
// Normalize content (LF line endings, UTF-8 enforced)
|
||||
let normalized_content = normalize_content(content)?;
|
||||
|
||||
let path = get_artifact_path(app_handle, thread_id, file_path);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Write atomically using temp file
|
||||
let temp_path = path.with_extension("tmp");
|
||||
fs::write(&temp_path, normalized_content.as_bytes()).map_err(|e| e.to_string())?;
|
||||
fs::rename(&temp_path, &path).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a history snapshot of an artifact
|
||||
pub fn create_history_snapshot<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
artifact: &Artifact,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
use chrono::Utc;
|
||||
use super::utils::{ensure_artifact_history_dir, get_artifact_history_dir};
|
||||
|
||||
// Ensure history directory exists
|
||||
ensure_artifact_history_dir(app_handle.clone(), thread_id, &artifact.id)?;
|
||||
|
||||
let history_dir = get_artifact_history_dir(app_handle, thread_id, &artifact.id);
|
||||
|
||||
// Generate timestamp-based filename
|
||||
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%SZ").to_string();
|
||||
let ext = Path::new(&artifact.file_path)
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("txt");
|
||||
let snapshot_path = history_dir.join(format!("{}.{}", timestamp, ext));
|
||||
|
||||
// Write snapshot
|
||||
fs::write(&snapshot_path, content.as_bytes()).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prune old history snapshots based on policy
|
||||
pub fn prune_history<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
artifact: &Artifact,
|
||||
max_entries: u32,
|
||||
max_days: u32,
|
||||
) -> Result<(), String> {
|
||||
use super::utils::get_artifact_history_dir;
|
||||
use std::time::Duration;
|
||||
|
||||
let history_dir = get_artifact_history_dir(app_handle, thread_id, &artifact.id);
|
||||
|
||||
if !history_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Read all history files
|
||||
let mut entries: Vec<_> = fs::read_dir(&history_dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.path().is_file())
|
||||
.collect();
|
||||
|
||||
// Sort by modification time (newest first)
|
||||
entries.sort_by_key(|entry| {
|
||||
entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
entries.reverse();
|
||||
|
||||
let now = SystemTime::now();
|
||||
let max_age = Duration::from_secs(max_days as u64 * 24 * 60 * 60);
|
||||
|
||||
// Delete old entries
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
let should_delete = if index >= max_entries as usize {
|
||||
true // Too many entries
|
||||
} else if let Ok(metadata) = entry.metadata() {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if let Ok(age) = now.duration_since(modified) {
|
||||
age > max_age // Too old
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if should_delete {
|
||||
let _ = fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a slug from a name (URL-safe, lowercase)
|
||||
pub fn generate_slug(name: &str) -> String {
|
||||
name.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() {
|
||||
c
|
||||
} else if c.is_whitespace() || c == '-' || c == '_' {
|
||||
'-'
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_artifact_path() {
|
||||
// Valid cases
|
||||
assert!(validate_artifact_path("01HZYX", "file.md").is_ok());
|
||||
assert!(validate_artifact_path("abc-123", "folder/file.ts").is_ok());
|
||||
|
||||
// Invalid cases
|
||||
assert!(validate_artifact_path("../etc", "file.md").is_err());
|
||||
assert!(validate_artifact_path("id", "../file.md").is_err());
|
||||
assert!(validate_artifact_path("id", "/etc/passwd").is_err());
|
||||
assert!(validate_artifact_path("id", "C:\\windows\\system32").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_hash() {
|
||||
let content = "Hello, world!";
|
||||
let hash = compute_hash(content);
|
||||
assert!(hash.starts_with("sha256:"));
|
||||
assert_eq!(hash.len(), 71); // "sha256:" + 64 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_content() {
|
||||
let content = "Line 1\r\nLine 2\rLine 3\nLine 4";
|
||||
let normalized = normalize_content(content).unwrap();
|
||||
assert_eq!(normalized, "Line 1\nLine 2\nLine 3\nLine 4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_slug() {
|
||||
assert_eq!(generate_slug("Project Brief"), "project-brief");
|
||||
assert_eq!(generate_slug("My Awesome Script!!!"), "my-awesome-script");
|
||||
assert_eq!(generate_slug("test_file.py"), "test-file-py");
|
||||
assert_eq!(generate_slug("UPPER CASE"), "upper-case");
|
||||
}
|
||||
}
|
||||
|
||||
28
src-tauri/src/core/artifacts/mod.rs
Normal file
28
src-tauri/src/core/artifacts/mod.rs
Normal file
@ -0,0 +1,28 @@
|
||||
/*!
|
||||
Artifact Persistence and Management Module
|
||||
|
||||
This module provides all logic for managing artifacts within threads, including creation,
|
||||
modification, deletion, and versioning. Artifacts are separate workspace files (markdown, code, etc.)
|
||||
that live alongside conversations.
|
||||
|
||||
**Storage Structure:**
|
||||
- Artifacts are stored per-thread in `threads/{thread_id}/artifacts/`
|
||||
- An index file (`artifacts.json`) maintains metadata and schema version
|
||||
- History is sharded by artifact ID in `.history/{artifact_id}/`
|
||||
- Proposals from assistants are staged in `.proposals/`
|
||||
|
||||
**Concurrency and Consistency:**
|
||||
- Artifact index writes use atomic file operations (temp + rename)
|
||||
- Content updates include version and hash validation for conflict detection
|
||||
- All file operations enforce UTF-8 encoding and LF line endings
|
||||
*/
|
||||
|
||||
pub mod commands;
|
||||
mod constants;
|
||||
pub mod helpers;
|
||||
mod models;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
135
src-tauri/src/core/artifacts/models.rs
Normal file
135
src-tauri/src/core/artifacts/models.rs
Normal file
@ -0,0 +1,135 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Schema version and history policy for artifact index
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HistoryKeepPolicy {
|
||||
pub max_entries: u32,
|
||||
pub max_days: u32,
|
||||
}
|
||||
|
||||
impl Default for HistoryKeepPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_entries: super::constants::DEFAULT_MAX_HISTORY_ENTRIES,
|
||||
max_days: super::constants::DEFAULT_MAX_HISTORY_DAYS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Artifact metadata stored in the index
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtifactMetadata {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_message_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub archived: bool,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ArtifactMetadata {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source_message_id: None,
|
||||
archived: false,
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual artifact entry
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Artifact {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub content_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
pub created_at: u64,
|
||||
pub updated_at: u64,
|
||||
pub version: u32,
|
||||
pub bytes: u64,
|
||||
pub hash: String,
|
||||
pub file_path: String,
|
||||
#[serde(default)]
|
||||
pub read_only: bool,
|
||||
#[serde(default)]
|
||||
pub extensions: HashMap<String, serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub metadata: ArtifactMetadata,
|
||||
}
|
||||
|
||||
/// Root artifact index structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtifactIndex {
|
||||
pub schema_version: u32,
|
||||
#[serde(default)]
|
||||
pub history_keep: HistoryKeepPolicy,
|
||||
#[serde(default)]
|
||||
pub artifacts: Vec<Artifact>,
|
||||
pub active_artifact_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ArtifactIndex {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema_version: super::constants::SCHEMA_VERSION,
|
||||
history_keep: HistoryKeepPolicy::default(),
|
||||
artifacts: Vec::new(),
|
||||
active_artifact_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Data for creating a new artifact
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ArtifactCreate {
|
||||
pub name: String,
|
||||
pub content_type: String,
|
||||
pub language: Option<String>,
|
||||
pub content: String,
|
||||
pub source_message_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Diff hunk for selective application
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiffHunk {
|
||||
pub start_line: u32,
|
||||
pub end_line: u32,
|
||||
pub content: String,
|
||||
pub change_type: String, // "add", "remove", "modify"
|
||||
}
|
||||
|
||||
/// Diff preview returned from propose_update
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiffPreview {
|
||||
pub proposal_id: String,
|
||||
pub artifact_id: String,
|
||||
pub current_version: u32,
|
||||
pub current_hash: String,
|
||||
pub proposed_hash: String,
|
||||
pub hunks: Vec<DiffHunk>,
|
||||
pub full_diff: String,
|
||||
}
|
||||
|
||||
/// Result of import operation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportResult {
|
||||
pub imported_count: u32,
|
||||
pub skipped_count: u32,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Proposed content reference in message metadata
|
||||
/// Used only on TypeScript side, defined here for documentation
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProposedContentRef {
|
||||
pub storage: String,
|
||||
pub path: String,
|
||||
pub bytes: u64,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
78
src-tauri/src/core/artifacts/tests.rs
Normal file
78
src-tauri/src/core/artifacts/tests.rs
Normal file
@ -0,0 +1,78 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::helpers::*;
|
||||
use super::super::models::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_artifact_path() {
|
||||
// Valid paths
|
||||
assert!(validate_artifact_path("01HZYX", "file.md").is_ok());
|
||||
assert!(validate_artifact_path("abc-123_xyz", "folder/file.ts").is_ok());
|
||||
|
||||
// Invalid artifact IDs
|
||||
assert!(validate_artifact_path("../etc", "file.md").is_err());
|
||||
assert!(validate_artifact_path("id;drop", "file.md").is_err());
|
||||
|
||||
// Invalid file paths
|
||||
assert!(validate_artifact_path("valid-id", "../file.md").is_err());
|
||||
assert!(validate_artifact_path("valid-id", "/etc/passwd").is_err());
|
||||
assert!(validate_artifact_path("valid-id", "C:\\windows").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_hash() {
|
||||
let content = "Hello, world!";
|
||||
let hash1 = compute_hash(content);
|
||||
let hash2 = compute_hash(content);
|
||||
|
||||
assert!(hash1.starts_with("sha256:"));
|
||||
assert_eq!(hash1, hash2); // Same content = same hash
|
||||
|
||||
let different_hash = compute_hash("Different content");
|
||||
assert_ne!(hash1, different_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_content() {
|
||||
// Windows line endings
|
||||
let content = "Line 1\r\nLine 2\r\nLine 3";
|
||||
let normalized = normalize_content(content).unwrap();
|
||||
assert_eq!(normalized, "Line 1\nLine 2\nLine 3");
|
||||
|
||||
// Mac line endings
|
||||
let content = "Line 1\rLine 2\rLine 3";
|
||||
let normalized = normalize_content(content).unwrap();
|
||||
assert_eq!(normalized, "Line 1\nLine 2\nLine 3");
|
||||
|
||||
// Mixed line endings
|
||||
let content = "Line 1\r\nLine 2\rLine 3\nLine 4";
|
||||
let normalized = normalize_content(content).unwrap();
|
||||
assert_eq!(normalized, "Line 1\nLine 2\nLine 3\nLine 4");
|
||||
|
||||
// Already normalized
|
||||
let content = "Line 1\nLine 2\nLine 3";
|
||||
let normalized = normalize_content(content).unwrap();
|
||||
assert_eq!(normalized, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_slug() {
|
||||
assert_eq!(generate_slug("Project Brief"), "project-brief");
|
||||
assert_eq!(generate_slug("My Awesome Script!!!"), "my-awesome-script");
|
||||
assert_eq!(generate_slug("test_file.py"), "test-file-py");
|
||||
assert_eq!(generate_slug("UPPER CASE"), "upper-case");
|
||||
assert_eq!(generate_slug(" spaces everywhere "), "spaces-everywhere");
|
||||
assert_eq!(generate_slug("Special@#$Characters"), "special-characters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_artifact_index_default() {
|
||||
let index = ArtifactIndex::default();
|
||||
assert_eq!(index.schema_version, super::super::constants::SCHEMA_VERSION);
|
||||
assert_eq!(index.artifacts.len(), 0);
|
||||
assert_eq!(index.active_artifact_id, None);
|
||||
assert_eq!(index.history_keep.max_entries, 50);
|
||||
assert_eq!(index.history_keep.max_days, 30);
|
||||
}
|
||||
}
|
||||
|
||||
92
src-tauri/src/core/artifacts/utils.rs
Normal file
92
src-tauri/src/core/artifacts/utils.rs
Normal file
@ -0,0 +1,92 @@
|
||||
use std::path::PathBuf;
|
||||
use tauri::Runtime;
|
||||
|
||||
use super::constants::{
|
||||
ARTIFACTS_DIR, ARTIFACTS_INDEX, HISTORY_DIR, PROPOSALS_DIR, SEARCH_INDEX,
|
||||
};
|
||||
use crate::core::threads::utils::get_thread_dir;
|
||||
|
||||
/// Get the artifacts directory for a thread
|
||||
pub fn get_artifacts_dir<R: Runtime>(app_handle: tauri::AppHandle<R>, thread_id: &str) -> PathBuf {
|
||||
get_thread_dir(app_handle, thread_id).join(ARTIFACTS_DIR)
|
||||
}
|
||||
|
||||
/// Get the path to the artifacts index file
|
||||
pub fn get_index_path<R: Runtime>(app_handle: tauri::AppHandle<R>, thread_id: &str) -> PathBuf {
|
||||
get_artifacts_dir(app_handle, thread_id).join(ARTIFACTS_INDEX)
|
||||
}
|
||||
|
||||
/// Get the path to the search index file (reserved for future search feature)
|
||||
#[allow(dead_code)]
|
||||
pub fn get_search_index_path<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
) -> PathBuf {
|
||||
get_artifacts_dir(app_handle, thread_id).join(SEARCH_INDEX)
|
||||
}
|
||||
|
||||
/// Get the history directory for a specific artifact
|
||||
pub fn get_artifact_history_dir<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
artifact_id: &str,
|
||||
) -> PathBuf {
|
||||
get_artifacts_dir(app_handle, thread_id)
|
||||
.join(HISTORY_DIR)
|
||||
.join(artifact_id)
|
||||
}
|
||||
|
||||
/// Get the proposals directory
|
||||
pub fn get_proposals_dir<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
) -> PathBuf {
|
||||
get_artifacts_dir(app_handle, thread_id).join(PROPOSALS_DIR)
|
||||
}
|
||||
|
||||
/// Get the full path to an artifact file
|
||||
pub fn get_artifact_path<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
file_path: &str,
|
||||
) -> PathBuf {
|
||||
get_artifacts_dir(app_handle, thread_id).join(file_path)
|
||||
}
|
||||
|
||||
/// Ensure artifacts directory structure exists
|
||||
pub fn ensure_artifacts_dirs<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let artifacts_dir = get_artifacts_dir(app_handle.clone(), thread_id);
|
||||
if !artifacts_dir.exists() {
|
||||
std::fs::create_dir_all(&artifacts_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Create history and proposals directories
|
||||
let history_dir = artifacts_dir.join(HISTORY_DIR);
|
||||
if !history_dir.exists() {
|
||||
std::fs::create_dir_all(&history_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let proposals_dir = artifacts_dir.join(PROPOSALS_DIR);
|
||||
if !proposals_dir.exists() {
|
||||
std::fs::create_dir_all(&proposals_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure history directory exists for a specific artifact
|
||||
pub fn ensure_artifact_history_dir<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
thread_id: &str,
|
||||
artifact_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let history_dir = get_artifact_history_dir(app_handle, thread_id, artifact_id);
|
||||
if !history_dir.exists() {
|
||||
std::fs::create_dir_all(&history_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
pub mod app;
|
||||
pub mod artifacts;
|
||||
pub mod downloads;
|
||||
pub mod extensions;
|
||||
pub mod filesystem;
|
||||
|
||||
@ -107,6 +107,19 @@ pub fn run() {
|
||||
core::threads::commands::get_thread_assistant,
|
||||
core::threads::commands::create_thread_assistant,
|
||||
core::threads::commands::modify_thread_assistant,
|
||||
// Artifacts
|
||||
core::artifacts::commands::list_artifacts,
|
||||
core::artifacts::commands::create_artifact,
|
||||
core::artifacts::commands::get_artifact_content,
|
||||
core::artifacts::commands::update_artifact,
|
||||
core::artifacts::commands::delete_artifact,
|
||||
core::artifacts::commands::rename_artifact,
|
||||
core::artifacts::commands::set_active_artifact,
|
||||
core::artifacts::commands::propose_update,
|
||||
core::artifacts::commands::apply_proposal,
|
||||
core::artifacts::commands::discard_proposal,
|
||||
core::artifacts::commands::export_artifacts,
|
||||
core::artifacts::commands::import_artifacts,
|
||||
// Download
|
||||
core::downloads::commands::download_files,
|
||||
core::downloads::commands::cancel_download_task,
|
||||
|
||||
@ -61,6 +61,7 @@
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"lucide-react": "0.536.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"motion": "12.18.1",
|
||||
"next-themes": "0.4.6",
|
||||
"posthog-js": "1.255.1",
|
||||
|
||||
206
web-app/src/containers/ArtifactActionMessage.tsx
Normal file
206
web-app/src/containers/ArtifactActionMessage.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
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
|
||||
}
|
||||
|
||||
88
web-app/src/containers/ArtifactPanel/ArtifactList.tsx
Normal file
88
web-app/src/containers/ArtifactPanel/ArtifactList.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import type { Artifact } from '@janhq/core'
|
||||
import { useArtifacts } from '@/hooks/useArtifacts'
|
||||
|
||||
type ArtifactListProps = {
|
||||
threadId: string
|
||||
artifacts: Artifact[]
|
||||
activeArtifactId: string | null
|
||||
}
|
||||
|
||||
export function ArtifactList({ threadId, artifacts: artifactList, activeArtifactId }: ArtifactListProps) {
|
||||
const artifacts = useArtifacts()
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const now = Date.now() / 1000
|
||||
const diff = now - timestamp
|
||||
if (diff < 60) return 'Just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
const getTypeIcon = (contentType: string) => {
|
||||
if (contentType === 'text/markdown') return '📝'
|
||||
if (contentType.startsWith('text/x-')) return '💻'
|
||||
return '📄'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-main-view border-r border-main-view-fg/10">
|
||||
{/* Sidebar Header */}
|
||||
<div className="px-3 py-2 border-b border-main-view-fg/10">
|
||||
<div className="text-xs font-medium text-main-view-fg/60">
|
||||
{artifactList.length} {artifactList.length === 1 ? 'Artifact' : 'Artifacts'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Artifact List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{artifactList.length === 0 ? (
|
||||
<div className="p-4 text-center text-xs text-main-view-fg/40">
|
||||
No artifacts yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{artifactList.map((artifact) => {
|
||||
const isActive = artifact.id === activeArtifactId
|
||||
const isDirty = artifacts.dirtyArtifacts.has(artifact.id)
|
||||
const hasPendingProposal = !!artifacts.pendingProposals[artifact.id]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={artifact.id}
|
||||
onClick={() => artifacts.setActiveArtifact(threadId, artifact.id)}
|
||||
className={`w-full text-left px-2 py-2 rounded text-xs hover:bg-main-view-fg/5 transition-colors ${
|
||||
isActive ? 'bg-main-view-fg/10' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-base leading-none mt-0.5">{getTypeIcon(artifact.content_type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-medium truncate ${isActive ? 'text-main-view-fg' : 'text-main-view-fg/80'}`}>
|
||||
{artifact.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-main-view-fg/50 mt-0.5">
|
||||
v{artifact.version} • {formatDate(artifact.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
{isDirty && (
|
||||
<span className="text-[10px] text-yellow-500">●</span>
|
||||
)}
|
||||
{hasPendingProposal && (
|
||||
<span className="text-[10px] px-1 py-0.5 rounded bg-blue-500/20 text-blue-600">
|
||||
Δ
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
191
web-app/src/containers/ArtifactPanel/DiffPreview.tsx
Normal file
191
web-app/src/containers/ArtifactPanel/DiffPreview.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import { useState } from 'react'
|
||||
import type { DiffPreview as DiffPreviewType } from '@janhq/core'
|
||||
import { useArtifacts } from '@/hooks/useArtifacts'
|
||||
|
||||
type DiffPreviewProps = {
|
||||
threadId: string
|
||||
artifactId: string
|
||||
diff: DiffPreviewType
|
||||
}
|
||||
|
||||
export function DiffPreview({ threadId, artifactId, diff }: DiffPreviewProps) {
|
||||
const artifacts = useArtifacts()
|
||||
const [selectedHunks, setSelectedHunks] = useState<Set<number>>(
|
||||
new Set(diff.hunks.map((_, i) => i))
|
||||
)
|
||||
const [applying, setApplying] = useState(false)
|
||||
|
||||
const handleToggleHunk = (index: number) => {
|
||||
setSelectedHunks((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index)
|
||||
} else {
|
||||
newSet.add(index)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const handleApplySelected = async () => {
|
||||
if (selectedHunks.size === 0) return
|
||||
|
||||
setApplying(true)
|
||||
try {
|
||||
await artifacts.applyProposal(
|
||||
threadId,
|
||||
artifactId,
|
||||
diff.proposal_id,
|
||||
Array.from(selectedHunks)
|
||||
)
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscard = () => {
|
||||
artifacts.discardProposal(threadId, artifactId, diff.proposal_id)
|
||||
}
|
||||
|
||||
const allSelected = selectedHunks.size === diff.hunks.length
|
||||
const someSelected = selectedHunks.size > 0 && selectedHunks.size < diff.hunks.length
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-main-view">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-main-view-fg/10 bg-yellow-500/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" className="text-yellow-600">
|
||||
<path
|
||||
d="M10 6V10M10 14H10.01M19 10C19 14.9706 14.9706 19 10 19C5.02944 19 1 14.9706 1 10C1 5.02944 5.02944 1 10 1C14.9706 1 19 5.02944 19 10Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Proposed Changes</span>
|
||||
<span className="text-xs text-main-view-fg/60">
|
||||
{diff.hunks.length} change{diff.hunks.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDiscard}
|
||||
disabled={applying}
|
||||
className="px-3 py-1 text-xs border border-main-view-fg/20 rounded hover:bg-main-view-fg/5 disabled:opacity-50"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApplySelected}
|
||||
disabled={applying || selectedHunks.size === 0}
|
||||
className="px-3 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{applying
|
||||
? 'Applying...'
|
||||
: selectedHunks.size === diff.hunks.length
|
||||
? 'Apply All'
|
||||
: `Apply ${selectedHunks.size} Selected`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hunk Selection */}
|
||||
{diff.hunks.length > 1 && (
|
||||
<div className="px-4 py-2 border-b border-main-view-fg/10 bg-main-view-fg/5">
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(input) => {
|
||||
if (input) {
|
||||
input.indeterminate = someSelected
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedHunks(new Set(diff.hunks.map((_, i) => i)))
|
||||
} else {
|
||||
setSelectedHunks(new Set())
|
||||
}
|
||||
}}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
<span>
|
||||
{allSelected
|
||||
? 'Deselect all'
|
||||
: someSelected
|
||||
? 'Select all'
|
||||
: 'Select all'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diff Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{diff.hunks.map((hunk, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`border rounded ${
|
||||
selectedHunks.has(index)
|
||||
? 'border-blue-500/30 bg-blue-500/5'
|
||||
: 'border-main-view-fg/10'
|
||||
}`}
|
||||
>
|
||||
{diff.hunks.length > 1 && (
|
||||
<label className="flex items-center gap-2 px-3 py-2 border-b border-main-view-fg/10 cursor-pointer hover:bg-main-view-fg/5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedHunks.has(index)}
|
||||
onChange={() => handleToggleHunk(index)}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
<span className="text-xs font-medium">
|
||||
Hunk {index + 1} • Line {hunk.start_line}
|
||||
{hunk.end_line !== hunk.start_line && `-${hunk.end_line}`}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
hunk.change_type === 'add'
|
||||
? 'bg-green-500/20 text-green-700'
|
||||
: hunk.change_type === 'remove'
|
||||
? 'bg-red-500/20 text-red-700'
|
||||
: 'bg-blue-500/20 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{hunk.change_type}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<pre className="p-3 text-xs font-mono overflow-x-auto bg-main-view-fg/5">
|
||||
{hunk.content.split('\n').map((line, lineIndex) => {
|
||||
const firstChar = line[0]
|
||||
const color =
|
||||
firstChar === '+'
|
||||
? 'text-green-600'
|
||||
: firstChar === '-'
|
||||
? 'text-red-600'
|
||||
: 'text-main-view-fg/70'
|
||||
return (
|
||||
<div key={lineIndex} className={color}>
|
||||
{line || '\u00A0'}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with version info */}
|
||||
<div className="px-4 py-2 border-t border-main-view-fg/10 bg-main-view-fg/5 text-xs text-main-view-fg/60">
|
||||
<div>Current version: {diff.current_version}</div>
|
||||
<div className="font-mono">Current hash: {diff.current_hash.substring(0, 16)}...</div>
|
||||
<div className="font-mono">Proposed hash: {diff.proposed_hash.substring(0, 16)}...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
101
web-app/src/containers/ArtifactPanel/MonacoEditor.tsx
Normal file
101
web-app/src/containers/ArtifactPanel/MonacoEditor.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
|
||||
// Lazy-loaded Monaco editor
|
||||
// This component uses dynamic import to only load Monaco when needed
|
||||
type MonacoEditorProps = {
|
||||
value: string
|
||||
language: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function MonacoEditor({ value, language, onChange }: MonacoEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<any>(null)
|
||||
const { isDark } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
// Dynamically import Monaco
|
||||
let mounted = true
|
||||
|
||||
const initMonaco = async () => {
|
||||
try {
|
||||
const monaco = await import('monaco-editor')
|
||||
|
||||
if (!mounted || !containerRef.current) return
|
||||
|
||||
// Create editor
|
||||
editorRef.current = monaco.editor.create(containerRef.current, {
|
||||
value,
|
||||
language,
|
||||
theme: isDark ? 'vs-dark' : 'vs',
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: true },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
renderWhitespace: 'selection',
|
||||
tabSize: 2,
|
||||
wordWrap: 'on',
|
||||
})
|
||||
|
||||
// Listen for changes
|
||||
editorRef.current.onDidChangeModelContent(() => {
|
||||
if (editorRef.current) {
|
||||
const newValue = editorRef.current.getValue()
|
||||
onChange(newValue)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load Monaco editor:', error)
|
||||
}
|
||||
}
|
||||
|
||||
initMonaco()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
if (editorRef.current) {
|
||||
editorRef.current.dispose()
|
||||
}
|
||||
}
|
||||
}, [language]) // Only reinitialize if language changes
|
||||
|
||||
// Update value when it changes externally (but not from editor itself)
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.getValue() !== value) {
|
||||
const model = editorRef.current.getModel()
|
||||
if (model) {
|
||||
editorRef.current.pushUndoStop()
|
||||
model.pushEditOperations(
|
||||
[],
|
||||
[
|
||||
{
|
||||
range: model.getFullModelRange(),
|
||||
text: value,
|
||||
},
|
||||
],
|
||||
() => null
|
||||
)
|
||||
editorRef.current.pushUndoStop()
|
||||
}
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Update theme
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
const monaco = editorRef.current._themeService?._theme
|
||||
if (monaco) {
|
||||
import('monaco-editor').then((m) => {
|
||||
m.editor.setTheme(isDark ? 'vs-dark' : 'vs')
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isDark])
|
||||
|
||||
return <div ref={containerRef} className="w-full h-full" />
|
||||
}
|
||||
|
||||
192
web-app/src/containers/ArtifactPanel/index.tsx
Normal file
192
web-app/src/containers/ArtifactPanel/index.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import { useArtifacts } from '@/hooks/useArtifacts'
|
||||
import { useEffect, useState, lazy, Suspense } from 'react'
|
||||
import type { Artifact } from '@janhq/core'
|
||||
import { CONTENT_TYPE_TO_LANGUAGE } from '@janhq/core'
|
||||
import { DiffPreview } from './DiffPreview'
|
||||
import { ArtifactList } from './ArtifactList'
|
||||
import { ConflictResolutionDialog } from '../dialogs/ConflictResolutionDialog'
|
||||
|
||||
// Lazy-load Monaco editor
|
||||
const MonacoEditor = lazy(() =>
|
||||
import('./MonacoEditor').then((module) => ({ default: module.MonacoEditor }))
|
||||
)
|
||||
|
||||
type ArtifactPanelProps = {
|
||||
threadId: string
|
||||
}
|
||||
|
||||
export function ArtifactPanel({ threadId }: ArtifactPanelProps) {
|
||||
const artifacts = useArtifacts()
|
||||
const [activeArtifact, setActiveArtifact] = useState<Artifact | null>(null)
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
const threadArtifacts = artifacts.threadArtifacts[threadId] || []
|
||||
const activeArtifactId = artifacts.threadIndex[threadId]?.active_artifact_id
|
||||
|
||||
// Load artifacts when panel opens
|
||||
useEffect(() => {
|
||||
artifacts.loadArtifacts(threadId)
|
||||
}, [threadId])
|
||||
|
||||
// Force save when thread changes or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Save all dirty artifacts when panel closes or thread changes
|
||||
artifacts.forceSaveAll(threadId)
|
||||
}
|
||||
}, [threadId])
|
||||
|
||||
// Force save on window unload
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
artifacts.forceSaveAll(threadId)
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [threadId])
|
||||
|
||||
// Load active artifact content
|
||||
useEffect(() => {
|
||||
if (activeArtifactId) {
|
||||
const artifact = threadArtifacts.find((a) => a.id === activeArtifactId)
|
||||
setActiveArtifact(artifact || null)
|
||||
|
||||
if (artifact) {
|
||||
artifacts.getArtifactContent(threadId, artifact.id).then((content) => {
|
||||
if (content !== null) {
|
||||
setContent(content)
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setActiveArtifact(null)
|
||||
setContent('')
|
||||
}
|
||||
}, [activeArtifactId, threadArtifacts])
|
||||
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setContent(newContent)
|
||||
if (activeArtifact) {
|
||||
artifacts.updateArtifactContent(activeArtifact.id, newContent)
|
||||
}
|
||||
}
|
||||
|
||||
const isDirty = activeArtifact ? artifacts.dirtyArtifacts.has(activeArtifact.id) : false
|
||||
const isSaving = activeArtifact ? artifacts.savingArtifacts.has(activeArtifact.id) : false
|
||||
const pendingProposal = activeArtifact ? artifacts.pendingProposals[activeArtifact.id] : undefined
|
||||
const conflict = activeArtifact ? artifacts.conflicts[activeArtifact.id] : undefined
|
||||
|
||||
// Determine if we should use Monaco (for code) or textarea (for markdown/text)
|
||||
const isCodeArtifact = activeArtifact && activeArtifact.content_type !== 'text/markdown'
|
||||
const monacoLanguage = activeArtifact
|
||||
? CONTENT_TYPE_TO_LANGUAGE[activeArtifact.content_type] || 'plaintext'
|
||||
: 'plaintext'
|
||||
|
||||
return (
|
||||
<div className="h-full flex bg-main-view border-l border-main-view-fg/10">
|
||||
{/* Artifact List Sidebar */}
|
||||
{threadArtifacts.length > 0 && (
|
||||
<div className="w-48 flex-shrink-0">
|
||||
<ArtifactList
|
||||
threadId={threadId}
|
||||
artifacts={threadArtifacts}
|
||||
activeArtifactId={activeArtifactId || null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-main-view-fg/10">
|
||||
<div className="flex items-center gap-2">
|
||||
{activeArtifact ? (
|
||||
<>
|
||||
<span className="font-medium">{activeArtifact.name}</span>
|
||||
<span className="text-xs text-main-view-fg/60">{activeArtifact.content_type}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-main-view-fg/10">
|
||||
v{activeArtifact.version}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-main-view-fg/60">No artifact selected</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{isDirty && <span className="text-yellow-500">● Unsaved</span>}
|
||||
{isSaving && <span className="text-blue-500">Saving...</span>}
|
||||
{!isDirty && !isSaving && activeArtifact && (
|
||||
<span className="text-green-500">Saved</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{pendingProposal && activeArtifact ? (
|
||||
<DiffPreview threadId={threadId} artifactId={activeArtifact.id} diff={pendingProposal} />
|
||||
) : activeArtifact ? (
|
||||
isCodeArtifact ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full text-main-view-fg/40">
|
||||
Loading editor...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MonacoEditor
|
||||
value={content}
|
||||
language={monacoLanguage}
|
||||
onChange={handleContentChange}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
className="w-full h-full p-4 bg-main-view text-main-view-fg font-mono text-sm resize-none focus:outline-none"
|
||||
placeholder="Start typing..."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-main-view-fg/40">
|
||||
{threadArtifacts.length === 0 ? (
|
||||
<div className="text-center">
|
||||
<div className="text-lg mb-2">No artifacts yet</div>
|
||||
<div className="text-sm">Create an artifact to get started</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="text-lg mb-2">Select an artifact</div>
|
||||
<div className="text-sm">Choose from the list to view or edit</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conflict Resolution Dialog */}
|
||||
{activeArtifact && conflict && (
|
||||
<ConflictResolutionDialog
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
artifacts.clearConflict(activeArtifact.id)
|
||||
}
|
||||
}}
|
||||
artifact={activeArtifact}
|
||||
localContent={conflict.localContent}
|
||||
serverContent={conflict.serverContent}
|
||||
onResolve={(resolution) => {
|
||||
if (resolution !== 'manual') {
|
||||
artifacts.resolveConflict(threadId, activeArtifact.id, resolution)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
157
web-app/src/containers/ArtifactQuickSwitcher.tsx
Normal file
157
web-app/src/containers/ArtifactQuickSwitcher.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useArtifacts } from '@/hooks/useArtifacts'
|
||||
import type { Artifact } from '@janhq/core'
|
||||
|
||||
type ArtifactQuickSwitcherProps = {
|
||||
threadId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ArtifactQuickSwitcher({
|
||||
threadId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ArtifactQuickSwitcherProps) {
|
||||
const artifacts = useArtifacts()
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
const threadArtifacts = artifacts.threadArtifacts[threadId] || []
|
||||
|
||||
// Filter artifacts by search
|
||||
const filteredArtifacts = threadArtifacts.filter((artifact) => {
|
||||
const searchLower = search.toLowerCase()
|
||||
return (
|
||||
artifact.name.toLowerCase().includes(searchLower) ||
|
||||
artifact.slug.toLowerCase().includes(searchLower) ||
|
||||
artifact.content_type.toLowerCase().includes(searchLower)
|
||||
)
|
||||
})
|
||||
|
||||
// Reset selection when search changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [search])
|
||||
|
||||
// Reset search when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearch('')
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSelect = (artifact: Artifact) => {
|
||||
artifacts.setActiveArtifact(threadId, artifact.id)
|
||||
if (!artifacts.splitViewOpen[threadId]) {
|
||||
artifacts.toggleSplitView(threadId)
|
||||
}
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, filteredArtifacts.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
} else if (e.key === 'Enter' && filteredArtifacts[selectedIndex]) {
|
||||
e.preventDefault()
|
||||
handleSelect(filteredArtifacts[selectedIndex])
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const now = Date.now() / 1000
|
||||
const diff = now - timestamp
|
||||
if (diff < 60) return 'Just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
const getTypeIcon = (contentType: string) => {
|
||||
if (contentType === 'text/markdown') return '📝'
|
||||
if (contentType.startsWith('text/x-')) return '💻'
|
||||
return '📄'
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] p-0">
|
||||
<div className="flex flex-col max-h-[500px]">
|
||||
{/* Search Input */}
|
||||
<div className="p-4 border-b border-main-view-fg/10">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search artifacts..."
|
||||
className="w-full px-3 py-2 bg-main-view text-main-view-fg border border-main-view-fg/20 rounded focus:outline-none focus:border-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="overflow-y-auto">
|
||||
{filteredArtifacts.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-main-view-fg/40">
|
||||
{search ? 'No artifacts found' : 'No artifacts in this thread'}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{filteredArtifacts.map((artifact, index) => {
|
||||
const isSelected = index === selectedIndex
|
||||
const isActive = artifacts.threadIndex[threadId]?.active_artifact_id === artifact.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={artifact.id}
|
||||
onClick={() => handleSelect(artifact)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-main-view-fg/10 transition-colors ${
|
||||
isSelected ? 'bg-blue-500/10' : 'hover:bg-main-view-fg/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl leading-none mt-0.5">
|
||||
{getTypeIcon(artifact.content_type)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{artifact.name}</span>
|
||||
{isActive && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-700">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-main-view-fg/60 mt-1">
|
||||
{artifact.content_type} • v{artifact.version} •{' '}
|
||||
{formatDate(artifact.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Hint */}
|
||||
<div className="px-4 py-2 border-t border-main-view-fg/10 bg-main-view-fg/5 text-xs text-main-view-fg/60">
|
||||
Use ↑ ↓ to navigate • Enter to select • Esc to close
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
219
web-app/src/containers/ArtifactSidePanel.tsx
Normal file
219
web-app/src/containers/ArtifactSidePanel.tsx
Normal file
@ -0,0 +1,219 @@
|
||||
/**
|
||||
* ArtifactSidePanel - Claude-style on-demand artifact viewer
|
||||
*
|
||||
* Slides in from the right side as an overlay panel.
|
||||
* Features:
|
||||
* - Preview/Code toggle
|
||||
* - Markdown rendering or Monaco editor
|
||||
* - Copy, Download, Share actions
|
||||
* - Artifacts sidebar at bottom-right
|
||||
*/
|
||||
|
||||
import { useArtifacts } from '@/hooks/useArtifacts'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { RenderMarkdown } from '@/containers/RenderMarkdown'
|
||||
import { IconX, IconCopy, IconDownload, IconShare, IconCode, IconEye } from '@tabler/icons-react'
|
||||
import { ArtifactsSidebar } from '@/containers/ArtifactsSidebar'
|
||||
import type { Artifact } from '@janhq/core'
|
||||
import { MonacoEditor } from '@/containers/ArtifactPanel/MonacoEditor'
|
||||
|
||||
type ArtifactSidePanelProps = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
threadId: string
|
||||
}
|
||||
|
||||
export function ArtifactSidePanel({ isOpen, onClose, threadId }: ArtifactSidePanelProps) {
|
||||
const artifacts = useArtifacts()
|
||||
const [viewMode, setViewMode] = useState<'preview' | 'code'>('preview')
|
||||
const [content, setContent] = useState<string>('')
|
||||
const activeArtifactId = artifacts.threadIndex[threadId]?.active_artifact_id
|
||||
const activeArtifact = activeArtifactId
|
||||
? artifacts.threadArtifacts[threadId]?.find((a: Artifact) => a.id === activeArtifactId)
|
||||
: undefined
|
||||
|
||||
// Load artifact content when active artifact changes
|
||||
useEffect(() => {
|
||||
if (activeArtifact && activeArtifactId) {
|
||||
artifacts.getArtifactContent(threadId, activeArtifactId).then((loadedContent) => {
|
||||
if (loadedContent !== null) {
|
||||
setContent(loadedContent)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setContent('')
|
||||
}
|
||||
}, [activeArtifact?.id, threadId])
|
||||
|
||||
const handleCopy = () => {
|
||||
if (content) {
|
||||
navigator.clipboard.writeText(content)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!activeArtifact || !content) return
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = activeArtifact.file_path.split('/').pop() || 'artifact.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const getFileExtension = () => {
|
||||
if (!activeArtifact) return 'TXT'
|
||||
const ext = activeArtifact.file_path.split('.').pop()
|
||||
return ext?.toUpperCase() || 'TXT'
|
||||
}
|
||||
|
||||
const isCodeArtifact = () => {
|
||||
return activeArtifact?.content_type.startsWith('text/x-')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Side Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed right-0 top-0 h-full w-[min(600px,50vw)] bg-background",
|
||||
"shadow-2xl border-l border-main-view-fg/10 z-50",
|
||||
"transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "translate-x-full"
|
||||
)}
|
||||
>
|
||||
{!activeArtifact ? (
|
||||
<div className="flex items-center justify-center h-full text-main-view-fg/50">
|
||||
<p>No artifact selected</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-main-view-fg/10">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 text-sm font-medium text-main-view-fg/70 hover:text-main-view-fg transition-colors"
|
||||
>
|
||||
<IconX size={16} />
|
||||
Go back
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Preview/Code Toggle */}
|
||||
<div className="flex items-center gap-1 p-1 bg-main-view-fg/5 rounded-md">
|
||||
<button
|
||||
onClick={() => setViewMode('preview')}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded transition-colors",
|
||||
viewMode === 'preview'
|
||||
? "bg-background shadow-sm text-main-view-fg"
|
||||
: "text-main-view-fg/60 hover:text-main-view-fg"
|
||||
)}
|
||||
>
|
||||
<IconEye size={14} />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('code')}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded transition-colors",
|
||||
viewMode === 'code'
|
||||
? "bg-background shadow-sm text-main-view-fg"
|
||||
: "text-main-view-fg/60 hover:text-main-view-fg"
|
||||
)}
|
||||
>
|
||||
<IconCode size={14} />
|
||||
Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-2 hover:bg-main-view-fg/10 rounded-md transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-2 hover:bg-main-view-fg/10 rounded-md transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 hover:bg-main-view-fg/10 rounded-md transition-colors"
|
||||
title="Share"
|
||||
>
|
||||
<IconShare size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div className="px-4 py-3 border-b border-main-view-fg/10">
|
||||
<h2 className="text-base font-medium text-main-view-fg">
|
||||
{activeArtifact.name} · {getFileExtension()}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto relative">
|
||||
{viewMode === 'preview' ? (
|
||||
<div className="p-6">
|
||||
{activeArtifact.content_type === 'text/markdown' ? (
|
||||
<RenderMarkdown content={content} />
|
||||
) : (
|
||||
<pre className="text-sm text-main-view-fg/80 whitespace-pre-wrap font-mono">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
isCodeArtifact() ? (
|
||||
<MonacoEditor
|
||||
language={activeArtifact.language || 'plaintext'}
|
||||
value={content}
|
||||
onChange={(value: string) => {
|
||||
setContent(value || '')
|
||||
artifacts.updateArtifactContent(activeArtifact.id, value || '')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => {
|
||||
setContent(e.target.value)
|
||||
artifacts.updateArtifactContent(activeArtifact.id, e.target.value)
|
||||
}}
|
||||
className="w-full h-full p-6 bg-transparent text-sm text-main-view-fg/80 font-mono resize-none focus:outline-none"
|
||||
style={{ minHeight: 'calc(100vh - 200px)' }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Artifacts Sidebar (bottom-right) */}
|
||||
<div className="absolute bottom-4 right-4 max-w-xs">
|
||||
<ArtifactsSidebar threadId={threadId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
122
web-app/src/containers/ArtifactsSidebar.tsx
Normal file
122
web-app/src/containers/ArtifactsSidebar.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* ArtifactsSidebar - Floating list of all artifacts in current conversation
|
||||
*
|
||||
* Appears in bottom-right of the side panel.
|
||||
* Lists all artifacts with download buttons and click-to-switch functionality.
|
||||
*/
|
||||
|
||||
import { useArtifacts } from '@/hooks/useArtifacts'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { IconDownload, IconFileText, IconCode } from '@tabler/icons-react'
|
||||
|
||||
type ArtifactsSidebarProps = {
|
||||
threadId: string
|
||||
}
|
||||
|
||||
export function ArtifactsSidebar({ threadId }: ArtifactsSidebarProps) {
|
||||
const artifacts = useArtifacts()
|
||||
const threadArtifacts = artifacts.threadArtifacts[threadId] || []
|
||||
const activeArtifactId = artifacts.threadIndex[threadId]?.active_artifact_id
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
// Download all artifacts as individual files
|
||||
threadArtifacts.forEach((artifact: any) => {
|
||||
const blob = new Blob([artifact.content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = artifact.file_path.split('/').pop() || 'artifact.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectArtifact = (artifactId: string) => {
|
||||
artifacts.setActiveArtifact(threadId, artifactId)
|
||||
}
|
||||
|
||||
const handleDownloadSingle = (artifact: any, e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent selecting the artifact
|
||||
const blob = new Blob([artifact.content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = artifact.file_path.split('/').pop() || 'artifact.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const getArtifactIcon = (artifact: any) => {
|
||||
if (artifact.content_type.startsWith('text/x-')) {
|
||||
return <IconCode size={14} />
|
||||
}
|
||||
return <IconFileText size={14} />
|
||||
}
|
||||
|
||||
const getFileType = (artifact: any) => {
|
||||
const ext = artifact.file_path.split('.').pop()
|
||||
return ext?.toUpperCase() || 'TXT'
|
||||
}
|
||||
|
||||
if (threadArtifacts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background border border-main-view-fg/10 rounded-lg shadow-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-main-view-fg/10 bg-main-view-fg/5">
|
||||
<h3 className="text-xs font-semibold text-main-view-fg">Artifacts</h3>
|
||||
{threadArtifacts.length > 1 && (
|
||||
<button
|
||||
onClick={handleDownloadAll}
|
||||
className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-main-view-fg/70 hover:text-main-view-fg hover:bg-main-view-fg/10 rounded transition-colors"
|
||||
>
|
||||
<IconDownload size={12} />
|
||||
Download all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Artifact List */}
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{threadArtifacts.map((artifact) => (
|
||||
<button
|
||||
key={artifact.id}
|
||||
onClick={() => handleSelectArtifact(artifact.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 p-3 text-left transition-colors border-b border-main-view-fg/5 last:border-b-0",
|
||||
artifact.id === activeArtifactId
|
||||
? "bg-main-view-fg/10"
|
||||
: "hover:bg-main-view-fg/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0 text-main-view-fg/60">
|
||||
{getArtifactIcon(artifact)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-main-view-fg truncate">
|
||||
{artifact.name}
|
||||
</div>
|
||||
<div className="text-xs text-main-view-fg/50 mt-0.5">
|
||||
{artifact.content_type === 'text/markdown' ? 'Document' : 'Code'} · {getFileType(artifact)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleDownloadSingle(artifact, e)}
|
||||
className="flex-shrink-0 p-1.5 hover:bg-main-view-fg/10 rounded transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<IconDownload size={14} />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
155
web-app/src/containers/CreateArtifactDialog.tsx
Normal file
155
web-app/src/containers/CreateArtifactDialog.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useArtifacts } from '@/hooks/useArtifacts'
|
||||
import { CONTENT_TYPES } from '@janhq/core'
|
||||
|
||||
// Simple label component inline
|
||||
const Label = ({ htmlFor, children, className = '' }: { htmlFor?: string; children: React.ReactNode; className?: string }) => (
|
||||
<label htmlFor={htmlFor} className={`text-sm font-medium ${className}`}>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
type CreateArtifactDialogProps = {
|
||||
threadId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CreateArtifactDialog({
|
||||
threadId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CreateArtifactDialogProps) {
|
||||
const artifacts = useArtifacts()
|
||||
const [name, setName] = useState('')
|
||||
const [contentType, setContentType] = useState<string>(CONTENT_TYPES.MARKDOWN)
|
||||
const [language, setLanguage] = useState<string>()
|
||||
const [initialContent, setInitialContent] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
await artifacts.createArtifact(threadId, name.trim(), contentType, language, initialContent)
|
||||
|
||||
// Reset form
|
||||
setName('')
|
||||
setContentType(CONTENT_TYPES.MARKDOWN)
|
||||
setLanguage(undefined)
|
||||
setInitialContent('')
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to create artifact:', error)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isCodeType = contentType !== CONTENT_TYPES.MARKDOWN && contentType !== CONTENT_TYPES.JSON
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Artifact</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Project Brief, Script, Notes..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content Type */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="content-type">Type</Label>
|
||||
<select
|
||||
id="content-type"
|
||||
value={contentType}
|
||||
onChange={(e) => {
|
||||
setContentType(e.target.value)
|
||||
// Auto-set language for code types
|
||||
if (e.target.value === CONTENT_TYPES.TYPESCRIPT) setLanguage('typescript')
|
||||
else if (e.target.value === CONTENT_TYPES.JAVASCRIPT) setLanguage('javascript')
|
||||
else if (e.target.value === CONTENT_TYPES.PYTHON) setLanguage('python')
|
||||
else if (e.target.value === CONTENT_TYPES.RUST) setLanguage('rust')
|
||||
else if (e.target.value === CONTENT_TYPES.GO) setLanguage('go')
|
||||
else setLanguage(undefined)
|
||||
}}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<optgroup label="Documents">
|
||||
<option value={CONTENT_TYPES.MARKDOWN}>Markdown</option>
|
||||
<option value={CONTENT_TYPES.JSON}>JSON</option>
|
||||
</optgroup>
|
||||
<optgroup label="Code">
|
||||
<option value={CONTENT_TYPES.TYPESCRIPT}>TypeScript</option>
|
||||
<option value={CONTENT_TYPES.JAVASCRIPT}>JavaScript</option>
|
||||
<option value={CONTENT_TYPES.PYTHON}>Python</option>
|
||||
<option value={CONTENT_TYPES.RUST}>Rust</option>
|
||||
<option value={CONTENT_TYPES.GO}>Go</option>
|
||||
<option value={CONTENT_TYPES.JAVA}>Java</option>
|
||||
<option value={CONTENT_TYPES.C}>C</option>
|
||||
<option value={CONTENT_TYPES.CPP}>C++</option>
|
||||
<option value={CONTENT_TYPES.HTML}>HTML</option>
|
||||
<option value={CONTENT_TYPES.CSS}>CSS</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Language (for code types) */}
|
||||
{isCodeType && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="language">Language (for syntax highlighting)</Label>
|
||||
<Input
|
||||
id="language"
|
||||
value={language || ''}
|
||||
onChange={(e) => setLanguage(e.target.value || undefined)}
|
||||
placeholder="e.g., typescript, python..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial Content */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="initial-content">Initial Content (optional)</Label>
|
||||
<textarea
|
||||
id="initial-content"
|
||||
value={initialContent}
|
||||
onChange={(e) => setInitialContent(e.target.value)}
|
||||
placeholder="Start with some content, or leave blank..."
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" onClick={() => onOpenChange(false)} disabled={creating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || creating}>
|
||||
{creating ? 'Creating...' : 'Create Artifact'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
166
web-app/src/containers/InlineArtifactCard.tsx
Normal file
166
web-app/src/containers/InlineArtifactCard.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* InlineArtifactCard - Claude-style inline artifact preview card
|
||||
*
|
||||
* Displays a collapsed preview of an artifact within the chat message flow.
|
||||
* Clicking "Preview contents" opens the artifact in the split view/side panel.
|
||||
*/
|
||||
|
||||
import type { Artifact } from '@janhq/core'
|
||||
import { useArtifacts } from '@/hooks/useArtifacts'
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type InlineArtifactCardProps = {
|
||||
artifact: Artifact & { content: string } // Artifact with loaded content
|
||||
threadId: string
|
||||
isCreating?: boolean
|
||||
}
|
||||
|
||||
export function InlineArtifactCard({ artifact, threadId, isCreating }: InlineArtifactCardProps) {
|
||||
const artifacts = useArtifacts()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
const handlePreviewClick = () => {
|
||||
// Open split view and set this as active artifact
|
||||
artifacts.setActiveArtifact(threadId, artifact.id)
|
||||
if (!artifacts.splitViewOpen[threadId]) {
|
||||
artifacts.toggleSplitView(threadId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
// Create blob and download
|
||||
const blob = new Blob([artifact.content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = artifact.file_path.split('/').pop() || 'artifact.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const getFileExtension = () => {
|
||||
const ext = artifact.file_path.split('.').pop()
|
||||
return ext?.toUpperCase() || 'TXT'
|
||||
}
|
||||
|
||||
const getArtifactType = () => {
|
||||
if (artifact.content_type.startsWith('text/x-')) {
|
||||
return 'Code'
|
||||
}
|
||||
if (artifact.content_type === 'text/markdown') {
|
||||
return 'Document'
|
||||
}
|
||||
return 'File'
|
||||
}
|
||||
|
||||
const getContentPreview = () => {
|
||||
// First ~200 chars for preview
|
||||
const preview = artifact.content.substring(0, 200)
|
||||
return preview.length < artifact.content.length ? preview + '...' : preview
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-3 border border-main-view-fg/10 rounded-lg overflow-hidden bg-main-view-fg/5">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={handlePreviewClick}
|
||||
disabled={isCreating}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-3 text-left transition-colors",
|
||||
"hover:bg-main-view-fg/10",
|
||||
isCreating && "opacity-60 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className="text-main-view-fg/60"
|
||||
>
|
||||
<path
|
||||
d="M6 2C5.44772 2 5 2.44772 5 3V17C5 17.5523 5.44772 18 6 18H14C14.5523 18 15 17.5523 15 17V6.41421C15 6.149 14.8946 5.89464 14.7071 5.70711L11.2929 2.29289C11.1054 2.10536 10.851 2 10.5858 2H6Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
<path d="M11 2V5C11 5.55228 11.4477 6 12 6H15" 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">
|
||||
{isCreating ? 'Creating' : artifact.name}
|
||||
</div>
|
||||
<div className="text-xs text-main-view-fg/60 mt-0.5">
|
||||
{getArtifactType()} · {getFileExtension()}
|
||||
</div>
|
||||
</div>
|
||||
{isCreating && (
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 text-main-view-fg/60"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Collapsible Preview */}
|
||||
{isExpanded && !isCreating && (
|
||||
<div className="border-t border-main-view-fg/10 p-4 bg-main-view-fg/5">
|
||||
<pre className="text-xs text-main-view-fg/70 whitespace-pre-wrap font-mono overflow-auto max-h-40">
|
||||
{getContentPreview()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="border-t border-main-view-fg/10 p-3 flex items-center justify-between bg-main-view-fg/5">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
disabled={isCreating}
|
||||
className="text-xs text-main-view-fg/60 hover:text-main-view-fg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isExpanded ? 'Hide preview' : 'Show preview'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isCreating}
|
||||
className="px-2 py-1 text-xs font-medium rounded-md hover:bg-main-view-fg/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreviewClick}
|
||||
disabled={isCreating}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md bg-main-view-fg/10 hover:bg-main-view-fg/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Preview contents'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
import { useModelProvider } from '@/hooks/useModelProvider'
|
||||
import { extractFilesFromPrompt } from '@/lib/fileMetadata'
|
||||
import { createImageAttachment } from '@/types/attachment'
|
||||
import { ArtifactActionMessage } from '@/containers/ArtifactActionMessage'
|
||||
|
||||
const CopyButton = ({ text }: { text: string }) => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
@ -376,6 +377,11 @@ export const ThreadContent = memo(
|
||||
components={linkComponents}
|
||||
/>
|
||||
|
||||
{/* Render artifact actions */}
|
||||
{item.metadata?.artifact_action && (
|
||||
<ArtifactActionMessage message={item} />
|
||||
)}
|
||||
|
||||
{isToolCalls && item.metadata?.tool_calls ? (
|
||||
<>
|
||||
{(item.metadata.tool_calls as ToolCall[]).map((toolCall) => (
|
||||
|
||||
132
web-app/src/containers/dialogs/ConflictResolutionDialog.tsx
Normal file
132
web-app/src/containers/dialogs/ConflictResolutionDialog.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useState } from 'react'
|
||||
|
||||
// Import Artifact type - will be available after core is built
|
||||
type Artifact = {
|
||||
id: string
|
||||
name: string
|
||||
version: number
|
||||
content_type: string
|
||||
}
|
||||
|
||||
type ConflictResolutionDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
artifact: Artifact
|
||||
localContent: string
|
||||
serverContent: string
|
||||
onResolve: (resolution: 'keep-mine' | 'take-theirs' | 'manual') => void
|
||||
}
|
||||
|
||||
export function ConflictResolutionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
artifact,
|
||||
localContent,
|
||||
serverContent,
|
||||
onResolve,
|
||||
}: ConflictResolutionDialogProps) {
|
||||
const [selectedOption, setSelectedOption] = useState<'keep-mine' | 'take-theirs'>('keep-mine')
|
||||
|
||||
const handleResolve = () => {
|
||||
onResolve(selectedOption)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Conflict Detected: {artifact.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This artifact has been modified elsewhere. Choose how to resolve the conflict.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Conflict Info */}
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded text-sm">
|
||||
<div className="font-medium mb-1">Version Mismatch</div>
|
||||
<div className="text-xs text-main-view-fg/60">
|
||||
Your changes are based on version {artifact.version - 1}, but the server has version{' '}
|
||||
{artifact.version}.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Option: Keep Mine */}
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedOption === 'keep-mine'
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-main-view-fg/10 hover:bg-main-view-fg/5'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="conflict-resolution"
|
||||
value="keep-mine"
|
||||
checked={selectedOption === 'keep-mine'}
|
||||
onChange={() => setSelectedOption('keep-mine')}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">Keep My Changes</div>
|
||||
<div className="text-xs text-main-view-fg/60 mt-1">
|
||||
Overwrite the server version with your local changes. The other changes will be
|
||||
lost.
|
||||
</div>
|
||||
<div className="mt-2 p-2 bg-main-view-fg/5 rounded text-xs font-mono max-h-32 overflow-auto">
|
||||
{localContent.substring(0, 200)}
|
||||
{localContent.length > 200 && '...'}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Option: Take Theirs */}
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedOption === 'take-theirs'
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-main-view-fg/10 hover:bg-main-view-fg/5'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="conflict-resolution"
|
||||
value="take-theirs"
|
||||
checked={selectedOption === 'take-theirs'}
|
||||
onChange={() => setSelectedOption('take-theirs')}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">Use Server Version</div>
|
||||
<div className="text-xs text-main-view-fg/60 mt-1">
|
||||
Discard your local changes and use the version from the server.
|
||||
</div>
|
||||
<div className="mt-2 p-2 bg-main-view-fg/5 rounded text-xs font-mono max-h-32 overflow-auto">
|
||||
{serverContent.substring(0, 200)}
|
||||
{serverContent.length > 200 && '...'}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleResolve}>Resolve Conflict</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
499
web-app/src/hooks/useArtifacts.ts
Normal file
499
web-app/src/hooks/useArtifacts.ts
Normal file
@ -0,0 +1,499 @@
|
||||
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 }
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
@ -45,7 +45,73 @@ export const defaultAssistant: Assistant = {
|
||||
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.\n\nWhen responding:\n- Answer directly from your knowledge when you can\n- Be concise, clear, and helpful\n- Admit when you’re unsure rather than making things up\n\nIf tools are available to you:\n- Only use tools when they add real value to your response\n- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code")\n- Use tools for information you don’t know or that needs verification\n- Never use tools just because they’re available\n\nWhen using tools:\n- Use one tool at a time and wait for results\n- Use actual values as arguments, not variable names\n- Learn from each result before deciding next steps\n- Avoid repeating the same tool call with identical parameters\n\nRemember: Most questions can be answered without tools. Think first whether you need them.\n\nCurrent date: {{current_date}}',
|
||||
`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
|
||||
|
||||
@ -4,7 +4,17 @@ declare const IS_WEB_APP: boolean
|
||||
declare const IS_IOS: boolean
|
||||
declare const IS_ANDROID: boolean
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TAURI__?: any
|
||||
}
|
||||
}
|
||||
|
||||
export const isPlatformTauri = (): boolean => {
|
||||
// Check if we actually have Tauri available
|
||||
if (typeof window !== 'undefined' && !window.__TAURI__) {
|
||||
return false
|
||||
}
|
||||
if (typeof IS_WEB_APP === 'undefined') {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -38,4 +38,29 @@ export const PlatformShortcuts: ShortcutMap = {
|
||||
key: '-',
|
||||
usePlatformMetaKey: true,
|
||||
},
|
||||
|
||||
// Toggle artifacts split view
|
||||
[ShortcutAction.TOGGLE_ARTIFACTS]: {
|
||||
key: '\\',
|
||||
usePlatformMetaKey: true,
|
||||
},
|
||||
|
||||
// Create new artifact
|
||||
[ShortcutAction.NEW_ARTIFACT]: {
|
||||
key: 'a',
|
||||
shiftKey: true,
|
||||
usePlatformMetaKey: true,
|
||||
},
|
||||
|
||||
// Force save current artifact
|
||||
[ShortcutAction.SAVE_ARTIFACT]: {
|
||||
key: 's',
|
||||
usePlatformMetaKey: true,
|
||||
},
|
||||
|
||||
// Artifact quick switcher
|
||||
[ShortcutAction.ARTIFACT_QUICK_SWITCHER]: {
|
||||
key: 'k',
|
||||
usePlatformMetaKey: true,
|
||||
},
|
||||
}
|
||||
|
||||
@ -9,6 +9,10 @@ export enum ShortcutAction {
|
||||
GO_TO_SETTINGS = 'goSettings',
|
||||
ZOOM_IN = 'zoomIn',
|
||||
ZOOM_OUT = 'zoomOut',
|
||||
TOGGLE_ARTIFACTS = 'toggleArtifacts',
|
||||
NEW_ARTIFACT = 'newArtifact',
|
||||
SAVE_ARTIFACT = 'saveArtifact',
|
||||
ARTIFACT_QUICK_SWITCHER = 'artifactQuickSwitcher',
|
||||
}
|
||||
|
||||
export interface ShortcutSpec {
|
||||
|
||||
@ -31,6 +31,7 @@ import { Route as SettingsAppearanceImport } from './routes/settings/appearance'
|
||||
import { Route as ProjectProjectIdImport } from './routes/project/$projectId'
|
||||
import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs'
|
||||
import { Route as HubModelIdImport } from './routes/hub/$modelId'
|
||||
import { Route as DevArtifactsImport } from './routes/dev.artifacts'
|
||||
import { Route as SettingsProvidersIndexImport } from './routes/settings/providers/index'
|
||||
import { Route as SettingsProvidersProviderNameImport } from './routes/settings/providers/$providerName'
|
||||
import { Route as AuthGoogleCallbackImport } from './routes/auth.google.callback'
|
||||
@ -157,6 +158,12 @@ const HubModelIdRoute = HubModelIdImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const DevArtifactsRoute = DevArtifactsImport.update({
|
||||
id: '/dev/artifacts',
|
||||
path: '/dev/artifacts',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const SettingsProvidersIndexRoute = SettingsProvidersIndexImport.update({
|
||||
id: '/settings/providers/',
|
||||
path: '/settings/providers/',
|
||||
@ -208,6 +215,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SystemMonitorImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/dev/artifacts': {
|
||||
id: '/dev/artifacts'
|
||||
path: '/dev/artifacts'
|
||||
fullPath: '/dev/artifacts'
|
||||
preLoaderRoute: typeof DevArtifactsImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/hub/$modelId': {
|
||||
id: '/hub/$modelId'
|
||||
path: '/hub/$modelId'
|
||||
@ -351,6 +365,7 @@ export interface FileRoutesByFullPath {
|
||||
'/assistant': typeof AssistantRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/system-monitor': typeof SystemMonitorRoute
|
||||
'/dev/artifacts': typeof DevArtifactsRoute
|
||||
'/hub/$modelId': typeof HubModelIdRoute
|
||||
'/local-api-server/logs': typeof LocalApiServerLogsRoute
|
||||
'/project/$projectId': typeof ProjectProjectIdRoute
|
||||
@ -377,6 +392,7 @@ export interface FileRoutesByTo {
|
||||
'/assistant': typeof AssistantRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/system-monitor': typeof SystemMonitorRoute
|
||||
'/dev/artifacts': typeof DevArtifactsRoute
|
||||
'/hub/$modelId': typeof HubModelIdRoute
|
||||
'/local-api-server/logs': typeof LocalApiServerLogsRoute
|
||||
'/project/$projectId': typeof ProjectProjectIdRoute
|
||||
@ -404,6 +420,7 @@ export interface FileRoutesById {
|
||||
'/assistant': typeof AssistantRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/system-monitor': typeof SystemMonitorRoute
|
||||
'/dev/artifacts': typeof DevArtifactsRoute
|
||||
'/hub/$modelId': typeof HubModelIdRoute
|
||||
'/local-api-server/logs': typeof LocalApiServerLogsRoute
|
||||
'/project/$projectId': typeof ProjectProjectIdRoute
|
||||
@ -432,6 +449,7 @@ export interface FileRouteTypes {
|
||||
| '/assistant'
|
||||
| '/logs'
|
||||
| '/system-monitor'
|
||||
| '/dev/artifacts'
|
||||
| '/hub/$modelId'
|
||||
| '/local-api-server/logs'
|
||||
| '/project/$projectId'
|
||||
@ -457,6 +475,7 @@ export interface FileRouteTypes {
|
||||
| '/assistant'
|
||||
| '/logs'
|
||||
| '/system-monitor'
|
||||
| '/dev/artifacts'
|
||||
| '/hub/$modelId'
|
||||
| '/local-api-server/logs'
|
||||
| '/project/$projectId'
|
||||
@ -482,6 +501,7 @@ export interface FileRouteTypes {
|
||||
| '/assistant'
|
||||
| '/logs'
|
||||
| '/system-monitor'
|
||||
| '/dev/artifacts'
|
||||
| '/hub/$modelId'
|
||||
| '/local-api-server/logs'
|
||||
| '/project/$projectId'
|
||||
@ -509,6 +529,7 @@ export interface RootRouteChildren {
|
||||
AssistantRoute: typeof AssistantRoute
|
||||
LogsRoute: typeof LogsRoute
|
||||
SystemMonitorRoute: typeof SystemMonitorRoute
|
||||
DevArtifactsRoute: typeof DevArtifactsRoute
|
||||
HubModelIdRoute: typeof HubModelIdRoute
|
||||
LocalApiServerLogsRoute: typeof LocalApiServerLogsRoute
|
||||
ProjectProjectIdRoute: typeof ProjectProjectIdRoute
|
||||
@ -535,6 +556,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AssistantRoute: AssistantRoute,
|
||||
LogsRoute: LogsRoute,
|
||||
SystemMonitorRoute: SystemMonitorRoute,
|
||||
DevArtifactsRoute: DevArtifactsRoute,
|
||||
HubModelIdRoute: HubModelIdRoute,
|
||||
LocalApiServerLogsRoute: LocalApiServerLogsRoute,
|
||||
ProjectProjectIdRoute: ProjectProjectIdRoute,
|
||||
@ -570,6 +592,7 @@ export const routeTree = rootRoute
|
||||
"/assistant",
|
||||
"/logs",
|
||||
"/system-monitor",
|
||||
"/dev/artifacts",
|
||||
"/hub/$modelId",
|
||||
"/local-api-server/logs",
|
||||
"/project/$projectId",
|
||||
@ -603,6 +626,9 @@ export const routeTree = rootRoute
|
||||
"/system-monitor": {
|
||||
"filePath": "system-monitor.tsx"
|
||||
},
|
||||
"/dev/artifacts": {
|
||||
"filePath": "dev.artifacts.tsx"
|
||||
},
|
||||
"/hub/$modelId": {
|
||||
"filePath": "hub/$modelId.tsx"
|
||||
},
|
||||
|
||||
337
web-app/src/routes/dev.artifacts.tsx
Normal file
337
web-app/src/routes/dev.artifacts.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useThreads } from '@/hooks/useThreads'
|
||||
import { useArtifacts } from '@/hooks/useArtifacts'
|
||||
|
||||
export const Route = createFileRoute('/dev/artifacts' as any)({
|
||||
component: DevArtifactsInspector,
|
||||
})
|
||||
|
||||
function DevArtifactsInspector() {
|
||||
const { threads } = useThreads()
|
||||
const artifacts = useArtifacts()
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null)
|
||||
const [artifactsData, setArtifactsData] = useState<any[]>([])
|
||||
|
||||
// Auto-select first thread
|
||||
useEffect(() => {
|
||||
if (Array.isArray(threads) && threads.length > 0 && !selectedThreadId) {
|
||||
setSelectedThreadId(threads[0].id)
|
||||
}
|
||||
}, [threads, selectedThreadId])
|
||||
|
||||
// Load artifacts for selected thread
|
||||
useEffect(() => {
|
||||
if (selectedThreadId) {
|
||||
artifacts.loadArtifacts(selectedThreadId).then(() => {
|
||||
const threadArtifacts = artifacts.threadArtifacts[selectedThreadId] || []
|
||||
setArtifactsData(threadArtifacts)
|
||||
})
|
||||
}
|
||||
}, [selectedThreadId])
|
||||
|
||||
const threadIndex = selectedThreadId ? artifacts.threadIndex[selectedThreadId] : null
|
||||
const dirtyArtifacts = artifacts.dirtyArtifacts
|
||||
const savingArtifacts = artifacts.savingArtifacts
|
||||
const pendingProposals = artifacts.pendingProposals
|
||||
const splitViewOpen = selectedThreadId ? artifacts.splitViewOpen[selectedThreadId] : false
|
||||
const splitRatio = selectedThreadId ? artifacts.splitRatio[selectedThreadId] : 60
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const now = Date.now() / 1000
|
||||
const diff = now - timestamp
|
||||
if (diff < 60) return `${Math.floor(diff)}s ago`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
const formatHash = (hash: string) => {
|
||||
const parts = hash.split(':')
|
||||
return parts.length > 1 ? parts[1].substring(0, 8) : hash.substring(0, 8)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', fontFamily: 'monospace', fontSize: '12px' }}>
|
||||
<h1 style={{ margin: '0 0 20px 0', fontSize: '18px', fontWeight: 'bold' }}>
|
||||
Artifacts Dev Inspector
|
||||
</h1>
|
||||
|
||||
{/* Thread Selector */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ marginRight: '10px' }}>Thread:</label>
|
||||
<select
|
||||
value={selectedThreadId || ''}
|
||||
onChange={(e) => setSelectedThreadId(e.target.value)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<option value="">Select thread...</option>
|
||||
{Array.isArray(threads) && threads.map((thread) => (
|
||||
<option key={thread.id} value={thread.id}>
|
||||
{thread.title || thread.id} ({thread.id.substring(0, 8)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedThreadId && (
|
||||
<>
|
||||
{/* Thread Info */}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
padding: '10px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>Thread ID:</strong> {selectedThreadId}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Active Artifact:</strong> {threadIndex?.active_artifact_id || 'None'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Split View:</strong> {splitViewOpen ? `Open (${splitRatio}%)` : 'Closed'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Schema Version:</strong> {threadIndex?.schema_version || 'N/A'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>History Policy:</strong> Max {threadIndex?.history_keep.max_entries || 0}{' '}
|
||||
entries, {threadIndex?.history_keep.max_days || 0} days
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Artifacts Table */}
|
||||
{artifactsData.length === 0 ? (
|
||||
<div style={{ padding: '20px', color: '#666' }}>No artifacts in this thread</div>
|
||||
) : (
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#f0f0f0' }}>
|
||||
<th style={{ padding: '8px', textAlign: 'left', border: '1px solid #ddd' }}>
|
||||
Name
|
||||
</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left', border: '1px solid #ddd' }}>
|
||||
Type
|
||||
</th>
|
||||
<th style={{ padding: '8px', textAlign: 'right', border: '1px solid #ddd' }}>
|
||||
Version
|
||||
</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left', border: '1px solid #ddd' }}>
|
||||
Hash
|
||||
</th>
|
||||
<th style={{ padding: '8px', textAlign: 'right', border: '1px solid #ddd' }}>
|
||||
Bytes
|
||||
</th>
|
||||
<th style={{ padding: '8px', textAlign: 'right', border: '1px solid #ddd' }}>
|
||||
Updated
|
||||
</th>
|
||||
<th style={{ padding: '8px', textAlign: 'center', border: '1px solid #ddd' }}>
|
||||
Proposal?
|
||||
</th>
|
||||
<th style={{ padding: '8px', textAlign: 'center', border: '1px solid #ddd' }}>
|
||||
Dirty?
|
||||
</th>
|
||||
<th style={{ padding: '8px', textAlign: 'center', border: '1px solid #ddd' }}>
|
||||
Saving?
|
||||
</th>
|
||||
<th style={{ padding: '8px', textAlign: 'center', border: '1px solid #ddd' }}>
|
||||
Active?
|
||||
</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left', border: '1px solid #ddd' }}>
|
||||
File Path
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{artifactsData.map((artifact) => {
|
||||
const isDirty = dirtyArtifacts.has(artifact.id)
|
||||
const isSaving = savingArtifacts.has(artifact.id)
|
||||
const hasProposal = !!pendingProposals[artifact.id]
|
||||
const isActive = threadIndex?.active_artifact_id === artifact.id
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={artifact.id}
|
||||
style={{
|
||||
backgroundColor: isActive ? '#ffffcc' : 'white',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '8px', border: '1px solid #ddd' }}>
|
||||
{artifact.name}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
{artifact.content_type}
|
||||
{artifact.language && ` (${artifact.language})`}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px',
|
||||
textAlign: 'right',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
>
|
||||
{artifact.version}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
}}
|
||||
>
|
||||
{formatHash(artifact.hash)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px',
|
||||
textAlign: 'right',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
>
|
||||
{formatBytes(artifact.bytes)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px',
|
||||
textAlign: 'right',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
>
|
||||
{formatDate(artifact.updated_at)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px',
|
||||
textAlign: 'center',
|
||||
border: '1px solid #ddd',
|
||||
color: hasProposal ? 'orange' : 'inherit',
|
||||
fontWeight: hasProposal ? 'bold' : 'normal',
|
||||
}}
|
||||
>
|
||||
{hasProposal ? 'Yes' : 'No'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px',
|
||||
textAlign: 'center',
|
||||
border: '1px solid #ddd',
|
||||
color: isDirty ? 'red' : 'inherit',
|
||||
fontWeight: isDirty ? 'bold' : 'normal',
|
||||
}}
|
||||
>
|
||||
{isDirty ? 'Yes' : 'No'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px',
|
||||
textAlign: 'center',
|
||||
border: '1px solid #ddd',
|
||||
color: isSaving ? 'blue' : 'inherit',
|
||||
}}
|
||||
>
|
||||
{isSaving ? 'Yes' : 'No'}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px',
|
||||
textAlign: 'center',
|
||||
border: '1px solid #ddd',
|
||||
fontWeight: isActive ? 'bold' : 'normal',
|
||||
}}
|
||||
>
|
||||
{isActive ? '★' : ''}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
fontSize: '10px',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{artifact.file_path}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* State Debug Info */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '20px',
|
||||
padding: '10px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 10px 0' }}>State Debug</h3>
|
||||
<div>
|
||||
<strong>Dirty Artifacts:</strong> [{Array.from(dirtyArtifacts).join(', ')}]
|
||||
</div>
|
||||
<div>
|
||||
<strong>Saving Artifacts:</strong> [{Array.from(savingArtifacts).join(', ')}]
|
||||
</div>
|
||||
<div>
|
||||
<strong>Pending Proposals:</strong> {Object.keys(pendingProposals).length} active
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button
|
||||
onClick={() => artifacts.loadArtifacts(selectedThreadId)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
marginRight: '10px',
|
||||
fontFamily: 'monospace',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reload Artifacts
|
||||
</button>
|
||||
<button
|
||||
onClick={() => artifacts.forceSaveAll(selectedThreadId)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontFamily: 'monospace',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Force Save All
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -28,6 +28,13 @@ import { ThreadPadding } from '@/containers/ThreadPadding'
|
||||
import { TEMPORARY_CHAT_ID, TEMPORARY_CHAT_QUERY_ID } from '@/constants/chat'
|
||||
import { IconInfoCircle } from '@tabler/icons-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useArtifacts } from '@/hooks/useArtifacts'
|
||||
import { CreateArtifactDialog } from '@/containers/CreateArtifactDialog'
|
||||
import { ArtifactQuickSwitcher } from '@/containers/ArtifactQuickSwitcher'
|
||||
import { ArtifactSidePanel } from '@/containers/ArtifactSidePanel'
|
||||
import { useState } from 'react'
|
||||
import { useKeyboardShortcut } from '@/hooks/useHotkeys'
|
||||
import { PlatformShortcuts, ShortcutAction } from '@/lib/shortcuts'
|
||||
|
||||
const CONVERSATION_NOT_FOUND_EVENT = 'conversation-not-found'
|
||||
|
||||
@ -91,6 +98,51 @@ function ThreadDetail() {
|
||||
const isMobile = useMobileScreen()
|
||||
useTools()
|
||||
|
||||
// Artifacts
|
||||
const artifacts = useArtifacts()
|
||||
const splitViewOpen = artifacts.splitViewOpen[threadId] || false
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [quickSwitcherOpen, setQuickSwitcherOpen] = useState(false)
|
||||
|
||||
// Auto-open split view if there's an active artifact
|
||||
useEffect(() => {
|
||||
const activeArtifactId = artifacts.threadIndex[threadId]?.active_artifact_id
|
||||
if (activeArtifactId && !splitViewOpen) {
|
||||
artifacts.toggleSplitView(threadId)
|
||||
}
|
||||
}, [artifacts.threadIndex[threadId]?.active_artifact_id])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcut({
|
||||
...PlatformShortcuts[ShortcutAction.TOGGLE_ARTIFACTS],
|
||||
callback: () => artifacts.toggleSplitView(threadId),
|
||||
})
|
||||
|
||||
useKeyboardShortcut({
|
||||
...PlatformShortcuts[ShortcutAction.NEW_ARTIFACT],
|
||||
callback: () => setCreateDialogOpen(true),
|
||||
})
|
||||
|
||||
useKeyboardShortcut({
|
||||
...PlatformShortcuts[ShortcutAction.SAVE_ARTIFACT],
|
||||
callback: () => {
|
||||
const activeArtifactId = artifacts.threadIndex[threadId]?.active_artifact_id
|
||||
if (activeArtifactId) {
|
||||
artifacts.saveArtifact(threadId, activeArtifactId)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
useKeyboardShortcut({
|
||||
...PlatformShortcuts[ShortcutAction.ARTIFACT_QUICK_SWITCHER],
|
||||
callback: () => {
|
||||
const threadArtifacts = artifacts.threadArtifacts[threadId] || []
|
||||
if (threadArtifacts.length > 0) {
|
||||
setQuickSwitcherOpen(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const { messages } = useMessages(
|
||||
useShallow((state) => ({
|
||||
messages: state.messages[threadId],
|
||||
@ -206,22 +258,8 @@ function ThreadDetail() {
|
||||
|
||||
if (!messages || !threadModel) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100dvh-(env(safe-area-inset-bottom)+env(safe-area-inset-top)))]">
|
||||
<HeaderPage>
|
||||
<div className="flex items-center justify-between w-full pr-2">
|
||||
<div>
|
||||
{PlatformFeatures[PlatformFeature.ASSISTANTS] && (
|
||||
<DropdownAssistant />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex justify-center">
|
||||
{threadId === TEMPORARY_CHAT_ID && <TemporaryChatIndicator t={t} />}
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</HeaderPage>
|
||||
<div className="flex flex-col h-[calc(100%-40px)]">
|
||||
const chatContent = (
|
||||
<div className="flex flex-col h-full">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
@ -296,6 +334,71 @@ function ThreadDetail() {
|
||||
<ChatInput model={threadModel} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100dvh-(env(safe-area-inset-bottom)+env(safe-area-inset-top)))]">
|
||||
<HeaderPage>
|
||||
<div className="flex items-center justify-between w-full pr-2">
|
||||
<div>
|
||||
{PlatformFeatures[PlatformFeature.ASSISTANTS] && (
|
||||
<DropdownAssistant />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex justify-center">
|
||||
{threadId === TEMPORARY_CHAT_ID && <TemporaryChatIndicator t={t} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 z-20">
|
||||
{/* Create Artifact Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('Create artifact clicked')
|
||||
setCreateDialogOpen(true)
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm font-medium border-2 border-blue-500 bg-blue-500 text-white rounded hover:bg-blue-600 cursor-pointer"
|
||||
style={{ position: 'relative', zIndex: 100 }}
|
||||
>
|
||||
📄 New Artifact
|
||||
</button>
|
||||
{/* Debug: Toggle split view */}
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('Toggle artifacts clicked')
|
||||
artifacts.toggleSplitView(threadId)
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm font-medium border-2 border-main-view-fg/40 rounded hover:bg-main-view-fg/10 cursor-pointer"
|
||||
style={{ position: 'relative', zIndex: 100 }}
|
||||
>
|
||||
{splitViewOpen ? '✕ Hide' : '👁 Show'} Artifacts
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</HeaderPage>
|
||||
<div className="flex flex-col h-[calc(100%-40px)] relative">
|
||||
{/* Main chat area - always visible */}
|
||||
{chatContent}
|
||||
|
||||
{/* On-demand side panel */}
|
||||
<ArtifactSidePanel
|
||||
isOpen={splitViewOpen}
|
||||
onClose={() => artifacts.toggleSplitView(threadId)}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create Artifact Dialog */}
|
||||
<CreateArtifactDialog
|
||||
threadId={threadId}
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Quick Switcher */}
|
||||
<ArtifactQuickSwitcher
|
||||
threadId={threadId}
|
||||
open={quickSwitcherOpen}
|
||||
onOpenChange={setQuickSwitcherOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
94
web-app/src/services/artifacts/default.ts
Normal file
94
web-app/src/services/artifacts/default.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Default Artifacts Service - Stub implementation for non-Tauri platforms
|
||||
*/
|
||||
|
||||
import type {
|
||||
Artifact,
|
||||
ArtifactCreate,
|
||||
ArtifactIndex,
|
||||
DiffPreview,
|
||||
ImportResult,
|
||||
} from '@janhq/core'
|
||||
import type { ArtifactsService } from './types'
|
||||
|
||||
export class DefaultArtifactsService implements ArtifactsService {
|
||||
async listArtifacts(_threadId: string): Promise<ArtifactIndex> {
|
||||
return {
|
||||
schema_version: 1,
|
||||
history_keep: {
|
||||
max_entries: 50,
|
||||
max_days: 30,
|
||||
},
|
||||
artifacts: [],
|
||||
active_artifact_id: null,
|
||||
}
|
||||
}
|
||||
|
||||
async createArtifact(_threadId: string, _artifact: ArtifactCreate): Promise<Artifact> {
|
||||
throw new Error('Artifacts are not supported on this platform')
|
||||
}
|
||||
|
||||
async getArtifactContent(_threadId: string, _artifactId: string): Promise<string> {
|
||||
throw new Error('Artifacts are not supported on this platform')
|
||||
}
|
||||
|
||||
async updateArtifact(
|
||||
_threadId: string,
|
||||
_artifactId: string,
|
||||
_content: string,
|
||||
_version: number,
|
||||
_hash: string
|
||||
): Promise<Artifact> {
|
||||
throw new Error('Artifacts are not supported on this platform')
|
||||
}
|
||||
|
||||
async deleteArtifact(_threadId: string, _artifactId: string): Promise<void> {
|
||||
throw new Error('Artifacts are not supported on this platform')
|
||||
}
|
||||
|
||||
async renameArtifact(
|
||||
_threadId: string,
|
||||
_artifactId: string,
|
||||
_newName: string
|
||||
): Promise<Artifact> {
|
||||
throw new Error('Artifacts are not supported on this platform')
|
||||
}
|
||||
|
||||
async setActiveArtifact(_threadId: string, _artifactId: string | null): Promise<void> {
|
||||
// Silent no-op for non-Tauri platforms
|
||||
}
|
||||
|
||||
async proposeUpdate(
|
||||
_threadId: string,
|
||||
_artifactId: string,
|
||||
_content: string
|
||||
): Promise<DiffPreview> {
|
||||
throw new Error('Artifacts are not supported on this platform')
|
||||
}
|
||||
|
||||
async applyProposal(
|
||||
_threadId: string,
|
||||
_artifactId: string,
|
||||
_proposalId: string,
|
||||
_selectedHunks?: number[]
|
||||
): Promise<Artifact> {
|
||||
throw new Error('Artifacts are not supported on this platform')
|
||||
}
|
||||
|
||||
async discardProposal(
|
||||
_threadId: string,
|
||||
_artifactId: string,
|
||||
_proposalId: string
|
||||
): Promise<void> {
|
||||
// Silent no-op
|
||||
}
|
||||
|
||||
async exportArtifacts(_threadId: string, _outputPath: string): Promise<void> {
|
||||
throw new Error('Export is not supported on this platform')
|
||||
}
|
||||
|
||||
async importArtifacts(_threadId: string, _archivePath: string): Promise<ImportResult> {
|
||||
throw new Error('Import is not supported on this platform')
|
||||
}
|
||||
}
|
||||
|
||||
171
web-app/src/services/artifacts/tauri.ts
Normal file
171
web-app/src/services/artifacts/tauri.ts
Normal file
@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Tauri Artifacts Service - Desktop implementation
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type {
|
||||
Artifact,
|
||||
ArtifactCreate,
|
||||
ArtifactIndex,
|
||||
DiffPreview,
|
||||
ImportResult,
|
||||
} from '@janhq/core'
|
||||
import { DefaultArtifactsService } from './default'
|
||||
|
||||
export class TauriArtifactsService extends DefaultArtifactsService {
|
||||
async listArtifacts(threadId: string): Promise<ArtifactIndex> {
|
||||
try {
|
||||
return await invoke<ArtifactIndex>('list_artifacts', { threadId })
|
||||
} catch (error) {
|
||||
console.error('Error listing artifacts:', error)
|
||||
// Return empty index on error
|
||||
return {
|
||||
schema_version: 1,
|
||||
history_keep: {
|
||||
max_entries: 50,
|
||||
max_days: 30,
|
||||
},
|
||||
artifacts: [],
|
||||
active_artifact_id: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createArtifact(threadId: string, artifact: ArtifactCreate): Promise<Artifact> {
|
||||
try {
|
||||
return await invoke<Artifact>('create_artifact', { threadId, artifact })
|
||||
} catch (error) {
|
||||
console.error('Error creating artifact:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getArtifactContent(threadId: string, artifactId: string): Promise<string> {
|
||||
try {
|
||||
return await invoke<string>('get_artifact_content', { threadId, artifactId })
|
||||
} catch (error) {
|
||||
console.error('Error getting artifact content:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async updateArtifact(
|
||||
threadId: string,
|
||||
artifactId: string,
|
||||
content: string,
|
||||
version: number,
|
||||
hash: string
|
||||
): Promise<Artifact> {
|
||||
try {
|
||||
return await invoke<Artifact>('update_artifact', {
|
||||
threadId,
|
||||
artifactId,
|
||||
content,
|
||||
version,
|
||||
hash,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating artifact:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteArtifact(threadId: string, artifactId: string): Promise<void> {
|
||||
try {
|
||||
await invoke<void>('delete_artifact', { threadId, artifactId })
|
||||
} catch (error) {
|
||||
console.error('Error deleting artifact:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async renameArtifact(
|
||||
threadId: string,
|
||||
artifactId: string,
|
||||
newName: string
|
||||
): Promise<Artifact> {
|
||||
try {
|
||||
return await invoke<Artifact>('rename_artifact', { threadId, artifactId, newName })
|
||||
} catch (error) {
|
||||
console.error('Error renaming artifact:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async setActiveArtifact(threadId: string, artifactId: string | null): Promise<void> {
|
||||
try {
|
||||
await invoke<void>('set_active_artifact', { threadId, artifactId })
|
||||
} catch (error) {
|
||||
console.error('Error setting active artifact:', error)
|
||||
// Don't throw - this is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async proposeUpdate(
|
||||
threadId: string,
|
||||
artifactId: string,
|
||||
content: string
|
||||
): Promise<DiffPreview> {
|
||||
try {
|
||||
return await invoke<DiffPreview>('propose_update', {
|
||||
threadId,
|
||||
artifactId,
|
||||
content,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error proposing update:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async applyProposal(
|
||||
threadId: string,
|
||||
artifactId: string,
|
||||
proposalId: string,
|
||||
selectedHunks?: number[]
|
||||
): Promise<Artifact> {
|
||||
try {
|
||||
return await invoke<Artifact>('apply_proposal', {
|
||||
threadId,
|
||||
artifactId,
|
||||
proposalId,
|
||||
selectedHunks: selectedHunks ?? null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error applying proposal:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async discardProposal(
|
||||
threadId: string,
|
||||
artifactId: string,
|
||||
proposalId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await invoke<void>('discard_proposal', { threadId, artifactId, proposalId })
|
||||
} catch (error) {
|
||||
console.error('Error discarding proposal:', error)
|
||||
// Don't throw - this is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async exportArtifacts(threadId: string, outputPath: string): Promise<void> {
|
||||
try {
|
||||
await invoke<void>('export_artifacts', { threadId, outputPath })
|
||||
} catch (error) {
|
||||
console.error('Error exporting artifacts:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async importArtifacts(threadId: string, archivePath: string): Promise<ImportResult> {
|
||||
try {
|
||||
return await invoke<ImportResult>('import_artifacts', { threadId, archivePath })
|
||||
} catch (error) {
|
||||
console.error('Error importing artifacts:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
85
web-app/src/services/artifacts/types.ts
Normal file
85
web-app/src/services/artifacts/types.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Artifacts Service Types
|
||||
*/
|
||||
|
||||
import type {
|
||||
Artifact,
|
||||
ArtifactCreate,
|
||||
ArtifactIndex,
|
||||
DiffPreview,
|
||||
ImportResult,
|
||||
} from '@janhq/core'
|
||||
|
||||
export interface ArtifactsService {
|
||||
/**
|
||||
* List all artifacts for a thread
|
||||
*/
|
||||
listArtifacts(threadId: string): Promise<ArtifactIndex>
|
||||
|
||||
/**
|
||||
* Create a new artifact
|
||||
*/
|
||||
createArtifact(threadId: string, artifact: ArtifactCreate): Promise<Artifact>
|
||||
|
||||
/**
|
||||
* Get artifact content
|
||||
*/
|
||||
getArtifactContent(threadId: string, artifactId: string): Promise<string>
|
||||
|
||||
/**
|
||||
* Update artifact content with conflict detection
|
||||
*/
|
||||
updateArtifact(
|
||||
threadId: string,
|
||||
artifactId: string,
|
||||
content: string,
|
||||
version: number,
|
||||
hash: string
|
||||
): Promise<Artifact>
|
||||
|
||||
/**
|
||||
* Delete an artifact
|
||||
*/
|
||||
deleteArtifact(threadId: string, artifactId: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Rename an artifact
|
||||
*/
|
||||
renameArtifact(threadId: string, artifactId: string, newName: string): Promise<Artifact>
|
||||
|
||||
/**
|
||||
* Set the active artifact for a thread
|
||||
*/
|
||||
setActiveArtifact(threadId: string, artifactId: string | null): Promise<void>
|
||||
|
||||
/**
|
||||
* Propose an update (creates a diff preview)
|
||||
*/
|
||||
proposeUpdate(threadId: string, artifactId: string, content: string): Promise<DiffPreview>
|
||||
|
||||
/**
|
||||
* Apply a proposed update
|
||||
*/
|
||||
applyProposal(
|
||||
threadId: string,
|
||||
artifactId: string,
|
||||
proposalId: string,
|
||||
selectedHunks?: number[]
|
||||
): Promise<Artifact>
|
||||
|
||||
/**
|
||||
* Discard a proposal
|
||||
*/
|
||||
discardProposal(threadId: string, artifactId: string, proposalId: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Export artifacts to a ZIP file
|
||||
*/
|
||||
exportArtifacts(threadId: string, outputPath: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Import artifacts from a ZIP file
|
||||
*/
|
||||
importArtifacts(threadId: string, archivePath: string): Promise<ImportResult>
|
||||
}
|
||||
|
||||
97
web-app/src/services/artifacts/web.ts
Normal file
97
web-app/src/services/artifacts/web.ts
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Web implementation of ArtifactsService (minimal stub)
|
||||
*
|
||||
* This is a minimal implementation for browser mode.
|
||||
* Most functionality requires Tauri backend.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Artifact,
|
||||
ArtifactIndex,
|
||||
ArtifactCreate,
|
||||
DiffPreview,
|
||||
ImportResult,
|
||||
} from '@janhq/core'
|
||||
import type { ArtifactsService } from './types'
|
||||
|
||||
export class WebArtifactsService implements ArtifactsService {
|
||||
private notImplemented(method: string): never {
|
||||
throw new Error(`${method} is not implemented in web mode. Use Tauri app for full artifacts support.`)
|
||||
}
|
||||
|
||||
async listArtifacts(_threadId: string): Promise<ArtifactIndex> {
|
||||
return {
|
||||
schema_version: 1,
|
||||
artifacts: [],
|
||||
active_artifact_id: null,
|
||||
history_keep: {
|
||||
max_entries: 50,
|
||||
max_days: 30,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async createArtifact(_threadId: string, _data: ArtifactCreate): Promise<Artifact> {
|
||||
return this.notImplemented('createArtifact')
|
||||
}
|
||||
|
||||
async getArtifactContent(_threadId: string, _artifactId: string): Promise<string> {
|
||||
return this.notImplemented('getArtifactContent')
|
||||
}
|
||||
|
||||
async updateArtifact(
|
||||
_threadId: string,
|
||||
_artifactId: string,
|
||||
_content: string,
|
||||
_version: number,
|
||||
_hash: string
|
||||
): Promise<Artifact> {
|
||||
return this.notImplemented('updateArtifact')
|
||||
}
|
||||
|
||||
async deleteArtifact(_threadId: string, _artifactId: string): Promise<void> {
|
||||
return this.notImplemented('deleteArtifact')
|
||||
}
|
||||
|
||||
async renameArtifact(_threadId: string, _artifactId: string, _newName: string): Promise<Artifact> {
|
||||
return this.notImplemented('renameArtifact')
|
||||
}
|
||||
|
||||
async setActiveArtifact(_threadId: string, _artifactId: string | null): Promise<void> {
|
||||
// No-op in web mode
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
async proposeUpdate(_threadId: string, _artifactId: string, _content: string): Promise<DiffPreview> {
|
||||
return {
|
||||
proposal_id: 'stub',
|
||||
artifact_id: _artifactId,
|
||||
current_version: 1,
|
||||
current_hash: '',
|
||||
proposed_hash: '',
|
||||
hunks: [],
|
||||
full_diff: '',
|
||||
}
|
||||
}
|
||||
|
||||
async applyProposal(
|
||||
_threadId: string,
|
||||
_artifactId: string,
|
||||
_proposalId: string,
|
||||
_selectedHunks?: number[]
|
||||
): Promise<Artifact> {
|
||||
return this.notImplemented('applyProposal')
|
||||
}
|
||||
|
||||
async discardProposal(_threadId: string, _artifactId: string, _proposalId: string): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
async exportArtifacts(_threadId: string, _outputPath: string): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
async importArtifacts(_threadId: string, _archivePath: string): Promise<ImportResult> {
|
||||
return this.notImplemented('importArtifacts')
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,8 @@ import { DefaultRAGService } from './rag/default'
|
||||
import type { RAGService } from './rag/types'
|
||||
import { DefaultUploadsService } from './uploads/default'
|
||||
import type { UploadsService } from './uploads/types'
|
||||
import { DefaultArtifactsService } from './artifacts/default'
|
||||
import type { ArtifactsService } from './artifacts/types'
|
||||
|
||||
// Import service types
|
||||
import type { ThemeService } from './theme/types'
|
||||
@ -76,6 +78,7 @@ export interface ServiceHub {
|
||||
projects(): ProjectsService
|
||||
rag(): RAGService
|
||||
uploads(): UploadsService
|
||||
artifacts(): ArtifactsService
|
||||
}
|
||||
|
||||
class PlatformServiceHub implements ServiceHub {
|
||||
@ -100,6 +103,7 @@ class PlatformServiceHub implements ServiceHub {
|
||||
private projectsService: ProjectsService = new DefaultProjectsService()
|
||||
private ragService: RAGService = new DefaultRAGService()
|
||||
private uploadsService: UploadsService = new DefaultUploadsService()
|
||||
private artifactsService: ArtifactsService = new DefaultArtifactsService()
|
||||
private initialized = false
|
||||
|
||||
/**
|
||||
@ -132,6 +136,7 @@ class PlatformServiceHub implements ServiceHub {
|
||||
pathModule,
|
||||
coreModule,
|
||||
deepLinkModule,
|
||||
artifactsModule,
|
||||
] = await Promise.all([
|
||||
import('./theme/tauri'),
|
||||
import('./window/tauri'),
|
||||
@ -146,6 +151,7 @@ class PlatformServiceHub implements ServiceHub {
|
||||
import('./path/tauri'),
|
||||
import('./core/tauri'),
|
||||
import('./deeplink/tauri'),
|
||||
import('./artifacts/tauri'),
|
||||
])
|
||||
|
||||
this.themeService = new themeModule.TauriThemeService()
|
||||
@ -161,6 +167,7 @@ class PlatformServiceHub implements ServiceHub {
|
||||
this.pathService = new pathModule.TauriPathService()
|
||||
this.coreService = new coreModule.TauriCoreService()
|
||||
this.deepLinkService = new deepLinkModule.TauriDeepLinkService()
|
||||
this.artifactsService = new artifactsModule.TauriArtifactsService()
|
||||
} else if (isPlatformIOS() || isPlatformAndroid()) {
|
||||
const [
|
||||
themeModule,
|
||||
@ -212,6 +219,7 @@ class PlatformServiceHub implements ServiceHub {
|
||||
providersModule,
|
||||
mcpModule,
|
||||
projectsModule,
|
||||
artifactsModule,
|
||||
] = await Promise.all([
|
||||
import('./theme/web'),
|
||||
import('./app/web'),
|
||||
@ -224,6 +232,7 @@ class PlatformServiceHub implements ServiceHub {
|
||||
import('./providers/web'),
|
||||
import('./mcp/web'),
|
||||
import('./projects/web'),
|
||||
import('./artifacts/web'),
|
||||
])
|
||||
|
||||
this.themeService = new themeModule.WebThemeService()
|
||||
@ -237,6 +246,7 @@ class PlatformServiceHub implements ServiceHub {
|
||||
this.providersService = new providersModule.WebProvidersService()
|
||||
this.mcpService = new mcpModule.WebMCPService()
|
||||
this.projectsService = new projectsModule.WebProjectsService()
|
||||
this.artifactsService = new artifactsModule.WebArtifactsService()
|
||||
}
|
||||
|
||||
this.initialized = true
|
||||
@ -361,6 +371,11 @@ class PlatformServiceHub implements ServiceHub {
|
||||
this.ensureInitialized()
|
||||
return this.uploadsService
|
||||
}
|
||||
|
||||
artifacts(): ArtifactsService {
|
||||
this.ensureInitialized()
|
||||
return this.artifactsService
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeServiceHub(): Promise<ServiceHub> {
|
||||
|
||||
24
yarn.lock
24
yarn.lock
@ -3581,6 +3581,7 @@ __metadata:
|
||||
lodash.clonedeep: "npm:4.5.0"
|
||||
lodash.debounce: "npm:4.0.8"
|
||||
lucide-react: "npm:0.536.0"
|
||||
monaco-editor: "npm:^0.52.2"
|
||||
motion: "npm:12.18.1"
|
||||
next-themes: "npm:0.4.6"
|
||||
posthog-js: "npm:1.255.1"
|
||||
@ -7811,6 +7812,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^20.0.0":
|
||||
version: 20.19.24
|
||||
resolution: "@types/node@npm:20.19.24"
|
||||
dependencies:
|
||||
undici-types: "npm:~6.21.0"
|
||||
checksum: 10c0/c872ce80a1e832fe035a3c94a27acb2d6e45ffa1209c0241ac6e2d405db8d6f47eea7a2509b5c2dbedae6231dafb9dbed873dd5daaebbad1f11fdaa58726ce5e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/node@npm:^22.10.0":
|
||||
version: 22.18.3
|
||||
resolution: "@types/node@npm:22.18.3"
|
||||
@ -7896,6 +7906,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/whatwg-mimetype@npm:^3.0.2":
|
||||
version: 3.0.2
|
||||
resolution: "@types/whatwg-mimetype@npm:3.0.2"
|
||||
checksum: 10c0/dad39d1e4abe760a0a963c84bbdbd26b1df0eb68aff83bdf6ecbb50ad781ead777f6906d19a87007790b750f7500a12e5624d31fc6a1529d14bd19b5c3a316d1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/eslint-plugin@npm:8.31.0":
|
||||
version: 8.31.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.31.0"
|
||||
@ -15544,6 +15561,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"monaco-editor@npm:^0.52.2":
|
||||
version: 0.52.2
|
||||
resolution: "monaco-editor@npm:0.52.2"
|
||||
checksum: 10c0/5a92da64f1e2ab375c0ce99364137f794d057c97bed10ecc65a08d6e6846804b8ecbd377eacf01e498f7dfbe1b21e8be64f728256681448f0484df90e767b435
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"motion-dom@npm:^12.18.1":
|
||||
version: 12.18.1
|
||||
resolution: "motion-dom@npm:12.18.1"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user