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:
Nicholai 2025-11-02 12:19:36 -07:00
parent 154301b3ad
commit 4e92884d51
44 changed files with 5183 additions and 26 deletions

View 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'
}

View File

@ -0,0 +1,2 @@
export * from './artifactEntity'

View File

@ -11,3 +11,4 @@ export * from './setting'
export * from './engine' export * from './engine'
export * from './hardware' export * from './hardware'
export * from './mcp' export * from './mcp'
export * from './artifact'

View File

@ -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 users behalf.', 'Jan is a helpful desktop assistant that can reason through complex tasks and use tools to complete them on the users behalf.',
model: '*', model: '*',
instructions: 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 youre 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 dont know or that needs verification\n- Never use tools just because theyre 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: [ tools: [
{ {
type: 'retrieval', type: 'retrieval',

View File

@ -342,41 +342,73 @@ __metadata:
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension": "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension":
version: 0.1.10 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: dependencies:
rxjs: "npm:^7.8.1" rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0" ulidx: "npm:^2.3.0"
checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c peerDependencies:
react: 19.0.0
checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd
languageName: node languageName: node
linkType: hard linkType: hard
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension": "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension":
version: 0.1.10 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: dependencies:
rxjs: "npm:^7.8.1" rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0" ulidx: "npm:^2.3.0"
checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c peerDependencies:
react: 19.0.0
checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd
languageName: node languageName: node
linkType: hard linkType: hard
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension": "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension":
version: 0.1.10 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: dependencies:
rxjs: "npm:^7.8.1" rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0" ulidx: "npm:^2.3.0"
checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c peerDependencies:
react: 19.0.0
checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd
languageName: node languageName: node
linkType: hard linkType: hard
"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension": "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension":
version: 0.1.10 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: dependencies:
rxjs: "npm:^7.8.1" rxjs: "npm:^7.8.1"
ulidx: "npm:^2.3.0" 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 languageName: node
linkType: hard linkType: hard
@ -418,6 +450,20 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft 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": "@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 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" 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 languageName: node
linkType: soft 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": "@jridgewell/sourcemap-codec@npm:^1.5.0":
version: 1.5.0 version: 1.5.0
resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" resolution: "@jridgewell/sourcemap-codec@npm:1.5.0"

14
src-tauri/Cargo.lock generated
View File

@ -6,11 +6,13 @@ version = 3
name = "Jan" name = "Jan"
version = "0.6.599" version = "0.6.599"
dependencies = [ dependencies = [
"chrono",
"dirs", "dirs",
"env", "env",
"fix-path-env", "fix-path-env",
"flate2", "flate2",
"futures-util", "futures-util",
"hex",
"hyper 0.14.32", "hyper 0.14.32",
"jan-utils", "jan-utils",
"libc", "libc",
@ -23,6 +25,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"sha2",
"sqlx", "sqlx",
"tar", "tar",
"tauri", "tauri",
@ -45,6 +48,7 @@ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"tokio-util", "tokio-util",
"ulid",
"url", "url",
"uuid", "uuid",
"windows-sys 0.60.2", "windows-sys 0.60.2",
@ -7005,6 +7009,16 @@ dependencies = [
"winapi", "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]] [[package]]
name = "unic-char-property" name = "unic-char-property"
version = "0.9.0" version = "0.9.0"

View File

@ -93,6 +93,10 @@ tokio = { version = "1", features = ["full"] }
tokio-util = "0.7.14" tokio-util = "0.7.14"
url = "2.5" url = "2.5"
uuid = { version = "1.7", features = ["v4"] } uuid = { version = "1.7", features = ["v4"] }
ulid = "1.1"
sha2 = "0.10"
hex = "0.4"
chrono = "0.4"
[dependencies.tauri] [dependencies.tauri]
version = "2.8.5" version = "2.8.5"

View File

@ -52,6 +52,30 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft 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": "@npmcli/agent@npm:^3.0.0":
version: 3.0.0 version: 3.0.0
resolution: "@npmcli/agent@npm:3.0.0" resolution: "@npmcli/agent@npm:3.0.0"

View 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, &current_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(&current_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
}

View 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";

View 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");
}
}

View 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;

View 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,
}

View 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);
}
}

View 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(())
}

View File

@ -1,4 +1,5 @@
pub mod app; pub mod app;
pub mod artifacts;
pub mod downloads; pub mod downloads;
pub mod extensions; pub mod extensions;
pub mod filesystem; pub mod filesystem;

View File

@ -107,6 +107,19 @@ pub fn run() {
core::threads::commands::get_thread_assistant, core::threads::commands::get_thread_assistant,
core::threads::commands::create_thread_assistant, core::threads::commands::create_thread_assistant,
core::threads::commands::modify_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 // Download
core::downloads::commands::download_files, core::downloads::commands::download_files,
core::downloads::commands::cancel_download_task, core::downloads::commands::cancel_download_task,

View File

@ -61,6 +61,7 @@
"lodash.clonedeep": "4.5.0", "lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8", "lodash.debounce": "4.0.8",
"lucide-react": "0.536.0", "lucide-react": "0.536.0",
"monaco-editor": "^0.52.2",
"motion": "12.18.1", "motion": "12.18.1",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"posthog-js": "1.255.1", "posthog-js": "1.255.1",

View 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
}

View 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>
)
}

View 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>
)
}

View 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" />
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -28,6 +28,7 @@ import { useTranslation } from '@/i18n/react-i18next-compat'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import { extractFilesFromPrompt } from '@/lib/fileMetadata' import { extractFilesFromPrompt } from '@/lib/fileMetadata'
import { createImageAttachment } from '@/types/attachment' import { createImageAttachment } from '@/types/attachment'
import { ArtifactActionMessage } from '@/containers/ArtifactActionMessage'
const CopyButton = ({ text }: { text: string }) => { const CopyButton = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@ -376,6 +377,11 @@ export const ThreadContent = memo(
components={linkComponents} components={linkComponents}
/> />
{/* Render artifact actions */}
{item.metadata?.artifact_action && (
<ArtifactActionMessage message={item} />
)}
{isToolCalls && item.metadata?.tool_calls ? ( {isToolCalls && item.metadata?.tool_calls ? (
<> <>
{(item.metadata.tool_calls as ToolCall[]).map((toolCall) => ( {(item.metadata.tool_calls as ToolCall[]).map((toolCall) => (

View 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>
)
}

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

View File

@ -45,7 +45,73 @@ export const defaultAssistant: Assistant = {
description: description:
'Jan is a helpful desktop assistant that can reason through complex tasks and use tools to complete them on the users behalf.', 'Jan is a helpful desktop assistant that can reason through complex tasks and use tools to complete them on the users behalf.',
instructions: 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 youre 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 dont know or that needs verification\n- Never use tools just because theyre 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 // Platform-aware initial state

View File

@ -4,7 +4,17 @@ declare const IS_WEB_APP: boolean
declare const IS_IOS: boolean declare const IS_IOS: boolean
declare const IS_ANDROID: boolean declare const IS_ANDROID: boolean
declare global {
interface Window {
__TAURI__?: any
}
}
export const isPlatformTauri = (): boolean => { 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') { if (typeof IS_WEB_APP === 'undefined') {
return true return true
} }

View File

@ -38,4 +38,29 @@ export const PlatformShortcuts: ShortcutMap = {
key: '-', key: '-',
usePlatformMetaKey: true, 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,
},
} }

View File

@ -9,6 +9,10 @@ export enum ShortcutAction {
GO_TO_SETTINGS = 'goSettings', GO_TO_SETTINGS = 'goSettings',
ZOOM_IN = 'zoomIn', ZOOM_IN = 'zoomIn',
ZOOM_OUT = 'zoomOut', ZOOM_OUT = 'zoomOut',
TOGGLE_ARTIFACTS = 'toggleArtifacts',
NEW_ARTIFACT = 'newArtifact',
SAVE_ARTIFACT = 'saveArtifact',
ARTIFACT_QUICK_SWITCHER = 'artifactQuickSwitcher',
} }
export interface ShortcutSpec { export interface ShortcutSpec {

View File

@ -31,6 +31,7 @@ import { Route as SettingsAppearanceImport } from './routes/settings/appearance'
import { Route as ProjectProjectIdImport } from './routes/project/$projectId' import { Route as ProjectProjectIdImport } from './routes/project/$projectId'
import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs' import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs'
import { Route as HubModelIdImport } from './routes/hub/$modelId' 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 SettingsProvidersIndexImport } from './routes/settings/providers/index'
import { Route as SettingsProvidersProviderNameImport } from './routes/settings/providers/$providerName' import { Route as SettingsProvidersProviderNameImport } from './routes/settings/providers/$providerName'
import { Route as AuthGoogleCallbackImport } from './routes/auth.google.callback' import { Route as AuthGoogleCallbackImport } from './routes/auth.google.callback'
@ -157,6 +158,12 @@ const HubModelIdRoute = HubModelIdImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const DevArtifactsRoute = DevArtifactsImport.update({
id: '/dev/artifacts',
path: '/dev/artifacts',
getParentRoute: () => rootRoute,
} as any)
const SettingsProvidersIndexRoute = SettingsProvidersIndexImport.update({ const SettingsProvidersIndexRoute = SettingsProvidersIndexImport.update({
id: '/settings/providers/', id: '/settings/providers/',
path: '/settings/providers/', path: '/settings/providers/',
@ -208,6 +215,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SystemMonitorImport preLoaderRoute: typeof SystemMonitorImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/dev/artifacts': {
id: '/dev/artifacts'
path: '/dev/artifacts'
fullPath: '/dev/artifacts'
preLoaderRoute: typeof DevArtifactsImport
parentRoute: typeof rootRoute
}
'/hub/$modelId': { '/hub/$modelId': {
id: '/hub/$modelId' id: '/hub/$modelId'
path: '/hub/$modelId' path: '/hub/$modelId'
@ -351,6 +365,7 @@ export interface FileRoutesByFullPath {
'/assistant': typeof AssistantRoute '/assistant': typeof AssistantRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/system-monitor': typeof SystemMonitorRoute '/system-monitor': typeof SystemMonitorRoute
'/dev/artifacts': typeof DevArtifactsRoute
'/hub/$modelId': typeof HubModelIdRoute '/hub/$modelId': typeof HubModelIdRoute
'/local-api-server/logs': typeof LocalApiServerLogsRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute
'/project/$projectId': typeof ProjectProjectIdRoute '/project/$projectId': typeof ProjectProjectIdRoute
@ -377,6 +392,7 @@ export interface FileRoutesByTo {
'/assistant': typeof AssistantRoute '/assistant': typeof AssistantRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/system-monitor': typeof SystemMonitorRoute '/system-monitor': typeof SystemMonitorRoute
'/dev/artifacts': typeof DevArtifactsRoute
'/hub/$modelId': typeof HubModelIdRoute '/hub/$modelId': typeof HubModelIdRoute
'/local-api-server/logs': typeof LocalApiServerLogsRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute
'/project/$projectId': typeof ProjectProjectIdRoute '/project/$projectId': typeof ProjectProjectIdRoute
@ -404,6 +420,7 @@ export interface FileRoutesById {
'/assistant': typeof AssistantRoute '/assistant': typeof AssistantRoute
'/logs': typeof LogsRoute '/logs': typeof LogsRoute
'/system-monitor': typeof SystemMonitorRoute '/system-monitor': typeof SystemMonitorRoute
'/dev/artifacts': typeof DevArtifactsRoute
'/hub/$modelId': typeof HubModelIdRoute '/hub/$modelId': typeof HubModelIdRoute
'/local-api-server/logs': typeof LocalApiServerLogsRoute '/local-api-server/logs': typeof LocalApiServerLogsRoute
'/project/$projectId': typeof ProjectProjectIdRoute '/project/$projectId': typeof ProjectProjectIdRoute
@ -432,6 +449,7 @@ export interface FileRouteTypes {
| '/assistant' | '/assistant'
| '/logs' | '/logs'
| '/system-monitor' | '/system-monitor'
| '/dev/artifacts'
| '/hub/$modelId' | '/hub/$modelId'
| '/local-api-server/logs' | '/local-api-server/logs'
| '/project/$projectId' | '/project/$projectId'
@ -457,6 +475,7 @@ export interface FileRouteTypes {
| '/assistant' | '/assistant'
| '/logs' | '/logs'
| '/system-monitor' | '/system-monitor'
| '/dev/artifacts'
| '/hub/$modelId' | '/hub/$modelId'
| '/local-api-server/logs' | '/local-api-server/logs'
| '/project/$projectId' | '/project/$projectId'
@ -482,6 +501,7 @@ export interface FileRouteTypes {
| '/assistant' | '/assistant'
| '/logs' | '/logs'
| '/system-monitor' | '/system-monitor'
| '/dev/artifacts'
| '/hub/$modelId' | '/hub/$modelId'
| '/local-api-server/logs' | '/local-api-server/logs'
| '/project/$projectId' | '/project/$projectId'
@ -509,6 +529,7 @@ export interface RootRouteChildren {
AssistantRoute: typeof AssistantRoute AssistantRoute: typeof AssistantRoute
LogsRoute: typeof LogsRoute LogsRoute: typeof LogsRoute
SystemMonitorRoute: typeof SystemMonitorRoute SystemMonitorRoute: typeof SystemMonitorRoute
DevArtifactsRoute: typeof DevArtifactsRoute
HubModelIdRoute: typeof HubModelIdRoute HubModelIdRoute: typeof HubModelIdRoute
LocalApiServerLogsRoute: typeof LocalApiServerLogsRoute LocalApiServerLogsRoute: typeof LocalApiServerLogsRoute
ProjectProjectIdRoute: typeof ProjectProjectIdRoute ProjectProjectIdRoute: typeof ProjectProjectIdRoute
@ -535,6 +556,7 @@ const rootRouteChildren: RootRouteChildren = {
AssistantRoute: AssistantRoute, AssistantRoute: AssistantRoute,
LogsRoute: LogsRoute, LogsRoute: LogsRoute,
SystemMonitorRoute: SystemMonitorRoute, SystemMonitorRoute: SystemMonitorRoute,
DevArtifactsRoute: DevArtifactsRoute,
HubModelIdRoute: HubModelIdRoute, HubModelIdRoute: HubModelIdRoute,
LocalApiServerLogsRoute: LocalApiServerLogsRoute, LocalApiServerLogsRoute: LocalApiServerLogsRoute,
ProjectProjectIdRoute: ProjectProjectIdRoute, ProjectProjectIdRoute: ProjectProjectIdRoute,
@ -570,6 +592,7 @@ export const routeTree = rootRoute
"/assistant", "/assistant",
"/logs", "/logs",
"/system-monitor", "/system-monitor",
"/dev/artifacts",
"/hub/$modelId", "/hub/$modelId",
"/local-api-server/logs", "/local-api-server/logs",
"/project/$projectId", "/project/$projectId",
@ -603,6 +626,9 @@ export const routeTree = rootRoute
"/system-monitor": { "/system-monitor": {
"filePath": "system-monitor.tsx" "filePath": "system-monitor.tsx"
}, },
"/dev/artifacts": {
"filePath": "dev.artifacts.tsx"
},
"/hub/$modelId": { "/hub/$modelId": {
"filePath": "hub/$modelId.tsx" "filePath": "hub/$modelId.tsx"
}, },

View 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>
)
}

View File

@ -28,6 +28,13 @@ import { ThreadPadding } from '@/containers/ThreadPadding'
import { TEMPORARY_CHAT_ID, TEMPORARY_CHAT_QUERY_ID } from '@/constants/chat' import { TEMPORARY_CHAT_ID, TEMPORARY_CHAT_QUERY_ID } from '@/constants/chat'
import { IconInfoCircle } from '@tabler/icons-react' import { IconInfoCircle } from '@tabler/icons-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' 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' const CONVERSATION_NOT_FOUND_EVENT = 'conversation-not-found'
@ -91,6 +98,51 @@ function ThreadDetail() {
const isMobile = useMobileScreen() const isMobile = useMobileScreen()
useTools() 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( const { messages } = useMessages(
useShallow((state) => ({ useShallow((state) => ({
messages: state.messages[threadId], messages: state.messages[threadId],
@ -206,22 +258,8 @@ function ThreadDetail() {
if (!messages || !threadModel) return null if (!messages || !threadModel) return null
return ( const chatContent = (
<div className="flex flex-col h-[calc(100dvh-(env(safe-area-inset-bottom)+env(safe-area-inset-top)))]"> <div className="flex flex-col h-full">
<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)]">
<div <div
ref={scrollContainerRef} ref={scrollContainerRef}
className={cn( className={cn(
@ -296,6 +334,71 @@ function ThreadDetail() {
<ChatInput model={threadModel} /> <ChatInput model={threadModel} />
</div> </div>
</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> </div>
) )
} }

View 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')
}
}

View 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
}
}
}

View 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>
}

View 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')
}
}

View File

@ -31,6 +31,8 @@ import { DefaultRAGService } from './rag/default'
import type { RAGService } from './rag/types' import type { RAGService } from './rag/types'
import { DefaultUploadsService } from './uploads/default' import { DefaultUploadsService } from './uploads/default'
import type { UploadsService } from './uploads/types' import type { UploadsService } from './uploads/types'
import { DefaultArtifactsService } from './artifacts/default'
import type { ArtifactsService } from './artifacts/types'
// Import service types // Import service types
import type { ThemeService } from './theme/types' import type { ThemeService } from './theme/types'
@ -76,6 +78,7 @@ export interface ServiceHub {
projects(): ProjectsService projects(): ProjectsService
rag(): RAGService rag(): RAGService
uploads(): UploadsService uploads(): UploadsService
artifacts(): ArtifactsService
} }
class PlatformServiceHub implements ServiceHub { class PlatformServiceHub implements ServiceHub {
@ -100,6 +103,7 @@ class PlatformServiceHub implements ServiceHub {
private projectsService: ProjectsService = new DefaultProjectsService() private projectsService: ProjectsService = new DefaultProjectsService()
private ragService: RAGService = new DefaultRAGService() private ragService: RAGService = new DefaultRAGService()
private uploadsService: UploadsService = new DefaultUploadsService() private uploadsService: UploadsService = new DefaultUploadsService()
private artifactsService: ArtifactsService = new DefaultArtifactsService()
private initialized = false private initialized = false
/** /**
@ -132,6 +136,7 @@ class PlatformServiceHub implements ServiceHub {
pathModule, pathModule,
coreModule, coreModule,
deepLinkModule, deepLinkModule,
artifactsModule,
] = await Promise.all([ ] = await Promise.all([
import('./theme/tauri'), import('./theme/tauri'),
import('./window/tauri'), import('./window/tauri'),
@ -146,6 +151,7 @@ class PlatformServiceHub implements ServiceHub {
import('./path/tauri'), import('./path/tauri'),
import('./core/tauri'), import('./core/tauri'),
import('./deeplink/tauri'), import('./deeplink/tauri'),
import('./artifacts/tauri'),
]) ])
this.themeService = new themeModule.TauriThemeService() this.themeService = new themeModule.TauriThemeService()
@ -161,6 +167,7 @@ class PlatformServiceHub implements ServiceHub {
this.pathService = new pathModule.TauriPathService() this.pathService = new pathModule.TauriPathService()
this.coreService = new coreModule.TauriCoreService() this.coreService = new coreModule.TauriCoreService()
this.deepLinkService = new deepLinkModule.TauriDeepLinkService() this.deepLinkService = new deepLinkModule.TauriDeepLinkService()
this.artifactsService = new artifactsModule.TauriArtifactsService()
} else if (isPlatformIOS() || isPlatformAndroid()) { } else if (isPlatformIOS() || isPlatformAndroid()) {
const [ const [
themeModule, themeModule,
@ -212,6 +219,7 @@ class PlatformServiceHub implements ServiceHub {
providersModule, providersModule,
mcpModule, mcpModule,
projectsModule, projectsModule,
artifactsModule,
] = await Promise.all([ ] = await Promise.all([
import('./theme/web'), import('./theme/web'),
import('./app/web'), import('./app/web'),
@ -224,6 +232,7 @@ class PlatformServiceHub implements ServiceHub {
import('./providers/web'), import('./providers/web'),
import('./mcp/web'), import('./mcp/web'),
import('./projects/web'), import('./projects/web'),
import('./artifacts/web'),
]) ])
this.themeService = new themeModule.WebThemeService() this.themeService = new themeModule.WebThemeService()
@ -237,6 +246,7 @@ class PlatformServiceHub implements ServiceHub {
this.providersService = new providersModule.WebProvidersService() this.providersService = new providersModule.WebProvidersService()
this.mcpService = new mcpModule.WebMCPService() this.mcpService = new mcpModule.WebMCPService()
this.projectsService = new projectsModule.WebProjectsService() this.projectsService = new projectsModule.WebProjectsService()
this.artifactsService = new artifactsModule.WebArtifactsService()
} }
this.initialized = true this.initialized = true
@ -361,6 +371,11 @@ class PlatformServiceHub implements ServiceHub {
this.ensureInitialized() this.ensureInitialized()
return this.uploadsService return this.uploadsService
} }
artifacts(): ArtifactsService {
this.ensureInitialized()
return this.artifactsService
}
} }
export async function initializeServiceHub(): Promise<ServiceHub> { export async function initializeServiceHub(): Promise<ServiceHub> {

View File

@ -3581,6 +3581,7 @@ __metadata:
lodash.clonedeep: "npm:4.5.0" lodash.clonedeep: "npm:4.5.0"
lodash.debounce: "npm:4.0.8" lodash.debounce: "npm:4.0.8"
lucide-react: "npm:0.536.0" lucide-react: "npm:0.536.0"
monaco-editor: "npm:^0.52.2"
motion: "npm:12.18.1" motion: "npm:12.18.1"
next-themes: "npm:0.4.6" next-themes: "npm:0.4.6"
posthog-js: "npm:1.255.1" posthog-js: "npm:1.255.1"
@ -7811,6 +7812,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/node@npm:^22.10.0":
version: 22.18.3 version: 22.18.3
resolution: "@types/node@npm:22.18.3" resolution: "@types/node@npm:22.18.3"
@ -7896,6 +7906,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@typescript-eslint/eslint-plugin@npm:8.31.0":
version: 8.31.0 version: 8.31.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.31.0" resolution: "@typescript-eslint/eslint-plugin@npm:8.31.0"
@ -15544,6 +15561,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "motion-dom@npm:^12.18.1":
version: 12.18.1 version: 12.18.1
resolution: "motion-dom@npm:12.18.1" resolution: "motion-dom@npm:12.18.1"