Unverified 提交 78eaeaf5 authored 作者: Adekunle James Adeniji's avatar Adekunle James Adeniji 提交者: GitHub

Refactor Prompt Library to Support Slug-Based Skills and Slash Commands (#2712)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2712" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.6 <noreply@anthropic.com> Co-authored-by: 's avatarWill Chen <willchen90@gmail.com>
上级 6aaf9e01
ALTER TABLE `prompts` ADD `slug` text;--> statement-breakpoint
CREATE UNIQUE INDEX `prompts_slug_unique` ON `prompts` (`slug`);
\ No newline at end of file
{
"version": "6",
"dialect": "sqlite",
"id": "80dda8f1-cd0c-411a-8d8c-1e9c32c485e3",
"prevId": "8aa6589b-2eb5-4d16-98a1-6354539472c4",
"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
},
"supabase_parent_project_id": {
"name": "supabase_parent_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_organization_slug": {
"name": "supabase_organization_slug",
"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
},
"is_favorite": {
"name": "is_favorite",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
},
"theme_id": {
"name": "theme_id",
"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())"
},
"compacted_at": {
"name": "compacted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"compaction_backup_path": {
"name": "compaction_backup_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pending_compaction": {
"name": "pending_compaction",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"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": {}
},
"custom_themes": {
"name": "custom_themes",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"prompt": {
"name": "prompt",
"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": {}
},
"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": {}
},
"mcp_servers": {
"name": "mcp_servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transport": {
"name": "transport",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"args": {
"name": "args",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"env_json": {
"name": "env_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"headers_json": {
"name": "headers_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
},
"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": {}
},
"mcp_tool_consents": {
"name": "mcp_tool_consents",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tool_name": {
"name": "tool_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consent": {
"name": "consent",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ask'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"uniq_mcp_consent": {
"name": "uniq_mcp_consent",
"columns": [
"server_id",
"tool_name"
],
"isUnique": true
}
},
"foreignKeys": {
"mcp_tool_consents_server_id_mcp_servers_id_fk": {
"name": "mcp_tool_consents_server_id_mcp_servers_id_fk",
"tableFrom": "mcp_tool_consents",
"tableTo": "mcp_servers",
"columnsFrom": [
"server_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
},
"source_commit_hash": {
"name": "source_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"request_id": {
"name": "request_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_tokens_used": {
"name": "max_tokens_used",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ai_messages_json": {
"name": "ai_messages_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"using_free_agent_mode_quota": {
"name": "using_free_agent_mode_quota",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_compaction_summary": {
"name": "is_compaction_summary",
"type": "integer",
"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
},
"slug": {
"name": "slug",
"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": {
"prompts_slug_unique": {
"name": "prompts_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"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": {}
}
}
\ No newline at end of file
...@@ -183,6 +183,13 @@ ...@@ -183,6 +183,13 @@
"when": 1770256089560, "when": 1770256089560,
"tag": "0025_lush_stark_industries", "tag": "0025_lush_stark_industries",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1771025337064,
"tag": "0026_lying_joseph",
"breakpoints": true
} }
] ]
} }
\ No newline at end of file
...@@ -12,10 +12,12 @@ export class PromptLibrary { ...@@ -12,10 +12,12 @@ export class PromptLibrary {
title, title,
description, description,
content, content,
slug,
}: { }: {
title: string; title: string;
description?: string; description?: string;
content: string; content: string;
slug?: string;
}) { }) {
await this.page.getByRole("button", { name: "New Prompt" }).click(); await this.page.getByRole("button", { name: "New Prompt" }).click();
await this.page.getByRole("textbox", { name: "Title" }).fill(title); await this.page.getByRole("textbox", { name: "Title" }).fill(title);
...@@ -24,6 +26,9 @@ export class PromptLibrary { ...@@ -24,6 +26,9 @@ export class PromptLibrary {
.getByRole("textbox", { name: "Description (optional)" }) .getByRole("textbox", { name: "Description (optional)" })
.fill(description); .fill(description);
} }
if (slug !== undefined) {
await this.page.getByPlaceholder("Slash command (optional)").fill(slug);
}
await this.page.getByRole("textbox", { name: "Content" }).fill(content); await this.page.getByRole("textbox", { name: "Content" }).fill(content);
await this.page.getByRole("button", { name: "Save" }).click(); await this.page.getByRole("button", { name: "Save" }).click();
} }
......
...@@ -70,3 +70,53 @@ test("use prompt", async ({ po }) => { ...@@ -70,3 +70,53 @@ test("use prompt", async ({ po }) => {
await po.snapshotServerDump("last-message"); await po.snapshotServerDump("last-message");
}); });
test("slash menu shows skills and selecting one inserts command", async ({
po,
}) => {
await po.setUp();
await po.navigation.goToLibraryTab();
await po.page.getByRole("link", { name: "Prompts" }).click();
await po.promptLibrary.createPrompt({
title: "E2E Test Skill",
description: "desc",
content: "Run the E2E test skill content.",
slug: "e2e-test-skill",
});
await po.navigation.goToAppsTab();
const chatInput = po.chatActions.getChatInput();
await chatInput.click();
await chatInput.fill("/");
const skillsMenu = po.page.locator('[data-mentions-menu="true"]');
await expect(skillsMenu).toBeVisible();
await expect(skillsMenu).toContainText("e2e-test-skill");
await expect(skillsMenu).toContainText("Skill");
await skillsMenu.getByText("e2e-test-skill").click();
await expect(chatInput).toContainText("/e2e-test-skill");
});
test("slash command is expanded to prompt content in message", async ({
po,
}) => {
await po.setUp();
await po.navigation.goToLibraryTab();
await po.page.getByRole("link", { name: "Prompts" }).click();
await po.promptLibrary.createPrompt({
title: "Webapp Testing Skill",
description: "E2E testing helper",
content: "Run comprehensive E2E tests for the login and signup flows.",
slug: "webapp-testing",
});
await po.navigation.goToAppsTab();
const chatInput = po.chatActions.getChatInput();
await chatInput.click();
await chatInput.fill("[dump] /webapp-testing for the new feature");
await po.page.getByRole("button", { name: "Send message" }).click();
await po.chatActions.waitForChatCompletion();
await po.snapshotServerDump("all-messages");
});
===
role: system
message: [[SYSTEM_MESSAGE]]
===
role: user
message: [dump] Run comprehensive E2E tests for the login and signup flows. for the new feature
\ No newline at end of file
import { describe, expect, it } from "vitest";
import {
replaceSlashSkillReference,
slugForPrompt,
} from "@/ipc/utils/replaceSlashSkillReference";
describe("replaceSlashSkillReference", () => {
it("returns original when no slash-slug pattern present", () => {
const input = "Hello world";
const output = replaceSlashSkillReference(input, {
webapp: "content",
});
expect(output).toBe(input);
});
it("returns original when promptsBySlug is empty", () => {
const input = "/webapp-testing for the login page";
const output = replaceSlashSkillReference(input, {});
expect(output).toBe(input);
});
it("replaces /slug at start with content", () => {
const input = "/webapp-testing for the login page";
const promptsBySlug = { "webapp-testing": "Run E2E tests for the app." };
const output = replaceSlashSkillReference(input, promptsBySlug);
expect(output).toBe("Run E2E tests for the app. for the login page");
});
it("replaces /slug after space with content", () => {
const input = "Please do /github-actions-debugging now";
const promptsBySlug = {
"github-actions-debugging": "Debug failing GitHub Actions workflows.",
};
const output = replaceSlashSkillReference(input, promptsBySlug);
expect(output).toBe(
"Please do Debug failing GitHub Actions workflows. now",
);
});
it("replaces multiple /slug occurrences", () => {
const input = "/one and /two end";
const promptsBySlug = { one: "First", two: "Second" };
const output = replaceSlashSkillReference(input, promptsBySlug);
expect(output).toBe("First and Second end");
});
it("leaves unknown slug intact", () => {
const input = "/unknown-slug here";
const promptsBySlug = { "other-slug": "Content" };
const output = replaceSlashSkillReference(input, promptsBySlug);
expect(output).toBe("/unknown-slug here");
});
it("does not replace slash in middle of path-like text", () => {
const input = "path/to/file";
const promptsBySlug = { path: "Nope", to: "Nope", file: "Nope" };
const output = replaceSlashSkillReference(input, promptsBySlug);
expect(output).toBe("path/to/file");
});
it("replaces /slug and preserves trailing space", () => {
const input = "/webapp-testing extra";
const promptsBySlug = { "webapp-testing": "Content" };
const output = replaceSlashSkillReference(input, promptsBySlug);
expect(output).toBe("Content extra");
});
it("matches case-sensitive slugs", () => {
const input = "/FOO-Bar here";
const promptsBySlug = { "FOO-Bar": "Matched", "foo-bar": "Wrong" };
const output = replaceSlashSkillReference(input, promptsBySlug);
expect(output).toBe("Matched here");
});
it("does not match different-cased slug", () => {
const input = "/foo-bar here";
const promptsBySlug = { "FOO-BAR": "Content" };
const output = replaceSlashSkillReference(input, promptsBySlug);
expect(output).toBe("/foo-bar here");
});
});
describe("slugForPrompt", () => {
it("returns explicit slug when set", () => {
expect(slugForPrompt({ title: "My Prompt", slug: "custom" })).toBe(
"custom",
);
});
it("returns null when slug is null", () => {
expect(slugForPrompt({ title: "Web App Testing", slug: null })).toBeNull();
});
it("returns null when slug is empty string", () => {
expect(slugForPrompt({ title: "Web App Testing", slug: "" })).toBeNull();
});
});
...@@ -13,6 +13,8 @@ import { ...@@ -13,6 +13,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Plus, Save, Edit2 } from "lucide-react"; import { Plus, Save, Edit2 } from "lucide-react";
const SLUG_REGEX = /^[a-zA-Z0-9-]*$/;
interface CreateOrEditPromptDialogProps { interface CreateOrEditPromptDialogProps {
mode: "create" | "edit"; mode: "create" | "edit";
prompt?: { prompt?: {
...@@ -20,17 +22,20 @@ interface CreateOrEditPromptDialogProps { ...@@ -20,17 +22,20 @@ interface CreateOrEditPromptDialogProps {
title: string; title: string;
description: string | null; description: string | null;
content: string; content: string;
slug: string | null;
}; };
onCreatePrompt?: (prompt: { onCreatePrompt?: (prompt: {
title: string; title: string;
description?: string; description?: string;
content: string; content: string;
slug?: string | null;
}) => Promise<any>; }) => Promise<any>;
onUpdatePrompt?: (prompt: { onUpdatePrompt?: (prompt: {
id: number; id: number;
title: string; title: string;
description?: string; description?: string;
content: string; content: string;
slug?: string | null;
}) => Promise<any>; }) => Promise<any>;
trigger?: React.ReactNode; trigger?: React.ReactNode;
prefillData?: { prefillData?: {
...@@ -60,6 +65,7 @@ export function CreateOrEditPromptDialog({ ...@@ -60,6 +65,7 @@ export function CreateOrEditPromptDialog({
title: "", title: "",
description: "", description: "",
content: "", content: "",
slug: "",
}); });
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
...@@ -89,15 +95,17 @@ export function CreateOrEditPromptDialog({ ...@@ -89,15 +95,17 @@ export function CreateOrEditPromptDialog({
title: prompt.title, title: prompt.title,
description: prompt.description || "", description: prompt.description || "",
content: prompt.content, content: prompt.content,
slug: prompt.slug || "",
}); });
} else if (prefillData) { } else if (prefillData) {
setDraft({ setDraft({
title: prefillData.title, title: prefillData.title,
description: prefillData.description, description: prefillData.description,
content: prefillData.content, content: prefillData.content,
slug: "",
}); });
} else { } else {
setDraft({ title: "", description: "", content: "" }); setDraft({ title: "", description: "", content: "", slug: "" });
} }
}, [mode, prompt, prefillData, open]); }, [mode, prompt, prefillData, open]);
...@@ -120,26 +128,36 @@ export function CreateOrEditPromptDialog({ ...@@ -120,26 +128,36 @@ export function CreateOrEditPromptDialog({
title: prompt.title, title: prompt.title,
description: prompt.description || "", description: prompt.description || "",
content: prompt.content, content: prompt.content,
slug: prompt.slug || "",
}); });
} else if (prefillData) { } else if (prefillData) {
setDraft({ setDraft({
title: prefillData.title, title: prefillData.title,
description: prefillData.description, description: prefillData.description,
content: prefillData.content, content: prefillData.content,
slug: "",
}); });
} else { } else {
setDraft({ title: "", description: "", content: "" }); setDraft({ title: "", description: "", content: "", slug: "" });
} }
}; };
const slugTrimmed = draft.slug.trim();
const slugInvalid = slugTrimmed !== "" && !SLUG_REGEX.test(slugTrimmed);
const onSave = async () => { const onSave = async () => {
if (!draft.title.trim() || !draft.content.trim()) return; if (!draft.title.trim() || !draft.content.trim() || slugInvalid) return;
// In edit mode, empty slug means "clear it" (null), not "don't change" (undefined).
const slugValue =
slugTrimmed === "" ? (mode === "edit" ? null : undefined) : slugTrimmed;
if (mode === "create" && onCreatePrompt) { if (mode === "create" && onCreatePrompt) {
await onCreatePrompt({ await onCreatePrompt({
title: draft.title.trim(), title: draft.title.trim(),
description: draft.description.trim() || undefined, description: draft.description.trim() || undefined,
content: draft.content, content: draft.content,
slug: slugValue,
}); });
} else if (mode === "edit" && onUpdatePrompt && prompt) { } else if (mode === "edit" && onUpdatePrompt && prompt) {
await onUpdatePrompt({ await onUpdatePrompt({
...@@ -147,6 +165,7 @@ export function CreateOrEditPromptDialog({ ...@@ -147,6 +165,7 @@ export function CreateOrEditPromptDialog({
title: draft.title.trim(), title: draft.title.trim(),
description: draft.description.trim() || undefined, description: draft.description.trim() || undefined,
content: draft.content, content: draft.content,
slug: slugValue,
}); });
} }
...@@ -199,6 +218,23 @@ export function CreateOrEditPromptDialog({ ...@@ -199,6 +218,23 @@ export function CreateOrEditPromptDialog({
setDraft((d) => ({ ...d, description: e.target.value })) setDraft((d) => ({ ...d, description: e.target.value }))
} }
/> />
<div>
<Input
placeholder="Slash command (optional)"
value={draft.slug}
onChange={(e) => {
const v = e.target.value;
if (v === "" || SLUG_REGEX.test(v))
setDraft((d) => ({ ...d, slug: v }));
}}
className="font-mono"
/>
<p className="text-xs text-muted-foreground mt-1">
{slugInvalid
? "Use only letters, numbers, and hyphens."
: "Used as /command in chat."}
</p>
</div>
<Textarea <Textarea
ref={textareaRef} ref={textareaRef}
placeholder="Content" placeholder="Content"
...@@ -218,7 +254,9 @@ export function CreateOrEditPromptDialog({ ...@@ -218,7 +254,9 @@ export function CreateOrEditPromptDialog({
</Button> </Button>
<Button <Button
onClick={onSave} onClick={onSave}
disabled={!draft.title.trim() || !draft.content.trim()} disabled={
!draft.title.trim() || !draft.content.trim() || slugInvalid
}
> >
<Save className="mr-2 h-4 w-4" /> Save <Save className="mr-2 h-4 w-4" /> Save
</Button> </Button>
...@@ -239,6 +277,7 @@ export function CreatePromptDialog({ ...@@ -239,6 +277,7 @@ export function CreatePromptDialog({
title: string; title: string;
description?: string; description?: string;
content: string; content: string;
slug?: string | null;
}) => Promise<any>; }) => Promise<any>;
prefillData?: { prefillData?: {
title: string; title: string;
......
...@@ -28,11 +28,14 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms"; ...@@ -28,11 +28,14 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { MENTION_REGEX, parseAppMentions } from "@/shared/parse_mention_apps"; import { MENTION_REGEX, parseAppMentions } from "@/shared/parse_mention_apps";
import { useLoadApp } from "@/hooks/useLoadApp"; import { useLoadApp } from "@/hooks/useLoadApp";
import { HistoryNavigation, HISTORY_TRIGGER } from "./HistoryNavigation"; import { HistoryNavigation, HISTORY_TRIGGER } from "./HistoryNavigation";
import { slugForPrompt } from "@/ipc/utils/replaceSlashSkillReference";
// Define the theme for mentions // Define the theme for mentions
const beautifulMentionsTheme: BeautifulMentionsTheme = { const beautifulMentionsTheme: BeautifulMentionsTheme = {
"@": "px-2 py-0.5 mx-0.5 bg-accent text-accent-foreground rounded-md", "@": "px-2 py-0.5 mx-0.5 bg-accent text-accent-foreground rounded-md",
"@Focused": "outline-none ring-2 ring-ring", "@Focused": "outline-none ring-2 ring-ring",
"/": "px-2 py-0.5 mx-0.5 bg-accent text-accent-foreground rounded-md",
"/Focused": "outline-none ring-2 ring-ring",
}; };
// Custom menu item component // Custom menu item component
...@@ -41,9 +44,18 @@ const CustomMenuItem = forwardRef< ...@@ -41,9 +44,18 @@ const CustomMenuItem = forwardRef<
BeautifulMentionsMenuItemProps BeautifulMentionsMenuItemProps
>(({ selected, item, ...props }, ref) => { >(({ selected, item, ...props }, ref) => {
const isPrompt = item.data?.type === "prompt"; const isPrompt = item.data?.type === "prompt";
const isSkill = item.data?.type === "skill";
const isApp = item.data?.type === "app"; const isApp = item.data?.type === "app";
const isHistory = item.data?.type === "history"; const isHistory = item.data?.type === "history";
const label = isPrompt ? "Prompt" : isApp ? "App" : isHistory ? "" : "File"; const label = isSkill
? "Skill"
: isPrompt
? "Prompt"
: isApp
? "App"
: isHistory
? ""
: "File";
const value = (item as any)?.value; const value = (item as any)?.value;
// For history items, show full text without label // For history items, show full text without label
...@@ -76,7 +88,7 @@ const CustomMenuItem = forwardRef< ...@@ -76,7 +88,7 @@ const CustomMenuItem = forwardRef<
<div className="flex items-center space-x-2 min-w-0"> <div className="flex items-center space-x-2 min-w-0">
<span <span
className={`px-2 py-0.5 text-xs font-medium rounded-md flex-shrink-0 ${ className={`px-2 py-0.5 text-xs font-medium rounded-md flex-shrink-0 ${
isPrompt isSkill || isPrompt
? "bg-purple-500 text-white" ? "bg-purple-500 text-white"
: isApp : isApp
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
...@@ -286,7 +298,11 @@ export function LexicalChatInput({ ...@@ -286,7 +298,11 @@ export function LexicalChatInput({
// Prepare mention items - convert apps to mention format // Prepare mention items - convert apps to mention format
const mentionItems = React.useMemo(() => { const mentionItems = React.useMemo(() => {
const result: Record<string, any[]> = { "@": [], [HISTORY_TRIGGER]: [] }; const result: Record<string, any[]> = {
"@": [],
"/": [],
[HISTORY_TRIGGER]: [],
};
// Add history items under the history trigger - always available regardless of app loading // Add history items under the history trigger - always available regardless of app loading
// Reverse so most recent appears at the bottom // Reverse so most recent appears at the bottom
...@@ -299,6 +315,16 @@ export function LexicalChatInput({ ...@@ -299,6 +315,16 @@ export function LexicalChatInput({
})); }));
result[HISTORY_TRIGGER] = historyItems; result[HISTORY_TRIGGER] = historyItems;
// Skills (slash commands): all prompts by slug
const skillItems = (prompts || [])
.map((p) => ({
value: slugForPrompt(p),
type: "skill",
id: p.id,
}))
.filter((item) => item.value != null && item.value !== "");
result["/"] = skillItems;
if (!apps) return result; if (!apps) return result;
// Get current app name // Get current app name
......
...@@ -10,18 +10,23 @@ export type AiMessagesJsonV6 = { ...@@ -10,18 +10,23 @@ export type AiMessagesJsonV6 = {
sdkVersion: typeof AI_MESSAGES_SDK_VERSION; sdkVersion: typeof AI_MESSAGES_SDK_VERSION;
}; };
export const prompts = sqliteTable("prompts", { export const prompts = sqliteTable(
id: integer("id").primaryKey({ autoIncrement: true }), "prompts",
title: text("title").notNull(), {
description: text("description"), id: integer("id").primaryKey({ autoIncrement: true }),
content: text("content").notNull(), title: text("title").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }) description: text("description"),
.notNull() content: text("content").notNull(),
.default(sql`(unixepoch())`), slug: text("slug"),
updatedAt: integer("updated_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })
.notNull() .notNull()
.default(sql`(unixepoch())`), .default(sql`(unixepoch())`),
}); updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => [unique("prompts_slug_unique").on(table.slug)],
);
export const apps = sqliteTable("apps", { export const apps = sqliteTable("apps", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
......
...@@ -7,6 +7,7 @@ export interface PromptItem { ...@@ -7,6 +7,7 @@ export interface PromptItem {
title: string; title: string;
description: string | null; description: string | null;
content: string; content: string;
slug: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
...@@ -26,8 +27,14 @@ export function usePrompts() { ...@@ -26,8 +27,14 @@ export function usePrompts() {
title: string; title: string;
description?: string; description?: string;
content: string; content: string;
slug?: string | null;
}): Promise<PromptItem> => { }): Promise<PromptItem> => {
return ipc.prompt.create(params); return ipc.prompt.create({
title: params.title,
description: params.description,
content: params.content,
slug: params.slug ?? undefined,
});
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all }); queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
...@@ -43,8 +50,15 @@ export function usePrompts() { ...@@ -43,8 +50,15 @@ export function usePrompts() {
title: string; title: string;
description?: string; description?: string;
content: string; content: string;
slug?: string | null;
}): Promise<void> => { }): Promise<void> => {
return ipc.prompt.update(params); return ipc.prompt.update({
id: params.id,
title: params.title,
description: params.description,
content: params.content,
slug: params.slug ?? undefined,
});
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all }); queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
......
...@@ -81,6 +81,7 @@ import { parseAppMentions } from "@/shared/parse_mention_apps"; ...@@ -81,6 +81,7 @@ import { parseAppMentions } from "@/shared/parse_mention_apps";
import { prompts as promptsTable } from "../../db/schema"; import { prompts as promptsTable } from "../../db/schema";
import { inArray } from "drizzle-orm"; import { inArray } from "drizzle-orm";
import { replacePromptReference } from "../utils/replacePromptReference"; import { replacePromptReference } from "../utils/replacePromptReference";
import { replaceSlashSkillReference } from "../utils/replaceSlashSkillReference";
import { parsePlanFile, validatePlanId } from "./planUtils"; import { parsePlanFile, validatePlanId } from "./planUtils";
import { ensureDyadGitignored } from "./gitignoreUtils"; import { ensureDyadGitignored } from "./gitignoreUtils";
import { DYAD_MEDIA_DIR_NAME } from "../utils/media_path_utils"; import { DYAD_MEDIA_DIR_NAME } from "../utils/media_path_utils";
...@@ -375,6 +376,23 @@ export function registerChatStreamHandlers() { ...@@ -375,6 +376,23 @@ export function registerChatStreamHandlers() {
logger.error("Failed to inline referenced prompts:", e); logger.error("Failed to inline referenced prompts:", e);
} }
// Expand /slug skill references (e.g. /webapp-testing) to prompt content
try {
const slashSkillPattern = /(?:^|\s)\/([a-zA-Z0-9-]+)(?=\s|$)/;
if (slashSkillPattern.test(userPrompt)) {
const allPrompts = db.select().from(promptsTable).all();
const promptsBySlug: Record<string, string> = {};
for (const p of allPrompts) {
if (p.slug && !promptsBySlug[p.slug]) {
promptsBySlug[p.slug] = p.content;
}
}
userPrompt = replaceSlashSkillReference(userPrompt, promptsBySlug);
}
} catch (e) {
logger.error("Failed to expand slash skill references:", e);
}
// Expand /implement-plan= into full implementation prompt // Expand /implement-plan= into full implementation prompt
// Keep the original short form for display in the UI; the expanded // Keep the original short form for display in the UI; the expanded
// content is only injected into the AI message history. // content is only injected into the AI message history.
......
...@@ -15,13 +15,14 @@ export function registerPromptHandlers() { ...@@ -15,13 +15,14 @@ export function registerPromptHandlers() {
title: r.title, title: r.title,
description: r.description, description: r.description,
content: r.content, content: r.content,
slug: r.slug,
createdAt: r.createdAt, createdAt: r.createdAt,
updatedAt: r.updatedAt, updatedAt: r.updatedAt,
})); }));
}); });
createTypedHandler(promptContracts.create, async (_, params) => { createTypedHandler(promptContracts.create, async (_, params) => {
const { title, content, description } = params; const { title, content, description, slug } = params;
if (!title || !content) { if (!title || !content) {
throw new Error("Title and content are required"); throw new Error("Title and content are required");
} }
...@@ -31,6 +32,7 @@ export function registerPromptHandlers() { ...@@ -31,6 +32,7 @@ export function registerPromptHandlers() {
title, title,
description, description,
content, content,
slug: slug ?? null,
}) })
.run(); .run();
...@@ -42,19 +44,21 @@ export function registerPromptHandlers() { ...@@ -42,19 +44,21 @@ export function registerPromptHandlers() {
title: row.title, title: row.title,
description: row.description, description: row.description,
content: row.content, content: row.content,
slug: row.slug,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
}); });
createTypedHandler(promptContracts.update, async (_, params) => { createTypedHandler(promptContracts.update, async (_, params) => {
const { id, title, content, description } = params; const { id, title, content, description, slug } = params;
if (!id) throw new Error("Prompt id is required"); if (!id) throw new Error("Prompt id is required");
const now = new Date(); const now = new Date();
const updateData: Record<string, any> = { updatedAt: now }; const updateData: Record<string, any> = { updatedAt: now };
if (title !== undefined) updateData.title = title; if (title !== undefined) updateData.title = title;
if (content !== undefined) updateData.content = content; if (content !== undefined) updateData.content = content;
if (description !== undefined) updateData.description = description; if (description !== undefined) updateData.description = description;
if (slug !== undefined) updateData.slug = slug ?? null;
db.update(prompts).set(updateData).where(eq(prompts.id, id)).run(); db.update(prompts).set(updateData).where(eq(prompts.id, id)).run();
}); });
......
...@@ -5,11 +5,23 @@ import { defineContract, createClient } from "../contracts/core"; ...@@ -5,11 +5,23 @@ import { defineContract, createClient } from "../contracts/core";
// Prompt Schemas // Prompt Schemas
// ============================================================================= // =============================================================================
const slugSchema = z
.string()
.optional()
.nullable()
.refine(
(s) =>
s === undefined || s === null || s === "" || /^[a-zA-Z0-9-]+$/.test(s),
"Slug must be letters, numbers, and hyphens only",
)
.transform((s) => (s === "" ? undefined : s));
export const PromptDtoSchema = z.object({ export const PromptDtoSchema = z.object({
id: z.number(), id: z.number(),
title: z.string(), title: z.string(),
description: z.string().nullable(), description: z.string().nullable(),
content: z.string(), content: z.string(),
slug: z.string().nullable(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
}); });
...@@ -20,6 +32,7 @@ export const CreatePromptParamsDtoSchema = z.object({ ...@@ -20,6 +32,7 @@ export const CreatePromptParamsDtoSchema = z.object({
title: z.string(), title: z.string(),
description: z.string().optional(), description: z.string().optional(),
content: z.string(), content: z.string(),
slug: slugSchema,
}); });
export type CreatePromptParamsDto = z.infer<typeof CreatePromptParamsDtoSchema>; export type CreatePromptParamsDto = z.infer<typeof CreatePromptParamsDtoSchema>;
...@@ -29,6 +42,7 @@ export const UpdatePromptParamsDtoSchema = z.object({ ...@@ -29,6 +42,7 @@ export const UpdatePromptParamsDtoSchema = z.object({
title: z.string().optional(), title: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
content: z.string().optional(), content: z.string().optional(),
slug: slugSchema,
}); });
export type UpdatePromptParamsDto = z.infer<typeof UpdatePromptParamsDtoSchema>; export type UpdatePromptParamsDto = z.infer<typeof UpdatePromptParamsDtoSchema>;
......
/**
* Returns the explicit slug for a prompt, or null if none is set.
*/
export function slugForPrompt(p: {
title: string;
slug: string | null;
}): string | null {
return p.slug || null;
}
/**
* Replaces slash-skill references like /webapp-testing with the corresponding
* prompt content. Only matches /slug when slug is a single token (letters,
* numbers, hyphens) at word boundary (start of string or after
* whitespace, and followed by space or end).
*/
export function replaceSlashSkillReference(
userPrompt: string,
promptsBySlug: Record<string, string>,
): string {
if (typeof userPrompt !== "string" || userPrompt.length === 0)
return userPrompt;
if (Object.keys(promptsBySlug).length === 0) return userPrompt;
return userPrompt.replace(
/(^|\s)\/([a-zA-Z0-9-]+)(?=\s|$)/g,
(match: string, before: string, slug: string) => {
const content = promptsBySlug[slug];
return content !== undefined ? `${before}${content}` : match;
},
);
}
...@@ -85,6 +85,8 @@ export default function LibraryPage() { ...@@ -85,6 +85,8 @@ export default function LibraryPage() {
); );
} }
import { slugForPrompt } from "@/ipc/utils/replaceSlashSkillReference";
function PromptCard({ function PromptCard({
prompt, prompt,
onUpdate, onUpdate,
...@@ -95,15 +97,18 @@ function PromptCard({ ...@@ -95,15 +97,18 @@ function PromptCard({
title: string; title: string;
description: string | null; description: string | null;
content: string; content: string;
slug: string | null;
}; };
onUpdate: (p: { onUpdate: (p: {
id: number; id: number;
title: string; title: string;
description?: string; description?: string;
content: string; content: string;
slug?: string | null;
}) => Promise<void>; }) => Promise<void>;
onDelete: (id: number) => Promise<void>; onDelete: (id: number) => Promise<void>;
}) { }) {
const slashCommand = slugForPrompt(prompt);
return ( return (
<div <div
data-testid="prompt-card" data-testid="prompt-card"
...@@ -118,6 +123,11 @@ function PromptCard({ ...@@ -118,6 +123,11 @@ function PromptCard({
{prompt.description} {prompt.description}
</p> </p>
)} )}
{slashCommand && (
<p className="text-xs text-muted-foreground mt-1 font-mono">
Use /{slashCommand} in chat
</p>
)}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<CreateOrEditPromptDialog <CreateOrEditPromptDialog
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论