Prompt gallery (#957)
- [x] show prompt instead of app in autocomplete - [x] use proper array/list for db (tags) - [x] don't do <dyad-prompt> - replace inline
This commit is contained in:
8
drizzle/0011_light_zeigeist.sql
Normal file
8
drizzle/0011_light_zeigeist.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE `prompts` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`description` text,
|
||||||
|
`content` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||||
|
);
|
||||||
578
drizzle/meta/0011_snapshot.json
Normal file
578
drizzle/meta/0011_snapshot.json
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "6ac2fe61-675b-4e3f-baf7-0f7d5f76bb2c",
|
||||||
|
"prevId": "a7f4a6e1-2a38-4dc8-a37e-b473b6304bab",
|
||||||
|
"tables": {
|
||||||
|
"apps": {
|
||||||
|
"name": "apps",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"name": "path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"github_org": {
|
||||||
|
"name": "github_org",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"github_repo": {
|
||||||
|
"name": "github_repo",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"github_branch": {
|
||||||
|
"name": "github_branch",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"supabase_project_id": {
|
||||||
|
"name": "supabase_project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"neon_project_id": {
|
||||||
|
"name": "neon_project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"neon_development_branch_id": {
|
||||||
|
"name": "neon_development_branch_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"neon_preview_branch_id": {
|
||||||
|
"name": "neon_preview_branch_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"vercel_project_id": {
|
||||||
|
"name": "vercel_project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"vercel_project_name": {
|
||||||
|
"name": "vercel_project_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"vercel_team_id": {
|
||||||
|
"name": "vercel_team_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"vercel_deployment_url": {
|
||||||
|
"name": "vercel_deployment_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"install_command": {
|
||||||
|
"name": "install_command",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"start_command": {
|
||||||
|
"name": "start_command",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"chat_context": {
|
||||||
|
"name": "chat_context",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"chats": {
|
||||||
|
"name": "chats",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"app_id": {
|
||||||
|
"name": "app_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"initial_commit_hash": {
|
||||||
|
"name": "initial_commit_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"chats_app_id_apps_id_fk": {
|
||||||
|
"name": "chats_app_id_apps_id_fk",
|
||||||
|
"tableFrom": "chats",
|
||||||
|
"tableTo": "apps",
|
||||||
|
"columnsFrom": [
|
||||||
|
"app_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"language_model_providers": {
|
||||||
|
"name": "language_model_providers",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"api_base_url": {
|
||||||
|
"name": "api_base_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"env_var_name": {
|
||||||
|
"name": "env_var_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"language_models": {
|
||||||
|
"name": "language_models",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"api_name": {
|
||||||
|
"name": "api_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"builtin_provider_id": {
|
||||||
|
"name": "builtin_provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"custom_provider_id": {
|
||||||
|
"name": "custom_provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"max_output_tokens": {
|
||||||
|
"name": "max_output_tokens",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"context_window": {
|
||||||
|
"name": "context_window",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"language_models_custom_provider_id_language_model_providers_id_fk": {
|
||||||
|
"name": "language_models_custom_provider_id_language_model_providers_id_fk",
|
||||||
|
"tableFrom": "language_models",
|
||||||
|
"tableTo": "language_model_providers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"custom_provider_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"name": "messages",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"chat_id": {
|
||||||
|
"name": "chat_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"approval_state": {
|
||||||
|
"name": "approval_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"commit_hash": {
|
||||||
|
"name": "commit_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"messages_chat_id_chats_id_fk": {
|
||||||
|
"name": "messages_chat_id_chats_id_fk",
|
||||||
|
"tableFrom": "messages",
|
||||||
|
"tableTo": "chats",
|
||||||
|
"columnsFrom": [
|
||||||
|
"chat_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"prompts": {
|
||||||
|
"name": "prompts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"versions": {
|
||||||
|
"name": "versions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"app_id": {
|
||||||
|
"name": "app_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"commit_hash": {
|
||||||
|
"name": "commit_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"neon_db_timestamp": {
|
||||||
|
"name": "neon_db_timestamp",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"versions_app_commit_unique": {
|
||||||
|
"name": "versions_app_commit_unique",
|
||||||
|
"columns": [
|
||||||
|
"app_id",
|
||||||
|
"commit_hash"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"versions_app_id_apps_id_fk": {
|
||||||
|
"name": "versions_app_id_apps_id_fk",
|
||||||
|
"tableFrom": "versions",
|
||||||
|
"tableTo": "apps",
|
||||||
|
"columnsFrom": [
|
||||||
|
"app_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,13 @@
|
|||||||
"when": 1755110011615,
|
"when": 1755110011615,
|
||||||
"tag": "0010_nappy_fat_cobra",
|
"tag": "0010_nappy_fat_cobra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1755545060076,
|
||||||
|
"tag": "0011_light_zeigeist",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -727,6 +727,30 @@ export class PageObject {
|
|||||||
await this.page.getByRole("link", { name: "Settings" }).click();
|
await this.page.getByRole("link", { name: "Settings" }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async goToLibraryTab() {
|
||||||
|
await this.page.getByRole("link", { name: "Library" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPrompt({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
content: string;
|
||||||
|
}) {
|
||||||
|
await this.page.getByRole("button", { name: "New Prompt" }).click();
|
||||||
|
await this.page.getByRole("textbox", { name: "Title" }).fill(title);
|
||||||
|
if (description) {
|
||||||
|
await this.page
|
||||||
|
.getByRole("textbox", { name: "Description (optional)" })
|
||||||
|
.fill(description);
|
||||||
|
}
|
||||||
|
await this.page.getByRole("textbox", { name: "Content" }).fill(content);
|
||||||
|
await this.page.getByRole("button", { name: "Save" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
getTitleBarAppNameButton() {
|
getTitleBarAppNameButton() {
|
||||||
return this.page.getByTestId("title-bar-app-name-button");
|
return this.page.getByTestId("title-bar-app-name-button");
|
||||||
}
|
}
|
||||||
|
|||||||
56
e2e-tests/prompt_library.spec.ts
Normal file
56
e2e-tests/prompt_library.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { test } from "./helpers/test_helper";
|
||||||
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("create and edit prompt", async ({ po }) => {
|
||||||
|
await po.setUp();
|
||||||
|
await po.goToLibraryTab();
|
||||||
|
await po.createPrompt({
|
||||||
|
title: "title1",
|
||||||
|
description: "desc",
|
||||||
|
content: "prompt1content",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(po.page.getByTestId("prompt-card")).toMatchAriaSnapshot();
|
||||||
|
|
||||||
|
await po.page.getByTestId("edit-prompt-button").click();
|
||||||
|
await po.page
|
||||||
|
.getByRole("textbox", { name: "Content" })
|
||||||
|
.fill("prompt1content-edited");
|
||||||
|
await po.page.getByRole("button", { name: "Save" }).click();
|
||||||
|
|
||||||
|
await expect(po.page.getByTestId("prompt-card")).toMatchAriaSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete prompt", async ({ po }) => {
|
||||||
|
await po.setUp();
|
||||||
|
await po.goToLibraryTab();
|
||||||
|
await po.createPrompt({
|
||||||
|
title: "title1",
|
||||||
|
description: "desc",
|
||||||
|
content: "prompt1content",
|
||||||
|
});
|
||||||
|
|
||||||
|
await po.page.getByTestId("delete-prompt-button").click();
|
||||||
|
await po.page.getByRole("button", { name: "Delete" }).click();
|
||||||
|
|
||||||
|
await expect(po.page.getByTestId("prompt-card")).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("use prompt", async ({ po }) => {
|
||||||
|
await po.setUp();
|
||||||
|
await po.goToLibraryTab();
|
||||||
|
await po.createPrompt({
|
||||||
|
title: "title1",
|
||||||
|
description: "desc",
|
||||||
|
content: "prompt1content",
|
||||||
|
});
|
||||||
|
|
||||||
|
await po.goToAppsTab();
|
||||||
|
await po.getChatInput().click();
|
||||||
|
await po.getChatInput().fill("[dump] @");
|
||||||
|
await po.page.getByRole("menuitem", { name: "Choose title1" }).click();
|
||||||
|
await po.page.getByRole("button", { name: "Send message" }).click();
|
||||||
|
await po.waitForChatCompletion();
|
||||||
|
|
||||||
|
await po.snapshotServerDump("last-message");
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- heading "title1" [level=3]
|
||||||
|
- paragraph: desc
|
||||||
|
- button:
|
||||||
|
- img
|
||||||
|
- button:
|
||||||
|
- img
|
||||||
|
- text: prompt1content
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- heading "title1" [level=3]
|
||||||
|
- paragraph: desc
|
||||||
|
- button:
|
||||||
|
- img
|
||||||
|
- button:
|
||||||
|
- img
|
||||||
|
- text: prompt1content-edited
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
===
|
||||||
|
role: user
|
||||||
|
message: [dump] prompt1content
|
||||||
41
src/__tests__/replacePromptReference.test.ts
Normal file
41
src/__tests__/replacePromptReference.test.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { replacePromptReference } from "@/ipc/utils/replacePromptReference";
|
||||||
|
|
||||||
|
describe("replacePromptReference", () => {
|
||||||
|
it("returns original when no references present", () => {
|
||||||
|
const input = "Hello world";
|
||||||
|
const output = replacePromptReference(input, {});
|
||||||
|
expect(output).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces a single @prompt:id with content", () => {
|
||||||
|
const input = "Use this: @prompt:42";
|
||||||
|
const prompts = { 42: "Meaning of life" };
|
||||||
|
const output = replacePromptReference(input, prompts);
|
||||||
|
expect(output).toBe("Use this: Meaning of life");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces multiple occurrences and keeps surrounding text", () => {
|
||||||
|
const input = "A @prompt:1 and B @prompt:2 end";
|
||||||
|
const prompts = { 1: "One", 2: "Two" };
|
||||||
|
const output = replacePromptReference(input, prompts);
|
||||||
|
expect(output).toBe("A One and B Two end");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unknown references intact", () => {
|
||||||
|
const input = "Unknown @prompt:99 here";
|
||||||
|
const prompts = { 1: "One" };
|
||||||
|
const output = replacePromptReference(input, prompts);
|
||||||
|
expect(output).toBe("Unknown @prompt:99 here");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports string keys in map as well as numeric", () => {
|
||||||
|
const input = "Mix @prompt:7 and @prompt:8";
|
||||||
|
const prompts = { "7": "Seven", 8: "Eight" } as Record<
|
||||||
|
string | number,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
const output = replacePromptReference(input, prompts);
|
||||||
|
expect(output).toBe("Mix Seven and Eight");
|
||||||
|
});
|
||||||
|
});
|
||||||
235
src/components/CreatePromptDialog.tsx
Normal file
235
src/components/CreatePromptDialog.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Plus, Save, Edit2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface CreateOrEditPromptDialogProps {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
prompt?: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
onCreatePrompt?: (prompt: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
content: string;
|
||||||
|
}) => Promise<any>;
|
||||||
|
onUpdatePrompt?: (prompt: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
content: string;
|
||||||
|
}) => Promise<any>;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateOrEditPromptDialog({
|
||||||
|
mode,
|
||||||
|
prompt,
|
||||||
|
onCreatePrompt,
|
||||||
|
onUpdatePrompt,
|
||||||
|
trigger,
|
||||||
|
}: CreateOrEditPromptDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [draft, setDraft] = useState({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Auto-resize textarea function
|
||||||
|
const adjustTextareaHeight = () => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (textarea) {
|
||||||
|
// Store current height to avoid flicker
|
||||||
|
const currentHeight = textarea.style.height;
|
||||||
|
textarea.style.height = "auto";
|
||||||
|
const scrollHeight = textarea.scrollHeight;
|
||||||
|
const maxHeight = window.innerHeight * 0.6 - 100; // 60vh in pixels
|
||||||
|
const minHeight = 150; // 150px minimum
|
||||||
|
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
|
||||||
|
|
||||||
|
// Only update if height actually changed to reduce reflows
|
||||||
|
if (`${newHeight}px` !== currentHeight) {
|
||||||
|
textarea.style.height = `${newHeight}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize draft with prompt data when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "edit" && prompt) {
|
||||||
|
setDraft({
|
||||||
|
title: prompt.title,
|
||||||
|
description: prompt.description || "",
|
||||||
|
content: prompt.content,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setDraft({ title: "", description: "", content: "" });
|
||||||
|
}
|
||||||
|
}, [mode, prompt, open]);
|
||||||
|
|
||||||
|
// Auto-resize textarea when content changes
|
||||||
|
useEffect(() => {
|
||||||
|
adjustTextareaHeight();
|
||||||
|
}, [draft.content]);
|
||||||
|
|
||||||
|
// Trigger resize when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
// Small delay to ensure the dialog is fully rendered
|
||||||
|
setTimeout(adjustTextareaHeight, 0);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const resetDraft = () => {
|
||||||
|
if (mode === "edit" && prompt) {
|
||||||
|
setDraft({
|
||||||
|
title: prompt.title,
|
||||||
|
description: prompt.description || "",
|
||||||
|
content: prompt.content,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setDraft({ title: "", description: "", content: "" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!draft.title.trim() || !draft.content.trim()) return;
|
||||||
|
|
||||||
|
if (mode === "create" && onCreatePrompt) {
|
||||||
|
await onCreatePrompt({
|
||||||
|
title: draft.title.trim(),
|
||||||
|
description: draft.description.trim() || undefined,
|
||||||
|
content: draft.content,
|
||||||
|
});
|
||||||
|
} else if (mode === "edit" && onUpdatePrompt && prompt) {
|
||||||
|
await onUpdatePrompt({
|
||||||
|
id: prompt.id,
|
||||||
|
title: draft.title.trim(),
|
||||||
|
description: draft.description.trim() || undefined,
|
||||||
|
content: draft.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
resetDraft();
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
{trigger ? (
|
||||||
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
|
) : mode === "create" ? (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> New Prompt
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
data-testid="edit-prompt-button"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Edit prompt</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{mode === "create" ? "Create New Prompt" : "Edit Prompt"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{mode === "create"
|
||||||
|
? "Create a new prompt template for your library."
|
||||||
|
: "Edit your prompt template."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Title"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft((d) => ({ ...d, description: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
placeholder="Content"
|
||||||
|
value={draft.content}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft((d) => ({ ...d, content: e.target.value }));
|
||||||
|
// Use requestAnimationFrame for smoother updates
|
||||||
|
requestAnimationFrame(adjustTextareaHeight);
|
||||||
|
}}
|
||||||
|
className="resize-none overflow-y-auto"
|
||||||
|
style={{ minHeight: "150px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!draft.title.trim() || !draft.content.trim()}
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" /> Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatibility wrapper for create mode
|
||||||
|
export function CreatePromptDialog({
|
||||||
|
onCreatePrompt,
|
||||||
|
}: {
|
||||||
|
onCreatePrompt: (prompt: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
content: string;
|
||||||
|
}) => Promise<any>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<CreateOrEditPromptDialog mode="create" onCreatePrompt={onCreatePrompt} />
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/DeleteConfirmationDialog.tsx
Normal file
71
src/components/DeleteConfirmationDialog.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
interface DeleteConfirmationDialogProps {
|
||||||
|
itemName: string;
|
||||||
|
itemType?: string;
|
||||||
|
onDelete: () => void | Promise<void>;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmationDialog({
|
||||||
|
itemName,
|
||||||
|
itemType = "item",
|
||||||
|
onDelete,
|
||||||
|
trigger,
|
||||||
|
}: DeleteConfirmationDialogProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
{trigger ? (
|
||||||
|
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||||
|
) : (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
data-testid="delete-prompt-button"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Delete {itemType.toLowerCase()}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete {itemType}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{itemName}"? This action cannot be
|
||||||
|
undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Home, Inbox, Settings, HelpCircle, Store } from "lucide-react";
|
import {
|
||||||
|
Home,
|
||||||
|
Inbox,
|
||||||
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
|
Store,
|
||||||
|
BookOpen,
|
||||||
|
} from "lucide-react";
|
||||||
import { Link, useRouterState } from "@tanstack/react-router";
|
import { Link, useRouterState } from "@tanstack/react-router";
|
||||||
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
|
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
@@ -39,6 +46,11 @@ const items = [
|
|||||||
to: "/settings",
|
to: "/settings",
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Library",
|
||||||
|
to: "/library",
|
||||||
|
icon: BookOpen,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Hub",
|
title: "Hub",
|
||||||
to: "/hub",
|
to: "/hub",
|
||||||
@@ -51,6 +63,7 @@ type HoverState =
|
|||||||
| "start-hover:app"
|
| "start-hover:app"
|
||||||
| "start-hover:chat"
|
| "start-hover:chat"
|
||||||
| "start-hover:settings"
|
| "start-hover:settings"
|
||||||
|
| "start-hover:library"
|
||||||
| "clear-hover"
|
| "clear-hover"
|
||||||
| "no-hover";
|
| "no-hover";
|
||||||
|
|
||||||
@@ -92,6 +105,8 @@ export function AppSidebar() {
|
|||||||
selectedItem = "Chat";
|
selectedItem = "Chat";
|
||||||
} else if (hoverState === "start-hover:settings") {
|
} else if (hoverState === "start-hover:settings") {
|
||||||
selectedItem = "Settings";
|
selectedItem = "Settings";
|
||||||
|
} else if (hoverState === "start-hover:library") {
|
||||||
|
selectedItem = "Library";
|
||||||
} else if (state === "expanded") {
|
} else if (state === "expanded") {
|
||||||
if (isAppRoute) {
|
if (isAppRoute) {
|
||||||
selectedItem = "Apps";
|
selectedItem = "Apps";
|
||||||
@@ -195,6 +210,8 @@ function AppIcons({
|
|||||||
onHoverChange("start-hover:chat");
|
onHoverChange("start-hover:chat");
|
||||||
} else if (item.title === "Settings") {
|
} else if (item.title === "Settings") {
|
||||||
onHoverChange("start-hover:settings");
|
onHoverChange("start-hover:settings");
|
||||||
|
} else if (item.title === "Library") {
|
||||||
|
onHoverChange("start-hover:library");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "lexical-beautiful-mentions";
|
} from "lexical-beautiful-mentions";
|
||||||
import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from "lexical";
|
import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from "lexical";
|
||||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||||
|
import { usePrompts } from "@/hooks/usePrompts";
|
||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||||
@@ -36,26 +37,35 @@ const beautifulMentionsTheme: BeautifulMentionsTheme = {
|
|||||||
const CustomMenuItem = forwardRef<
|
const CustomMenuItem = forwardRef<
|
||||||
HTMLLIElement,
|
HTMLLIElement,
|
||||||
BeautifulMentionsMenuItemProps
|
BeautifulMentionsMenuItemProps
|
||||||
>(({ selected, item, ...props }, ref) => (
|
>(({ selected, item, ...props }, ref) => {
|
||||||
<li
|
const isPrompt = typeof item !== "string" && item.data?.type === "prompt";
|
||||||
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
|
const label = isPrompt ? "Prompt" : "App";
|
||||||
selected
|
const value = typeof item === "string" ? item : (item as any)?.value;
|
||||||
? "bg-accent text-accent-foreground"
|
return (
|
||||||
: "bg-popover text-popover-foreground hover:bg-accent/50"
|
<li
|
||||||
}`}
|
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
|
||||||
{...props}
|
selected
|
||||||
ref={ref}
|
? "bg-accent text-accent-foreground"
|
||||||
>
|
: "bg-popover text-popover-foreground hover:bg-accent/50"
|
||||||
<div className="flex items-center space-x-2 min-w-0">
|
}`}
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-primary text-primary-foreground rounded-md flex-shrink-0">
|
{...props}
|
||||||
App
|
ref={ref}
|
||||||
</span>
|
>
|
||||||
<span className="truncate text-sm">
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
{typeof item === "string" ? item : item.value}
|
<span
|
||||||
</span>
|
className={`px-2 py-0.5 text-xs font-medium rounded-md flex-shrink-0 ${
|
||||||
</div>
|
isPrompt
|
||||||
</li>
|
? "bg-purple-500 text-white"
|
||||||
));
|
: "bg-primary text-primary-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm">{value}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Custom menu component
|
// Custom menu component
|
||||||
function CustomMenu({ loading: _loading, ...props }: any) {
|
function CustomMenu({ loading: _loading, ...props }: any) {
|
||||||
@@ -136,13 +146,24 @@ function ClearEditorPlugin({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Plugin to sync external value prop into the editor
|
// Plugin to sync external value prop into the editor
|
||||||
function ExternalValueSyncPlugin({ value }: { value: string }) {
|
function ExternalValueSyncPlugin({
|
||||||
|
value,
|
||||||
|
promptsById,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
promptsById: Record<number, string>;
|
||||||
|
}) {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Derive the display text that should appear in the editor (@Name) from the
|
// Derive the display text that should appear in the editor (@Name) from the
|
||||||
// internal value representation (@app:Name)
|
// internal value representation (@app:Name)
|
||||||
const displayText = (value || "").replace(MENTION_REGEX, "@$1");
|
let displayText = (value || "").replace(MENTION_REGEX, "@$1");
|
||||||
|
displayText = displayText.replace(/@prompt:(\d+)/g, (_m, idStr) => {
|
||||||
|
const id = Number(idStr);
|
||||||
|
const title = promptsById[id];
|
||||||
|
return title ? `@${title}` : _m;
|
||||||
|
});
|
||||||
|
|
||||||
const currentText = editor.getEditorState().read(() => {
|
const currentText = editor.getEditorState().read(() => {
|
||||||
const root = $getRoot();
|
const root = $getRoot();
|
||||||
@@ -157,34 +178,32 @@ function ExternalValueSyncPlugin({ value }: { value: string }) {
|
|||||||
|
|
||||||
const paragraph = $createParagraphNode();
|
const paragraph = $createParagraphNode();
|
||||||
|
|
||||||
// Build nodes from the internal value, turning @app:Name into a mention node
|
// Build nodes from internal value, turning @app:Name and @prompt:<id> into mention nodes
|
||||||
const mentionRegex = /@app:([a-zA-Z0-9_-]+)/g;
|
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
const combined = /@app:([a-zA-Z0-9_-]+)|@prompt:(\d+)/g;
|
||||||
while ((match = mentionRegex.exec(value)) !== null) {
|
while ((match = combined.exec(value)) !== null) {
|
||||||
const [full, name] = match;
|
|
||||||
const start = match.index;
|
const start = match.index;
|
||||||
|
const full = match[0];
|
||||||
// Append any text before the mention
|
|
||||||
if (start > lastIndex) {
|
if (start > lastIndex) {
|
||||||
const textBefore = value.slice(lastIndex, start);
|
const textBefore = value.slice(lastIndex, start);
|
||||||
if (textBefore) paragraph.append($createTextNode(textBefore));
|
if (textBefore) paragraph.append($createTextNode(textBefore));
|
||||||
}
|
}
|
||||||
|
if (match[1]) {
|
||||||
// Append the actual mention node (@ trigger with value = Name)
|
const appName = match[1];
|
||||||
paragraph.append($createBeautifulMentionNode("@", name));
|
paragraph.append($createBeautifulMentionNode("@", appName));
|
||||||
|
} else if (match[2]) {
|
||||||
|
const id = Number(match[2]);
|
||||||
|
const title = promptsById[id] || `prompt:${id}`;
|
||||||
|
paragraph.append($createBeautifulMentionNode("@", title));
|
||||||
|
}
|
||||||
lastIndex = start + full.length;
|
lastIndex = start + full.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append any trailing text after the last mention
|
|
||||||
if (lastIndex < value.length) {
|
if (lastIndex < value.length) {
|
||||||
const trailing = value.slice(lastIndex);
|
const trailing = value.slice(lastIndex);
|
||||||
if (trailing) paragraph.append($createTextNode(trailing));
|
if (trailing) paragraph.append($createTextNode(trailing));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there were no mentions at all, just append the raw value as text
|
|
||||||
if (value && paragraph.getTextContent() === "") {
|
if (value && paragraph.getTextContent() === "") {
|
||||||
paragraph.append($createTextNode(value));
|
paragraph.append($createTextNode(value));
|
||||||
}
|
}
|
||||||
@@ -192,7 +211,7 @@ function ExternalValueSyncPlugin({ value }: { value: string }) {
|
|||||||
root.append(paragraph);
|
root.append(paragraph);
|
||||||
paragraph.selectEnd();
|
paragraph.selectEnd();
|
||||||
});
|
});
|
||||||
}, [editor, value]);
|
}, [editor, value, promptsById]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -221,6 +240,7 @@ export function LexicalChatInput({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}: LexicalChatInputProps) {
|
}: LexicalChatInputProps) {
|
||||||
const { apps } = useLoadApps();
|
const { apps } = useLoadApps();
|
||||||
|
const { prompts } = usePrompts();
|
||||||
const [shouldClear, setShouldClear] = useState(false);
|
const [shouldClear, setShouldClear] = useState(false);
|
||||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||||
|
|
||||||
@@ -252,10 +272,17 @@ export function LexicalChatInput({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const appMentions = filteredApps.map((app) => app.name);
|
const appMentions = filteredApps.map((app) => app.name);
|
||||||
|
|
||||||
|
const promptItems = (prompts || []).map((p) => ({
|
||||||
|
value: p.title,
|
||||||
|
type: "prompt",
|
||||||
|
id: p.id,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"@": appMentions,
|
"@": [...appMentions, ...promptItems],
|
||||||
};
|
};
|
||||||
}, [apps, selectedAppId, value, excludeCurrentApp]);
|
}, [apps, selectedAppId, value, excludeCurrentApp, prompts]);
|
||||||
|
|
||||||
const initialConfig = {
|
const initialConfig = {
|
||||||
namespace: "ChatInput",
|
namespace: "ChatInput",
|
||||||
@@ -291,11 +318,18 @@ export function LexicalChatInput({
|
|||||||
);
|
);
|
||||||
textContent = textContent.replace(mentionRegex, "@app:$1");
|
textContent = textContent.replace(mentionRegex, "@app:$1");
|
||||||
}
|
}
|
||||||
|
// Convert @PromptTitle to @prompt:<id>
|
||||||
|
const map = new Map((prompts || []).map((p) => [p.title, p.id]));
|
||||||
|
for (const [title, id] of map.entries()) {
|
||||||
|
const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const regex = new RegExp(`@(${escapedTitle})(?![\\w-])`, "g");
|
||||||
|
textContent = textContent.replace(regex, `@prompt:${id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onChange(textContent);
|
onChange(textContent);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[onChange, apps],
|
[onChange, apps, prompts],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
@@ -343,7 +377,12 @@ export function LexicalChatInput({
|
|||||||
<OnChangePlugin onChange={handleEditorChange} />
|
<OnChangePlugin onChange={handleEditorChange} />
|
||||||
<HistoryPlugin />
|
<HistoryPlugin />
|
||||||
<EnterKeyPlugin onSubmit={handleSubmit} />
|
<EnterKeyPlugin onSubmit={handleSubmit} />
|
||||||
<ExternalValueSyncPlugin value={value} />
|
<ExternalValueSyncPlugin
|
||||||
|
value={value}
|
||||||
|
promptsById={Object.fromEntries(
|
||||||
|
(prompts || []).map((p) => [p.id, p.title]),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<ClearEditorPlugin
|
<ClearEditorPlugin
|
||||||
shouldClear={shouldClear}
|
shouldClear={shouldClear}
|
||||||
onCleared={handleCleared}
|
onCleared={handleCleared}
|
||||||
|
|||||||
26
src/components/ui/textarea.tsx
Normal file
26
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background",
|
||||||
|
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -2,6 +2,19 @@ import { sql } from "drizzle-orm";
|
|||||||
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const prompts = sqliteTable("prompts", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch())`),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(unixepoch())`),
|
||||||
|
});
|
||||||
|
|
||||||
export const apps = sqliteTable("apps", {
|
export const apps = sqliteTable("apps", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
|||||||
81
src/hooks/usePrompts.ts
Normal file
81
src/hooks/usePrompts.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { IpcClient } from "@/ipc/ipc_client";
|
||||||
|
|
||||||
|
export interface PromptItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
content: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrompts() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const listQuery = useQuery({
|
||||||
|
queryKey: ["prompts"],
|
||||||
|
queryFn: async (): Promise<PromptItem[]> => {
|
||||||
|
const ipc = IpcClient.getInstance();
|
||||||
|
return ipc.listPrompts();
|
||||||
|
},
|
||||||
|
meta: { showErrorToast: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async (params: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
content: string;
|
||||||
|
}): Promise<PromptItem> => {
|
||||||
|
const ipc = IpcClient.getInstance();
|
||||||
|
return ipc.createPrompt(params);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["prompts"] });
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
showErrorToast: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: async (params: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
content: string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const ipc = IpcClient.getInstance();
|
||||||
|
return ipc.updatePrompt(params);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["prompts"] });
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
showErrorToast: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number): Promise<void> => {
|
||||||
|
const ipc = IpcClient.getInstance();
|
||||||
|
return ipc.deletePrompt(id);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["prompts"] });
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
showErrorToast: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompts: listQuery.data ?? [],
|
||||||
|
isLoading: listQuery.isLoading,
|
||||||
|
error: listQuery.error,
|
||||||
|
refetch: listQuery.refetch,
|
||||||
|
createPrompt: createMutation.mutateAsync,
|
||||||
|
updatePrompt: updateMutation.mutateAsync,
|
||||||
|
deletePrompt: deleteMutation.mutateAsync,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ import { FileUploadsState } from "../utils/file_uploads_state";
|
|||||||
import { OpenAIResponsesProviderOptions } from "@ai-sdk/openai";
|
import { OpenAIResponsesProviderOptions } from "@ai-sdk/openai";
|
||||||
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
|
import { extractMentionedAppsCodebases } from "../utils/mention_apps";
|
||||||
import { parseAppMentions } from "@/shared/parse_mention_apps";
|
import { parseAppMentions } from "@/shared/parse_mention_apps";
|
||||||
|
import { prompts as promptsTable } from "../../db/schema";
|
||||||
|
import { inArray } from "drizzle-orm";
|
||||||
|
import { replacePromptReference } from "../utils/replacePromptReference";
|
||||||
|
|
||||||
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
|
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
|
||||||
|
|
||||||
@@ -274,6 +277,26 @@ export function registerChatStreamHandlers() {
|
|||||||
|
|
||||||
// Add user message to database with attachment info
|
// Add user message to database with attachment info
|
||||||
let userPrompt = req.prompt + (attachmentInfo ? attachmentInfo : "");
|
let userPrompt = req.prompt + (attachmentInfo ? attachmentInfo : "");
|
||||||
|
// Inline referenced prompt contents for mentions like @prompt:<id>
|
||||||
|
try {
|
||||||
|
const matches = Array.from(userPrompt.matchAll(/@prompt:(\d+)/g));
|
||||||
|
if (matches.length > 0) {
|
||||||
|
const ids = Array.from(new Set(matches.map((m) => Number(m[1]))));
|
||||||
|
const referenced = await db
|
||||||
|
.select()
|
||||||
|
.from(promptsTable)
|
||||||
|
.where(inArray(promptsTable.id, ids));
|
||||||
|
if (referenced.length > 0) {
|
||||||
|
const promptsMap: Record<number, string> = {};
|
||||||
|
for (const p of referenced) {
|
||||||
|
promptsMap[p.id] = p.content;
|
||||||
|
}
|
||||||
|
userPrompt = replacePromptReference(userPrompt, promptsMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to inline referenced prompts:", e);
|
||||||
|
}
|
||||||
if (req.selectedComponent) {
|
if (req.selectedComponent) {
|
||||||
let componentSnippet = "[component snippet not available]";
|
let componentSnippet = "[component snippet not available]";
|
||||||
try {
|
try {
|
||||||
|
|||||||
91
src/ipc/handlers/prompt_handlers.ts
Normal file
91
src/ipc/handlers/prompt_handlers.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { IpcMainInvokeEvent } from "electron";
|
||||||
|
import log from "electron-log";
|
||||||
|
import { createLoggedHandler } from "./safe_handle";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { prompts } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
CreatePromptParamsDto,
|
||||||
|
PromptDto,
|
||||||
|
UpdatePromptParamsDto,
|
||||||
|
} from "../ipc_types";
|
||||||
|
|
||||||
|
const logger = log.scope("prompt_handlers");
|
||||||
|
const handle = createLoggedHandler(logger);
|
||||||
|
|
||||||
|
export function registerPromptHandlers() {
|
||||||
|
handle("prompts:list", async (): Promise<PromptDto[]> => {
|
||||||
|
const rows = db.select().from(prompts).all();
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id!,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description ?? null,
|
||||||
|
content: r.content,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
handle(
|
||||||
|
"prompts:create",
|
||||||
|
async (
|
||||||
|
_e: IpcMainInvokeEvent,
|
||||||
|
params: CreatePromptParamsDto,
|
||||||
|
): Promise<PromptDto> => {
|
||||||
|
const { title, description, content } = params;
|
||||||
|
if (!title || !content) {
|
||||||
|
throw new Error("Title and content are required");
|
||||||
|
}
|
||||||
|
const result = db
|
||||||
|
.insert(prompts)
|
||||||
|
.values({
|
||||||
|
title,
|
||||||
|
description: description ?? null,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const id = Number(result.lastInsertRowid);
|
||||||
|
const row = db.select().from(prompts).where(eq(prompts.id, id)).get();
|
||||||
|
if (!row) throw new Error("Failed to fetch created prompt");
|
||||||
|
return {
|
||||||
|
id: row.id!,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description ?? null,
|
||||||
|
content: row.content,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
handle(
|
||||||
|
"prompts:update",
|
||||||
|
async (
|
||||||
|
_e: IpcMainInvokeEvent,
|
||||||
|
params: UpdatePromptParamsDto,
|
||||||
|
): Promise<void> => {
|
||||||
|
const { id, title, description, content } = params;
|
||||||
|
if (!id) throw new Error("Prompt id is required");
|
||||||
|
if (!title || !content) throw new Error("Title and content are required");
|
||||||
|
const now = new Date();
|
||||||
|
db.update(prompts)
|
||||||
|
.set({
|
||||||
|
title,
|
||||||
|
description: description ?? null,
|
||||||
|
content,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(prompts.id, id))
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
handle(
|
||||||
|
"prompts:delete",
|
||||||
|
async (_e: IpcMainInvokeEvent, id: number): Promise<void> => {
|
||||||
|
if (!id) throw new Error("Prompt id is required");
|
||||||
|
db.delete(prompts).where(eq(prompts.id, id)).run();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,6 +58,9 @@ import type {
|
|||||||
RevertVersionResponse,
|
RevertVersionResponse,
|
||||||
RevertVersionParams,
|
RevertVersionParams,
|
||||||
RespondToAppInputParams,
|
RespondToAppInputParams,
|
||||||
|
PromptDto,
|
||||||
|
CreatePromptParamsDto,
|
||||||
|
UpdatePromptParamsDto,
|
||||||
} from "./ipc_types";
|
} from "./ipc_types";
|
||||||
import type { Template } from "../shared/templates";
|
import type { Template } from "../shared/templates";
|
||||||
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
||||||
@@ -1069,4 +1072,21 @@ export class IpcClient {
|
|||||||
public async getTemplates(): Promise<Template[]> {
|
public async getTemplates(): Promise<Template[]> {
|
||||||
return this.ipcRenderer.invoke("get-templates");
|
return this.ipcRenderer.invoke("get-templates");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Prompts Library ---
|
||||||
|
public async listPrompts(): Promise<PromptDto[]> {
|
||||||
|
return this.ipcRenderer.invoke("prompts:list");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createPrompt(params: CreatePromptParamsDto): Promise<PromptDto> {
|
||||||
|
return this.ipcRenderer.invoke("prompts:create", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updatePrompt(params: UpdatePromptParamsDto): Promise<void> {
|
||||||
|
await this.ipcRenderer.invoke("prompts:update", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePrompt(id: number): Promise<void> {
|
||||||
|
await this.ipcRenderer.invoke("prompts:delete", id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { registerProblemsHandlers } from "./handlers/problems_handlers";
|
|||||||
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
|
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
|
||||||
import { registerTemplateHandlers } from "./handlers/template_handlers";
|
import { registerTemplateHandlers } from "./handlers/template_handlers";
|
||||||
import { registerPortalHandlers } from "./handlers/portal_handlers";
|
import { registerPortalHandlers } from "./handlers/portal_handlers";
|
||||||
|
import { registerPromptHandlers } from "./handlers/prompt_handlers";
|
||||||
|
|
||||||
export function registerIpcHandlers() {
|
export function registerIpcHandlers() {
|
||||||
// Register all IPC handlers by category
|
// Register all IPC handlers by category
|
||||||
@@ -61,4 +62,5 @@ export function registerIpcHandlers() {
|
|||||||
registerAppEnvVarsHandlers();
|
registerAppEnvVarsHandlers();
|
||||||
registerTemplateHandlers();
|
registerTemplateHandlers();
|
||||||
registerPortalHandlers();
|
registerPortalHandlers();
|
||||||
|
registerPromptHandlers();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,6 +353,26 @@ export interface UploadFileToCodebaseResult {
|
|||||||
filePath: string;
|
filePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Prompts ---
|
||||||
|
export interface PromptDto {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
content: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePromptParamsDto {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePromptParamsDto extends CreatePromptParamsDto {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FileAttachment {
|
export interface FileAttachment {
|
||||||
file: File;
|
file: File;
|
||||||
type: "upload-to-codebase" | "chat-context";
|
type: "upload-to-codebase" | "chat-context";
|
||||||
|
|||||||
16
src/ipc/utils/replacePromptReference.ts
Normal file
16
src/ipc/utils/replacePromptReference.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export function replacePromptReference(
|
||||||
|
userPrompt: string,
|
||||||
|
promptsById: Record<number | string, string>,
|
||||||
|
): string {
|
||||||
|
if (typeof userPrompt !== "string" || userPrompt.length === 0)
|
||||||
|
return userPrompt;
|
||||||
|
|
||||||
|
return userPrompt.replace(
|
||||||
|
/@prompt:(\d+)/g,
|
||||||
|
(_match: string, idStr: string) => {
|
||||||
|
const idNum = Number(idStr);
|
||||||
|
const replacement = promptsById[idNum] ?? promptsById[idStr];
|
||||||
|
return replacement !== undefined ? replacement : _match;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/pages/library.tsx
Normal file
97
src/pages/library.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { usePrompts } from "@/hooks/usePrompts";
|
||||||
|
import {
|
||||||
|
CreatePromptDialog,
|
||||||
|
CreateOrEditPromptDialog,
|
||||||
|
} from "@/components/CreatePromptDialog";
|
||||||
|
import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog";
|
||||||
|
|
||||||
|
export default function LibraryPage() {
|
||||||
|
const { prompts, isLoading, createPrompt, updatePrompt, deletePrompt } =
|
||||||
|
usePrompts();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-8 py-6">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-3xl font-bold mr-4">Library: Prompts</h1>
|
||||||
|
<CreatePromptDialog onCreatePrompt={createPrompt} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div>Loading...</div>
|
||||||
|
) : prompts.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
No prompts yet. Create one to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{prompts.map((p) => (
|
||||||
|
<PromptCard
|
||||||
|
key={p.id}
|
||||||
|
prompt={p}
|
||||||
|
onUpdate={updatePrompt}
|
||||||
|
onDelete={deletePrompt}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptCard({
|
||||||
|
prompt,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
prompt: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
onUpdate: (p: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
content: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
onDelete: (id: number) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="prompt-card"
|
||||||
|
className="border rounded-lg p-4 bg-(--background-lightest) min-w-80"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{prompt.title}</h3>
|
||||||
|
{prompt.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{prompt.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<CreateOrEditPromptDialog
|
||||||
|
mode="edit"
|
||||||
|
prompt={prompt}
|
||||||
|
onUpdatePrompt={onUpdate}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
itemName={prompt.title}
|
||||||
|
itemType="Prompt"
|
||||||
|
onDelete={() => onDelete(prompt.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre className="text-sm whitespace-pre-wrap bg-transparent border rounded p-2 max-h-48 overflow-auto">
|
||||||
|
{prompt.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -106,6 +106,11 @@ const validInvokeChannels = [
|
|||||||
"restart-dyad",
|
"restart-dyad",
|
||||||
"get-templates",
|
"get-templates",
|
||||||
"portal:migrate-create",
|
"portal:migrate-create",
|
||||||
|
// Prompts
|
||||||
|
"prompts:list",
|
||||||
|
"prompts:create",
|
||||||
|
"prompts:update",
|
||||||
|
"prompts:delete",
|
||||||
// Test-only channels
|
// Test-only channels
|
||||||
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
|
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
|
||||||
// We can't detect with IS_TEST_BUILD in the preload script because
|
// We can't detect with IS_TEST_BUILD in the preload script because
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { settingsRoute } from "./routes/settings";
|
|||||||
import { providerSettingsRoute } from "./routes/settings/providers/$provider";
|
import { providerSettingsRoute } from "./routes/settings/providers/$provider";
|
||||||
import { appDetailsRoute } from "./routes/app-details";
|
import { appDetailsRoute } from "./routes/app-details";
|
||||||
import { hubRoute } from "./routes/hub";
|
import { hubRoute } from "./routes/hub";
|
||||||
|
import { libraryRoute } from "./routes/library";
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
homeRoute,
|
homeRoute,
|
||||||
hubRoute,
|
hubRoute,
|
||||||
|
libraryRoute,
|
||||||
chatRoute,
|
chatRoute,
|
||||||
appDetailsRoute,
|
appDetailsRoute,
|
||||||
settingsRoute.addChildren([providerSettingsRoute]),
|
settingsRoute.addChildren([providerSettingsRoute]),
|
||||||
|
|||||||
9
src/routes/library.ts
Normal file
9
src/routes/library.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Route } from "@tanstack/react-router";
|
||||||
|
import { rootRoute } from "./root";
|
||||||
|
import LibraryPage from "@/pages/library";
|
||||||
|
|
||||||
|
export const libraryRoute = new Route({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/library",
|
||||||
|
component: LibraryPage,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user