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,
|
||||
"tag": "0010_nappy_fat_cobra",
|
||||
"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();
|
||||
}
|
||||
|
||||
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() {
|
||||
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 { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
@@ -39,6 +46,11 @@ const items = [
|
||||
to: "/settings",
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
title: "Library",
|
||||
to: "/library",
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
title: "Hub",
|
||||
to: "/hub",
|
||||
@@ -51,6 +63,7 @@ type HoverState =
|
||||
| "start-hover:app"
|
||||
| "start-hover:chat"
|
||||
| "start-hover:settings"
|
||||
| "start-hover:library"
|
||||
| "clear-hover"
|
||||
| "no-hover";
|
||||
|
||||
@@ -92,6 +105,8 @@ export function AppSidebar() {
|
||||
selectedItem = "Chat";
|
||||
} else if (hoverState === "start-hover:settings") {
|
||||
selectedItem = "Settings";
|
||||
} else if (hoverState === "start-hover:library") {
|
||||
selectedItem = "Library";
|
||||
} else if (state === "expanded") {
|
||||
if (isAppRoute) {
|
||||
selectedItem = "Apps";
|
||||
@@ -195,6 +210,8 @@ function AppIcons({
|
||||
onHoverChange("start-hover:chat");
|
||||
} else if (item.title === "Settings") {
|
||||
onHoverChange("start-hover:settings");
|
||||
} else if (item.title === "Library") {
|
||||
onHoverChange("start-hover:library");
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "lexical-beautiful-mentions";
|
||||
import { KEY_ENTER_COMMAND, COMMAND_PRIORITY_HIGH } from "lexical";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
import { usePrompts } from "@/hooks/usePrompts";
|
||||
import { forwardRef } from "react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
@@ -36,7 +37,11 @@ const beautifulMentionsTheme: BeautifulMentionsTheme = {
|
||||
const CustomMenuItem = forwardRef<
|
||||
HTMLLIElement,
|
||||
BeautifulMentionsMenuItemProps
|
||||
>(({ selected, item, ...props }, ref) => (
|
||||
>(({ selected, item, ...props }, ref) => {
|
||||
const isPrompt = typeof item !== "string" && item.data?.type === "prompt";
|
||||
const label = isPrompt ? "Prompt" : "App";
|
||||
const value = typeof item === "string" ? item : (item as any)?.value;
|
||||
return (
|
||||
<li
|
||||
className={`m-0 flex items-center px-3 py-2 cursor-pointer whitespace-nowrap ${
|
||||
selected
|
||||
@@ -47,15 +52,20 @@ const CustomMenuItem = forwardRef<
|
||||
ref={ref}
|
||||
>
|
||||
<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">
|
||||
App
|
||||
</span>
|
||||
<span className="truncate text-sm">
|
||||
{typeof item === "string" ? item : item.value}
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-medium rounded-md flex-shrink-0 ${
|
||||
isPrompt
|
||||
? "bg-purple-500 text-white"
|
||||
: "bg-primary text-primary-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate text-sm">{value}</span>
|
||||
</div>
|
||||
</li>
|
||||
));
|
||||
);
|
||||
});
|
||||
|
||||
// Custom menu component
|
||||
function CustomMenu({ loading: _loading, ...props }: any) {
|
||||
@@ -136,13 +146,24 @@ function ClearEditorPlugin({
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
useEffect(() => {
|
||||
// Derive the display text that should appear in the editor (@Name) from the
|
||||
// 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 root = $getRoot();
|
||||
@@ -157,34 +178,32 @@ function ExternalValueSyncPlugin({ value }: { value: string }) {
|
||||
|
||||
const paragraph = $createParagraphNode();
|
||||
|
||||
// Build nodes from the internal value, turning @app:Name into a mention node
|
||||
const mentionRegex = /@app:([a-zA-Z0-9_-]+)/g;
|
||||
// Build nodes from internal value, turning @app:Name and @prompt:<id> into mention nodes
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = mentionRegex.exec(value)) !== null) {
|
||||
const [full, name] = match;
|
||||
const combined = /@app:([a-zA-Z0-9_-]+)|@prompt:(\d+)/g;
|
||||
while ((match = combined.exec(value)) !== null) {
|
||||
const start = match.index;
|
||||
|
||||
// Append any text before the mention
|
||||
const full = match[0];
|
||||
if (start > lastIndex) {
|
||||
const textBefore = value.slice(lastIndex, start);
|
||||
if (textBefore) paragraph.append($createTextNode(textBefore));
|
||||
}
|
||||
|
||||
// Append the actual mention node (@ trigger with value = Name)
|
||||
paragraph.append($createBeautifulMentionNode("@", name));
|
||||
|
||||
if (match[1]) {
|
||||
const appName = match[1];
|
||||
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;
|
||||
}
|
||||
|
||||
// Append any trailing text after the last mention
|
||||
if (lastIndex < value.length) {
|
||||
const trailing = value.slice(lastIndex);
|
||||
if (trailing) paragraph.append($createTextNode(trailing));
|
||||
}
|
||||
|
||||
// If there were no mentions at all, just append the raw value as text
|
||||
if (value && paragraph.getTextContent() === "") {
|
||||
paragraph.append($createTextNode(value));
|
||||
}
|
||||
@@ -192,7 +211,7 @@ function ExternalValueSyncPlugin({ value }: { value: string }) {
|
||||
root.append(paragraph);
|
||||
paragraph.selectEnd();
|
||||
});
|
||||
}, [editor, value]);
|
||||
}, [editor, value, promptsById]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -221,6 +240,7 @@ export function LexicalChatInput({
|
||||
disabled = false,
|
||||
}: LexicalChatInputProps) {
|
||||
const { apps } = useLoadApps();
|
||||
const { prompts } = usePrompts();
|
||||
const [shouldClear, setShouldClear] = useState(false);
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
|
||||
@@ -252,10 +272,17 @@ export function LexicalChatInput({
|
||||
});
|
||||
|
||||
const appMentions = filteredApps.map((app) => app.name);
|
||||
|
||||
const promptItems = (prompts || []).map((p) => ({
|
||||
value: p.title,
|
||||
type: "prompt",
|
||||
id: p.id,
|
||||
}));
|
||||
|
||||
return {
|
||||
"@": appMentions,
|
||||
"@": [...appMentions, ...promptItems],
|
||||
};
|
||||
}, [apps, selectedAppId, value, excludeCurrentApp]);
|
||||
}, [apps, selectedAppId, value, excludeCurrentApp, prompts]);
|
||||
|
||||
const initialConfig = {
|
||||
namespace: "ChatInput",
|
||||
@@ -291,11 +318,18 @@ export function LexicalChatInput({
|
||||
);
|
||||
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, apps],
|
||||
[onChange, apps, prompts],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
@@ -343,7 +377,12 @@ export function LexicalChatInput({
|
||||
<OnChangePlugin onChange={handleEditorChange} />
|
||||
<HistoryPlugin />
|
||||
<EnterKeyPlugin onSubmit={handleSubmit} />
|
||||
<ExternalValueSyncPlugin value={value} />
|
||||
<ExternalValueSyncPlugin
|
||||
value={value}
|
||||
promptsById={Object.fromEntries(
|
||||
(prompts || []).map((p) => [p.id, p.title]),
|
||||
)}
|
||||
/>
|
||||
<ClearEditorPlugin
|
||||
shouldClear={shouldClear}
|
||||
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 { 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", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
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 { extractMentionedAppsCodebases } from "../utils/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>;
|
||||
|
||||
@@ -274,6 +277,26 @@ export function registerChatStreamHandlers() {
|
||||
|
||||
// Add user message to database with attachment info
|
||||
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) {
|
||||
let componentSnippet = "[component snippet not available]";
|
||||
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,
|
||||
RevertVersionParams,
|
||||
RespondToAppInputParams,
|
||||
PromptDto,
|
||||
CreatePromptParamsDto,
|
||||
UpdatePromptParamsDto,
|
||||
} from "./ipc_types";
|
||||
import type { Template } from "../shared/templates";
|
||||
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
|
||||
@@ -1069,4 +1072,21 @@ export class IpcClient {
|
||||
public async getTemplates(): Promise<Template[]> {
|
||||
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 { registerTemplateHandlers } from "./handlers/template_handlers";
|
||||
import { registerPortalHandlers } from "./handlers/portal_handlers";
|
||||
import { registerPromptHandlers } from "./handlers/prompt_handlers";
|
||||
|
||||
export function registerIpcHandlers() {
|
||||
// Register all IPC handlers by category
|
||||
@@ -61,4 +62,5 @@ export function registerIpcHandlers() {
|
||||
registerAppEnvVarsHandlers();
|
||||
registerTemplateHandlers();
|
||||
registerPortalHandlers();
|
||||
registerPromptHandlers();
|
||||
}
|
||||
|
||||
@@ -353,6 +353,26 @@ export interface UploadFileToCodebaseResult {
|
||||
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 {
|
||||
file: File;
|
||||
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",
|
||||
"get-templates",
|
||||
"portal:migrate-create",
|
||||
// Prompts
|
||||
"prompts:list",
|
||||
"prompts:create",
|
||||
"prompts:update",
|
||||
"prompts:delete",
|
||||
// Test-only channels
|
||||
// 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
|
||||
|
||||
@@ -6,10 +6,12 @@ import { settingsRoute } from "./routes/settings";
|
||||
import { providerSettingsRoute } from "./routes/settings/providers/$provider";
|
||||
import { appDetailsRoute } from "./routes/app-details";
|
||||
import { hubRoute } from "./routes/hub";
|
||||
import { libraryRoute } from "./routes/library";
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
homeRoute,
|
||||
hubRoute,
|
||||
libraryRoute,
|
||||
chatRoute,
|
||||
appDetailsRoute,
|
||||
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