diff --git a/migrations/sqlite-drizzle/0003_tearful_metal_master.sql b/migrations/sqlite-drizzle/0003_tearful_metal_master.sql new file mode 100644 index 00000000000..dad35f911b6 --- /dev/null +++ b/migrations/sqlite-drizzle/0003_tearful_metal_master.sql @@ -0,0 +1,31 @@ +CREATE TABLE `assistant_prompt` ( + `assistant_id` text NOT NULL, + `prompt_id` text NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + PRIMARY KEY(`assistant_id`, `prompt_id`), + FOREIGN KEY (`prompt_id`) REFERENCES `prompt`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `assistant_prompt_assistant_id_idx` ON `assistant_prompt` (`assistant_id`);--> statement-breakpoint +CREATE TABLE `prompt` ( + `id` text PRIMARY KEY NOT NULL, + `title` text NOT NULL, + `content` text NOT NULL, + `current_version` integer DEFAULT 1 NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + `created_at` integer, + `updated_at` integer +); +--> statement-breakpoint +CREATE INDEX `prompt_sort_order_idx` ON `prompt` (`sort_order`);--> statement-breakpoint +CREATE INDEX `prompt_updated_at_idx` ON `prompt` (`updated_at`);--> statement-breakpoint +CREATE TABLE `prompt_version` ( + `id` text PRIMARY KEY NOT NULL, + `prompt_id` text NOT NULL, + `version` integer NOT NULL, + `content` text NOT NULL, + `created_at` integer, + FOREIGN KEY (`prompt_id`) REFERENCES `prompt`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `prompt_version_prompt_id_version_idx` ON `prompt_version` (`prompt_id`,`version`); \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/0003_snapshot.json b/migrations/sqlite-drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000000..8ae6355aaf7 --- /dev/null +++ b/migrations/sqlite-drizzle/meta/0003_snapshot.json @@ -0,0 +1,1120 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "aeef7918-432c-4a31-a0ab-2f3688418c3e", + "prevId": "f283d5d9-7eaa-40ff-8f9f-fbad46e536fd", + "tables": { + "app_state": { + "name": "app_state", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assistant_prompt": { + "name": "assistant_prompt", + "columns": { + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "assistant_prompt_assistant_id_idx": { + "name": "assistant_prompt_assistant_id_idx", + "columns": [ + "assistant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assistant_prompt_prompt_id_prompt_id_fk": { + "name": "assistant_prompt_prompt_id_prompt_id_fk", + "tableFrom": "assistant_prompt", + "tableTo": "prompt", + "columnsFrom": [ + "prompt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "assistant_prompt_assistant_id_prompt_id_pk": { + "columns": [ + "assistant_id", + "prompt_id" + ], + "name": "assistant_prompt_assistant_id_prompt_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "group_entity_sort_idx": { + "name": "group_entity_sort_idx", + "columns": [ + "entity_type", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mcp_server": { + "name": "mcp_server", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registry_url": { + "name": "registry_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "args": { + "name": "args", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_url": { + "name": "provider_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "long_running": { + "name": "long_running", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dxt_version": { + "name": "dxt_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dxt_path": { + "name": "dxt_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_key": { + "name": "search_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "config_sample": { + "name": "config_sample", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled_tools": { + "name": "disabled_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled_auto_approve_tools": { + "name": "disabled_auto_approve_tools", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "should_config": { + "name": "should_config", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "install_source": { + "name": "install_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_trusted": { + "name": "is_trusted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trusted_at": { + "name": "trusted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "mcp_server_name_idx": { + "name": "mcp_server_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "mcp_server_is_active_idx": { + "name": "mcp_server_is_active_idx", + "columns": [ + "is_active" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "mcp_server_type_check": { + "name": "mcp_server_type_check", + "value": "\"mcp_server\".\"type\" IS NULL OR \"mcp_server\".\"type\" IN ('stdio', 'sse', 'streamableHttp', 'inMemory')" + }, + "mcp_server_install_source_check": { + "name": "mcp_server_install_source_check", + "value": "\"mcp_server\".\"install_source\" IS NULL OR \"mcp_server\".\"install_source\" IN ('builtin', 'manual', 'protocol', 'unknown')" + } + } + }, + "message": { + "name": "message", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "searchable_text": { + "name": "searchable_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "siblings_group_id": { + "name": "siblings_group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_meta": { + "name": "model_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "message_parent_id_idx": { + "name": "message_parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "message_topic_created_idx": { + "name": "message_topic_created_idx", + "columns": [ + "topic_id", + "created_at" + ], + "isUnique": false + }, + "message_trace_id_idx": { + "name": "message_trace_id_idx", + "columns": [ + "trace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "message_topic_id_topic_id_fk": { + "name": "message_topic_id_topic_id_fk", + "tableFrom": "message", + "tableTo": "topic", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_parent_id_message_id_fk": { + "name": "message_parent_id_message_id_fk", + "tableFrom": "message", + "tableTo": "message", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "message_role_check": { + "name": "message_role_check", + "value": "\"message\".\"role\" IN ('user', 'assistant', 'system')" + }, + "message_status_check": { + "name": "message_status_check", + "value": "\"message\".\"status\" IN ('pending', 'success', 'error', 'paused')" + } + } + }, + "preference": { + "name": "preference", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "preference_scope_key_pk": { + "columns": [ + "scope", + "key" + ], + "name": "preference_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt": { + "name": "prompt", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "prompt_sort_order_idx": { + "name": "prompt_sort_order_idx", + "columns": [ + "sort_order" + ], + "isUnique": false + }, + "prompt_updated_at_idx": { + "name": "prompt_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "prompt_version": { + "name": "prompt_version", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "prompt_id": { + "name": "prompt_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "prompt_version_prompt_id_version_idx": { + "name": "prompt_version_prompt_id_version_idx", + "columns": [ + "prompt_id", + "version" + ], + "isUnique": true + } + }, + "foreignKeys": { + "prompt_version_prompt_id_prompt_id_fk": { + "name": "prompt_version_prompt_id_prompt_id_fk", + "tableFrom": "prompt_version", + "tableTo": "prompt", + "columnsFrom": [ + "prompt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entity_tag": { + "name": "entity_tag", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "entity_tag_tag_id_idx": { + "name": "entity_tag_tag_id_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "entity_tag_tag_id_tag_id_fk": { + "name": "entity_tag_tag_id_tag_id_fk", + "tableFrom": "entity_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entity_tag_entity_type_entity_id_tag_id_pk": { + "columns": [ + "entity_type", + "entity_id", + "tag_id" + ], + "name": "entity_tag_entity_type_entity_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tag_name_unique": { + "name": "tag_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "topic": { + "name": "topic", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_name_manually_edited": { + "name": "is_name_manually_edited", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_node_id": { + "name": "active_node_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "pinned_order": { + "name": "pinned_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "topic_group_updated_idx": { + "name": "topic_group_updated_idx", + "columns": [ + "group_id", + "updated_at" + ], + "isUnique": false + }, + "topic_group_sort_idx": { + "name": "topic_group_sort_idx", + "columns": [ + "group_id", + "sort_order" + ], + "isUnique": false + }, + "topic_updated_at_idx": { + "name": "topic_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "topic_is_pinned_idx": { + "name": "topic_is_pinned_idx", + "columns": [ + "is_pinned", + "pinned_order" + ], + "isUnique": false + }, + "topic_assistant_id_idx": { + "name": "topic_assistant_id_idx", + "columns": [ + "assistant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "topic_group_id_group_id_fk": { + "name": "topic_group_id_group_id_fk", + "tableFrom": "topic", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index aa570dccf7b..c74ab53924c 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -21,7 +21,14 @@ "when": 1772954746790, "tag": "0002_tired_glorian", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1773894648822, + "tag": "0003_tearful_metal_master", + "breakpoints": true } ], "version": "7" -} +} \ No newline at end of file diff --git a/packages/shared/data/api/schemas/index.ts b/packages/shared/data/api/schemas/index.ts index 703b92ff247..cfc3dbd32ab 100644 --- a/packages/shared/data/api/schemas/index.ts +++ b/packages/shared/data/api/schemas/index.ts @@ -21,6 +21,7 @@ import type { AssertValidSchemas } from '../apiTypes' import type { MessageSchemas } from './messages' +import type { PromptSchemas } from './prompts' import type { TestSchemas } from './test' import type { TopicSchemas } from './topics' @@ -36,4 +37,4 @@ import type { TopicSchemas } from './topics' * 1. Create the schema file (e.g., topic.ts) * 2. Import and add to intersection below */ -export type ApiSchemas = AssertValidSchemas +export type ApiSchemas = AssertValidSchemas diff --git a/packages/shared/data/api/schemas/prompts.ts b/packages/shared/data/api/schemas/prompts.ts new file mode 100644 index 00000000000..3c842754849 --- /dev/null +++ b/packages/shared/data/api/schemas/prompts.ts @@ -0,0 +1,123 @@ +/** + * Prompt API Schema definitions + * + * Contains all prompt-related endpoints for CRUD, version management, and reordering. + */ + +import type { Prompt, PromptVersion } from '@shared/data/types/prompt' +import * as z from 'zod' + +// ============================================================================ +// Zod Schemas for DTOs +// ============================================================================ + +export const CreatePromptDtoSchema = z.object({ + title: z.string().min(1), + content: z.string().min(1), + assistantId: z.string().optional() +}) + +export const UpdatePromptDtoSchema = z.object({ + title: z.string().min(1).optional(), + content: z.string().min(1).optional() +}) + +export const ReorderPromptsDtoSchema = z.object({ + assistantId: z.string().optional(), + items: z.array( + z.object({ + id: z.string(), + sortOrder: z.number().int().min(0) + }) + ) +}) + +export const RollbackPromptDtoSchema = z.object({ + version: z.number().int().min(1) +}) + +// ============================================================================ +// DTO Types (inferred from Zod schemas) +// ============================================================================ + +export type CreatePromptDto = z.infer +export type UpdatePromptDto = z.infer +export type ReorderPromptsDto = z.infer +export type RollbackPromptDto = z.infer + +// ============================================================================ +// API Schema Definitions +// ============================================================================ + +export interface PromptQueryParams { + /** + * Filter by prompt scope when assistantId is not provided. + * - 'all': returns all prompts regardless of association + * - 'global': returns only prompts not linked to any assistant + * - undefined (default): behaves the same as 'all' + * + * When both assistantId and scope are provided, assistantId takes precedence. + */ + scope?: 'all' | 'global' + /** Filter by assistant ID: returns only prompts linked to this assistant */ + assistantId?: string +} + +export interface PromptSchemas { + '/prompts': { + /** Get prompts. Use query params to filter by scope or assistantId. */ + GET: { + query?: PromptQueryParams + response: Prompt[] + } + /** Create a new prompt */ + POST: { + body: CreatePromptDto + response: Prompt + } + } + + '/prompts/reorder': { + /** Batch update sort order */ + PATCH: { + body: ReorderPromptsDto + response: void + } + } + + '/prompts/:id': { + /** Get a prompt by ID */ + GET: { + params: { id: string } + response: Prompt + } + /** Update a prompt (auto-creates version if content changed) */ + PATCH: { + params: { id: string } + body: UpdatePromptDto + response: Prompt + } + /** Delete a prompt and all its versions */ + DELETE: { + params: { id: string } + response: void + } + } + + '/prompts/:id/versions': { + /** Get version history for a prompt */ + GET: { + params: { id: string } + response: PromptVersion[] + } + } + + '/prompts/:id/rollback': { + /** Rollback to a previous version */ + POST: { + params: { id: string } + body: RollbackPromptDto + response: Prompt + } + } +} diff --git a/packages/shared/data/types/prompt.ts b/packages/shared/data/types/prompt.ts new file mode 100644 index 00000000000..60d9afe747e --- /dev/null +++ b/packages/shared/data/types/prompt.ts @@ -0,0 +1,38 @@ +/** + * Prompt entity types + * + * Prompts are user-managed prompt templates with version history. + * Replaces the legacy QuickPhrase system. + * Template variables use ${var} syntax in content and are filled inline by the user. + */ + +import * as z from 'zod' + +// ============================================================================ +// Zod Schemas +// ============================================================================ + +export const PromptSchema = z.object({ + id: z.string().uuid(), + title: z.string(), + content: z.string(), + currentVersion: z.number().int().min(1), + sortOrder: z.number().int().min(0), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime() +}) + +export const PromptVersionSchema = z.object({ + id: z.string().uuid(), + promptId: z.string().uuid(), + version: z.number().int().min(1), + content: z.string(), + createdAt: z.iso.datetime() +}) + +// ============================================================================ +// Types (inferred from Zod schemas) +// ============================================================================ + +export type Prompt = z.infer +export type PromptVersion = z.infer diff --git a/src/main/data/api/handlers/index.ts b/src/main/data/api/handlers/index.ts index 87072fdfc00..4eeb5e9bbfd 100644 --- a/src/main/data/api/handlers/index.ts +++ b/src/main/data/api/handlers/index.ts @@ -13,6 +13,7 @@ import type { ApiImplementation } from '@shared/data/api/apiTypes' import { messageHandlers } from './messages' +import { promptHandlers } from './prompts' import { testHandlers } from './test' import { topicHandlers } from './topics' @@ -26,5 +27,6 @@ import { topicHandlers } from './topics' export const apiHandlers: ApiImplementation = { ...testHandlers, ...topicHandlers, - ...messageHandlers + ...messageHandlers, + ...promptHandlers } diff --git a/src/main/data/api/handlers/prompts.ts b/src/main/data/api/handlers/prompts.ts new file mode 100644 index 00000000000..f9d5a7c718e --- /dev/null +++ b/src/main/data/api/handlers/prompts.ts @@ -0,0 +1,81 @@ +/** + * Prompt API Handlers + * + * Implements all prompt-related API endpoints including: + * - Prompt CRUD operations + * - Version history and rollback + * - Batch reordering + */ + +import { promptService } from '@data/services/PromptService' +import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes' +import { + CreatePromptDtoSchema, + type PromptQueryParams, + type PromptSchemas, + ReorderPromptsDtoSchema, + RollbackPromptDtoSchema, + UpdatePromptDtoSchema +} from '@shared/data/api/schemas/prompts' + +type PromptHandler> = ApiHandler + +export const promptHandlers: { + [Path in keyof PromptSchemas]: { + [Method in keyof PromptSchemas[Path]]: PromptHandler> + } +} = { + '/prompts': { + GET: ({ query }) => { + const q = (query || {}) as PromptQueryParams + + // assistant-specific queries are more specific than scope filters + if (q.assistantId) { + return promptService.getForAssistant(q.assistantId) + } + + if (q.scope === 'global') { + return promptService.getGlobal() + } + + // 'all' and undefined both return the full prompt list + return promptService.getAll() + }, + + POST: ({ body }) => { + return promptService.create(CreatePromptDtoSchema.parse(body)) + } + }, + + '/prompts/reorder': { + PATCH: ({ body }) => { + return promptService.reorder(ReorderPromptsDtoSchema.parse(body)) + } + }, + + '/prompts/:id': { + GET: ({ params }) => { + return promptService.getById(params.id) + }, + + PATCH: ({ params, body }) => { + return promptService.update(params.id, UpdatePromptDtoSchema.parse(body)) + }, + + DELETE: ({ params }) => { + return promptService.delete(params.id) + } + }, + + '/prompts/:id/versions': { + GET: ({ params }) => { + return promptService.getVersions(params.id) + } + }, + + '/prompts/:id/rollback': { + POST: ({ params, body }) => { + return promptService.rollback(params.id, RollbackPromptDtoSchema.parse(body)) + } + } +} diff --git a/src/main/data/db/schemas/assistantPrompt.ts b/src/main/data/db/schemas/assistantPrompt.ts new file mode 100644 index 00000000000..5e4d9eb022a --- /dev/null +++ b/src/main/data/db/schemas/assistantPrompt.ts @@ -0,0 +1,27 @@ +import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +import { promptTable } from './prompt' + +/** + * Assistant Prompt relation table + * + * Maps prompts to specific assistants. + * Global prompts have no entry in this table. + */ +export const assistantPromptTable = sqliteTable( + 'assistant_prompt', + { + // FK to assistant (handled in Redux/Main for now, no hard constraint) + assistantId: text('assistant_id').notNull(), + // FK to prompt - CASCADE: remove mapping if prompt is deleted + promptId: text('prompt_id') + .notNull() + .references(() => promptTable.id, { onDelete: 'cascade' }), + // Sort order within this assistant + sortOrder: integer('sort_order').notNull().default(0) + }, + (t) => [ + primaryKey({ columns: [t.assistantId, t.promptId] }), + index('assistant_prompt_assistant_id_idx').on(t.assistantId) + ] +) diff --git a/src/main/data/db/schemas/prompt.ts b/src/main/data/db/schemas/prompt.ts new file mode 100644 index 00000000000..88bc6a4b523 --- /dev/null +++ b/src/main/data/db/schemas/prompt.ts @@ -0,0 +1,50 @@ +import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core' + +import { createUpdateTimestamps, uuidPrimaryKeyOrdered } from './_columnHelpers' + +/** + * Prompt table - stores user prompt templates (replaces legacy QuickPhrase) + * + * Template variables use ${var} syntax in content and are filled inline by the user. + */ +export const promptTable = sqliteTable( + 'prompt', + { + id: uuidPrimaryKeyOrdered(), + title: text().notNull(), + // Denormalized cache of the active prompt content for fast current-state reads. + // The source of truth for version history remains prompt_version. + content: text().notNull(), + // Current active version number + currentVersion: integer().notNull().default(1), + // Sort order + sortOrder: integer().notNull().default(0), + + ...createUpdateTimestamps + }, + (t) => [index('prompt_sort_order_idx').on(t.sortOrder), index('prompt_updated_at_idx').on(t.updatedAt)] +) + +/** + * Prompt version table - stores version snapshots + * + * A new version is created automatically when content changes. + * Rollback creates a new version with the target version's content. + */ +export const promptVersionTable = sqliteTable( + 'prompt_version', + { + id: uuidPrimaryKeyOrdered(), + // FK to prompt - CASCADE: delete versions when prompt is deleted + promptId: text() + .notNull() + .references(() => promptTable.id, { onDelete: 'cascade' }), + // Version number (1, 2, 3...) + version: integer().notNull(), + // Snapshot of content at this version + content: text().notNull(), + + createdAt: integer().$defaultFn(() => Date.now()) + }, + (t) => [uniqueIndex('prompt_version_prompt_id_version_idx').on(t.promptId, t.version)] +) diff --git a/src/main/data/migration/v2/migrators/PromptMigrator.ts b/src/main/data/migration/v2/migrators/PromptMigrator.ts new file mode 100644 index 00000000000..c284a4ec3ad --- /dev/null +++ b/src/main/data/migration/v2/migrators/PromptMigrator.ts @@ -0,0 +1,210 @@ +/** + * Prompt migrator - migrates quick phrases from Dexie to SQLite prompt table + * + * Data sources: + * - Dexie quick_phrases table + * Target tables: + * - prompt (with auto-generated v1 version in prompt_version) + * + * Mapping: + * QuickPhrase.id → prompt.id + * QuickPhrase.title → prompt.title + * QuickPhrase.content → prompt.content (${var} syntax preserved) + * QuickPhrase.order → prompt.sortOrder + * QuickPhrase.createdAt → prompt.createdAt + * QuickPhrase.updatedAt → prompt.updatedAt + * (default) → prompt.currentVersion = 1 + */ + +import { promptTable, promptVersionTable } from '@data/db/schemas/prompt' +import { loggerService } from '@logger' +import type { ExecuteResult, PrepareResult, ValidateResult, ValidationError } from '@shared/data/migration/v2/types' +import { sql } from 'drizzle-orm' + +import type { MigrationContext } from '../core/MigrationContext' +import { BaseMigrator } from './BaseMigrator' + +const logger = loggerService.withContext('PromptMigrator') + +/** Legacy QuickPhrase shape from Dexie */ +interface LegacyQuickPhrase { + id: string + title: string + content: string + createdAt: number + updatedAt: number + order?: number +} + +export class PromptMigrator extends BaseMigrator { + readonly id = 'prompt' + readonly name = 'Prompts' + readonly description = 'Migrate quick phrases to prompts' + readonly order = 5 + + private promptCount = 0 + private skippedCount = 0 + private preparedPhrases: LegacyQuickPhrase[] = [] + + async prepare(ctx: MigrationContext): Promise { + try { + const exists = await ctx.sources.dexieExport.tableExists('quick_phrases') + if (!exists) { + logger.info('quick_phrases table not found, skipping') + return { + success: true, + itemCount: 0, + warnings: ['quick_phrases table not found - skipping'] + } + } + + const phrases = await ctx.sources.dexieExport.readTable('quick_phrases') + this.preparedPhrases = phrases.filter((p) => p.id && p.content) + this.skippedCount = phrases.length - this.preparedPhrases.length + this.promptCount = this.preparedPhrases.length + + if (this.skippedCount > 0) { + logger.warn('Skipped invalid quick phrases', { skipped: this.skippedCount }) + } + + logger.info('Prepared prompt migration', { count: this.promptCount, skipped: this.skippedCount }) + + return { + success: true, + itemCount: this.promptCount, + warnings: this.skippedCount > 0 ? [`Skipped ${this.skippedCount} invalid quick phrases`] : undefined + } + } catch (error) { + logger.error('Prepare failed', error as Error) + return { + success: false, + itemCount: 0, + warnings: [error instanceof Error ? error.message : String(error)] + } + } + } + + async execute(ctx: MigrationContext): Promise { + if (this.promptCount === 0) { + return { success: true, processedCount: 0 } + } + + let processedCount = 0 + + try { + const db = ctx.db + + await db.transaction(async (tx) => { + for (let i = 0; i < this.preparedPhrases.length; i++) { + const phrase = this.preparedPhrases[i] + + // Insert prompt + await tx.insert(promptTable).values({ + id: phrase.id, + title: phrase.title || 'Untitled', + content: phrase.content, + currentVersion: 1, + sortOrder: phrase.order ?? i, + createdAt: phrase.createdAt, + updatedAt: phrase.updatedAt + }) + + // Create v1 version snapshot + await tx.insert(promptVersionTable).values({ + promptId: phrase.id, + version: 1, + content: phrase.content, + createdAt: phrase.createdAt + }) + + processedCount++ + + // Report progress every 10 items + if (processedCount % 10 === 0 || processedCount === this.promptCount) { + this.reportProgress( + Math.round((processedCount / this.promptCount) * 100), + `Migrated ${processedCount}/${this.promptCount} prompts` + ) + } + } + }) + + logger.info('Prompt migration completed', { processedCount }) + return { success: true, processedCount } + } catch (error) { + logger.error('Execute failed', error as Error) + return { + success: false, + processedCount, + error: error instanceof Error ? error.message : String(error) + } + } + } + + async validate(ctx: MigrationContext): Promise { + const errors: ValidationError[] = [] + const db = ctx.db + + try { + // Count prompts in target + const promptResult = await db.select({ count: sql`count(*)` }).from(promptTable).get() + const targetCount = promptResult?.count ?? 0 + + // Count versions in target + const versionResult = await db.select({ count: sql`count(*)` }).from(promptVersionTable).get() + const targetVersionCount = versionResult?.count ?? 0 + + logger.info('Validation counts', { + sourceCount: this.promptCount, + targetPromptCount: targetCount, + targetVersionCount, + skippedCount: this.skippedCount + }) + + // promptCount is already the filtered (valid) count + if (targetCount < this.promptCount) { + errors.push({ + key: 'prompt_count_mismatch', + expected: this.promptCount, + actual: targetCount, + message: `Expected at least ${this.promptCount} prompts, got ${targetCount}` + }) + } + + // Each prompt should have exactly one version (v1) + if (targetVersionCount < targetCount) { + errors.push({ + key: 'version_count_mismatch', + expected: targetCount, + actual: targetVersionCount, + message: `Expected at least ${targetCount} versions, got ${targetVersionCount}` + }) + } + + return { + success: errors.length === 0, + errors, + stats: { + sourceCount: this.promptCount, + targetCount, + skippedCount: this.skippedCount + } + } + } catch (error) { + logger.error('Validation failed', error as Error) + errors.push({ + key: 'validation_error', + message: error instanceof Error ? error.message : String(error) + }) + return { + success: false, + errors, + stats: { + sourceCount: this.promptCount, + targetCount: 0, + skippedCount: this.skippedCount + } + } + } + } +} diff --git a/src/main/data/migration/v2/migrators/__tests__/PromptMigrator.test.ts b/src/main/data/migration/v2/migrators/__tests__/PromptMigrator.test.ts new file mode 100644 index 00000000000..456a1cfb47d --- /dev/null +++ b/src/main/data/migration/v2/migrators/__tests__/PromptMigrator.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { MigrationContext } from '../../core/MigrationContext' +import { PromptMigrator } from '../PromptMigrator' + +/** Helper: build a minimal MigrationContext mock */ +function createMockContext( + overrides: { tableExists?: boolean; tableData?: unknown[]; promptCount?: number; versionCount?: number } = {} +): MigrationContext { + const { tableExists = true, tableData = [], promptCount = 0, versionCount = 0 } = overrides + + const insertFn = vi.fn().mockImplementation(() => ({ + values: vi.fn().mockResolvedValue(undefined) + })) + + // validate() calls select().from(promptTable).get() then select().from(promptVersionTable).get() + let selectCallIndex = 0 + const counts = [promptCount, versionCount] + const selectFn = vi.fn().mockImplementation(() => ({ + from: vi.fn().mockImplementation(() => ({ + get: vi.fn().mockImplementation(() => { + const count = counts[selectCallIndex] ?? 0 + selectCallIndex++ + return Promise.resolve({ count }) + }) + })) + })) + + const txProxy = new Proxy( + { insert: insertFn }, + { + get(_target, prop) { + if (prop === 'insert') return insertFn + return undefined + } + } + ) + + const db = { + transaction: vi.fn().mockImplementation(async (fn: (tx: unknown) => Promise) => { + await fn(txProxy) + }), + select: selectFn + } + + return { + sources: { + electronStore: { get: vi.fn() }, + reduxState: { + getCategory: vi.fn(), + getAllCategories: vi.fn() + } as unknown as MigrationContext['sources']['reduxState'], + dexieExport: { + tableExists: vi.fn().mockResolvedValue(tableExists), + readTable: vi.fn().mockResolvedValue(tableData), + getExportPath: vi.fn().mockReturnValue('/tmp/export'), + createStreamReader: vi.fn(), + getTableFileSize: vi.fn() + } as unknown as MigrationContext['sources']['dexieExport'], + dexieSettings: { + get: vi.fn(), + getAll: vi.fn() + } as unknown as MigrationContext['sources']['dexieSettings'], + localStorage: { + get: vi.fn(), + getAll: vi.fn() + } as unknown as MigrationContext['sources']['localStorage'] + }, + db: db as unknown as MigrationContext['db'], + sharedData: new Map(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + } as unknown as MigrationContext['logger'] + } +} + +/** Helper: build a legacy QuickPhrase record */ +function makePhrase(overrides: Record = {}) { + return { + id: 'phrase-1', + title: 'Hello', + content: 'Hello ${name}!', + createdAt: 1700000000000, + updatedAt: 1700000000000, + order: 0, + ...overrides + } +} + +// ─── Tests ─────────────────────────────────────────────────────────── + +describe('PromptMigrator', () => { + describe('metadata', () => { + it('should have correct metadata', () => { + const migrator = new PromptMigrator() + expect(migrator.id).toBe('prompt') + expect(migrator.name).toBe('Prompts') + expect(migrator.order).toBe(5) + }) + }) + + // ── prepare ────────────────────────────────────────────────────── + + describe('prepare', () => { + it('should return success with 0 items when table does not exist', async () => { + const ctx = createMockContext({ tableExists: false }) + const migrator = new PromptMigrator() + + const result = await migrator.prepare(ctx) + + expect(result.success).toBe(true) + expect(result.itemCount).toBe(0) + expect(result.warnings).toContain('quick_phrases table not found - skipping') + }) + + it('should count valid phrases and skip invalid ones', async () => { + const ctx = createMockContext({ + tableData: [ + makePhrase({ id: 'a', content: 'valid' }), + makePhrase({ id: '', content: 'missing id' }), // invalid: empty id + makePhrase({ id: 'b', content: '' }), // invalid: empty content + makePhrase({ id: 'c', content: 'also valid' }) + ] + }) + const migrator = new PromptMigrator() + + const result = await migrator.prepare(ctx) + + expect(result.success).toBe(true) + expect(result.itemCount).toBe(2) + expect(result.warnings?.[0]).toMatch(/Skipped 2/) + }) + + it('should handle empty table', async () => { + const ctx = createMockContext({ tableData: [] }) + const migrator = new PromptMigrator() + + const result = await migrator.prepare(ctx) + + expect(result.success).toBe(true) + expect(result.itemCount).toBe(0) + }) + + it('should return failure when dexie read throws', async () => { + const ctx = createMockContext() + ;(ctx.sources.dexieExport.tableExists as ReturnType).mockRejectedValue(new Error('read error')) + const migrator = new PromptMigrator() + + const result = await migrator.prepare(ctx) + + expect(result.success).toBe(false) + expect(result.warnings).toContain('read error') + }) + }) + + // ── execute ────────────────────────────────────────────────────── + + describe('execute', () => { + it('should return immediately when no phrases prepared', async () => { + const ctx = createMockContext({ tableExists: false }) + const migrator = new PromptMigrator() + await migrator.prepare(ctx) + + const result = await migrator.execute(ctx) + + expect(result.success).toBe(true) + expect(result.processedCount).toBe(0) + expect(ctx.db.transaction).not.toHaveBeenCalled() + }) + + it('should insert prompt and version for each valid phrase', async () => { + const phrases = [ + makePhrase({ id: 'p1', title: 'First', content: 'c1', order: 0 }), + makePhrase({ id: 'p2', title: 'Second', content: 'c2', order: 1 }) + ] + const ctx = createMockContext({ tableData: phrases }) + const migrator = new PromptMigrator() + await migrator.prepare(ctx) + + const result = await migrator.execute(ctx) + + expect(result.success).toBe(true) + expect(result.processedCount).toBe(2) + // transaction should be called once (all inserts in one tx) + expect(ctx.db.transaction).toHaveBeenCalledTimes(1) + }) + + it('should default title to Untitled when missing', async () => { + const phrases = [makePhrase({ id: 'p1', title: '', content: 'c1' })] + const ctx = createMockContext({ tableData: phrases }) + const migrator = new PromptMigrator() + await migrator.prepare(ctx) + + // Capture insert calls + const insertCalls: unknown[] = [] + const mockInsert = vi.fn().mockImplementation(() => ({ + values: vi.fn().mockImplementation((val: unknown) => { + insertCalls.push(val) + return Promise.resolve() + }) + })) + + ;(ctx.db.transaction as ReturnType).mockImplementation( + async (fn: (tx: unknown) => Promise) => { + await fn({ insert: mockInsert }) + } + ) + + await migrator.execute(ctx) + + // First insert call is the prompt row + const promptRow = insertCalls[0] as Record + expect(promptRow.title).toBe('Untitled') + }) + + it('should report progress', async () => { + const phrases = Array.from({ length: 15 }, (_, i) => makePhrase({ id: `p${i}`, content: `c${i}` })) + const ctx = createMockContext({ tableData: phrases }) + const migrator = new PromptMigrator() + const progressFn = vi.fn() + migrator.setProgressCallback(progressFn) + await migrator.prepare(ctx) + + await migrator.execute(ctx) + + // Progress reported at 10 and 15 + expect(progressFn).toHaveBeenCalled() + }) + + it('should return failure when transaction throws', async () => { + const phrases = [makePhrase({ id: 'p1', content: 'c1' })] + const ctx = createMockContext({ tableData: phrases }) + const migrator = new PromptMigrator() + await migrator.prepare(ctx) + + ;(ctx.db.transaction as ReturnType).mockRejectedValue(new Error('db error')) + + const result = await migrator.execute(ctx) + + expect(result.success).toBe(false) + expect(result.error).toBe('db error') + }) + }) + + // ── validate ───────────────────────────────────────────────────── + + describe('validate', () => { + it('should succeed when counts match', async () => { + const phrases = [makePhrase({ id: 'p1', content: 'c1' })] + const ctx = createMockContext({ + tableData: phrases, + promptCount: 1, + versionCount: 1 + }) + const migrator = new PromptMigrator() + await migrator.prepare(ctx) + + const result = await migrator.validate(ctx) + + expect(result.success).toBe(true) + expect(result.errors).toHaveLength(0) + expect(result.stats.sourceCount).toBe(1) + expect(result.stats.targetCount).toBe(1) + }) + + it('should report error when prompt count is less than expected', async () => { + const phrases = [makePhrase({ id: 'p1', content: 'c1' }), makePhrase({ id: 'p2', content: 'c2' })] + const ctx = createMockContext({ + tableData: phrases, + promptCount: 1, // less than source + versionCount: 1 + }) + const migrator = new PromptMigrator() + await migrator.prepare(ctx) + + const result = await migrator.validate(ctx) + + expect(result.success).toBe(false) + expect(result.errors.some((e) => e.key === 'prompt_count_mismatch')).toBe(true) + }) + + it('should report error when version count is less than prompt count', async () => { + const phrases = [makePhrase({ id: 'p1', content: 'c1' })] + const ctx = createMockContext({ + tableData: phrases, + promptCount: 1, + versionCount: 0 // no versions + }) + const migrator = new PromptMigrator() + await migrator.prepare(ctx) + + const result = await migrator.validate(ctx) + + expect(result.success).toBe(false) + expect(result.errors.some((e) => e.key === 'version_count_mismatch')).toBe(true) + }) + + it('should handle db query failure gracefully', async () => { + const phrases = [makePhrase({ id: 'p1', content: 'c1' })] + const ctx = createMockContext({ tableData: phrases }) + const migrator = new PromptMigrator() + await migrator.prepare(ctx) + + ;(ctx.db as unknown as { select: ReturnType }).select.mockImplementation(() => { + throw new Error('query failed') + }) + + const result = await migrator.validate(ctx) + + expect(result.success).toBe(false) + expect(result.errors.some((e) => e.key === 'validation_error')).toBe(true) + }) + + it('should track skipped count in stats', async () => { + const phrases = [ + makePhrase({ id: 'p1', content: 'valid' }), + makePhrase({ id: '', content: 'invalid' }) // skipped + ] + const ctx = createMockContext({ + tableData: phrases, + promptCount: 1, + versionCount: 1 + }) + const migrator = new PromptMigrator() + await migrator.prepare(ctx) + + const result = await migrator.validate(ctx) + + expect(result.stats.skippedCount).toBe(1) + }) + }) +}) diff --git a/src/main/data/migration/v2/migrators/index.ts b/src/main/data/migration/v2/migrators/index.ts index 48b6f3df132..dd8c81cc506 100644 --- a/src/main/data/migration/v2/migrators/index.ts +++ b/src/main/data/migration/v2/migrators/index.ts @@ -10,9 +10,10 @@ import { ChatMigrator } from './ChatMigrator' import { KnowledgeMigrator } from './KnowledgeMigrator' import { McpServerMigrator } from './McpServerMigrator' import { PreferencesMigrator } from './PreferencesMigrator' +import { PromptMigrator } from './PromptMigrator' // Export migrator classes -export { AssistantMigrator, ChatMigrator, KnowledgeMigrator, McpServerMigrator, PreferencesMigrator } +export { AssistantMigrator, ChatMigrator, KnowledgeMigrator, McpServerMigrator, PreferencesMigrator, PromptMigrator } /** * Get all registered migrators in execution order @@ -23,6 +24,7 @@ export function getAllMigrators() { new McpServerMigrator(), new AssistantMigrator(), new KnowledgeMigrator(), - new ChatMigrator() + new ChatMigrator(), + new PromptMigrator() ] } diff --git a/src/main/data/services/PromptService.ts b/src/main/data/services/PromptService.ts new file mode 100644 index 00000000000..00c2b0c597c --- /dev/null +++ b/src/main/data/services/PromptService.ts @@ -0,0 +1,317 @@ +/** + * Prompt Service - handles prompt CRUD and version management + * + * Provides business logic for: + * - Prompt CRUD operations + * - Automatic version creation on content changes + * - Version history and rollback + */ + +import { dbService } from '@data/db/DbService' +import { assistantPromptTable } from '@data/db/schemas/assistantPrompt' +import { promptTable, promptVersionTable } from '@data/db/schemas/prompt' +import { loggerService } from '@logger' +import { DataApiErrorFactory } from '@shared/data/api' +import type { + CreatePromptDto, + ReorderPromptsDto, + RollbackPromptDto, + UpdatePromptDto +} from '@shared/data/api/schemas/prompts' +import type { Prompt, PromptVersion } from '@shared/data/types/prompt' +import { and, desc, eq, notExists } from 'drizzle-orm' + +const logger = loggerService.withContext('DataApi:PromptService') + +/** + * Convert database row to Prompt entity + */ +function rowToPrompt(row: typeof promptTable.$inferSelect): Prompt { + return { + id: row.id, + title: row.title, + content: row.content, + currentVersion: row.currentVersion, + sortOrder: row.sortOrder, + createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : new Date().toISOString(), + updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : new Date().toISOString() + } +} + +/** + * Convert database row to PromptVersion entity + */ +function rowToVersion(row: typeof promptVersionTable.$inferSelect): PromptVersion { + return { + id: row.id, + promptId: row.promptId, + version: row.version, + content: row.content, + createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : new Date().toISOString() + } +} + +export class PromptService { + private static instance: PromptService + + private constructor() {} + + public static getInstance(): PromptService { + if (!PromptService.instance) { + PromptService.instance = new PromptService() + } + return PromptService.instance + } + + /** + * Get all prompts, ordered by sortOrder + */ + async getAll(): Promise { + const db = dbService.getDb() + const rows = await db.select().from(promptTable).orderBy(promptTable.sortOrder) + return rows.map(rowToPrompt) + } + + /** + * Get all global prompts (not associated with any assistant) + */ + async getGlobal(): Promise { + const db = dbService.getDb() + const rows = await db + .select() + .from(promptTable) + .where(notExists(db.select().from(assistantPromptTable).where(eq(assistantPromptTable.promptId, promptTable.id)))) + .orderBy(promptTable.sortOrder) + return rows.map(rowToPrompt) + } + + /** + * Get all prompts for a specific assistant + */ + async getForAssistant(assistantId: string): Promise { + const db = dbService.getDb() + const rows = await db + .select({ + id: promptTable.id, + title: promptTable.title, + content: promptTable.content, + currentVersion: promptTable.currentVersion, + sortOrder: assistantPromptTable.sortOrder, + createdAt: promptTable.createdAt, + updatedAt: promptTable.updatedAt + }) + .from(promptTable) + .innerJoin(assistantPromptTable, eq(promptTable.id, assistantPromptTable.promptId)) + .where(eq(assistantPromptTable.assistantId, assistantId)) + .orderBy(assistantPromptTable.sortOrder) + + return rows.map(rowToPrompt) + } + + /** + * Get a prompt by ID + */ + async getById(id: string): Promise { + const db = dbService.getDb() + const [row] = await db.select().from(promptTable).where(eq(promptTable.id, id)).limit(1) + + if (!row) { + throw DataApiErrorFactory.notFound('Prompt', id) + } + + return rowToPrompt(row) + } + + /** + * Create a new prompt with initial version + */ + async create(dto: CreatePromptDto): Promise { + const db = dbService.getDb() + + return db.transaction(async (tx) => { + const [row] = await tx + .insert(promptTable) + .values({ + title: dto.title, + content: dto.content, + currentVersion: 1 + }) + .returning() + + // Create v1 version snapshot + await tx.insert(promptVersionTable).values({ + promptId: row.id, + version: 1, + content: dto.content + }) + + // If associated with an assistant, create mapping + if (dto.assistantId) { + await tx.insert(assistantPromptTable).values({ + assistantId: dto.assistantId, + promptId: row.id + }) + } + + logger.info('Created prompt', { id: row.id, title: dto.title, assistantId: dto.assistantId }) + + return rowToPrompt(row) + }) + } + + /** + * Update a prompt. Auto-creates a new version if content changed. + */ + async update(id: string, dto: UpdatePromptDto): Promise { + const db = dbService.getDb() + + return db.transaction(async (tx) => { + // Read inside transaction to prevent race conditions on currentVersion + const [existing] = await tx.select().from(promptTable).where(eq(promptTable.id, id)).limit(1) + if (!existing) { + throw DataApiErrorFactory.notFound('Prompt', id) + } + + const updates: Partial = {} + if (dto.title !== undefined) updates.title = dto.title + if (dto.content !== undefined) updates.content = dto.content + + // Check if content changed — if so, create a new version + const contentChanged = dto.content !== undefined && dto.content !== existing.content + + if (contentChanged) { + const newVersion = existing.currentVersion + 1 + updates.currentVersion = newVersion + + await tx.insert(promptVersionTable).values({ + promptId: id, + version: newVersion, + content: dto.content! + }) + + logger.info('Created prompt version', { id, version: newVersion }) + } + + const [row] = await tx.update(promptTable).set(updates).where(eq(promptTable.id, id)).returning() + + logger.info('Updated prompt', { id, changes: Object.keys(dto) }) + + return rowToPrompt(row) + }) + } + + /** + * Delete a prompt (versions are cascade deleted) + */ + async delete(id: string): Promise { + const db = dbService.getDb() + + const result = await db.delete(promptTable).where(eq(promptTable.id, id)) + + if (result.rowsAffected === 0) { + throw DataApiErrorFactory.notFound('Prompt', id) + } + + logger.info('Deleted prompt', { id }) + } + + /** + * Batch update sort order + */ + async reorder(dto: ReorderPromptsDto): Promise { + const db = dbService.getDb() + + await db.transaction(async (tx) => { + for (const item of dto.items) { + if (dto.assistantId) { + await tx + .update(assistantPromptTable) + .set({ sortOrder: item.sortOrder }) + .where( + and(eq(assistantPromptTable.assistantId, dto.assistantId), eq(assistantPromptTable.promptId, item.id)) + ) + } else { + await tx.update(promptTable).set({ sortOrder: item.sortOrder }).where(eq(promptTable.id, item.id)) + } + } + }) + + logger.info('Reordered prompts', { count: dto.items.length, assistantId: dto.assistantId }) + } + + /** + * Get version history for a prompt + */ + async getVersions(promptId: string): Promise { + const db = dbService.getDb() + + return db.transaction(async (tx) => { + const [prompt] = await tx.select().from(promptTable).where(eq(promptTable.id, promptId)).limit(1) + if (!prompt) { + throw DataApiErrorFactory.notFound('Prompt', promptId) + } + + const rows = await tx + .select() + .from(promptVersionTable) + .where(eq(promptVersionTable.promptId, promptId)) + .orderBy(desc(promptVersionTable.version)) + + return rows.map(rowToVersion) + }) + } + + /** + * Rollback to a previous version. + * Creates a new version with the target version's content. + */ + async rollback(promptId: string, dto: RollbackPromptDto): Promise { + const db = dbService.getDb() + + return db.transaction(async (tx) => { + // Read inside transaction to prevent race conditions on currentVersion + const [existing] = await tx.select().from(promptTable).where(eq(promptTable.id, promptId)).limit(1) + if (!existing) { + throw DataApiErrorFactory.notFound('Prompt', promptId) + } + + // Find the target version + const versions = await tx.select().from(promptVersionTable).where(eq(promptVersionTable.promptId, promptId)) + + const targetVersion = versions.find((v) => v.version === dto.version) + if (!targetVersion) { + throw DataApiErrorFactory.notFound('PromptVersion', `${promptId}@v${dto.version}`) + } + + // Create a new version with the target's content + const newVersion = existing.currentVersion + 1 + + await tx.insert(promptVersionTable).values({ + promptId, + version: newVersion, + content: targetVersion.content + }) + + // Update prompt to the rolled-back content + const [row] = await tx + .update(promptTable) + .set({ + content: targetVersion.content, + currentVersion: newVersion + }) + .where(eq(promptTable.id, promptId)) + .returning() + + logger.info('Rolled back prompt', { + id: promptId, + fromVersion: existing.currentVersion, + toVersion: dto.version, + newVersion + }) + + return rowToPrompt(row) + }) + } +} + +export const promptService = PromptService.getInstance() diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 35ee8b2000b..b043bc29438 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4889,6 +4889,24 @@ "enable_privacy_mode": "Anonymous reporting of errors and statistics", "title": "Privacy Settings" }, + "prompts": { + "add": "Add Prompt", + "assistant": "Assistant Prompts", + "contentLabel": "Content", + "contentPlaceholder": "Enter prompt content. Use ${variable} for template variables.\nExample: Help me plan a route from ${from} to ${to}, and send it to ${email}.", + "current": "current", + "delete": "Delete Prompt", + "deleteConfirm": "The prompt and all its versions will be permanently deleted. Continue?", + "edit": "Edit Prompt", + "global": "Global Prompts", + "locationLabel": "Add Location", + "rollback": "Rollback", + "rollbackConfirm": "Rollback to this version? A new version will be created with this content.", + "title": "Prompt Management", + "titleLabel": "Title", + "titlePlaceholder": "Enter prompt title", + "versionHistory": "Version History" + }, "provider": { "add": { "name": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 667a1aa5eee..5a3421737eb 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4889,6 +4889,24 @@ "enable_privacy_mode": "匿名发送错误报告和数据统计", "title": "隐私设置" }, + "prompts": { + "add": "添加提示词", + "assistant": "助手提示词", + "contentLabel": "内容", + "contentPlaceholder": "请输入提示词内容,支持使用 ${变量名} 作为模板变量。\n例如:帮我规划从 ${from} 到 ${to} 的路线,然后发送到 ${email}", + "current": "当前", + "delete": "删除提示词", + "deleteConfirm": "删除提示词后将无法恢复,所有版本历史也将被删除,是否继续?", + "edit": "编辑提示词", + "global": "全局提示词", + "locationLabel": "添加位置", + "rollback": "回滚", + "rollbackConfirm": "确定回滚到此版本?将以该版本内容创建新版本。", + "title": "提示词管理", + "titleLabel": "标题", + "titlePlaceholder": "请输入提示词标题", + "versionHistory": "版本历史" + }, "provider": { "add": { "name": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 466edb16011..18acba6680d 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4889,6 +4889,24 @@ "enable_privacy_mode": "匿名傳送錯誤報告和資料統計", "title": "隱私設定" }, + "prompts": { + "add": "新增提示詞", + "assistant": "助手提示詞", + "contentLabel": "內容", + "contentPlaceholder": "請輸入提示詞內容,支援使用 ${變量名} 作為模板變數。\n例如:幫我規劃從 ${from} 到 ${to} 的路線,並發送到 ${email}", + "current": "當前", + "delete": "刪除提示詞", + "deleteConfirm": "刪除提示詞後將無法恢復,所有版本歷史也將被刪除,是否繼續?", + "edit": "編輯提示詞", + "global": "全域提示詞", + "locationLabel": "加入位置", + "rollback": "回退", + "rollbackConfirm": "確定要回退到此版本嗎?系統將以此版本內容建立一個新版本。", + "title": "提示詞管理", + "titleLabel": "標題", + "titlePlaceholder": "請輸入提示詞標題", + "versionHistory": "版本歷史" + }, "provider": { "add": { "name": { diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 079e8e9cd6e..92ad927dd80 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -4889,6 +4889,24 @@ "enable_privacy_mode": "Fehlerberichte und Statistiken anonym senden", "title": "Datenschutzeinstellungen" }, + "prompts": { + "add": "Prompt hinzufügen", + "assistant": "Assistenten-Prompts", + "contentLabel": "Inhalt", + "contentPlaceholder": "Geben Sie den Prompt-Inhalt ein. Verwenden Sie ${variable} für Vorlagenvariablen.\nBeispiel: Hilf mir, eine Route von ${from} nach ${to} zu planen und an ${email} zu senden.", + "current": "Aktuell", + "delete": "Prompt löschen", + "deleteConfirm": "Nach dem Löschen kann der Prompt nicht wiederhergestellt werden. Auch der gesamte Versionsverlauf wird gelöscht. Fortfahren?", + "edit": "Prompt bearbeiten", + "global": "Globale Prompts", + "locationLabel": "Einfügeposition", + "rollback": "Rollback", + "rollbackConfirm": "Möchten Sie wirklich auf diese Version zurücksetzen? Es wird eine neue Version basierend auf diesem Inhalt erstellt.", + "title": "Prompt-Verwaltung", + "titleLabel": "Titel", + "titlePlaceholder": "Geben Sie einen Titel für den Prompt ein", + "versionHistory": "Versionsverlauf" + }, "provider": { "add": { "name": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 4324ab8369e..3f6f4b62596 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -4889,6 +4889,24 @@ "enable_privacy_mode": "Αποστολή ανώνυμων αναφορών σφαλμάτων και στατιστικών δεδομένων", "title": "Ρυθμίσεις Απορρήτου" }, + "prompts": { + "add": "Προσθήκη Prompt", + "assistant": "Prompts Βοηθού", + "contentLabel": "Περιεχόμενο", + "contentPlaceholder": "Εισαγάγετε το περιεχόμενο του prompt. Χρησιμοποιήστε το ${variable} για μεταβλητές προτύπου.\nΠαράδειγμα: Βοήθησέ με να σχεδιάσω μια διαδρομή από ${from} προς ${to} και στείλε τη στο ${email}", + "current": "Τρέχον", + "delete": "Διαγραφή Prompt", + "deleteConfirm": "Μετά τη διαγραφή, το prompt δεν μπορεί να ανακτηθεί και όλο το ιστορικό εκδόσεων θα διαγραφεί. Συνέχεια;", + "edit": "Επεξεργασία Prompt", + "global": "Καθολικά Prompts", + "locationLabel": "Θέση Προσθήκης", + "rollback": "Επαναφορά", + "rollbackConfirm": "Επιβεβαίωση επαναφοράς σε αυτή την έκδοση; Θα δημιουργηθεί μια νέα έκδοση με αυτό το περιεχόμενο.", + "title": "Διαχείριση Prompts", + "titleLabel": "Τίτλος", + "titlePlaceholder": "Εισαγάγετε τίτλο prompt", + "versionHistory": "Ιστορικό Εκδόσεων" + }, "provider": { "add": { "name": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 7201c62660e..0bfde043707 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -4889,6 +4889,24 @@ "enable_privacy_mode": "Enviar informes de errores y estadísticas de forma anónima", "title": "Configuración de privacidad" }, + "prompts": { + "add": "Añadir Prompt", + "assistant": "Prompts del asistente", + "contentLabel": "Contenido", + "contentPlaceholder": "Introduce el contenido del prompt. Usa ${variable} para variables de plantilla.\nEjemplo: Ayúdame a planificar una ruta de ${from} a ${to} y envíala a ${email}", + "current": "Actual", + "delete": "Eliminar Prompt", + "deleteConfirm": "Tras la eliminación, el prompt no se podrá recuperar y se borrará todo el historial de versiones. ¿Continuar?", + "edit": "Editar Prompt", + "global": "Prompts globales", + "locationLabel": "Ubicación de inserción", + "rollback": "Revertir", + "rollbackConfirm": "¿Confirmas que quieres revertir a esta versión? Se creará una nueva versión con este contenido.", + "title": "Gestión de Prompts", + "titleLabel": "Título", + "titlePlaceholder": "Introduce el título del prompt", + "versionHistory": "Historial de versiones" + }, "provider": { "add": { "name": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 18a51c71f21..7e21b526d84 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -4889,6 +4889,24 @@ "enable_privacy_mode": "Отправлять анонимные сообщения об ошибках и статистику", "title": "Настройки конфиденциальности" }, + "prompts": { + "add": "Ajouter un prompt", + "assistant": "Prompts de l'assistant", + "contentLabel": "Contenu", + "contentPlaceholder": "Saisissez le contenu du prompt. Utilisez ${variable} pour les variables de modèle.\nExemple : Aide-moi à planifier un itinéraire de ${from} à ${to} et envoie-le à ${email}", + "current": "Actuel", + "delete": "Supprimer le prompt", + "deleteConfirm": "Une fois supprimé, le prompt ne pourra pas être récupéré et tout l'historique des versions sera effacé. Continuer ?", + "edit": "Modifier le prompt", + "global": "Prompts globaux", + "locationLabel": "Emplacement d'ajout", + "rollback": "Restaurer", + "rollbackConfirm": "Confirmer la restauration de cette version ? Une nouvelle version sera créée avec ce contenu.", + "title": "Gestion des prompts", + "titleLabel": "Titre", + "titlePlaceholder": "Saisissez le titre du prompt", + "versionHistory": "Historique des versions" + }, "provider": { "add": { "name": { diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 80d48f24fea..73c4cc429d0 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -4889,6 +4889,24 @@ "enable_privacy_mode": "匿名エラーレポートとデータ統計の送信", "title": "プライバシー設定" }, + "prompts": { + "add": "プロンプトを追加", + "assistant": "アシスタントプロンプト", + "contentLabel": "内容", + "contentPlaceholder": "プロンプトの内容を入力してください。テンプレート変数として ${変数名} が使用可能です。\n例:${from} から ${to} までのルートを計画し、${email} に送信してください。", + "current": "現在", + "delete": "プロンプトを削除", + "deleteConfirm": "プロンプトを削除すると元に戻すことはできません。すべてのバージョン履歴も削除されます。続行しますか?", + "edit": "プロンプトを編集", + "global": "グローバルプロンプト", + "locationLabel": "挿入位置", + "rollback": "ロールバック", + "rollbackConfirm": "このバージョンにロールバックしますか?この内容で新しいバージョンが作成されます。", + "title": "プロンプト管理", + "titleLabel": "タイトル", + "titlePlaceholder": "プロンプトのタイトルを入力してください", + "versionHistory": "バージョン履歴" + }, "provider": { "add": { "name": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 7028aa5d407..458e40bb5fb 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -4889,6 +4889,24 @@ "enable_privacy_mode": "Enviar relatórios de erro e estatísticas de forma anônima", "title": "Configurações de Privacidade" }, + "prompts": { + "add": "Adicionar Prompt", + "assistant": "Prompts do Assistente", + "contentLabel": "Conteúdo", + "contentPlaceholder": "Introduza o conteúdo do prompt. Utilize ${variable} para variáveis de modelo.\nExemplo: Ajuda-me a planear uma rota de ${from} para ${to} e envia-a para ${email}", + "current": "Atual", + "delete": "Eliminar Prompt", + "deleteConfirm": "Após a eliminação, o prompt não poderá ser recuperado e todo o histórico de versões será apagado. Continuar?", + "edit": "Editar Prompt", + "global": "Prompts Globais", + "locationLabel": "Localização da Inserção", + "rollback": "Reverter", + "rollbackConfirm": "Confirmar a reversão para esta versão? Será criada uma nova versão com este conteúdo.", + "title": "Gestão de Prompts", + "titleLabel": "Título", + "titlePlaceholder": "Introduza o título do prompt", + "versionHistory": "Histórico de Versões" + }, "provider": { "add": { "name": { diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index d78f26c2d31..90ce8306229 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -4889,6 +4889,24 @@ "enable_privacy_mode": "Анонимная отчетность об ошибках и статистике", "title": "Настройки конфиденциальности" }, + "prompts": { + "add": "Добавить промпт", + "assistant": "Промпты ассистента", + "contentLabel": "Содержание", + "contentPlaceholder": "Введите текст промпта. Используйте ${variable} для переменных шаблона.\nПример: Помоги мне составить маршрут из ${from} в ${to} и отправь его на ${email}", + "current": "Текущий", + "delete": "Удалить промпт", + "deleteConfirm": "После удаления промпт нельзя будет восстановить, а вся история версий будет стерта. Продолжить?", + "edit": "Редактировать промпт", + "global": "Глобальные промпты", + "locationLabel": "Место добавления", + "rollback": "Откатить", + "rollbackConfirm": "Вы уверены, что хотите откатиться к этой версии? Будет создана новая версия с этим содержанием.", + "title": "Управление промптами", + "titleLabel": "Заголовок", + "titlePlaceholder": "Введите заголовок промпта", + "versionHistory": "История версий" + }, "provider": { "add": { "name": { diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/QuickPhrasesButton.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/QuickPhrasesButton.tsx index 3ce2647f1bd..9333e083c59 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/QuickPhrasesButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/QuickPhrasesButton.tsx @@ -1,4 +1,6 @@ import { Tooltip } from '@cherrystudio/ui' +import { useMutation, useQuery } from '@data/hooks/useDataApi' +import { loggerService } from '@logger' import { ActionIconButton } from '@renderer/components/Buttons' import { type QuickPanelListItem, @@ -7,16 +9,13 @@ import { type QuickPanelTriggerInfo } from '@renderer/components/QuickPanel' import { useQuickPanel } from '@renderer/components/QuickPanel' -import { useAssistant } from '@renderer/hooks/useAssistant' import { useTimer } from '@renderer/hooks/useTimer' import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types' -import QuickPhraseService from '@renderer/services/QuickPhraseService' -import type { QuickPhrase } from '@renderer/types' +import type { Prompt, PromptVersion } from '@shared/data/types/prompt' import { Input, Modal, Radio, Space } from 'antd' import { BotMessageSquare, Plus, Zap } from 'lucide-react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' interface Props { quickPanel: ToolQuickPanelApi @@ -25,37 +24,82 @@ interface Props { assistantId: string } +/** + * Prompt item used internally in this component. + */ +interface PromptItem { + id: string + title: string + content: string + currentVersion: number + source: 'global' | 'assistant' +} + +const logger = loggerService.withContext('QuickPhrasesButton') + const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assistantId }: Props) => { - const [quickPhrasesList, setQuickPhrasesList] = useState([]) - const [isModalOpen, setIsModalOpen] = useState(false) - const [formData, setFormData] = useState({ title: '', content: '', location: 'global' }) + const [isAddModalOpen, setIsAddModalOpen] = useState(false) + const [addFormData, setAddFormData] = useState({ title: '', content: '', location: 'global' }) + const [versionMenuPrompt, setVersionMenuPrompt] = useState(null) const { t } = useTranslation() const quickPanelHook = useQuickPanel() - const { assistant, updateAssistant } = useAssistant(assistantId) const { setTimeoutTimer } = useTimer() const triggerInfoRef = useRef< (QuickPanelTriggerInfo & { symbol?: QuickPanelReservedSymbol; searchText?: string }) | undefined >(undefined) - const loadQuickListPhrases = useCallback( - async (regularPhrases: QuickPhrase[] = []) => { - const phrases = await QuickPhraseService.getAll() - if (regularPhrases.length) { - setQuickPhrasesList([...regularPhrases, ...phrases]) - return - } - const assistantPrompts = assistant.regularPhrases || [] - setQuickPhrasesList([...assistantPrompts, ...phrases]) - }, - [assistant.regularPhrases] - ) + const { + data: globalPromptsRaw, + isLoading: isGlobalPromptsLoading, + error: globalPromptsError + } = useQuery('/prompts', { query: { scope: 'global' } }) + const { + data: assistantPromptsRaw, + isLoading: isAssistantPromptsLoading, + error: assistantPromptsError + } = useQuery('/prompts', { query: { assistantId } }) - useEffect(() => { - loadQuickListPhrases() - }, [loadQuickListPhrases]) + const versionMenuPath: `/prompts/${string}/versions` = `/prompts/${versionMenuPrompt?.id ?? '__pending__'}/versions` + const { + data: versionMenuVersionsRaw, + isLoading: isVersionMenuLoading, + error: versionMenuError + } = useQuery(versionMenuPath, { + enabled: !!versionMenuPrompt + }) + const versionMenuVersions = useMemo(() => (versionMenuVersionsRaw || []) as PromptVersion[], [versionMenuVersionsRaw]) - const handlePhraseSelect = useCallback( - (phrase: QuickPhrase) => { + const { trigger: createPrompt, isLoading: isCreatingPrompt } = useMutation('POST', '/prompts', { + refresh: ['/prompts'], + onError: (error) => { + logger.error('Failed to create prompt', error) + window.toast.error(t('message.error.unknown')) + } + }) + + const promptItems = useMemo(() => { + const assistantPrompts = (assistantPromptsRaw || []) as Prompt[] + const globalPrompts = (globalPromptsRaw || []) as Prompt[] + return [ + ...assistantPrompts.map((p) => ({ + id: p.id, + title: p.title, + content: p.content, + currentVersion: p.currentVersion, + source: 'assistant' as const + })), + ...globalPrompts.map((p) => ({ + id: p.id, + title: p.title, + content: p.content, + currentVersion: p.currentVersion, + source: 'global' as const + })) + ] + }, [assistantPromptsRaw, globalPromptsRaw]) + + const insertText = useCallback( + (text: string) => { setTimeoutTimer( 'handlePhraseSelect_1', () => { @@ -69,7 +113,7 @@ const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assista () => { if (textArea) { textArea.focus() - textArea.setSelectionRange(start, start + phrase.content.length) + textArea.setSelectionRange(start, start + text.length) } resizeTextArea() }, @@ -99,7 +143,7 @@ const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assista } } - const newText = prev.slice(0, startIndex) + phrase.content + prev.slice(endIndex) + const newText = prev.slice(0, startIndex) + text + prev.slice(endIndex) triggerInfoRef.current = undefined focusAndSelect(startIndex) return newText @@ -107,11 +151,11 @@ const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assista if (!textArea) { triggerInfoRef.current = undefined - return prev + phrase.content + return prev + text } const cursorPosition = textArea.selectionStart ?? prev.length - const newText = prev.slice(0, cursorPosition) + phrase.content + prev.slice(cursorPosition) + const newText = prev.slice(0, cursorPosition) + text + prev.slice(cursorPosition) triggerInfoRef.current = undefined focusAndSelect(cursorPosition) return newText @@ -123,56 +167,134 @@ const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assista [setTimeoutTimer, setInputValue, resizeTextArea] ) - const handleModalOk = async () => { - if (!formData.title.trim() || !formData.content.trim()) { + const handleItemSelect = useCallback( + (item: PromptItem) => { + insertText(item.content) + }, + [insertText] + ) + + const openVersionSubMenu = useCallback( + (item: PromptItem) => { + quickPanelHook.open({ + title: item.title, + list: [ + { + label: t('common.loading'), + description: item.content, + icon: , + disabled: true + } + ], + symbol: QuickPanelReservedSymbol.QuickPhrases + }) + setVersionMenuPrompt(item) + }, + [quickPanelHook, t] + ) + + useEffect(() => { + if (!versionMenuPrompt || isVersionMenuLoading) { return } - const updatedPrompts = [ - ...(assistant.regularPhrases || []), - { - id: crypto.randomUUID(), - title: formData.title, - content: formData.content, - createdAt: Date.now(), - updatedAt: Date.now() - } - ] - if (formData.location === 'assistant') { - // 添加到助手的 regularPhrases - await updateAssistant({ ...assistant, regularPhrases: updatedPrompts }) - } else { - // 添加到全局 Quick Phrases - await QuickPhraseService.add(formData) + if (versionMenuError) { + logger.error('Failed to fetch prompt versions', versionMenuError) + window.toast.error(t('message.error.unknown')) + insertText(versionMenuPrompt.content) + setVersionMenuPrompt(null) + return } - setIsModalOpen(false) - setFormData({ title: '', content: '', location: 'global' }) - if (formData.location === 'assistant') { - await loadQuickListPhrases(updatedPrompts) + + if (versionMenuVersions.length === 0) { + window.toast.error(t('message.error.unknown')) + insertText(versionMenuPrompt.content) + setVersionMenuPrompt(null) return } - await loadQuickListPhrases() - } - const phraseItems = useMemo(() => { - const newList: QuickPanelListItem[] = quickPhrasesList.map((phrase, index) => ({ - label: phrase.title, - description: phrase.content, - icon: index < (assistant.regularPhrases?.length || 0) ? : , - action: () => handlePhraseSelect(phrase) + const versionItems: QuickPanelListItem[] = versionMenuVersions.map((version) => ({ + label: `v${version.version}`, + description: version.content, + icon: , + isSelected: version.version === versionMenuPrompt.currentVersion, + action: () => insertText(version.content) })) + quickPanelHook.open({ + title: versionMenuPrompt.title, + list: versionItems, + symbol: QuickPanelReservedSymbol.QuickPhrases + }) + setVersionMenuPrompt(null) + }, [insertText, isVersionMenuLoading, quickPanelHook, t, versionMenuError, versionMenuPrompt, versionMenuVersions]) + + const handleAddModalOk = useCallback(async () => { + if (!addFormData.title.trim() || !addFormData.content.trim()) { + return + } + + try { + await createPrompt({ + body: { + title: addFormData.title, + content: addFormData.content, + assistantId: addFormData.location === 'assistant' ? assistantId : undefined + } + }) + setIsAddModalOpen(false) + setAddFormData({ title: '', content: '', location: 'global' }) + } catch { + // handled by useMutation onError + } + }, [addFormData, assistantId, createPrompt]) + + const isPromptsLoading = isGlobalPromptsLoading || isAssistantPromptsLoading + const promptsLoadError = globalPromptsError || assistantPromptsError + + const phraseItems = useMemo(() => { + const newList: QuickPanelListItem[] = [] + + if (isPromptsLoading && promptItems.length === 0) { + newList.push({ + label: t('common.loading'), + icon: , + disabled: true + }) + } else if (promptsLoadError && promptItems.length === 0) { + newList.push({ + label: t('message.error.unknown'), + icon: , + disabled: true + }) + } else { + newList.push( + ...promptItems.map((item) => { + const hasMultipleVersions = item.currentVersion > 1 + + return { + label: item.title, + description: item.content, + icon: item.source === 'assistant' ? : , + isMenu: hasMultipleVersions, + action: hasMultipleVersions ? () => openVersionSubMenu(item) : () => handleItemSelect(item) + } + }) + ) + } + newList.push({ - label: t('settings.quickPhrase.add') + '...', + label: t('settings.prompts.add') + '...', icon: , - action: () => setIsModalOpen(true) + action: () => setIsAddModalOpen(true) }) + return newList - }, [quickPhrasesList, t, handlePhraseSelect, assistant]) + }, [handleItemSelect, isPromptsLoading, openVersionSubMenu, promptItems, promptsLoadError, t]) const quickPanelOpenOptions = useMemo( () => ({ - title: t('settings.quickPhrase.title'), + title: t('settings.prompts.title'), list: phraseItems, symbol: QuickPanelReservedSymbol.QuickPhrases }), @@ -215,7 +337,7 @@ const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assista useEffect(() => { const disposeRootMenu = quickPanel.registerRootMenu([ { - label: t('settings.quickPhrase.title'), + label: t('settings.prompts.title'), description: '', icon: , isMenu: true, @@ -250,60 +372,62 @@ const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assista return ( <> - + } /> + {/* Add Prompt Modal */} { - setIsModalOpen(false) - setFormData({ title: '', content: '', location: 'global' }) + setIsAddModalOpen(false) + setAddFormData({ title: '', content: '', location: 'global' }) }} width={520} transitionName="animation-move-down" centered>
- +
{t('settings.prompts.titleLabel')}
setFormData({ ...formData, title: e.target.value })} + placeholder={t('settings.prompts.titlePlaceholder')} + value={addFormData.title} + onChange={(e) => setAddFormData({ ...addFormData, title: e.target.value })} />
- +
{t('settings.prompts.contentLabel')}
setFormData({ ...formData, content: e.target.value })} + placeholder={t('settings.prompts.contentPlaceholder')} + value={addFormData.content} + onChange={(e) => setAddFormData({ ...addFormData, content: e.target.value })} rows={6} style={{ resize: 'none' }} />
- +
{t('settings.prompts.locationLabel')}
setFormData({ ...formData, location: e.target.value })}> + value={addFormData.location} + onChange={(e) => setAddFormData({ ...addFormData, location: e.target.value })}> - {t('settings.quickPhrase.global', '全局快速短语')} + {t('settings.prompts.global')} - {t('settings.quickPhrase.assistant', '助手提示词')} + {t('settings.prompts.assistant')}
@@ -313,10 +437,4 @@ const QuickPhrasesButton = ({ quickPanel, setInputValue, resizeTextArea, assista ) } -const Label = styled.div` - font-size: 14px; - color: var(--color-text); - margin-bottom: 8px; -` - export default memo(QuickPhrasesButton) diff --git a/src/renderer/src/pages/settings/PromptSettings.tsx b/src/renderer/src/pages/settings/PromptSettings.tsx new file mode 100644 index 00000000000..44a17087a5e --- /dev/null +++ b/src/renderer/src/pages/settings/PromptSettings.tsx @@ -0,0 +1,375 @@ +import { ExclamationCircleOutlined } from '@ant-design/icons' +import { Button, Flex, Spinner } from '@cherrystudio/ui' +import { useMutation, useQuery } from '@data/hooks/useDataApi' +import { DraggableList } from '@renderer/components/DraggableList' +import { DeleteIcon, EditIcon } from '@renderer/components/Icons' +import { useTheme } from '@renderer/context/ThemeProvider' +import FileItem from '@renderer/pages/files/FileItem' +import type { Prompt, PromptVersion } from '@shared/data/types/prompt' +import { Input, Modal, Popconfirm, Space } from 'antd' +import { HistoryIcon, PlusIcon, RotateCcwIcon } from 'lucide-react' +import type { FC } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingTitle } from '.' + +const { TextArea } = Input + +const PromptSettings: FC = () => { + const { t } = useTranslation() + const { theme } = useTheme() + const [isModalOpen, setIsModalOpen] = useState(false) + const [isVersionModalOpen, setIsVersionModalOpen] = useState(false) + const [editingPrompt, setEditingPrompt] = useState(null) + const [formData, setFormData] = useState<{ title: string; content: string }>({ + title: '', + content: '' + }) + const [dragging, setDragging] = useState(false) + const [pendingDeletePromptId, setPendingDeletePromptId] = useState(null) + const [pendingRollbackVersion, setPendingRollbackVersion] = useState(null) + + const { + data: promptsList = [], + isLoading: isPromptsLoading, + error: promptsError + } = useQuery('/prompts', { + query: { scope: 'global' } + }) + + const promptPath: `/prompts/${string}` = `/prompts/${editingPrompt?.id ?? '__pending__'}` + const deletePromptPath: `/prompts/${string}` = `/prompts/${pendingDeletePromptId ?? '__pending__'}` + const versionsPath: `/prompts/${string}/versions` = `/prompts/${editingPrompt?.id ?? '__pending__'}/versions` + const rollbackPath: `/prompts/${string}/rollback` = `/prompts/${editingPrompt?.id ?? '__pending__'}/rollback` + + const { + data: versionsRaw, + isLoading: isVersionsLoading, + error: versionsError + } = useQuery(versionsPath, { + enabled: isVersionModalOpen && !!editingPrompt + }) + const versions = (versionsRaw || []) as PromptVersion[] + + const { trigger: createPrompt, isLoading: isCreatingPrompt } = useMutation('POST', '/prompts', { + refresh: ['/prompts'], + onError: () => window.toast.error(t('message.error.unknown')) + }) + + const { trigger: updatePrompt, isLoading: isUpdatingPrompt } = useMutation('PATCH', promptPath, { + refresh: ['/prompts'], + onError: () => window.toast.error(t('message.error.unknown')) + }) + + const { trigger: deletePrompt, isLoading: isDeletingPrompt } = useMutation('DELETE', deletePromptPath, { + refresh: ['/prompts'], + onError: () => window.toast.error(t('message.delete.failed')) + }) + + const { trigger: reorderPrompts } = useMutation('PATCH', '/prompts/reorder', { + refresh: ['/prompts'], + onError: () => window.toast.error(t('message.error.unknown')) + }) + + const { trigger: rollbackPrompt, isLoading: isRollingBack } = useMutation('POST', rollbackPath, { + refresh: ['/prompts'], + onError: () => window.toast.error(t('message.error.unknown')) + }) + + const deletePromptRef = useRef(deletePrompt) + useEffect(() => { + deletePromptRef.current = deletePrompt + }, [deletePrompt]) + + useEffect(() => { + if (!pendingDeletePromptId) { + return + } + + let cancelled = false + + const runDelete = async () => { + try { + await deletePromptRef.current() + } catch { + // handled by useMutation onError + } finally { + if (!cancelled) { + setPendingDeletePromptId(null) + } + } + } + + void runDelete() + + return () => { + cancelled = true + } + }, [pendingDeletePromptId]) + + useEffect(() => { + if (versionsError && isVersionModalOpen) { + window.toast.error(t('message.error.unknown')) + } + }, [isVersionModalOpen, t, versionsError]) + + const handleAdd = () => { + setEditingPrompt(null) + setFormData({ title: '', content: '' }) + setIsModalOpen(true) + } + + const handleEdit = (prompt: Prompt) => { + setEditingPrompt(prompt) + setFormData({ + title: prompt.title, + content: prompt.content + }) + setIsModalOpen(true) + } + + const handleDelete = (id: string) => { + setPendingDeletePromptId(id) + } + + const handleModalOk = async () => { + if (!formData.title.trim() || !formData.content.trim()) { + return + } + + try { + if (editingPrompt) { + await updatePrompt({ + body: { + title: formData.title, + content: formData.content + } + }) + } else { + await createPrompt({ + body: { + title: formData.title, + content: formData.content + } + }) + } + setIsModalOpen(false) + } catch { + // handled by useMutation onError + } + } + + const handleUpdateOrder = async (newPrompts: Prompt[]) => { + try { + await reorderPrompts({ + body: { + items: newPrompts.map((p, i) => ({ id: p.id, sortOrder: i })) + } + }) + } catch { + // handled by useMutation onError + } + } + + const handleShowVersions = (prompt: Prompt) => { + setEditingPrompt(prompt) + setIsVersionModalOpen(true) + } + + const handleRollback = async (version: number) => { + if (!editingPrompt) return + + setPendingRollbackVersion(version) + try { + await rollbackPrompt({ + body: { version } + }) + setIsVersionModalOpen(false) + } catch { + // handled by useMutation onError + } finally { + setPendingRollbackVersion(null) + } + } + + const reversedPrompts = useMemo(() => [...promptsList].reverse(), [promptsList]) + const isSavingPrompt = isCreatingPrompt || isUpdatingPrompt + + return ( + + + + {t('settings.prompts.title')} + + + + +
+ {isPromptsLoading && reversedPrompts.length === 0 ? ( +
+ +
+ ) : promptsError && reversedPrompts.length === 0 ? ( +
+ {t('message.error.unknown')} +
+ ) : ( + handleUpdateOrder([...newList].reverse())} + style={{ paddingBottom: dragging ? '34px' : 0 }} + onDragStart={() => setDragging(true)} + onDragEnd={() => setDragging(false)}> + {(prompt) => ( + + + {prompt.content.slice(0, 80)} + {prompt.content.length > 80 ? '...' : ''} + + + v{prompt.currentVersion} + +
+ ), + actions: ( + + + + handleDelete(prompt.id)} + icon={}> + + + + ) + }} + /> + )} + + )} + +
+
+ + {/* Edit / Create Modal */} + setIsModalOpen(false)} + width={600} + transitionName="animation-move-down" + centered + maskClosable={false}> + +
+
{t('settings.prompts.titleLabel')}
+ setFormData({ ...formData, title: e.target.value })} + /> +
+
+
{t('settings.prompts.contentLabel')}
+