diff --git a/core/src/types/artifact/artifactEntity.ts b/core/src/types/artifact/artifactEntity.ts new file mode 100644 index 000000000..7dfb1acff --- /dev/null +++ b/core/src/types/artifact/artifactEntity.ts @@ -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 + /** 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 = { + '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 = { + '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' +} + diff --git a/core/src/types/artifact/index.ts b/core/src/types/artifact/index.ts new file mode 100644 index 000000000..4fbceb764 --- /dev/null +++ b/core/src/types/artifact/index.ts @@ -0,0 +1,2 @@ +export * from './artifactEntity' + diff --git a/core/src/types/index.ts b/core/src/types/index.ts index 2f165b7b9..d0b2eff16 100644 --- a/core/src/types/index.ts +++ b/core/src/types/index.ts @@ -11,3 +11,4 @@ export * from './setting' export * from './engine' export * from './hardware' export * from './mcp' +export * from './artifact' diff --git a/extensions/assistant-extension/src/index.ts b/extensions/assistant-extension/src/index.ts index 2eb9eb347..8e3d25d8c 100644 --- a/extensions/assistant-extension/src/index.ts +++ b/extensions/assistant-extension/src/index.ts @@ -75,7 +75,73 @@ export default class JanAssistantExtension extends AssistantExtension { 'Jan is a helpful desktop assistant that can reason through complex tasks and use tools to complete them on the user’s behalf.', model: '*', instructions: - 'You are a helpful AI assistant. Your primary goal is to assist users with their questions and tasks to the best of your abilities.\n\nWhen responding:\n- Answer directly from your knowledge when you can\n- Be concise, clear, and helpful\n- Admit when you’re unsure rather than making things up\n\nIf tools are available to you:\n- Only use tools when they add real value to your response\n- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code")\n- Use tools for information you don’t know or that needs verification\n- Never use tools just because they’re available\n\nWhen using tools:\n- Use one tool at a time and wait for results\n- Use actual values as arguments, not variable names\n- Learn from each result before deciding next steps\n- Avoid repeating the same tool call with identical parameters\n\nRemember: Most questions can be answered without tools. Think first whether you need them.\n\nCurrent date: {{current_date}}', + `You are a helpful AI assistant. Your primary goal is to assist users with their questions and tasks to the best of your abilities. + +When responding: +- Answer directly from your knowledge when you can +- Be concise, clear, and helpful +- Admit when you're unsure rather than making things up + +If tools are available to you: +- Only use tools when they add real value to your response +- Use tools when the user explicitly asks (e.g., "search for...", "calculate...", "run this code") +- Use tools for information you don't know or that needs verification +- Never use tools just because they're available + +When using tools: +- Use one tool at a time and wait for results +- Use actual values as arguments, not variable names +- Learn from each result before deciding next steps +- Avoid repeating the same tool call with identical parameters + +Remember: Most questions can be answered without tools. Think first whether you need them. + +Artifacts - Persistent Workspace Documents: + +When the user needs to create, edit, or iterate on substantial content (code, documents, structured data), you can use artifacts to provide a persistent workspace alongside the conversation. + +When to create artifacts: +- User explicitly requests ("put this in an artifact", "create a document", "save this") +- Content is substantial and likely to be edited (>15 lines of code, documents, structured data) +- User signals intent to iterate ("so I can edit it", "we can refine", "I want to modify") + +When NOT to create artifacts: +- Simple Q&A responses +- Short explanations or examples +- Content user hasn't signaled they want to save + +To create an artifact, include this JSON in your response: +{ + "artifact_action": { + "type": "create", + "artifact_id": "unique-id", + "artifact": { + "name": "Descriptive Name", + "content_type": "text/markdown", + "language": "markdown", + "preview": "full content here" + } + } +} + +To update an artifact: +{ + "artifact_action": { + "type": "update", + "artifact_id": "existing-id", + "changes": { + "description": "Added timeline section" + }, + "proposed_content_ref": { + "storage": "inline", + "content": "updated full content here" + } + } +} + +Always announce artifact creation: "I'll create a [type] artifact for [purpose]!" + +Current date: {{current_date}}`, tools: [ { type: 'retrieval', diff --git a/extensions/yarn.lock b/extensions/yarn.lock index f4a58c14f..b70afe4c3 100644 --- a/extensions/yarn.lock +++ b/extensions/yarn.lock @@ -342,41 +342,73 @@ __metadata: "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension": version: 0.1.10 - resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension" + resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Fassistant-extension%40workspace%3Aassistant-extension" dependencies: rxjs: "npm:^7.8.1" ulidx: "npm:^2.3.0" - checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c + peerDependencies: + react: 19.0.0 + checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd languageName: node linkType: hard "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension": version: 0.1.10 - resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension" + resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Fconversational-extension%40workspace%3Aconversational-extension" dependencies: rxjs: "npm:^7.8.1" ulidx: "npm:^2.3.0" - checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c + peerDependencies: + react: 19.0.0 + checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd languageName: node linkType: hard "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension": version: 0.1.10 - resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension" + resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Fdownload-extension%40workspace%3Adownload-extension" dependencies: rxjs: "npm:^7.8.1" ulidx: "npm:^2.3.0" - checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c + peerDependencies: + react: 19.0.0 + checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd languageName: node linkType: hard "@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension": version: 0.1.10 - resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=f15485&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension" + resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension" dependencies: rxjs: "npm:^7.8.1" ulidx: "npm:^2.3.0" - checksum: 10c0/257621cb56db31a4dd3a2b509ec4c61217022e74bbd39cf6a1a172073654b9a65eee94ef9c1b4d4f5d2231d159c8818cb02846f3d88fe14f102f43169ad3737c + peerDependencies: + react: 19.0.0 + checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd + languageName: node + linkType: hard + +"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Frag-extension%40workspace%3Arag-extension": + version: 0.1.10 + resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Frag-extension%40workspace%3Arag-extension" + dependencies: + rxjs: "npm:^7.8.1" + ulidx: "npm:^2.3.0" + peerDependencies: + react: 19.0.0 + checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd + languageName: node + linkType: hard + +"@janhq/core@file:../../core/package.tgz::locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension": + version: 0.1.10 + resolution: "@janhq/core@file:../../core/package.tgz#../../core/package.tgz::hash=6b7602&locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension" + dependencies: + rxjs: "npm:^7.8.1" + ulidx: "npm:^2.3.0" + peerDependencies: + react: 19.0.0 + checksum: 10c0/327bce444da6697028c85ee8951deb9981ea518080ff76acc5d4aff1e0ae334629339b2dc65f2294ccef47707cda04114bad80f25b2f2e818c32eb1f2b9e9fcd languageName: node linkType: hard @@ -418,6 +450,20 @@ __metadata: languageName: unknown linkType: soft +"@janhq/rag-extension@workspace:rag-extension": + version: 0.0.0-use.local + resolution: "@janhq/rag-extension@workspace:rag-extension" + dependencies: + "@janhq/core": ../../core/package.tgz + "@janhq/tauri-plugin-rag-api": "link:../../src-tauri/plugins/tauri-plugin-rag" + "@janhq/tauri-plugin-vector-db-api": "link:../../src-tauri/plugins/tauri-plugin-vector-db" + cpx: "npm:1.5.0" + rimraf: "npm:6.0.1" + rolldown: "npm:1.0.0-beta.1" + typescript: "npm:5.9.2" + languageName: unknown + linkType: soft + "@janhq/tauri-plugin-hardware-api@link:../../src-tauri/plugins/tauri-plugin-hardware::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension": version: 0.0.0-use.local resolution: "@janhq/tauri-plugin-hardware-api@link:../../src-tauri/plugins/tauri-plugin-hardware::locator=%40janhq%2Fllamacpp-extension%40workspace%3Allamacpp-extension" @@ -430,6 +476,44 @@ __metadata: languageName: node linkType: soft +"@janhq/tauri-plugin-rag-api@link:../../src-tauri/plugins/tauri-plugin-rag::locator=%40janhq%2Frag-extension%40workspace%3Arag-extension": + version: 0.0.0-use.local + resolution: "@janhq/tauri-plugin-rag-api@link:../../src-tauri/plugins/tauri-plugin-rag::locator=%40janhq%2Frag-extension%40workspace%3Arag-extension" + languageName: node + linkType: soft + +"@janhq/tauri-plugin-rag-api@link:../../src-tauri/plugins/tauri-plugin-rag::locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension": + version: 0.0.0-use.local + resolution: "@janhq/tauri-plugin-rag-api@link:../../src-tauri/plugins/tauri-plugin-rag::locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension" + languageName: node + linkType: soft + +"@janhq/tauri-plugin-vector-db-api@link:../../src-tauri/plugins/tauri-plugin-vector-db::locator=%40janhq%2Frag-extension%40workspace%3Arag-extension": + version: 0.0.0-use.local + resolution: "@janhq/tauri-plugin-vector-db-api@link:../../src-tauri/plugins/tauri-plugin-vector-db::locator=%40janhq%2Frag-extension%40workspace%3Arag-extension" + languageName: node + linkType: soft + +"@janhq/tauri-plugin-vector-db-api@link:../../src-tauri/plugins/tauri-plugin-vector-db::locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension": + version: 0.0.0-use.local + resolution: "@janhq/tauri-plugin-vector-db-api@link:../../src-tauri/plugins/tauri-plugin-vector-db::locator=%40janhq%2Fvector-db-extension%40workspace%3Avector-db-extension" + languageName: node + linkType: soft + +"@janhq/vector-db-extension@workspace:vector-db-extension": + version: 0.0.0-use.local + resolution: "@janhq/vector-db-extension@workspace:vector-db-extension" + dependencies: + "@janhq/core": ../../core/package.tgz + "@janhq/tauri-plugin-rag-api": "link:../../src-tauri/plugins/tauri-plugin-rag" + "@janhq/tauri-plugin-vector-db-api": "link:../../src-tauri/plugins/tauri-plugin-vector-db" + cpx: "npm:1.5.0" + rimraf: "npm:6.0.1" + rolldown: "npm:1.0.0-beta.1" + typescript: "npm:5.9.2" + languageName: unknown + linkType: soft + "@jridgewell/sourcemap-codec@npm:^1.5.0": version: 1.5.0 resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1f2850c2f..93786346b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6,11 +6,13 @@ version = 3 name = "Jan" version = "0.6.599" dependencies = [ + "chrono", "dirs", "env", "fix-path-env", "flate2", "futures-util", + "hex", "hyper 0.14.32", "jan-utils", "libc", @@ -23,6 +25,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "sqlx", "tar", "tauri", @@ -45,6 +48,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-util", + "ulid", "url", "uuid", "windows-sys 0.60.2", @@ -7005,6 +7009,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.2", + "web-time", +] + [[package]] name = "unic-char-property" version = "0.9.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3c7fda787..28a694189 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -93,6 +93,10 @@ tokio = { version = "1", features = ["full"] } tokio-util = "0.7.14" url = "2.5" uuid = { version = "1.7", features = ["v4"] } +ulid = "1.1" +sha2 = "0.10" +hex = "0.4" +chrono = "0.4" [dependencies.tauri] version = "2.8.5" diff --git a/src-tauri/plugins/yarn.lock b/src-tauri/plugins/yarn.lock index 3fba44fdc..7474ac631 100644 --- a/src-tauri/plugins/yarn.lock +++ b/src-tauri/plugins/yarn.lock @@ -52,6 +52,30 @@ __metadata: languageName: unknown linkType: soft +"@janhq/tauri-plugin-rag-api@workspace:tauri-plugin-rag": + version: 0.0.0-use.local + resolution: "@janhq/tauri-plugin-rag-api@workspace:tauri-plugin-rag" + dependencies: + "@rollup/plugin-typescript": "npm:^12.0.0" + "@tauri-apps/api": "npm:>=2.0.0-beta.6" + rollup: "npm:^4.9.6" + tslib: "npm:^2.6.2" + typescript: "npm:^5.3.3" + languageName: unknown + linkType: soft + +"@janhq/tauri-plugin-vector-db-api@workspace:tauri-plugin-vector-db": + version: 0.0.0-use.local + resolution: "@janhq/tauri-plugin-vector-db-api@workspace:tauri-plugin-vector-db" + dependencies: + "@rollup/plugin-typescript": "npm:^12.0.0" + "@tauri-apps/api": "npm:>=2.0.0-beta.6" + rollup: "npm:^4.9.6" + tslib: "npm:^2.6.2" + typescript: "npm:^5.3.3" + languageName: unknown + linkType: soft + "@npmcli/agent@npm:^3.0.0": version: 3.0.0 resolution: "@npmcli/agent@npm:3.0.0" diff --git a/src-tauri/src/core/artifacts/commands.rs b/src-tauri/src/core/artifacts/commands.rs new file mode 100644 index 000000000..0e3dfab00 --- /dev/null +++ b/src-tauri/src/core/artifacts/commands.rs @@ -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( + app_handle: tauri::AppHandle, + thread_id: String, +) -> Result { + read_index(app_handle, &thread_id) +} + +/// Create a new artifact +#[tauri::command] +pub async fn create_artifact( + app_handle: tauri::AppHandle, + thread_id: String, + artifact: ArtifactCreate, +) -> Result { + // 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( + app_handle: tauri::AppHandle, + thread_id: String, + artifact_id: String, +) -> Result { + 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( + app_handle: tauri::AppHandle, + thread_id: String, + artifact_id: String, + content: String, + version: u32, + hash: String, +) -> Result { + let mut index = read_index(app_handle.clone(), &thread_id)?; + + // Find artifact + let artifact = index + .artifacts + .iter_mut() + .find(|a| a.id == artifact_id) + .ok_or_else(|| "Artifact not found".to_string())?; + + // Check if read-only + if artifact.read_only { + return Err("Artifact is read-only".to_string()); + } + + // Conflict detection: verify version and hash match + if artifact.version != version { + return Err(format!( + "Version conflict: expected {}, got {}", + artifact.version, version + )); + } + + if artifact.hash != hash { + return Err("Hash conflict: artifact has been modified".to_string()); + } + + // Read current content for history snapshot + let current_content = read_artifact_content( + app_handle.clone(), + &thread_id, + &artifact.id, + &artifact.file_path, + )?; + + // Create history snapshot before updating + create_history_snapshot(app_handle.clone(), &thread_id, artifact, ¤t_content)?; + + // Normalize and hash new content + let normalized_content = normalize_content(&content)?; + let new_hash = compute_hash(&normalized_content); + let new_bytes = normalized_content.len() as u64; + + // Write new content + write_artifact_content( + app_handle.clone(), + &thread_id, + &artifact.id, + &artifact.file_path, + &normalized_content, + )?; + + // Update artifact metadata + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + artifact.updated_at = now; + artifact.version += 1; + artifact.bytes = new_bytes; + artifact.hash = new_hash; + + // Prune history based on policy + prune_history( + app_handle.clone(), + &thread_id, + artifact, + index.history_keep.max_entries, + index.history_keep.max_days, + )?; + + let updated_artifact = artifact.clone(); + + // Save index + write_index(app_handle, &thread_id, &index)?; + + Ok(updated_artifact) +} + +/// Delete an artifact +#[tauri::command] +pub async fn delete_artifact( + app_handle: tauri::AppHandle, + 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( + app_handle: tauri::AppHandle, + thread_id: String, + artifact_id: String, + new_name: String, +) -> Result { + 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( + app_handle: tauri::AppHandle, + thread_id: String, + artifact_id: Option, +) -> 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( + app_handle: tauri::AppHandle, + thread_id: String, + artifact_id: String, + content: String, +) -> Result { + ensure_artifacts_dirs(app_handle.clone(), &thread_id)?; + + let index = read_index(app_handle.clone(), &thread_id)?; + + // Find artifact + let artifact = index + .artifacts + .iter() + .find(|a| a.id == artifact_id) + .ok_or_else(|| "Artifact not found".to_string())?; + + // Read current content + let current_content = read_artifact_content( + app_handle.clone(), + &thread_id, + &artifact.id, + &artifact.file_path, + )?; + + // Normalize proposed content + let normalized_content = normalize_content(&content)?; + let proposed_hash = compute_hash(&normalized_content); + + // Generate proposal ID + let proposal_id = Ulid::new().to_string(); + let ext = Path::new(&artifact.file_path) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("txt"); + let proposal_filename = format!("{}.{}", proposal_id, ext); + + // Write proposal to .proposals/ + let proposals_dir = get_proposals_dir(app_handle.clone(), &thread_id); + fs::create_dir_all(&proposals_dir).map_err(|e| e.to_string())?; + + let proposal_path = proposals_dir.join(&proposal_filename); + fs::write(&proposal_path, normalized_content.as_bytes()).map_err(|e| e.to_string())?; + + // Generate diff (simple line-by-line for now) + let hunks = generate_diff_hunks(¤t_content, &normalized_content); + let full_diff = format!( + "--- Current\n+++ Proposed\n{}", + hunks + .iter() + .map(|h| format!("@@ {} @@\n{}", h.change_type, h.content)) + .collect::>() + .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( + app_handle: tauri::AppHandle, + thread_id: String, + artifact_id: String, + proposal_id: String, + selected_hunks: Option>, +) -> Result { + // 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( + app_handle: tauri::AppHandle, + 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( + app_handle: tauri::AppHandle, + 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( + app_handle: tauri::AppHandle, + thread_id: String, + archive_path: String, +) -> Result { + 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 = 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 { + 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 +} + diff --git a/src-tauri/src/core/artifacts/constants.rs b/src-tauri/src/core/artifacts/constants.rs new file mode 100644 index 000000000..fa4ba1c58 --- /dev/null +++ b/src-tauri/src/core/artifacts/constants.rs @@ -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"; + diff --git a/src-tauri/src/core/artifacts/helpers.rs b/src-tauri/src/core/artifacts/helpers.rs new file mode 100644 index 000000000..1ca83dfe9 --- /dev/null +++ b/src-tauri/src/core/artifacts/helpers.rs @@ -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>> = + 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 { + // 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( + app_handle: tauri::AppHandle, + thread_id: &str, +) -> Result { + 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( + app_handle: tauri::AppHandle, + 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( + app_handle: tauri::AppHandle, + thread_id: &str, + artifact_id: &str, + file_path: &str, +) -> Result { + // 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( + app_handle: tauri::AppHandle, + 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( + app_handle: tauri::AppHandle, + 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( + app_handle: tauri::AppHandle, + 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::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .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"); + } +} + diff --git a/src-tauri/src/core/artifacts/mod.rs b/src-tauri/src/core/artifacts/mod.rs new file mode 100644 index 000000000..86218bd86 --- /dev/null +++ b/src-tauri/src/core/artifacts/mod.rs @@ -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; + diff --git a/src-tauri/src/core/artifacts/models.rs b/src-tauri/src/core/artifacts/models.rs new file mode 100644 index 000000000..a81243121 --- /dev/null +++ b/src-tauri/src/core/artifacts/models.rs @@ -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, + #[serde(default)] + pub archived: bool, + #[serde(default)] + pub tags: Vec, +} + +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, + 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, + #[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, + pub active_artifact_id: Option, +} + +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, + pub content: String, + pub source_message_id: Option, +} + +/// 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, + 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, +} + +/// 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, +} + diff --git a/src-tauri/src/core/artifacts/tests.rs b/src-tauri/src/core/artifacts/tests.rs new file mode 100644 index 000000000..187d227ed --- /dev/null +++ b/src-tauri/src/core/artifacts/tests.rs @@ -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); + } +} + diff --git a/src-tauri/src/core/artifacts/utils.rs b/src-tauri/src/core/artifacts/utils.rs new file mode 100644 index 000000000..4f18cac24 --- /dev/null +++ b/src-tauri/src/core/artifacts/utils.rs @@ -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(app_handle: tauri::AppHandle, 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(app_handle: tauri::AppHandle, 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( + app_handle: tauri::AppHandle, + 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( + app_handle: tauri::AppHandle, + 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( + app_handle: tauri::AppHandle, + 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( + app_handle: tauri::AppHandle, + 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( + app_handle: tauri::AppHandle, + 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( + app_handle: tauri::AppHandle, + 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(()) +} + diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index a20abd8dc..9d864907f 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod artifacts; pub mod downloads; pub mod extensions; pub mod filesystem; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 24da0e807..fe16b82aa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -107,6 +107,19 @@ pub fn run() { core::threads::commands::get_thread_assistant, core::threads::commands::create_thread_assistant, core::threads::commands::modify_thread_assistant, + // Artifacts + core::artifacts::commands::list_artifacts, + core::artifacts::commands::create_artifact, + core::artifacts::commands::get_artifact_content, + core::artifacts::commands::update_artifact, + core::artifacts::commands::delete_artifact, + core::artifacts::commands::rename_artifact, + core::artifacts::commands::set_active_artifact, + core::artifacts::commands::propose_update, + core::artifacts::commands::apply_proposal, + core::artifacts::commands::discard_proposal, + core::artifacts::commands::export_artifacts, + core::artifacts::commands::import_artifacts, // Download core::downloads::commands::download_files, core::downloads::commands::cancel_download_task, diff --git a/web-app/package.json b/web-app/package.json index 287756336..082cf6eeb 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -61,6 +61,7 @@ "lodash.clonedeep": "4.5.0", "lodash.debounce": "4.0.8", "lucide-react": "0.536.0", + "monaco-editor": "^0.52.2", "motion": "12.18.1", "next-themes": "0.4.6", "posthog-js": "1.255.1", diff --git a/web-app/src/containers/ArtifactActionMessage.tsx b/web-app/src/containers/ArtifactActionMessage.tsx new file mode 100644 index 000000000..aa2625f2c --- /dev/null +++ b/web-app/src/containers/ArtifactActionMessage.tsx @@ -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 + } + + if (artifactAction.type === 'update') { + return ( +
+
+ + + +
+
+
+ Updated artifact +
+
+ {artifactAction.changes.description} +
+ {artifactAction.changes.diff_preview && ( +
+ {artifactAction.changes.diff_preview} +
+ )} +
+ +
+ ) + } + + return null +} + diff --git a/web-app/src/containers/ArtifactPanel/ArtifactList.tsx b/web-app/src/containers/ArtifactPanel/ArtifactList.tsx new file mode 100644 index 000000000..377037d0b --- /dev/null +++ b/web-app/src/containers/ArtifactPanel/ArtifactList.tsx @@ -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 ( +
+ {/* Sidebar Header */} +
+
+ {artifactList.length} {artifactList.length === 1 ? 'Artifact' : 'Artifacts'} +
+
+ + {/* Artifact List */} +
+ {artifactList.length === 0 ? ( +
+ No artifacts yet +
+ ) : ( +
+ {artifactList.map((artifact) => { + const isActive = artifact.id === activeArtifactId + const isDirty = artifacts.dirtyArtifacts.has(artifact.id) + const hasPendingProposal = !!artifacts.pendingProposals[artifact.id] + + return ( + + ) + })} +
+ )} +
+
+ ) +} + diff --git a/web-app/src/containers/ArtifactPanel/DiffPreview.tsx b/web-app/src/containers/ArtifactPanel/DiffPreview.tsx new file mode 100644 index 000000000..8c675f171 --- /dev/null +++ b/web-app/src/containers/ArtifactPanel/DiffPreview.tsx @@ -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>( + 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 ( +
+ {/* Header */} +
+
+ + + + Proposed Changes + + {diff.hunks.length} change{diff.hunks.length !== 1 ? 's' : ''} + +
+
+ + +
+
+ + {/* Hunk Selection */} + {diff.hunks.length > 1 && ( +
+ +
+ )} + + {/* Diff Content */} +
+
+ {diff.hunks.map((hunk, index) => ( +
+ {diff.hunks.length > 1 && ( + + )} +
+                {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 (
+                    
+ {line || '\u00A0'} +
+ ) + })} +
+
+ ))} +
+
+ + {/* Footer with version info */} +
+
Current version: {diff.current_version}
+
Current hash: {diff.current_hash.substring(0, 16)}...
+
Proposed hash: {diff.proposed_hash.substring(0, 16)}...
+
+
+ ) +} + diff --git a/web-app/src/containers/ArtifactPanel/MonacoEditor.tsx b/web-app/src/containers/ArtifactPanel/MonacoEditor.tsx new file mode 100644 index 000000000..863b687af --- /dev/null +++ b/web-app/src/containers/ArtifactPanel/MonacoEditor.tsx @@ -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(null) + const editorRef = useRef(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
+} + diff --git a/web-app/src/containers/ArtifactPanel/index.tsx b/web-app/src/containers/ArtifactPanel/index.tsx new file mode 100644 index 000000000..3ea798c68 --- /dev/null +++ b/web-app/src/containers/ArtifactPanel/index.tsx @@ -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(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 ( +
+ {/* Artifact List Sidebar */} + {threadArtifacts.length > 0 && ( +
+ +
+ )} + + {/* Main Content Area */} +
+ {/* Header */} +
+
+ {activeArtifact ? ( + <> + {activeArtifact.name} + {activeArtifact.content_type} + + v{activeArtifact.version} + + + ) : ( + No artifact selected + )} +
+
+ {isDirty && ● Unsaved} + {isSaving && Saving...} + {!isDirty && !isSaving && activeArtifact && ( + Saved + )} +
+
+ + {/* Content */} +
+ {pendingProposal && activeArtifact ? ( + + ) : activeArtifact ? ( + isCodeArtifact ? ( + + Loading editor... +
+ } + > + + + ) : ( +