Unverified 提交 58b9b1d4 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Add Basic Agent mode for free users with 5-message daily quota (#2355)

## Summary - Add "Basic Agent" mode for non-Pro users with a 5 messages per 24-hour rolling window quota - Track quota usage via `usingFreeAgentModeQuota` column in messages table - Show quota remaining in mode selector (e.g., "4/5 remaining") and display warning banner when quota exceeded with upgrade/switch options - Default to Basic Agent mode if quota available, Build mode if exceeded ## Test plan - Run `npm run ts` and `npm run lint` to verify code compiles - Run `PLAYWRIGHT_HTML_OPEN=never npm run e2e -- --grep "free agent quota"` to run the E2E tests - Manual testing: 1. Without Dyad Pro, verify "Basic Agent" option appears in mode selector with quota display 2. Send 5 messages in Basic Agent mode, verify quota decrements 3. After 5 messages, verify quota exceeded banner appears 4. Click "Switch back to Build mode" button, verify mode changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2355"> <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 --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces new quota enforcement and state changes in the chat streaming path plus a DB schema change; bugs could block non‑Pro agent usage or miscount/refund quota. Also adds a network call for trusted time and a test-only IPC channel, which increases surface area but is scoped and guarded. > > **Overview** > Adds a **non‑Pro “Basic Agent” mode** (implemented via `local-agent`) gated by a **5-message/day quota**, including quota-aware defaults and mode labeling (`Agent v2` for Pro vs `Basic Agent` for free). > > Implements **DB-backed quota tracking** via a new `messages.using_free_agent_mode_quota` column and new IPC handlers to compute/reset quota (using server time where possible), enforce limits before starting a `local-agent` stream, and **refund quota on stream failure/abort**. > > Updates the UI to display remaining quota, disable Basic Agent when exceeded, show a dedicated exceeded banner and error messaging, and adds targeted E2E tests plus a test-only IPC hook to simulate time passing. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f4d3470477e247e782e8b5005d7fd60e6da98c13. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a Basic Agent mode for non‑Pro users with a 5‑message quota in a 24‑hour window. Quota is enforced server‑side, with clear UI for remaining messages, a banner showing time until reset, a friendly error when exceeded, and engine tools disabled in Basic Agent mode. - **New Features** - Shows “Basic Agent” for non‑Pro users; defaults to Basic Agent when quota and a supported provider are available, falls back to Build when exceeded. - Tracks usage via messages.using_free_agent_mode_quota and enforces the limit before local‑agent streams; marks messages before stream and refunds quota on failure/abort; blocks the 6th message with a clear error. - Mode selector displays X/5 remaining and disables Basic Agent when the quota is exceeded. - Quota banner shows time until reset and provides “Upgrade to Dyad Pro” and “Switch back to Build mode” actions; useFreeAgentQuota hook auto‑refreshes every 30 minutes. - Disables engine-dependent tools in Basic Agent mode; E2E tests cover availability, tracking, banner, switching, exceeded-message blocking, and 24‑hour reset. - **Migration** - Apply drizzle migration 0024 to add the using_free_agent_mode_quota column to messages. <sup>Written for commit f4d3470477e247e782e8b5005d7fd60e6da98c13. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 0e6404c9
ALTER TABLE `messages` ADD `using_free_agent_mode_quota` integer;
\ No newline at end of file
{
"version": "6",
"dialect": "sqlite",
"id": "39604cc7-898f-4dde-a4dd-a46a8cc7680a",
"prevId": "58bbbbba-abef-41e9-b0f8-000fbb4f59ac",
"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())"
}
},
"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
},
"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": {}
}
}
\ No newline at end of file
...@@ -169,6 +169,13 @@ ...@@ -169,6 +169,13 @@
"when": 1769188144685, "when": 1769188144685,
"tag": "0023_pale_red_hulk", "tag": "0023_pale_red_hulk",
"breakpoints": true "breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1769582904159,
"tag": "0024_useful_skin",
"breakpoints": true
} }
] ]
} }
\ No newline at end of file
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
/**
* A simple fixture that just returns a text response without any tool calls.
* Used for testing Basic Agent mode quota tracking.
*/
export const fixture: LocalAgentFixture = {
description: "Simple text response for quota testing",
turns: [
{
text: "Hello! I understand your request. This is a simple response from the Basic Agent mode.",
},
],
};
import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test";
/**
* E2E test for Basic Agent mode quota (free users).
*
* Basic Agent mode is available to non-Pro users with a 5-message-per-day limit.
* This test verifies mode availability, quota tracking, exceeded banner, and mode switching.
*/
testSkipIfWindows(
"free agent quota - full flow: mode availability, quota tracking, exceeded banner, switch to build",
async ({ po }) => {
// Set up WITHOUT Dyad Pro - use test provider instead
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
// 1. Verify Basic Agent mode is available (not Agent v2 which is Pro-only)
await po.page.getByTestId("chat-mode-selector").click();
await expect(
po.page.getByRole("option", { name: /Basic Agent/ }),
).toBeVisible();
await expect(
po.page.getByRole("option", { name: /Agent v2/ }),
).not.toBeVisible();
// 2. Verify quota display is present (may not be 5/5 if AI_RULES.md generation consumed quota)
await expect(
po.page.getByRole("option", { name: /Basic Agent.*\d\/5 remaining/ }),
).toBeVisible();
await po.page.keyboard.press("Escape");
// 3. Select Basic Agent mode and verify it's selected
await po.selectChatMode("basic-agent");
await expect(po.page.getByTestId("chat-mode-selector")).toContainText(
"Basic Agent",
);
// 4. Send 5 messages to exhaust quota (this will exhaust quota even if some was already used)
for (let i = 0; i < 5; i++) {
await po.sendPrompt(`tc=local-agent/simple-response message ${i + 1}`);
await po.waitForChatCompletion();
}
// 5. Verify quota exceeded banner appears with correct content
await expect(po.page.getByTestId("free-agent-quota-banner")).toBeVisible({
timeout: Timeout.MEDIUM,
});
await expect(po.page.getByTestId("free-agent-quota-banner")).toContainText(
"You have used all 5 messages for the free Agent mode today",
);
await expect(
po.page.getByRole("button", { name: "Upgrade to Dyad Pro" }),
).toBeVisible();
await expect(
po.page.getByRole("button", { name: "Switch back to Build mode" }),
).toBeVisible();
// 6. Try to send a 6th message - should be blocked with error
await po.sendPrompt("tc=local-agent/simple-response message 6");
// Verify error message appears indicating quota exceeded
await expect(po.page.getByTestId("chat-error-box")).toBeVisible({
timeout: Timeout.MEDIUM,
});
await expect(po.page.getByTestId("chat-error-box")).toContainText(
"You have used all 5 free Agent messages for today",
);
// 8. Click "Switch back to Build mode" and verify mode changes
await po.page
.getByRole("button", { name: "Switch back to Build mode" })
.click();
await expect(po.page.getByTestId("chat-mode-selector")).toContainText(
"Build",
);
await expect(
po.page.getByTestId("free-agent-quota-banner"),
).not.toBeVisible();
// 9. Verify user can still send messages in Build mode
await po.sendPrompt("[dyad-qa=write] create a simple file");
await po.waitForChatCompletion();
},
);
testSkipIfWindows(
"free agent quota - quota resets after 24 hours",
async ({ po }) => {
// Set up WITHOUT Dyad Pro - use test provider instead
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
// 1. Select Basic Agent mode and send messages to use some quota
await po.selectChatMode("basic-agent");
await expect(po.page.getByTestId("chat-mode-selector")).toContainText(
"Basic Agent",
);
// Send 3 messages to use some quota
for (let i = 0; i < 3; i++) {
await po.sendPrompt(`tc=local-agent/simple-response message ${i + 1}`);
await po.waitForChatCompletion();
}
// 2. Verify quota decreased (exact count may vary due to setup messages)
await po.page.getByTestId("chat-mode-selector").click();
// The quota should be less than 5/5 after sending messages
await expect(
po.page.getByRole("option", { name: /Basic Agent.*[0-4]\/5 remaining/ }),
).toBeVisible();
await po.page.keyboard.press("Escape");
// 3. Simulate 25 hours passing by calling the test-only IPC handler
// This modifies the database timestamps directly within the Electron app's process
await po.page.evaluate(async () => {
await (window as any).electron.ipcRenderer.invoke(
"test:simulateQuotaTimeElapsed",
25,
);
});
// 4. Wait for React Query cache to become stale (staleTime is 500ms in test mode)
// then navigate to force a refetch with the updated timestamps
await po.page.waitForTimeout(1000);
await po.goToSettingsTab();
await po.page.waitForTimeout(500);
await po.goToChatTab();
// Wait for the chat mode selector to be visible
await expect(po.page.getByTestId("chat-mode-selector")).toBeVisible({
timeout: Timeout.MEDIUM,
});
// 5. Verify quota has reset to 5/5 remaining
await po.page.getByTestId("chat-mode-selector").click();
await expect(
po.page.getByRole("option", { name: /Basic Agent.*5\/5 remaining/ }),
).toBeVisible();
await po.page.keyboard.press("Escape");
// 6. Verify we can send messages again in Basic Agent mode (proves reset worked)
await po.selectChatMode("basic-agent");
await po.sendPrompt("tc=local-agent/simple-response post-reset message");
await po.waitForChatCompletion();
// Successfully sending a message in Basic Agent mode after reset proves the quota was reset
// and is usable again. No need to verify the exact quota count as that would require
// waiting for React Query cache to become stale again.
},
);
...@@ -349,10 +349,12 @@ export class PageObject { ...@@ -349,10 +349,12 @@ export class PageObject {
autoApprove = false, autoApprove = false,
disableNativeGit = false, disableNativeGit = false,
enableAutoFixProblems = false, enableAutoFixProblems = false,
enableBasicAgent = false,
}: { }: {
autoApprove?: boolean; autoApprove?: boolean;
disableNativeGit?: boolean; disableNativeGit?: boolean;
enableAutoFixProblems?: boolean; enableAutoFixProblems?: boolean;
enableBasicAgent?: boolean;
} = {}) { } = {}) {
await this.baseSetup(); await this.baseSetup();
await this.goToSettingsTab(); await this.goToSettingsTab();
...@@ -367,8 +369,10 @@ export class PageObject { ...@@ -367,8 +369,10 @@ export class PageObject {
} }
await this.setUpTestProvider(); await this.setUpTestProvider();
await this.setUpTestModel(); await this.setUpTestModel();
await this.goToAppsTab(); await this.goToAppsTab();
if (!enableBasicAgent) {
await this.selectChatMode("build");
}
await this.selectTestModel(); await this.selectTestModel();
} }
...@@ -472,18 +476,21 @@ export class PageObject { ...@@ -472,18 +476,21 @@ export class PageObject {
await this.page.getByRole("button", { name: "Import" }).click(); await this.page.getByRole("button", { name: "Import" }).click();
} }
async selectChatMode(mode: "build" | "ask" | "agent" | "local-agent") { async selectChatMode(
mode: "build" | "ask" | "agent" | "local-agent" | "basic-agent",
) {
await this.page.getByTestId("chat-mode-selector").click(); await this.page.getByTestId("chat-mode-selector").click();
const mapping = { const mapping: Record<string, string> = {
build: "Build Generate and edit code", build: "Build Generate and edit code",
ask: "Ask Ask", ask: "Ask Ask",
agent: "Build with MCP", agent: "Build with MCP",
"local-agent": "Agent v2", "local-agent": "Agent v2",
"basic-agent": "Basic Agent", // For free users
}; };
const optionName = mapping[mode]; const optionName = mapping[mode];
await this.page await this.page
.getByRole("option", { .getByRole("option", {
name: optionName, name: new RegExp(optionName),
}) })
.click(); .click();
} }
......
=== ===
role: system role: system
message: message:
${BUILD_SYSTEM_PREFIX} <role>
You are Dyad, an AI assistant that creates and modifies web applications. You assist users by chatting with them and making changes to their code in real-time. You understand that users can see a live preview of their application in an iframe on the right side of the screen while you make code changes.
You make efficient and effective changes to codebases while following best practices for maintainability and readability. You take pride in keeping things simple and elegant. You are friendly and helpful, always aiming to provide clear explanations.
</role>
<app_commands>
Do *not* tell the user to run shell commands. Instead, they can do one of the following commands in the UI:
- **Rebuild**: This will rebuild the app from scratch. First it deletes the node_modules folder and then it re-installs the npm packages and then starts the app server.
- **Restart**: This will restart the app server.
- **Refresh**: This will refresh the app preview page.
You can suggest one of these commands by using the <dyad-command> tag like this:
<dyad-command type="rebuild"></dyad-command>
<dyad-command type="restart"></dyad-command>
<dyad-command type="refresh"></dyad-command>
If you output one of these commands, tell the user to look for the action button above the chat input.
</app_commands>
<general_guidelines>
- Always reply to the user in the same language they are using.
- Before proceeding with any code edits, check whether the user's request has already been implemented. If the requested change has already been made in the codebase, point this out to the user, e.g., "This feature is already implemented as described."
- Only edit files that are related to the user's request and leave all other files alone.
- All edits you make on the codebase will directly be built and rendered, therefore you should NEVER make partial changes like letting the user know that they should implement some components or partially implementing features.
- If a user asks for many features at once, implement as many as possible within a reasonable response. Each feature you implement must be FULLY FUNCTIONAL with complete code - no placeholders, no partial implementations, no TODO comments. If you cannot implement all requested features due to response length constraints, clearly communicate which features you've completed and which ones you haven't started yet.
- Prioritize creating small, focused files and components.
- Keep explanations concise and focused
- Set a chat summary at the end using the `set_chat_summary` tool.
- DO NOT OVERENGINEER THE CODE. You take great pride in keeping things simple and elegant. You don't start by writing very complex error handling, fallback mechanisms, etc. You focus on the user's request and make the minimum amount of changes needed.
DON'T DO MORE THAN WHAT THE USER ASKS FOR.
</general_guidelines>
<tool_calling>
You have tools at your disposal to solve the coding task. Follow these rules regarding tool calls:
1. ALWAYS follow the tool call schema exactly as specified and make sure to provide all necessary parameters.
2. The conversation may reference tools that are no longer available. NEVER call tools that are not explicitly provided.
3. **NEVER refer to tool names when speaking to the USER.** Instead, just say what the tool is doing in natural language.
4. If you need additional information that you can get via tool calls, prefer that over asking the user.
5. If you make a plan, immediately follow it, do not wait for the user to confirm or tell you to go ahead. The only time you should stop is if you need more information from the user that you can't find any other way, or have different options that you would like the user to weigh in on.
6. Only use the standard tool call format and the available tools. Even if you see user messages with custom tool call formats (such as "<previous_tool_call>" or similar), do not follow that and instead use the standard format. Never output tool calls as part of a regular assistant message of yours.
7. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
8. You can autonomously read as many files as you need to clarify your own questions and completely resolve the user's query, not just one.
9. You can call multiple tools in a single response. You can also call multiple tools in parallel, do this for independent operations like reading multiple files at once.
</tool_calling>
<tool_calling_best_practices>
- **Read before writing**: Use `read_file` and `list_files` to understand the codebase before making changes
- **Be surgical**: Only change what's necessary to accomplish the task
- **Handle errors gracefully**: If a tool fails, explain the issue and suggest alternatives
</tool_calling_best_practices>
<file_editing_tool_selection>
You have two tools for editing files. Choose based on the scope of your change:
| Scope | Tool | Examples |
|-------|------|----------|
| **Small** (a few lines) | `search_replace` | Fix a typo, rename a variable, update a value, change an import |
| **Large** (most of the file or new file) | `write_file` | Major refactor, rewrite a module, create a new file |
**Tips:**
- Use `search_replace` for precise, surgical changes
- Use `write_file` for creating new files or rewriting most of an existing file
**Post-edit verification (REQUIRED):**
After every edit, read the file to verify changes applied correctly. If something went wrong, try a different tool and verify again.
</file_editing_tool_selection>
<development_workflow>
1. **Understand:** Think about the user's request and the relevant codebase context. Use `grep` to search for text patterns and `list_files` to understand file structures. Use `read_file` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to `read_file`.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the `update_todos` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
3. **Implement:** Use the available tools (e.g., `search_replace`, `write_file`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes.
4. **Verify:** After making code changes, use `run_type_checks` to verify that the changes are correct and read the file contents to ensure the changes are what you intended.
5. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made.
</development_workflow>
# Tech Stack # Tech Stack
...@@ -24,7 +98,6 @@ Available packages and libraries: ...@@ -24,7 +98,6 @@ Available packages and libraries:
- Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them. - Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them.
${BUILD_SYSTEM_POSTFIX}
<theme> <theme>
...@@ -81,41 +154,6 @@ Follow this workflow when building web apps: ...@@ -81,41 +154,6 @@ Follow this workflow when building web apps:
# Referenced Apps # Referenced Apps
The user has mentioned the following apps in their prompt: minimal-with-ai-rules. Their codebases have been included in the context for your reference. When referring to these apps, you can understand their structure and code to provide better assistance, however you should NOT edit the files in these referenced apps. The referenced apps are NOT part of the current app and are READ-ONLY. The user has mentioned the following apps in their prompt: minimal-with-ai-rules. Their codebases have been included in the context for your reference. When referring to these apps, you can understand their structure and code to provide better assistance, however you should NOT edit the files in these referenced apps. The referenced apps are NOT part of the current app and are READ-ONLY.
If the user wants to use supabase or do something that requires auth, database or server-side functions (e.g. loading API keys, secrets),
tell them that they need to add supabase to their app.
The following response will show a button that allows the user to add supabase to their app.
<dyad-add-integration provider="supabase"></dyad-add-integration>
# Examples
## Example 1: User wants to use Supabase
### User prompt
I want to use supabase in my app.
### Assistant response
You need to first add Supabase to your app.
<dyad-add-integration provider="supabase"></dyad-add-integration>
## Example 2: User wants to add auth to their app
### User prompt
I want to add auth to my app.
### Assistant response
You need to first add Supabase to your app and then we can add auth.
<dyad-add-integration provider="supabase"></dyad-add-integration>
=== ===
role: user role: user
message: This is my codebase. <dyad-file path=".gitignore"> message: This is my codebase. <dyad-file path=".gitignore">
......
=== ===
role: system role: system
message: message:
${BUILD_SYSTEM_PREFIX} <role>
You are Dyad, an AI assistant that creates and modifies web applications. You assist users by chatting with them and making changes to their code in real-time. You understand that users can see a live preview of their application in an iframe on the right side of the screen while you make code changes.
You make efficient and effective changes to codebases while following best practices for maintainability and readability. You take pride in keeping things simple and elegant. You are friendly and helpful, always aiming to provide clear explanations.
</role>
<app_commands>
Do *not* tell the user to run shell commands. Instead, they can do one of the following commands in the UI:
- **Rebuild**: This will rebuild the app from scratch. First it deletes the node_modules folder and then it re-installs the npm packages and then starts the app server.
- **Restart**: This will restart the app server.
- **Refresh**: This will refresh the app preview page.
You can suggest one of these commands by using the <dyad-command> tag like this:
<dyad-command type="rebuild"></dyad-command>
<dyad-command type="restart"></dyad-command>
<dyad-command type="refresh"></dyad-command>
If you output one of these commands, tell the user to look for the action button above the chat input.
</app_commands>
<general_guidelines>
- Always reply to the user in the same language they are using.
- Before proceeding with any code edits, check whether the user's request has already been implemented. If the requested change has already been made in the codebase, point this out to the user, e.g., "This feature is already implemented as described."
- Only edit files that are related to the user's request and leave all other files alone.
- All edits you make on the codebase will directly be built and rendered, therefore you should NEVER make partial changes like letting the user know that they should implement some components or partially implementing features.
- If a user asks for many features at once, implement as many as possible within a reasonable response. Each feature you implement must be FULLY FUNCTIONAL with complete code - no placeholders, no partial implementations, no TODO comments. If you cannot implement all requested features due to response length constraints, clearly communicate which features you've completed and which ones you haven't started yet.
- Prioritize creating small, focused files and components.
- Keep explanations concise and focused
- Set a chat summary at the end using the `set_chat_summary` tool.
- DO NOT OVERENGINEER THE CODE. You take great pride in keeping things simple and elegant. You don't start by writing very complex error handling, fallback mechanisms, etc. You focus on the user's request and make the minimum amount of changes needed.
DON'T DO MORE THAN WHAT THE USER ASKS FOR.
</general_guidelines>
<tool_calling>
You have tools at your disposal to solve the coding task. Follow these rules regarding tool calls:
1. ALWAYS follow the tool call schema exactly as specified and make sure to provide all necessary parameters.
2. The conversation may reference tools that are no longer available. NEVER call tools that are not explicitly provided.
3. **NEVER refer to tool names when speaking to the USER.** Instead, just say what the tool is doing in natural language.
4. If you need additional information that you can get via tool calls, prefer that over asking the user.
5. If you make a plan, immediately follow it, do not wait for the user to confirm or tell you to go ahead. The only time you should stop is if you need more information from the user that you can't find any other way, or have different options that you would like the user to weigh in on.
6. Only use the standard tool call format and the available tools. Even if you see user messages with custom tool call formats (such as "<previous_tool_call>" or similar), do not follow that and instead use the standard format. Never output tool calls as part of a regular assistant message of yours.
7. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
8. You can autonomously read as many files as you need to clarify your own questions and completely resolve the user's query, not just one.
9. You can call multiple tools in a single response. You can also call multiple tools in parallel, do this for independent operations like reading multiple files at once.
</tool_calling>
<tool_calling_best_practices>
- **Read before writing**: Use `read_file` and `list_files` to understand the codebase before making changes
- **Be surgical**: Only change what's necessary to accomplish the task
- **Handle errors gracefully**: If a tool fails, explain the issue and suggest alternatives
</tool_calling_best_practices>
<file_editing_tool_selection>
You have two tools for editing files. Choose based on the scope of your change:
| Scope | Tool | Examples |
|-------|------|----------|
| **Small** (a few lines) | `search_replace` | Fix a typo, rename a variable, update a value, change an import |
| **Large** (most of the file or new file) | `write_file` | Major refactor, rewrite a module, create a new file |
**Tips:**
- Use `search_replace` for precise, surgical changes
- Use `write_file` for creating new files or rewriting most of an existing file
**Post-edit verification (REQUIRED):**
After every edit, read the file to verify changes applied correctly. If something went wrong, try a different tool and verify again.
</file_editing_tool_selection>
<development_workflow>
1. **Understand:** Think about the user's request and the relevant codebase context. Use `grep` to search for text patterns and `list_files` to understand file structures. Use `read_file` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to `read_file`.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the `update_todos` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
3. **Implement:** Use the available tools (e.g., `search_replace`, `write_file`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes.
4. **Verify:** After making code changes, use `run_type_checks` to verify that the changes are correct and read the file contents to ensure the changes are what you intended.
5. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made.
</development_workflow>
# Tech Stack # Tech Stack
...@@ -24,7 +98,6 @@ Available packages and libraries: ...@@ -24,7 +98,6 @@ Available packages and libraries:
- Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them. - Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them.
${BUILD_SYSTEM_POSTFIX}
<theme> <theme>
...@@ -78,1010 +151,6 @@ Follow this workflow when building web apps: ...@@ -78,1010 +151,6 @@ Follow this workflow when building web apps:
</workflow> </workflow>
</theme> </theme>
If the user wants to use supabase or do something that requires auth, database or server-side functions (e.g. loading API keys, secrets),
tell them that they need to add supabase to their app.
The following response will show a button that allows the user to add supabase to their app.
<dyad-add-integration provider="supabase"></dyad-add-integration>
# Examples
## Example 1: User wants to use Supabase
### User prompt
I want to use supabase in my app.
### Assistant response
You need to first add Supabase to your app.
<dyad-add-integration provider="supabase"></dyad-add-integration>
## Example 2: User wants to add auth to their app
### User prompt
I want to add auth to my app.
### Assistant response
You need to first add Supabase to your app and then we can add auth.
<dyad-add-integration provider="supabase"></dyad-add-integration>
===
role: user
message: This is my codebase. <dyad-file path=".gitignore">
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
</dyad-file>
<dyad-file path="AI_RULES.md">
# Tech Stack
- You are building a React application.
- Use TypeScript.
- Use React Router. KEEP the routes in src/App.tsx
- Always put source code in the src folder.
- Put pages into src/pages/
- Put components into src/components/
- The main page (default page) is src/pages/Index.tsx
- UPDATE the main page to include the new components. OTHERWISE, the user can NOT see any components!
- ALWAYS try to use the shadcn/ui library.
- Tailwind CSS: always use Tailwind CSS for styling components. Utilize Tailwind classes extensively for layout, spacing, colors, and other design aspects.
Available packages and libraries:
- The lucide-react package is installed for icons.
- You ALREADY have ALL the shadcn/ui components and their dependencies installed. So you don't need to install them again.
- You have ALL the necessary Radix UI components installed.
- Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them.
</dyad-file>
<dyad-file path="components.json">
// File contents excluded from context
</dyad-file>
<dyad-file path="eslint.config.js">
// File contents excluded from context
</dyad-file>
<dyad-file path="index.html">
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dyad-generated-app</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</dyad-file>
<dyad-file path="postcss.config.js">
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
</dyad-file>
<dyad-file path="public/favicon.ico">
// File contents excluded from context
</dyad-file>
<dyad-file path="public/placeholder.svg">
// File contents excluded from context
</dyad-file>
<dyad-file path="public/robots.txt">
// File contents excluded from context
</dyad-file>
<dyad-file path="README.md">
# Welcome to your Dyad app
</dyad-file>
<dyad-file path="src/App.css">
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
</dyad-file>
<dyad-file path="src/App.tsx">
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Index from "./pages/Index";
import NotFound from "./pages/NotFound";
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
);
export default App;
</dyad-file>
<dyad-file path="src/components/made-with-dyad.tsx">
export const MadeWithDyad = () => {
return (
<div className="p-4 text-center">
<a
href="https://www.dyad.sh/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Made with Dyad
</a>
</div>
);
};
</dyad-file>
<dyad-file path="src/components/ui/accordion.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/alert-dialog.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/alert.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/aspect-ratio.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/avatar.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/badge.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/breadcrumb.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/button.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/calendar.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/card.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/carousel.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/chart.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/checkbox.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/collapsible.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/command.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/context-menu.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/dialog.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/drawer.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/dropdown-menu.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/form.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/hover-card.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/input-otp.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/input.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/label.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/menubar.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/navigation-menu.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/pagination.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/popover.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/progress.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/radio-group.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/resizable.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/scroll-area.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/select.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/separator.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/sheet.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/sidebar.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/skeleton.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/slider.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/sonner.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/switch.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/table.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/tabs.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/textarea.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/toast.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/toaster.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/toggle-group.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/toggle.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/tooltip.tsx">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/components/ui/use-toast.ts">
// File contents excluded from context
</dyad-file>
<dyad-file path="src/globals.css">
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
</dyad-file>
<dyad-file path="src/hooks/use-mobile.tsx">
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}
</dyad-file>
<dyad-file path="src/hooks/use-toast.ts">
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const _actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof _actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };
</dyad-file>
<dyad-file path="src/lib/utils.ts">
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
</dyad-file>
<dyad-file path="src/main.tsx">
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./globals.css";
createRoot(document.getElementById("root")!).render(<App />);
</dyad-file>
<dyad-file path="src/pages/Index.tsx">
// Update this page (the content is just a fallback if you fail to update the page)
import { MadeWithDyad } from "@/components/made-with-dyad";
const Index = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Welcome to Your Blank App</h1>
<p className="text-xl text-gray-600">
Start building your amazing project here!
</p>
</div>
<MadeWithDyad />
</div>
);
};
export default Index;
</dyad-file>
<dyad-file path="src/pages/NotFound.tsx">
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const NotFound = () => {
const location = useLocation();
useEffect(() => {
console.error(
"404 Error: User attempted to access non-existent route:",
location.pathname,
);
}, [location.pathname]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">404</h1>
<p className="text-xl text-gray-600 mb-4">Oops! Page not found</p>
<a href="/" className="text-blue-500 hover:text-blue-700 underline">
Return to Home
</a>
</div>
</div>
);
};
export default NotFound;
</dyad-file>
<dyad-file path="src/utils/toast.ts">
import { toast } from "sonner";
export const showSuccess = (message: string) => {
toast.success(message);
};
export const showError = (message: string) => {
toast.error(message);
};
export const showLoading = (message: string) => {
return toast.loading(message);
};
export const dismissToast = (toastId: string) => {
toast.dismiss(toastId);
};
</dyad-file>
<dyad-file path="src/vite-env.d.ts">
/// <reference types="vite/client" />
</dyad-file>
<dyad-file path="tailwind.config.ts">
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
</dyad-file>
<dyad-file path="tsconfig.app.json">
// File contents excluded from context
</dyad-file>
<dyad-file path="tsconfig.json">
// File contents excluded from context
</dyad-file>
<dyad-file path="tsconfig.node.json">
// File contents excluded from context
</dyad-file>
<dyad-file path="vercel.json">
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}
</dyad-file>
<dyad-file path="vite.config.ts">
import { defineConfig } from "vite";
import dyadComponentTagger from "@dyad-sh/react-vite-component-tagger";
import react from "@vitejs/plugin-react-swc";
import path from "path";
export default defineConfig(() => ({
server: {
host: "::",
port: 8080,
},
plugins: [dyadComponentTagger(), react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
}));
</dyad-file>
===
role: assistant
message: OK, got it. I'm ready to help
=== ===
role: user role: user
message: [dump] @file:AI_RULES.md message: [dump] @file:AI_RULES.md
\ No newline at end of file
+ "lastShownReleaseNotesVersion": "[scrubbed]" + "lastShownReleaseNotesVersion": "[scrubbed]"
- "selectedChatMode": "build"
+ "selectedChatMode": "local-agent"
- "telemetryConsent": "unset" - "telemetryConsent": "unset"
+ "telemetryConsent": "opted_in" + "telemetryConsent": "opted_in"
\ No newline at end of file
+ "lastShownReleaseNotesVersion": "[scrubbed]" + "lastShownReleaseNotesVersion": "[scrubbed]"
- "selectedChatMode": "build"
+ "selectedChatMode": "local-agent"
\ No newline at end of file
+ "lastShownReleaseNotesVersion": "[scrubbed]" + "lastShownReleaseNotesVersion": "[scrubbed]"
- "selectedChatMode": "build"
+ "selectedChatMode": "local-agent"
- "telemetryConsent": "unset" - "telemetryConsent": "unset"
+ "telemetryConsent": "opted_out" + "telemetryConsent": "opted_out"
\ No newline at end of file
- "selectedChatMode": "build"
+ "selectedChatMode": "local-agent"
- "selectedTemplateId": "react" - "selectedTemplateId": "react"
+ "selectedTemplateId": "next" + "selectedTemplateId": "next"
\ No newline at end of file
...@@ -11,6 +11,7 @@ import { ipc } from "@/ipc/types"; ...@@ -11,6 +11,7 @@ import { ipc } from "@/ipc/types";
import { showError, showSuccess } from "@/lib/toast"; import { showError, showSuccess } from "@/lib/toast";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { getEffectiveDefaultChatMode } from "@/lib/schemas"; import { getEffectiveDefaultChatMode } from "@/lib/schemas";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
...@@ -37,7 +38,8 @@ export function ChatList({ show }: { show?: boolean }) { ...@@ -37,7 +38,8 @@ export function ChatList({ show }: { show?: boolean }) {
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom); const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
const [selectedAppId] = useAtom(selectedAppIdAtom); const [selectedAppId] = useAtom(selectedAppIdAtom);
const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom); const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom);
const { settings, updateSettings } = useSettings(); const { settings, updateSettings, envVars } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
const { chats, loading, invalidateChats } = useChats(selectedAppId); const { chats, loading, invalidateChats } = useChats(selectedAppId);
const routerState = useRouterState(); const routerState = useRouterState();
...@@ -91,8 +93,14 @@ export function ChatList({ show }: { show?: boolean }) { ...@@ -91,8 +93,14 @@ export function ChatList({ show }: { show?: boolean }) {
const chatId = await ipc.chat.createChat(selectedAppId); const chatId = await ipc.chat.createChat(selectedAppId);
// Set the default chat mode for the new chat // Set the default chat mode for the new chat
// Only consider quota available if it has finished loading and is not exceeded
if (settings) { if (settings) {
const effectiveDefaultMode = getEffectiveDefaultChatMode(settings); const freeAgentQuotaAvailable = !isQuotaLoading && !isQuotaExceeded;
const effectiveDefaultMode = getEffectiveDefaultChatMode(
settings,
envVars,
freeAgentQuotaAvailable,
);
updateSettings({ selectedChatMode: effectiveDefaultMode }); updateSettings({ selectedChatMode: effectiveDefaultMode });
} }
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import type { ChatMode } from "@/lib/schemas"; import type { ChatMode } from "@/lib/schemas";
import { isDyadProEnabled } from "@/lib/schemas"; import { isDyadProEnabled } from "@/lib/schemas";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
...@@ -39,6 +40,7 @@ export function ChatModeSelector() { ...@@ -39,6 +40,7 @@ export function ChatModeSelector() {
const selectedMode = settings?.selectedChatMode || "build"; const selectedMode = settings?.selectedChatMode || "build";
const isProEnabled = settings ? isDyadProEnabled(settings) : false; const isProEnabled = settings ? isDyadProEnabled(settings) : false;
const { messagesRemaining, isQuotaExceeded } = useFreeAgentQuota();
const handleModeChange = (value: string) => { const handleModeChange = (value: string) => {
const newMode = value as ChatMode; const newMode = value as ChatMode;
...@@ -81,7 +83,8 @@ export function ChatModeSelector() { ...@@ -81,7 +83,8 @@ export function ChatModeSelector() {
case "agent": case "agent":
return "Build (MCP)"; return "Build (MCP)";
case "local-agent": case "local-agent":
return "Agent"; // Show "Basic Agent" for non-Pro users, "Agent" for Pro users
return isProEnabled ? "Agent" : "Basic Agent";
default: default:
return "Build"; return "Build";
} }
...@@ -128,6 +131,24 @@ export function ChatModeSelector() { ...@@ -128,6 +131,24 @@ export function ChatModeSelector() {
</div> </div>
</SelectItem> </SelectItem>
)} )}
{!isProEnabled && (
<SelectItem value="local-agent" disabled={isQuotaExceeded}>
<div className="flex flex-col items-start">
<div className="flex items-center gap-1.5">
<span className="font-medium">Basic Agent</span>
<span className="text-xs text-muted-foreground">
({isQuotaExceeded ? "0" : messagesRemaining}/5 remaining for
today)
</span>
</div>
<span className="text-xs text-muted-foreground">
{isQuotaExceeded
? "Daily limit reached"
: "Try our AI agent for free"}
</span>
</div>
</SelectItem>
)}
<SelectItem value="build"> <SelectItem value="build">
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<span className="font-medium">Build</span> <span className="font-medium">Build</span>
......
...@@ -12,9 +12,12 @@ import { MessagesList } from "./chat/MessagesList"; ...@@ -12,9 +12,12 @@ import { MessagesList } from "./chat/MessagesList";
import { ChatInput } from "./chat/ChatInput"; import { ChatInput } from "./chat/ChatInput";
import { VersionPane } from "./chat/VersionPane"; import { VersionPane } from "./chat/VersionPane";
import { ChatError } from "./chat/ChatError"; import { ChatError } from "./chat/ChatError";
import { FreeAgentQuotaBanner } from "./chat/FreeAgentQuotaBanner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowDown } from "lucide-react"; import { ArrowDown } from "lucide-react";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { isBasicAgentMode } from "@/lib/schemas";
interface ChatPanelProps { interface ChatPanelProps {
chatId?: number; chatId?: number;
...@@ -33,7 +36,10 @@ export function ChatPanel({ ...@@ -33,7 +36,10 @@ export function ChatPanel({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const streamCountById = useAtomValue(chatStreamCountByIdAtom); const streamCountById = useAtomValue(chatStreamCountByIdAtom);
const isStreamingById = useAtomValue(isStreamingByIdAtom); const isStreamingById = useAtomValue(isStreamingByIdAtom);
const { settings } = useSettings(); const { settings, updateSettings } = useSettings();
const { isQuotaExceeded } = useFreeAgentQuota();
const showFreeAgentQuotaBanner =
settings && isBasicAgentMode(settings) && isQuotaExceeded;
const messagesEndRef = useRef<HTMLDivElement | null>(null); const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messagesContainerRef = useRef<HTMLDivElement | null>(null); const messagesContainerRef = useRef<HTMLDivElement | null>(null);
...@@ -240,6 +246,13 @@ export function ChatPanel({ ...@@ -240,6 +246,13 @@ export function ChatPanel({
</div> </div>
<ChatError error={error} onDismiss={() => setError(null)} /> <ChatError error={error} onDismiss={() => setError(null)} />
{showFreeAgentQuotaBanner && (
<FreeAgentQuotaBanner
onSwitchToBuildMode={() =>
updateSettings({ selectedChatMode: "build" })
}
/>
)}
<ChatInput chatId={chatId} /> <ChatInput chatId={chatId} />
</div> </div>
)} )}
......
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { import {
Select, Select,
SelectContent, SelectContent,
...@@ -10,14 +11,23 @@ import type { ChatMode } from "@/lib/schemas"; ...@@ -10,14 +11,23 @@ import type { ChatMode } from "@/lib/schemas";
import { isDyadProEnabled, getEffectiveDefaultChatMode } from "@/lib/schemas"; import { isDyadProEnabled, getEffectiveDefaultChatMode } from "@/lib/schemas";
export function DefaultChatModeSelector() { export function DefaultChatModeSelector() {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings, envVars } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
if (!settings) { if (!settings) {
return null; return null;
} }
const isProEnabled = isDyadProEnabled(settings); const isProEnabled = isDyadProEnabled(settings);
const effectiveDefault = getEffectiveDefaultChatMode(settings); // Wait for quota status to load before determining effective default
const freeAgentQuotaAvailable = !isQuotaLoading && !isQuotaExceeded;
const effectiveDefault = getEffectiveDefaultChatMode(
settings,
envVars,
freeAgentQuotaAvailable,
);
// Show Basic Agent option if user is Pro OR if they have free quota available
const showBasicAgentOption = isProEnabled || freeAgentQuotaAvailable;
const handleDefaultChatModeChange = (value: ChatMode) => { const handleDefaultChatModeChange = (value: ChatMode) => {
updateSettings({ defaultChatMode: value }); updateSettings({ defaultChatMode: value });
...@@ -30,7 +40,7 @@ export function DefaultChatModeSelector() { ...@@ -30,7 +40,7 @@ export function DefaultChatModeSelector() {
case "agent": case "agent":
return "Build (MCP)"; return "Build (MCP)";
case "local-agent": case "local-agent":
return "Agent"; return isProEnabled ? "Agent" : "Basic Agent";
case "ask": case "ask":
default: default:
throw new Error(`Unknown chat mode: ${mode}`); throw new Error(`Unknown chat mode: ${mode}`);
...@@ -54,12 +64,16 @@ export function DefaultChatModeSelector() { ...@@ -54,12 +64,16 @@ export function DefaultChatModeSelector() {
<SelectValue>{getModeDisplayName(effectiveDefault)}</SelectValue> <SelectValue>{getModeDisplayName(effectiveDefault)}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{isProEnabled && ( {showBasicAgentOption && (
<SelectItem value="local-agent"> <SelectItem value="local-agent">
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<span className="font-medium">Agent</span> <span className="font-medium">
{isProEnabled ? "Agent" : "Basic Agent"}
</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Better at bigger tasks {isProEnabled
? "Better at bigger tasks"
: "Free tier (5 messages/day)"}
</span> </span>
</div> </div>
</SelectItem> </SelectItem>
......
...@@ -11,7 +11,10 @@ export function ChatError({ error, onDismiss }: ChatErrorProps) { ...@@ -11,7 +11,10 @@ export function ChatError({ error, onDismiss }: ChatErrorProps) {
} }
return ( return (
<div className="relative flex items-start text-red-600 bg-red-100 border border-red-500 rounded-md text-sm p-3 mx-4 mb-2 shadow-sm"> <div
data-testid="chat-error"
className="relative flex items-start text-red-600 bg-red-100 border border-red-500 rounded-md text-sm p-3 mx-4 mb-2 shadow-sm"
>
<AlertTriangle <AlertTriangle
className="h-5 w-5 mr-2 flex-shrink-0" className="h-5 w-5 mr-2 flex-shrink-0"
aria-hidden="true" aria-hidden="true"
......
...@@ -106,6 +106,24 @@ export function ChatErrorBox({ ...@@ -106,6 +106,24 @@ export function ChatErrorBox({
if (error.includes(fallbackPrefix)) { if (error.includes(fallbackPrefix)) {
error = error.split(fallbackPrefix)[0]; error = error.split(fallbackPrefix)[0];
} }
// Handle FREE_AGENT_QUOTA_EXCEEDED error (Basic Agent mode quota exceeded)
if (error.includes("FREE_AGENT_QUOTA_EXCEEDED")) {
return (
<ChatErrorContainer onDismiss={onDismiss}>
You have used all 5 free Agent messages for today. Please upgrade to
Dyad Pro for unlimited access or switch to Build mode.
<div className="mt-2 space-y-2 space-x-2">
<ExternalLink
href="https://dyad.sh/pro?utm_source=dyad-app&utm_medium=app&utm_campaign=free-agent-quota-exceeded"
variant="primary"
>
Upgrade to Dyad Pro
</ExternalLink>
</div>
</ChatErrorContainer>
);
}
return ( return (
<ChatErrorContainer onDismiss={onDismiss}> <ChatErrorContainer onDismiss={onDismiss}>
{error} {error}
...@@ -172,7 +190,10 @@ function ChatErrorContainer({ ...@@ -172,7 +190,10 @@ function ChatErrorContainer({
children: React.ReactNode | string; children: React.ReactNode | string;
}) { }) {
return ( return (
<div className="relative mt-2 bg-red-50 border border-red-200 rounded-md shadow-sm p-2 mx-4"> <div
data-testid="chat-error-box"
className="relative mt-2 bg-red-50 border border-red-200 rounded-md shadow-sm p-2 mx-4"
>
<button <button
onClick={onDismiss} onClick={onDismiss}
className="absolute top-2.5 left-2 p-1 hover:bg-red-100 rounded" className="absolute top-2.5 left-2 p-1 hover:bg-red-100 rounded"
......
import { AlertTriangle, ArrowRight, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { ipc } from "@/ipc/types";
interface FreeAgentQuotaBannerProps {
onSwitchToBuildMode: () => void;
}
/**
* Banner displayed when a free user has exceeded their daily Basic Agent quota.
* Shows the time until quota resets and provides options to upgrade or switch modes.
*/
export function FreeAgentQuotaBanner({
onSwitchToBuildMode,
}: FreeAgentQuotaBannerProps) {
const { quotaStatus, isQuotaExceeded, hoursUntilReset, resetTime } =
useFreeAgentQuota();
if (!isQuotaExceeded || !quotaStatus) {
return null;
}
// Calculate reset time display
const resetTimeDisplay =
hoursUntilReset !== null
? hoursUntilReset === 0
? "less than 1 hour"
: `${hoursUntilReset} hour${hoursUntilReset === 1 ? "" : "s"}`
: "later";
// Format the actual reset time (e.g., "11:59 PM")
const resetDateTime = resetTime
? new Date(resetTime).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
: "";
const handleUpgrade = () => {
ipc.system.openExternalUrl("https://dyad.sh/pro");
};
return (
<div
className="mx-auto max-w-3xl my-3 p-3 rounded-lg border border-amber-500/30 bg-amber-500/10"
data-testid="free-agent-quota-banner"
>
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<p className="text-sm text-amber-700 dark:text-amber-300">
You have used all 5 messages for the free Agent mode today. Check
back in {resetTimeDisplay} ({resetDateTime}). If you don't want to
wait, upgrade to Dyad Pro or switch back to Build mode.
</p>
<div className="flex flex-wrap gap-2">
<Button onClick={handleUpgrade} size="sm" className="gap-1.5">
<Sparkles className="h-3.5 w-3.5" />
Upgrade to Dyad Pro
</Button>
<Button
onClick={onSwitchToBuildMode}
variant="outline"
size="sm"
className="gap-1.5 border-amber-500/50 hover:bg-amber-500/20"
>
<ArrowRight className="h-3.5 w-3.5" />
Switch back to Build mode
</Button>
</div>
</div>
</div>
</div>
);
}
...@@ -97,6 +97,10 @@ export const messages = sqliteTable("messages", { ...@@ -97,6 +97,10 @@ export const messages = sqliteTable("messages", {
aiMessagesJson: text("ai_messages_json", { aiMessagesJson: text("ai_messages_json", {
mode: "json", mode: "json",
}).$type<AiMessagesJsonV6 | null>(), }).$type<AiMessagesJsonV6 | null>(),
// Track if this message used the free agent quota (for non-Pro users)
usingFreeAgentModeQuota: integer("using_free_agent_mode_quota", {
mode: "boolean",
}),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp" })
.notNull() .notNull()
.default(sql`(unixepoch())`), .default(sql`(unixepoch())`),
......
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ipc, type FreeAgentQuotaStatus } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys";
import { useSettings } from "./useSettings";
import { isDyadProEnabled } from "@/lib/schemas";
const THIRTY_MINUTES_IN_MS = 30 * 60 * 1000;
// In test mode, use very short staleTime for faster E2E tests
const STALE_TIME_MS = 30_000;
const TEST_STALE_TIME_MS = 500;
/**
* Hook to get the free agent quota status for non-Pro users.
*
* - Only fetches for non-Pro users (Pro users have unlimited access)
* - Refetches every 30 minutes to update the UI when quota resets
* - Returns quota status including messages used, limit, and time until reset
*/
export function useFreeAgentQuota() {
const { settings } = useSettings();
const queryClient = useQueryClient();
const isPro = settings ? isDyadProEnabled(settings) : false;
const isTestMode = settings?.isTestMode ?? false;
const {
data: quotaStatus,
isLoading,
error,
} = useQuery<FreeAgentQuotaStatus, Error, FreeAgentQuotaStatus>({
queryKey: queryKeys.freeAgentQuota.status,
queryFn: () => ipc.freeAgentQuota.getFreeAgentQuotaStatus(),
// Only fetch for non-Pro users
enabled: !isPro && !!settings,
// Refetch periodically to check for quota reset
refetchInterval: THIRTY_MINUTES_IN_MS,
// Consider stale after 30 seconds (500ms in test mode for faster E2E tests)
staleTime: isTestMode ? TEST_STALE_TIME_MS : STALE_TIME_MS,
// Don't retry on error (e.g., if there's an issue with the DB)
retry: false,
});
const invalidateQuota = () => {
queryClient.invalidateQueries({
queryKey: queryKeys.freeAgentQuota.status,
});
};
return {
quotaStatus,
isLoading,
error,
invalidateQuota,
// Convenience properties for easier consumption
isQuotaExceeded: quotaStatus?.isQuotaExceeded ?? false,
messagesUsed: quotaStatus?.messagesUsed ?? 0,
messagesLimit: quotaStatus?.messagesLimit ?? 5,
messagesRemaining: quotaStatus
? Math.max(0, quotaStatus.messagesLimit - quotaStatus.messagesUsed)
: 5,
hoursUntilReset: quotaStatus?.hoursUntilReset ?? null,
resetTime: quotaStatus?.resetTime ?? null,
};
}
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ipc, type LanguageModelProvider } from "@/ipc/types"; import { ipc, type LanguageModelProvider } from "@/ipc/types";
import { useSettings } from "./useSettings"; import { useSettings } from "./useSettings";
import { import { cloudProviders } from "@/lib/schemas";
cloudProviders,
VertexProviderSetting,
AzureProviderSetting,
} from "@/lib/schemas";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { isProviderSetup as isProviderSetupUtil } from "@/lib/providerUtils";
export function useLanguageModelProviders() { export function useLanguageModelProviders() {
const { settings, envVars } = useSettings(); const { settings, envVars } = useSettings();
...@@ -19,44 +16,12 @@ export function useLanguageModelProviders() { ...@@ -19,44 +16,12 @@ export function useLanguageModelProviders() {
}); });
const isProviderSetup = (provider: string) => { const isProviderSetup = (provider: string) => {
const providerSettings = settings?.providerSettings[provider]; return isProviderSetupUtil(provider, {
if (queryResult.isLoading) { settings,
return false; envVars,
} providerData: queryResult.data,
// Vertex uses service account credentials instead of an API key isLoading: queryResult.isLoading,
if (provider === "vertex") { });
const vertexSettings = providerSettings as VertexProviderSetting;
if (
vertexSettings?.serviceAccountKey?.value &&
vertexSettings?.projectId &&
vertexSettings?.location
) {
return true;
}
return false;
}
if (provider === "azure") {
const azureSettings = providerSettings as AzureProviderSetting;
const hasSavedSettings = Boolean(
(azureSettings?.apiKey?.value ?? "").trim() &&
(azureSettings?.resourceName ?? "").trim(),
);
if (hasSavedSettings) {
return true;
}
if (envVars["AZURE_API_KEY"] && envVars["AZURE_RESOURCE_NAME"]) {
return true;
}
return false;
}
if (providerSettings?.apiKey?.value) {
return true;
}
const providerData = queryResult.data?.find((p) => p.id === provider);
if (providerData?.envVarName && envVars[providerData.envVarName]) {
return true;
}
return false;
}; };
const isAnyProviderSetup = () => { const isAnyProviderSetup = () => {
......
...@@ -194,6 +194,11 @@ export function useStreamChat({ ...@@ -194,6 +194,11 @@ export function useStreamChat({
refetchUserBudget(); refetchUserBudget();
// Invalidate free agent quota to update the UI after message
queryClient.invalidateQueries({
queryKey: queryKeys.freeAgentQuota.status,
});
// Keep the same as below // Keep the same as below
setIsStreamingById((prev) => { setIsStreamingById((prev) => {
const next = new Map(prev); const next = new Map(prev);
...@@ -221,6 +226,12 @@ export function useStreamChat({ ...@@ -221,6 +226,12 @@ export function useStreamChat({
return next; return next;
}); });
// Invalidate free agent quota to update the UI after error
// (the server may have refunded the quota)
queryClient.invalidateQueries({
queryKey: queryKeys.freeAgentQuota.status,
});
// Keep the same as above // Keep the same as above
setIsStreamingById((prev) => { setIsStreamingById((prev) => {
const next = new Map(prev); const next = new Map(prev);
......
...@@ -87,9 +87,15 @@ import { mcpManager } from "../utils/mcp_manager"; ...@@ -87,9 +87,15 @@ import { mcpManager } from "../utils/mcp_manager";
import z from "zod"; import z from "zod";
import { import {
isDyadProEnabled, isDyadProEnabled,
isBasicAgentMode,
isSupabaseConnected, isSupabaseConnected,
isTurboEditsV2Enabled, isTurboEditsV2Enabled,
} from "@/lib/schemas"; } from "@/lib/schemas";
import {
getFreeAgentQuotaStatus,
markMessageAsUsingFreeAgentQuota,
unmarkMessageAsUsingFreeAgentQuota,
} from "./free_agent_quota_handlers";
import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts"; import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts";
import { getCurrentCommitHash } from "../utils/git_utils"; import { getCurrentCommitHash } from "../utils/git_utils";
import { import {
...@@ -630,6 +636,7 @@ ${componentSnippet} ...@@ -630,6 +636,7 @@ ${componentSnippet}
: settings.selectedChatMode, : settings.selectedChatMode,
enableTurboEditsV2: isTurboEditsV2Enabled(settings), enableTurboEditsV2: isTurboEditsV2Enabled(settings),
themePrompt, themePrompt,
basicAgentMode: isBasicAgentMode(settings),
}); });
// Add information about mentioned apps if any // Add information about mentioned apps if any
...@@ -1046,12 +1053,49 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -1046,12 +1053,49 @@ This conversation includes one or more image attachments. When the user uploads
settings.selectedChatMode === "local-agent" && settings.selectedChatMode === "local-agent" &&
!mentionedAppsCodebases.length !mentionedAppsCodebases.length
) { ) {
await handleLocalAgentStream(event, req, abortController, { // Check quota for Basic Agent mode (non-Pro users)
const isBasicAgentModeRequest = isBasicAgentMode(settings);
if (isBasicAgentModeRequest) {
const quotaStatus = await getFreeAgentQuotaStatus();
if (quotaStatus.isQuotaExceeded) {
safeSend(event.sender, "chat:response:error", {
chatId: req.chatId,
error: JSON.stringify({
type: "FREE_AGENT_QUOTA_EXCEEDED",
hoursUntilReset: quotaStatus.hoursUntilReset,
resetTime: quotaStatus.resetTime,
}),
});
return;
}
}
// Mark the user message as using quota BEFORE starting the stream
// to prevent race conditions with parallel requests
if (isBasicAgentModeRequest && userMessageId) {
await markMessageAsUsingFreeAgentQuota(userMessageId);
}
let streamSuccess = false;
try {
streamSuccess = await handleLocalAgentStream(
event,
req,
abortController,
{
placeholderMessageId: placeholderAssistantMessage.id, placeholderMessageId: placeholderAssistantMessage.id,
systemPrompt, systemPrompt,
dyadRequestId: dyadRequestId ?? "[no-request-id]", dyadRequestId: dyadRequestId ?? "[no-request-id]",
messageOverride: isSummarizeIntent ? chatMessages : undefined, messageOverride: isSummarizeIntent ? chatMessages : undefined,
}); },
);
} finally {
// If the stream failed, was aborted, or threw, refund the quota
if (isBasicAgentModeRequest && userMessageId && !streamSuccess) {
await unmarkMessageAsUsingFreeAgentQuota(userMessageId);
}
}
return; return;
} }
......
import { db } from "../../db";
import { messages } from "../../db/schema";
import { eq } from "drizzle-orm";
import { createTypedHandler } from "./base";
import { freeAgentQuotaContracts } from "../types/free_agent_quota";
import log from "electron-log";
import { ipcMain } from "electron";
import { IS_TEST_BUILD } from "../utils/test_utils";
import fetch from "node-fetch";
const logger = log.scope("free_agent_quota_handlers");
/** Timeout for server time fetch in milliseconds */
const SERVER_TIME_TIMEOUT_MS = 5000;
/**
* Fetches the current time from a trusted server to prevent clock manipulation.
* Uses the HTTP Date header from api.dyad.sh.
* Falls back to local time if the server is unreachable (but logs a warning).
*/
async function getServerTime(): Promise<number> {
// In test builds, use local time to allow test manipulation
if (IS_TEST_BUILD) {
return Date.now();
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
SERVER_TIME_TIMEOUT_MS,
);
const response = await fetch("https://api.dyad.sh/health", {
method: "HEAD",
signal: controller.signal,
});
clearTimeout(timeoutId);
const dateHeader = response.headers.get("Date");
if (dateHeader) {
const serverTime = new Date(dateHeader).getTime();
if (!isNaN(serverTime)) {
logger.debug(
`Server time fetched: ${new Date(serverTime).toISOString()}`,
);
return serverTime;
}
}
logger.warn(
"Server response missing valid Date header, falling back to local time",
);
return Date.now();
} catch (error) {
logger.warn(
`Failed to fetch server time, falling back to local time: ${error}`,
);
return Date.now();
}
}
/** Maximum number of free agent messages per 24-hour window */
export const FREE_AGENT_QUOTA_LIMIT = 5;
/**
* Duration of the quota window in milliseconds (23 hours).
* We use 23 hours instead of 24 to provide a fudge factor since the client
* only polls every 30 minutes, ensuring users don't wait longer than expected.
*/
export const QUOTA_WINDOW_MS = 23 * 60 * 60 * 1000;
export function registerFreeAgentQuotaHandlers() {
createTypedHandler(
freeAgentQuotaContracts.getFreeAgentQuotaStatus,
async () => {
return getFreeAgentQuotaStatus();
},
);
// Test-only handler to simulate time passing for quota tests
if (IS_TEST_BUILD) {
ipcMain.handle(
"test:simulateQuotaTimeElapsed",
async (_event, hoursAgo: number) => {
const secondsAgo = hoursAgo * 60 * 60;
const newTimestamp = Math.floor(Date.now() / 1000) - secondsAgo;
db.$client
.prepare(
`UPDATE messages SET created_at = ? WHERE using_free_agent_mode_quota = 1`,
)
.run(newTimestamp);
logger.log(
`[TEST] Simulated ${hoursAgo} hours elapsed for quota messages`,
);
return { success: true };
},
);
}
}
/**
* Marks a message as using the free agent quota.
* This should be called BEFORE starting the agent stream to prevent race conditions.
* If the stream fails, call unmarkMessageAsUsingFreeAgentQuota to refund the quota.
*/
export async function markMessageAsUsingFreeAgentQuota(
messageId: number,
): Promise<void> {
await db
.update(messages)
.set({ usingFreeAgentModeQuota: true })
.where(eq(messages.id, messageId));
logger.log(`Marked message ${messageId} as using free agent quota`);
}
/**
* Unmarks a message as using the free agent quota (refunds quota).
* This should be called when an agent stream fails or is aborted to avoid
* penalizing users for unsuccessful requests.
*/
export async function unmarkMessageAsUsingFreeAgentQuota(
messageId: number,
): Promise<void> {
await db
.update(messages)
.set({ usingFreeAgentModeQuota: false })
.where(eq(messages.id, messageId));
logger.log(`Unmarked message ${messageId} (refunded free agent quota)`);
}
/**
* Gets the current free agent quota status.
* Exported for use in chat stream handlers.
*
* Quota behavior: All 5 messages are released at once when 24 hours have passed
* since the oldest message was sent (not a rolling window).
*/
export async function getFreeAgentQuotaStatus() {
// Get all messages with usingFreeAgentModeQuota = true, ordered by creation time
const quotaMessages = await db
.select({
createdAt: messages.createdAt,
})
.from(messages)
.where(eq(messages.usingFreeAgentModeQuota, true))
.orderBy(messages.createdAt);
// If there are no quota messages, quota is fresh
if (quotaMessages.length === 0) {
return {
messagesUsed: 0,
messagesLimit: FREE_AGENT_QUOTA_LIMIT,
isQuotaExceeded: false,
windowStartTime: null,
resetTime: null,
hoursUntilReset: null,
};
}
// Check if the oldest message is >= 24 hours old
// If so, all 5 messages are released at once (quota resets)
// Uses server time to prevent clock manipulation cheating
const oldestMessage = quotaMessages[0];
const windowStartTime = oldestMessage.createdAt.getTime();
const resetTime = windowStartTime + QUOTA_WINDOW_MS;
const now = await getServerTime();
if (now >= resetTime) {
// Clean up expired quota messages before returning fresh quota
// This prevents stale messages from accumulating and causing incorrect window calculations
await db
.update(messages)
.set({ usingFreeAgentModeQuota: false })
.where(eq(messages.usingFreeAgentModeQuota, true));
logger.log("Quota reset: cleaned up expired quota messages");
// Quota has reset - all messages are released
return {
messagesUsed: 0,
messagesLimit: FREE_AGENT_QUOTA_LIMIT,
isQuotaExceeded: false,
windowStartTime: null,
resetTime: null,
hoursUntilReset: null,
};
}
// Quota has not reset - count all quota messages
const messagesUsed = quotaMessages.length;
const isQuotaExceeded = messagesUsed >= FREE_AGENT_QUOTA_LIMIT;
let hoursUntilReset = Math.ceil((resetTime - now) / (60 * 60 * 1000));
if (hoursUntilReset < 0) hoursUntilReset = 0;
logger.log(
`Free agent quota status: ${messagesUsed}/${FREE_AGENT_QUOTA_LIMIT} used, exceeded: ${isQuotaExceeded}`,
);
return {
messagesUsed,
messagesLimit: FREE_AGENT_QUOTA_LIMIT,
isQuotaExceeded,
windowStartTime,
resetTime,
hoursUntilReset,
};
}
...@@ -36,6 +36,7 @@ import { registerMcpHandlers } from "./handlers/mcp_handlers"; ...@@ -36,6 +36,7 @@ import { registerMcpHandlers } from "./handlers/mcp_handlers";
import { registerSecurityHandlers } from "./handlers/security_handlers"; import { registerSecurityHandlers } from "./handlers/security_handlers";
import { registerVisualEditingHandlers } from "../pro/main/ipc/handlers/visual_editing_handlers"; import { registerVisualEditingHandlers } from "../pro/main/ipc/handlers/visual_editing_handlers";
import { registerAgentToolHandlers } from "../pro/main/ipc/handlers/local_agent/agent_tool_handlers"; import { registerAgentToolHandlers } from "../pro/main/ipc/handlers/local_agent/agent_tool_handlers";
import { registerFreeAgentQuotaHandlers } from "./handlers/free_agent_quota_handlers";
export function registerIpcHandlers() { export function registerIpcHandlers() {
// Register all IPC handlers by category // Register all IPC handlers by category
...@@ -77,4 +78,5 @@ export function registerIpcHandlers() { ...@@ -77,4 +78,5 @@ export function registerIpcHandlers() {
registerSecurityHandlers(); registerSecurityHandlers();
registerVisualEditingHandlers(); registerVisualEditingHandlers();
registerAgentToolHandlers(); registerAgentToolHandlers();
registerFreeAgentQuotaHandlers();
} }
...@@ -37,6 +37,7 @@ import { upgradeContracts } from "../types/upgrade"; ...@@ -37,6 +37,7 @@ import { upgradeContracts } from "../types/upgrade";
import { visualEditingContracts } from "../types/visual-editing"; import { visualEditingContracts } from "../types/visual-editing";
import { securityContracts } from "../types/security"; import { securityContracts } from "../types/security";
import { miscContracts, miscEvents } from "../types/misc"; import { miscContracts, miscEvents } from "../types/misc";
import { freeAgentQuotaContracts } from "../types/free_agent_quota";
// ============================================================================= // =============================================================================
// Invoke Channels (derived from all contracts) // Invoke Channels (derived from all contracts)
...@@ -45,6 +46,9 @@ import { miscContracts, miscEvents } from "../types/misc"; ...@@ -45,6 +46,9 @@ import { miscContracts, miscEvents } from "../types/misc";
const CHAT_STREAM_CHANNELS = getStreamChannels(chatStreamContract); const CHAT_STREAM_CHANNELS = getStreamChannels(chatStreamContract);
const HELP_STREAM_CHANNELS = getStreamChannels(helpStreamContract); const HELP_STREAM_CHANNELS = getStreamChannels(helpStreamContract);
// Test-only channels (handler only registered in E2E test builds, but channel always allowed)
const TEST_INVOKE_CHANNELS = ["test:simulateQuotaTimeElapsed"] as const;
/** /**
* All valid invoke channels derived from contracts. * All valid invoke channels derived from contracts.
* Used by preload.ts to whitelist IPC channels. * Used by preload.ts to whitelist IPC channels.
...@@ -83,6 +87,10 @@ export const VALID_INVOKE_CHANNELS = [ ...@@ -83,6 +87,10 @@ export const VALID_INVOKE_CHANNELS = [
...getInvokeChannels(visualEditingContracts), ...getInvokeChannels(visualEditingContracts),
...getInvokeChannels(securityContracts), ...getInvokeChannels(securityContracts),
...getInvokeChannels(miscContracts), ...getInvokeChannels(miscContracts),
...getInvokeChannels(freeAgentQuotaContracts),
// Test-only channels
...TEST_INVOKE_CHANNELS,
] as const; ] as const;
// ============================================================================= // =============================================================================
......
import { z } from "zod";
import { defineContract, createClient } from "../contracts/core";
// =============================================================================
// Free Agent Quota Contracts
// =============================================================================
/**
* Schema for free agent quota status response.
*/
export const FreeAgentQuotaStatusSchema = z.object({
/** Number of messages used in the current 24-hour window */
messagesUsed: z.number(),
/** Maximum messages allowed (always 5) */
messagesLimit: z.number(),
/** Whether the quota has been exceeded */
isQuotaExceeded: z.boolean(),
/** Unix timestamp of the first message in the current window (null if no messages) */
windowStartTime: z.number().nullable(),
/** Unix timestamp when quota resets (null if no messages) */
resetTime: z.number().nullable(),
/** Hours remaining until quota resets (null if no messages) */
hoursUntilReset: z.number().nullable(),
});
export type FreeAgentQuotaStatus = z.infer<typeof FreeAgentQuotaStatusSchema>;
/**
* Free agent quota contracts define the IPC interface for managing
* the 5-message-per-day quota for non-Pro users using Basic Agent mode.
*/
export const freeAgentQuotaContracts = {
/**
* Get current quota status for the free agent mode.
* Returns messages used, remaining, and time until reset.
*/
getFreeAgentQuotaStatus: defineContract({
channel: "free-agent-quota:get-status",
input: z.void(),
output: FreeAgentQuotaStatusSchema,
}),
} as const;
// =============================================================================
// Free Agent Quota Client
// =============================================================================
/**
* Type-safe client for free agent quota IPC operations.
*
* @example
* const status = await freeAgentQuotaClient.getFreeAgentQuotaStatus();
* if (status.isQuotaExceeded) {
* console.log(`Quota exceeded. Resets in ${status.hoursUntilReset} hours`);
* }
*/
export const freeAgentQuotaClient = createClient(freeAgentQuotaContracts);
// =============================================================================
// Type Exports
// =============================================================================
/** Output type for getFreeAgentQuotaStatus */
export type GetFreeAgentQuotaStatusOutput = z.infer<
(typeof freeAgentQuotaContracts)["getFreeAgentQuotaStatus"]["output"]
>;
...@@ -49,6 +49,7 @@ export { upgradeContracts } from "./upgrade"; ...@@ -49,6 +49,7 @@ export { upgradeContracts } from "./upgrade";
export { visualEditingContracts } from "./visual-editing"; export { visualEditingContracts } from "./visual-editing";
export { securityContracts } from "./security"; export { securityContracts } from "./security";
export { miscContracts, miscEvents } from "./misc"; export { miscContracts, miscEvents } from "./misc";
export { freeAgentQuotaContracts } from "./free_agent_quota";
// ============================================================================= // =============================================================================
// Client Exports // Client Exports
...@@ -77,6 +78,7 @@ export { upgradeClient } from "./upgrade"; ...@@ -77,6 +78,7 @@ export { upgradeClient } from "./upgrade";
export { visualEditingClient } from "./visual-editing"; export { visualEditingClient } from "./visual-editing";
export { securityClient } from "./security"; export { securityClient } from "./security";
export { miscClient, miscEventClient } from "./misc"; export { miscClient, miscEventClient } from "./misc";
export { freeAgentQuotaClient } from "./free_agent_quota";
// ============================================================================= // =============================================================================
// Type Exports // Type Exports
...@@ -276,6 +278,9 @@ export type { SecurityReviewResult } from "./security"; ...@@ -276,6 +278,9 @@ export type { SecurityReviewResult } from "./security";
// Misc types // Misc types
export type { ChatLogsData, DeepLinkData, AppOutput, EnvVar } from "./misc"; export type { ChatLogsData, DeepLinkData, AppOutput, EnvVar } from "./misc";
// Free agent quota types
export type { FreeAgentQuotaStatus } from "./free_agent_quota";
// ============================================================================= // =============================================================================
// Schema Exports (for validation in handlers/components) // Schema Exports (for validation in handlers/components)
// ============================================================================= // =============================================================================
...@@ -331,6 +336,7 @@ import { upgradeClient } from "./upgrade"; ...@@ -331,6 +336,7 @@ import { upgradeClient } from "./upgrade";
import { visualEditingClient } from "./visual-editing"; import { visualEditingClient } from "./visual-editing";
import { securityClient } from "./security"; import { securityClient } from "./security";
import { miscClient, miscEventClient } from "./misc"; import { miscClient, miscEventClient } from "./misc";
import { freeAgentQuotaClient } from "./free_agent_quota";
/** /**
* Unified IPC client with all domains organized by namespace. * Unified IPC client with all domains organized by namespace.
...@@ -385,6 +391,7 @@ export const ipc = { ...@@ -385,6 +391,7 @@ export const ipc = {
visualEditing: visualEditingClient, visualEditing: visualEditingClient,
security: securityClient, security: securityClient,
misc: miscClient, misc: miscClient,
freeAgentQuota: freeAgentQuotaClient,
// Event clients for main->renderer pub/sub // Event clients for main->renderer pub/sub
events: { events: {
......
...@@ -37,20 +37,20 @@ const dyadEngineUrl = process.env.DYAD_ENGINE_URL; ...@@ -37,20 +37,20 @@ const dyadEngineUrl = process.env.DYAD_ENGINE_URL;
const AUTO_MODELS = [ const AUTO_MODELS = [
{ {
provider: "google", provider: "openai",
name: "gemini-2.5-flash", name: GPT_5_2_MODEL_NAME,
}, },
{ {
provider: "openrouter", provider: "anthropic",
name: "qwen/qwen3-coder:free", name: SONNET_4_5,
}, },
{ {
provider: "anthropic", provider: "google",
name: "claude-sonnet-4-20250514", name: GEMINI_3_FLASH,
}, },
{ {
provider: "openai", provider: "google",
name: "gpt-4.1", name: "gemini-2.5-flash",
}, },
]; ];
......
import {
type UserSettings,
type VertexProviderSetting,
type AzureProviderSetting,
} from "./schemas";
import { PROVIDER_TO_ENV_VAR } from "../ipc/shared/language_model_constants";
export interface ProviderCheckOptions {
settings: UserSettings | null;
envVars: Record<string, string | undefined>;
/** Provider data from the query (needed for custom providers and env var lookup) */
providerData?: { id: string; envVarName?: string }[];
/** If true, returns false while data is still loading */
isLoading?: boolean;
}
/**
* Checks if a specific provider is set up with valid credentials.
* Works with settings and optionally env vars.
*/
export function isProviderSetup(
provider: string,
options: ProviderCheckOptions,
): boolean {
const { settings, envVars, providerData, isLoading } = options;
if (isLoading) {
return false;
}
const providerSettings = settings?.providerSettings[provider];
// Vertex uses service account credentials instead of an API key
if (provider === "vertex") {
const vertexSettings = providerSettings as VertexProviderSetting;
if (
vertexSettings?.serviceAccountKey?.value &&
vertexSettings?.projectId &&
vertexSettings?.location
) {
return true;
}
return false;
}
// Azure needs apiKey + resourceName
if (provider === "azure") {
const azureSettings = providerSettings as AzureProviderSetting;
const hasSavedSettings = Boolean(
(azureSettings?.apiKey?.value ?? "").trim() &&
(azureSettings?.resourceName ?? "").trim(),
);
if (hasSavedSettings) {
return true;
}
if (envVars["AZURE_API_KEY"] && envVars["AZURE_RESOURCE_NAME"]) {
return true;
}
return false;
}
// Check API key in settings
if (providerSettings?.apiKey?.value) {
return true;
}
// Check env var - first try the static mapping, then provider data
const staticEnvVar = PROVIDER_TO_ENV_VAR[provider];
if (staticEnvVar && envVars[staticEnvVar]) {
return true;
}
// Check provider data for env var name (for custom providers)
const providerInfo = providerData?.find((p) => p.id === provider);
if (providerInfo?.envVarName && envVars[providerInfo.envVarName]) {
return true;
}
return false;
}
/**
* Simple check for whether OpenAI or Anthropic provider is set up.
* Used for determining if basic agent mode should be available.
*/
export function isOpenAIOrAnthropicSetup(
settings: UserSettings,
envVars: Record<string, string | undefined>,
): boolean {
if (!settings) return false;
const options: ProviderCheckOptions = { settings, envVars };
return (
isProviderSetup("openai", options) || isProviderSetup("anthropic", options)
);
}
...@@ -176,6 +176,13 @@ export const queryKeys = { ...@@ -176,6 +176,13 @@ export const queryKeys = {
info: ["userBudgetInfo"] as const, info: ["userBudgetInfo"] as const,
}, },
// ─────────────────────────────────────────────────────────────────────────────
// Free Agent Quota
// ─────────────────────────────────────────────────────────────────────────────
freeAgentQuota: {
status: ["freeAgentQuotaStatus"] as const,
},
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Vercel Deployments // Vercel Deployments
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
...@@ -285,6 +292,9 @@ export type AppQueryKey = ...@@ -285,6 +292,9 @@ export type AppQueryKey =
(typeof queryKeys.languageModels)[keyof typeof queryKeys.languageModels] (typeof queryKeys.languageModels)[keyof typeof queryKeys.languageModels]
> >
| QueryKeyOf<(typeof queryKeys.userBudget)[keyof typeof queryKeys.userBudget]> | QueryKeyOf<(typeof queryKeys.userBudget)[keyof typeof queryKeys.userBudget]>
| QueryKeyOf<
(typeof queryKeys.freeAgentQuota)[keyof typeof queryKeys.freeAgentQuota]
>
| QueryKeyOf<(typeof queryKeys.vercel)[keyof typeof queryKeys.vercel]> | QueryKeyOf<(typeof queryKeys.vercel)[keyof typeof queryKeys.vercel]>
| QueryKeyOf< | QueryKeyOf<
(typeof queryKeys.appUpgrades)[keyof typeof queryKeys.appUpgrades] (typeof queryKeys.appUpgrades)[keyof typeof queryKeys.appUpgrades]
......
import { z } from "zod"; import { z } from "zod";
import { isOpenAIOrAnthropicSetup } from "./providerUtils";
export const SecretSchema = z.object({ export const SecretSchema = z.object({
value: z.string(), value: z.string(),
...@@ -337,24 +338,56 @@ export function hasDyadProKey(settings: UserSettings): boolean { ...@@ -337,24 +338,56 @@ export function hasDyadProKey(settings: UserSettings): boolean {
} }
/** /**
* Gets the effective default chat mode based on settings and pro status. * Gets the effective default chat mode based on settings, pro status, and free quota availability.
* - If defaultChatMode is set and valid for the user's Pro status, use it * - If defaultChatMode is set and valid for the user's Pro status, use it
* - If defaultChatMode is "local-agent" but user doesn't have Pro, fall back to "build" * - If defaultChatMode is "local-agent" but user doesn't have Pro:
* - If defaultChatMode is NOT set but user has Dyad Pro enabled, treat as "local-agent" * - If free agent quota available AND OpenAI/Anthropic is set up, use "local-agent" (basic agent mode)
* - If not pro, treat as "build" * - Otherwise, fall back to "build"
* - If defaultChatMode is NOT set:
* - Pro users: use "local-agent"
* - Non-Pro users with quota AND OpenAI/Anthropic set up: use "local-agent" (basic agent mode)
* - Non-Pro users without quota or provider: use "build"
*/ */
export function getEffectiveDefaultChatMode(settings: UserSettings): ChatMode { export function getEffectiveDefaultChatMode(
settings: UserSettings,
envVars: Record<string, string | undefined>,
freeAgentQuotaAvailable?: boolean,
): ChatMode {
const isPro = isDyadProEnabled(settings);
// We are checking that OpenAI or Anthropic is setup, which are the first two
// choices for the Auto model selection.
//
// If user only has Gemini API key, we don't default to local-agent because
// most likely it's a free API key with stringent limits and they'll get
// a bad experience with local-agent.
const hasPaidProviderSetup = isOpenAIOrAnthropicSetup(settings, envVars);
if (settings.defaultChatMode) { if (settings.defaultChatMode) {
// "local-agent" requires Pro - fall back to "build" if user lost Pro access // "local-agent" requires either Pro OR (available free quota AND provider setup)
if ( if (settings.defaultChatMode === "local-agent") {
settings.defaultChatMode === "local-agent" && if (isPro) return "local-agent";
!isDyadProEnabled(settings) if (freeAgentQuotaAvailable && hasPaidProviderSetup) return "local-agent";
) {
return "build"; return "build";
} }
return settings.defaultChatMode; return settings.defaultChatMode;
} }
return isDyadProEnabled(settings) ? "local-agent" : "build";
// No explicit default set
if (isPro) return "local-agent";
if (freeAgentQuotaAvailable && hasPaidProviderSetup) return "local-agent";
return "build";
}
/**
* Determines if the current session is using Basic Agent mode (free tier with quota).
* Basic Agent mode is when:
* - User is NOT a Pro subscriber
* - User is using local-agent chat mode
*/
export function isBasicAgentMode(settings: UserSettings): boolean {
return (
!isDyadProEnabled(settings) && settings.selectedChatMode === "local-agent"
);
} }
export function isSupabaseConnected(settings: UserSettings | null): boolean { export function isSupabaseConnected(settings: UserSettings | null): boolean {
......
...@@ -40,6 +40,7 @@ import { ...@@ -40,6 +40,7 @@ import {
SetupDyadProButton, SetupDyadProButton,
} from "@/components/ProBanner"; } from "@/components/ProBanner";
import { hasDyadProKey, getEffectiveDefaultChatMode } from "@/lib/schemas"; import { hasDyadProKey, getEffectiveDefaultChatMode } from "@/lib/schemas";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
// Adding an export for attachments // Adding an export for attachments
export interface HomeSubmitOptions { export interface HomeSubmitOptions {
...@@ -52,7 +53,8 @@ export default function HomePage() { ...@@ -52,7 +53,8 @@ export default function HomePage() {
const search = useSearch({ from: "/" }); const search = useSearch({ from: "/" });
const setSelectedAppId = useSetAtom(selectedAppIdAtom); const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const { refreshApps } = useLoadApps(); const { refreshApps } = useLoadApps();
const { settings, updateSettings } = useSettings(); const { settings, updateSettings, envVars } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom); const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
...@@ -139,16 +141,22 @@ export default function HomePage() { ...@@ -139,16 +141,22 @@ export default function HomePage() {
}, [appId, navigate]); }, [appId, navigate]);
// Apply default chat mode when navigating to home page // Apply default chat mode when navigating to home page
// Wait for quota status to load to avoid race condition where we default to Basic Agent
// before knowing if quota is actually exceeded
const hasAppliedDefaultChatMode = useRef(false); const hasAppliedDefaultChatMode = useRef(false);
useEffect(() => { useEffect(() => {
if (settings && !hasAppliedDefaultChatMode.current) { if (settings && !hasAppliedDefaultChatMode.current && !isQuotaLoading) {
hasAppliedDefaultChatMode.current = true; hasAppliedDefaultChatMode.current = true;
const effectiveDefaultMode = getEffectiveDefaultChatMode(settings); const effectiveDefaultMode = getEffectiveDefaultChatMode(
settings,
envVars,
!isQuotaExceeded,
);
if (settings.selectedChatMode !== effectiveDefaultMode) { if (settings.selectedChatMode !== effectiveDefaultMode) {
updateSettings({ selectedChatMode: effectiveDefaultMode }); updateSettings({ selectedChatMode: effectiveDefaultMode });
} }
} }
}, [settings, updateSettings]); }, [settings, updateSettings, isQuotaExceeded, isQuotaLoading, envVars]);
const handleSubmit = async (options?: HomeSubmitOptions) => { const handleSubmit = async (options?: HomeSubmitOptions) => {
const attachments = options?.attachments || []; const attachments = options?.attachments || [];
......
...@@ -17,8 +17,8 @@ const validReceiveChannels = VALID_RECEIVE_CHANNELS; ...@@ -17,8 +17,8 @@ const validReceiveChannels = VALID_RECEIVE_CHANNELS;
// the ipcRenderer without exposing the entire object // the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld("electron", { contextBridge.exposeInMainWorld("electron", {
ipcRenderer: { ipcRenderer: {
invoke: (channel: ValidInvokeChannel, ...args: unknown[]) => { invoke: (channel: ValidInvokeChannel | string, ...args: unknown[]) => {
if (validInvokeChannels.includes(channel)) { if ((validInvokeChannels as readonly string[]).includes(channel)) {
return ipcRenderer.invoke(channel, ...args); return ipcRenderer.invoke(channel, ...args);
} }
throw new Error(`Invalid channel: ${channel}`); throw new Error(`Invalid channel: ${channel}`);
......
...@@ -18,7 +18,7 @@ import { db } from "@/db"; ...@@ -18,7 +18,7 @@ import { db } from "@/db";
import { chats, messages } from "@/db/schema"; import { chats, messages } from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { isDyadProEnabled } from "@/lib/schemas"; import { isDyadProEnabled, isBasicAgentMode } from "@/lib/schemas";
import { readSettings } from "@/main/settings"; import { readSettings } from "@/main/settings";
import { getDyadAppPath } from "@/paths/paths"; import { getDyadAppPath } from "@/paths/paths";
import { getModelClient } from "@/ipc/utils/get_model_client"; import { getModelClient } from "@/ipc/utils/get_model_client";
...@@ -126,17 +126,18 @@ export async function handleLocalAgentStream( ...@@ -126,17 +126,18 @@ export async function handleLocalAgentStream(
*/ */
messageOverride?: ModelMessage[]; messageOverride?: ModelMessage[];
}, },
): Promise<void> { ): Promise<boolean> {
const settings = readSettings(); const settings = readSettings();
// Check Pro status // Check Pro status or Basic Agent mode
if (!isDyadProEnabled(settings)) { // Basic Agent mode allows non-Pro users with quota (quota check is done in chat_stream_handlers)
if (!isDyadProEnabled(settings) && !isBasicAgentMode(settings)) {
safeSend(event.sender, "chat:response:error", { safeSend(event.sender, "chat:response:error", {
chatId: req.chatId, chatId: req.chatId,
error: error:
"Agent v2 requires Dyad Pro. Please enable Dyad Pro in Settings → Pro.", "Agent v2 requires Dyad Pro. Please enable Dyad Pro in Settings → Pro.",
}); });
return; return false;
} }
// Get the chat and app // Get the chat and app
...@@ -191,6 +192,7 @@ export async function handleLocalAgentStream( ...@@ -191,6 +192,7 @@ export async function handleLocalAgentStream(
todos: [], todos: [],
dyadRequestId, dyadRequestId,
fileEditTracker, fileEditTracker,
isBasicAgentMode: isBasicAgentMode(settings),
onXmlStream: (accumulatedXml: string) => { onXmlStream: (accumulatedXml: string) => {
// Stream accumulated XML to UI without persisting // Stream accumulated XML to UI without persisting
streamingPreview = accumulatedXml; streamingPreview = accumulatedXml;
...@@ -473,7 +475,7 @@ export async function handleLocalAgentStream( ...@@ -473,7 +475,7 @@ export async function handleLocalAgentStream(
updatedFiles: !readOnly, updatedFiles: !readOnly,
} satisfies ChatResponseEnd); } satisfies ChatResponseEnd);
return; return true; // Success
} catch (error) { } catch (error) {
// Clean up any pending consent requests for this chat to prevent // Clean up any pending consent requests for this chat to prevent
// stale UI banners and orphaned promises // stale UI banners and orphaned promises
...@@ -487,7 +489,7 @@ export async function handleLocalAgentStream( ...@@ -487,7 +489,7 @@ export async function handleLocalAgentStream(
.set({ content: `${fullResponse}\n\n[Response cancelled by user]` }) .set({ content: `${fullResponse}\n\n[Response cancelled by user]` })
.where(eq(messages.id, placeholderMessageId)); .where(eq(messages.id, placeholderMessageId));
} }
return; return false; // Cancelled - don't consume quota
} }
logger.error("Local agent error:", error); logger.error("Local agent error:", error);
...@@ -495,7 +497,7 @@ export async function handleLocalAgentStream( ...@@ -495,7 +497,7 @@ export async function handleLocalAgentStream(
chatId: req.chatId, chatId: req.chatId,
error: `Error: ${error}`, error: `Error: ${error}`,
}); });
return; return false; // Error - don't consume quota
} }
} }
......
...@@ -76,6 +76,9 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> = ...@@ -76,6 +76,9 @@ export const codeSearchTool: ToolDefinition<z.infer<typeof codeSearchSchema>> =
inputSchema: codeSearchSchema, inputSchema: codeSearchSchema,
defaultConsent: "always", defaultConsent: "always",
// Disable in Basic Agent mode (free tier) - requires engine
isEnabled: (ctx) => !ctx.isBasicAgentMode,
getConsentPreview: (args) => `Search for "${args.query}"`, getConsentPreview: (args) => `Search for "${args.query}"`,
buildXml: (args, isComplete) => { buildXml: (args, isComplete) => {
......
...@@ -142,6 +142,9 @@ export const editFileTool: ToolDefinition<z.infer<typeof editFileSchema>> = { ...@@ -142,6 +142,9 @@ export const editFileTool: ToolDefinition<z.infer<typeof editFileSchema>> = {
defaultConsent: "always", defaultConsent: "always",
modifiesState: true, modifiesState: true,
// Disable in Basic Agent mode (free tier) - requires engine
isEnabled: (ctx) => !ctx.isBasicAgentMode,
getConsentPreview: (args) => `Edit ${args.path}`, getConsentPreview: (args) => `Edit ${args.path}`,
buildXml: (args, isComplete) => { buildXml: (args, isComplete) => {
......
...@@ -45,6 +45,7 @@ describe("searchReplaceTool", () => { ...@@ -45,6 +45,7 @@ describe("searchReplaceTool", () => {
supabaseOrganizationSlug: null, supabaseOrganizationSlug: null,
messageId: 1, messageId: 1,
isSharedModulesChanged: false, isSharedModulesChanged: false,
isBasicAgentMode: false,
todos: [], todos: [],
dyadRequestId: "test-request", dyadRequestId: "test-request",
fileEditTracker: {}, fileEditTracker: {},
......
...@@ -57,6 +57,11 @@ export interface AgentContext { ...@@ -57,6 +57,11 @@ export interface AgentContext {
dyadRequestId: string; dyadRequestId: string;
/** Tracks file edit tool usage per file for telemetry */ /** Tracks file edit tool usage per file for telemetry */
fileEditTracker: FileEditTracker; fileEditTracker: FileEditTracker;
/**
* If true, this is Basic Agent mode (free tier with quota).
* Engine-dependent tools are disabled in this mode.
*/
isBasicAgentMode: boolean;
/** /**
* Streams accumulated XML to UI without persisting to DB (for live preview). * Streams accumulated XML to UI without persisting to DB (for live preview).
* Call this repeatedly with the full accumulated XML so far. * Call this repeatedly with the full accumulated XML so far.
......
...@@ -80,6 +80,9 @@ export const webCrawlTool: ToolDefinition<z.infer<typeof webCrawlSchema>> = { ...@@ -80,6 +80,9 @@ export const webCrawlTool: ToolDefinition<z.infer<typeof webCrawlSchema>> = {
inputSchema: webCrawlSchema, inputSchema: webCrawlSchema,
defaultConsent: "ask", defaultConsent: "ask",
// Disable in Basic Agent mode (free tier) - requires engine
isEnabled: (ctx) => !ctx.isBasicAgentMode,
getConsentPreview: (args) => `Crawl URL: "${args.url}"`, getConsentPreview: (args) => `Crawl URL: "${args.url}"`,
buildXml: (args, isComplete) => { buildXml: (args, isComplete) => {
......
...@@ -161,6 +161,9 @@ export const webSearchTool: ToolDefinition<z.infer<typeof webSearchSchema>> = { ...@@ -161,6 +161,9 @@ export const webSearchTool: ToolDefinition<z.infer<typeof webSearchSchema>> = {
inputSchema: webSearchSchema, inputSchema: webSearchSchema,
defaultConsent: "ask", defaultConsent: "ask",
// Disable in Basic Agent mode (free tier) - requires engine
isEnabled: (ctx) => !ctx.isBasicAgentMode,
getConsentPreview: (args) => `Search the web: "${args.query}"`, getConsentPreview: (args) => `Search the web: "${args.query}"`,
execute: async (args, ctx: AgentContext) => { execute: async (args, ctx: AgentContext) => {
......
...@@ -3,60 +3,16 @@ ...@@ -3,60 +3,16 @@
* Tool-based agent with parallel execution support * Tool-based agent with parallel execution support
*/ */
/** // ============================================================================
* System prompt for Local Agent v2 in Ask Mode (read-only) // Shared Prompt Blocks (used by both Pro and Basic Agent modes)
* The agent can read and analyze code, but cannot make changes // ============================================================================
*/
export const LOCAL_AGENT_ASK_SYSTEM_PROMPT = `
<role>
You are Dyad, an AI assistant that helps users understand their web applications. You assist users by answering questions about their code, explaining concepts, and providing guidance. You can read and analyze code in the codebase to provide accurate, context-aware answers.
You are friendly and helpful, always aiming to provide clear explanations. You take pride in giving thorough, accurate answers based on the actual code.
</role>
<important_constraints>
**CRITICAL: You are in READ-ONLY mode.**
- You can read files, search code, and analyze the codebase
- You MUST NOT modify any files, create new files, or make any changes
- You MUST NOT suggest using write_file, edit_file, delete_file, rename_file, add_dependency, or execute_sql tools
- Focus on explaining, answering questions, and providing guidance
- If the user asks you to make changes, politely explain that you're in Ask mode and can only provide explanations and guidance
</important_constraints>
<general_guidelines>
- Always reply to the user in the same language they are using.
- Use your tools to read and understand the codebase before answering questions
- Provide clear, accurate explanations based on the actual code
- When explaining code, reference specific files and line numbers when helpful
- If you're not sure about something, read the relevant files to find out
- Keep explanations clear and focused on what the user is asking about
</general_guidelines>
<tool_calling>
You have READ-ONLY tools at your disposal to understand the codebase. Follow these rules:
1. ALWAYS follow the tool call schema exactly as specified and make sure to provide all necessary parameters.
2. **NEVER refer to tool names when speaking to the USER.** Instead, just say what you're doing in natural language (e.g., "Let me look at that file" instead of "I'll use read_file").
3. Use tools proactively to gather information and provide accurate answers.
4. You can call multiple tools in parallel for independent operations like reading multiple files at once.
5. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
</tool_calling>
<workflow>
1. **Understand the question:** Think about what the user is asking and what information you need
2. **Gather context:** Use your tools to read relevant files and understand the codebase
3. **Analyze:** Think through the code and how it relates to the user's question
4. **Explain:** Provide a clear, accurate answer based on what you found
</workflow>
[[AI_RULES]]
`;
export const LOCAL_AGENT_SYSTEM_PROMPT = ` const ROLE_BLOCK = `<role>
<role>
You are Dyad, an AI assistant that creates and modifies web applications. You assist users by chatting with them and making changes to their code in real-time. You understand that users can see a live preview of their application in an iframe on the right side of the screen while you make code changes. You are Dyad, an AI assistant that creates and modifies web applications. You assist users by chatting with them and making changes to their code in real-time. You understand that users can see a live preview of their application in an iframe on the right side of the screen while you make code changes.
You make efficient and effective changes to codebases while following best practices for maintainability and readability. You take pride in keeping things simple and elegant. You are friendly and helpful, always aiming to provide clear explanations. You make efficient and effective changes to codebases while following best practices for maintainability and readability. You take pride in keeping things simple and elegant. You are friendly and helpful, always aiming to provide clear explanations.
</role> </role>`;
<app_commands> const APP_COMMANDS_BLOCK = `<app_commands>
Do *not* tell the user to run shell commands. Instead, they can do one of the following commands in the UI: Do *not* tell the user to run shell commands. Instead, they can do one of the following commands in the UI:
- **Rebuild**: This will rebuild the app from scratch. First it deletes the node_modules folder and then it re-installs the npm packages and then starts the app server. - **Rebuild**: This will rebuild the app from scratch. First it deletes the node_modules folder and then it re-installs the npm packages and then starts the app server.
...@@ -69,9 +25,9 @@ You can suggest one of these commands by using the <dyad-command> tag like this: ...@@ -69,9 +25,9 @@ You can suggest one of these commands by using the <dyad-command> tag like this:
<dyad-command type="refresh"></dyad-command> <dyad-command type="refresh"></dyad-command>
If you output one of these commands, tell the user to look for the action button above the chat input. If you output one of these commands, tell the user to look for the action button above the chat input.
</app_commands> </app_commands>`;
<general_guidelines> const GENERAL_GUIDELINES_BLOCK = `<general_guidelines>
- Always reply to the user in the same language they are using. - Always reply to the user in the same language they are using.
- Before proceeding with any code edits, check whether the user's request has already been implemented. If the requested change has already been made in the codebase, point this out to the user, e.g., "This feature is already implemented as described." - Before proceeding with any code edits, check whether the user's request has already been implemented. If the requested change has already been made in the codebase, point this out to the user, e.g., "This feature is already implemented as described."
- Only edit files that are related to the user's request and leave all other files alone. - Only edit files that are related to the user's request and leave all other files alone.
...@@ -82,9 +38,9 @@ If you output one of these commands, tell the user to look for the action button ...@@ -82,9 +38,9 @@ If you output one of these commands, tell the user to look for the action button
- Set a chat summary at the end using the \`set_chat_summary\` tool. - Set a chat summary at the end using the \`set_chat_summary\` tool.
- DO NOT OVERENGINEER THE CODE. You take great pride in keeping things simple and elegant. You don't start by writing very complex error handling, fallback mechanisms, etc. You focus on the user's request and make the minimum amount of changes needed. - DO NOT OVERENGINEER THE CODE. You take great pride in keeping things simple and elegant. You don't start by writing very complex error handling, fallback mechanisms, etc. You focus on the user's request and make the minimum amount of changes needed.
DON'T DO MORE THAN WHAT THE USER ASKS FOR. DON'T DO MORE THAN WHAT THE USER ASKS FOR.
</general_guidelines> </general_guidelines>`;
<tool_calling> const TOOL_CALLING_BLOCK = `<tool_calling>
You have tools at your disposal to solve the coding task. Follow these rules regarding tool calls: You have tools at your disposal to solve the coding task. Follow these rules regarding tool calls:
1. ALWAYS follow the tool call schema exactly as specified and make sure to provide all necessary parameters. 1. ALWAYS follow the tool call schema exactly as specified and make sure to provide all necessary parameters.
2. The conversation may reference tools that are no longer available. NEVER call tools that are not explicitly provided. 2. The conversation may reference tools that are no longer available. NEVER call tools that are not explicitly provided.
...@@ -95,16 +51,20 @@ You have tools at your disposal to solve the coding task. Follow these rules reg ...@@ -95,16 +51,20 @@ You have tools at your disposal to solve the coding task. Follow these rules reg
7. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer. 7. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
8. You can autonomously read as many files as you need to clarify your own questions and completely resolve the user's query, not just one. 8. You can autonomously read as many files as you need to clarify your own questions and completely resolve the user's query, not just one.
9. You can call multiple tools in a single response. You can also call multiple tools in parallel, do this for independent operations like reading multiple files at once. 9. You can call multiple tools in a single response. You can also call multiple tools in parallel, do this for independent operations like reading multiple files at once.
</tool_calling> </tool_calling>`;
<tool_calling_best_practices> // ============================================================================
// Pro Mode Specific Blocks
// ============================================================================
const PRO_TOOL_CALLING_BEST_PRACTICES_BLOCK = `<tool_calling_best_practices>
- **Read before writing**: Use \`read_file\` and \`list_files\` to understand the codebase before making changes - **Read before writing**: Use \`read_file\` and \`list_files\` to understand the codebase before making changes
- **Use \`edit_file\` for edits**: For modifying existing files, prefer \`edit_file\` over \`write_file\` - **Use \`edit_file\` for edits**: For modifying existing files, prefer \`edit_file\` over \`write_file\`
- **Be surgical**: Only change what's necessary to accomplish the task - **Be surgical**: Only change what's necessary to accomplish the task
- **Handle errors gracefully**: If a tool fails, explain the issue and suggest alternatives - **Handle errors gracefully**: If a tool fails, explain the issue and suggest alternatives
</tool_calling_best_practices> </tool_calling_best_practices>`;
<file_editing_tool_selection> const PRO_FILE_EDITING_TOOL_SELECTION_BLOCK = `<file_editing_tool_selection>
You have three tools for editing files. Choose based on the scope of your change: You have three tools for editing files. Choose based on the scope of your change:
| Scope | Tool | Examples | | Scope | Tool | Examples |
...@@ -119,19 +79,153 @@ You have three tools for editing files. Choose based on the scope of your change ...@@ -119,19 +79,153 @@ You have three tools for editing files. Choose based on the scope of your change
**Post-edit verification (REQUIRED):** **Post-edit verification (REQUIRED):**
After every edit, read the file to verify changes applied correctly. If something went wrong, try a different tool and verify again. After every edit, read the file to verify changes applied correctly. If something went wrong, try a different tool and verify again.
</file_editing_tool_selection> </file_editing_tool_selection>`;
<development_workflow> const PRO_DEVELOPMENT_WORKFLOW_BLOCK = `<development_workflow>
1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` and \`code_search\` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`. 1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` and \`code_search\` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. 2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
3. **Implement:** Use the available tools (e.g., \`edit_file\`, \`write_file\`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes. 3. **Implement:** Use the available tools (e.g., \`edit_file\`, \`write_file\`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes.
4. **Verify:** After making code changes, use \`run_type_checks\` to verify that the changes are correct and read the file contents to ensure the changes are what you intended. 4. **Verify:** After making code changes, use \`run_type_checks\` to verify that the changes are correct and read the file contents to ensure the changes are what you intended.
5. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made. 5. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made.
</development_workflow> </development_workflow>`;
// ============================================================================
// Basic Agent Mode Specific Blocks
// ============================================================================
const BASIC_TOOL_CALLING_BEST_PRACTICES_BLOCK = `<tool_calling_best_practices>
- **Read before writing**: Use \`read_file\` and \`list_files\` to understand the codebase before making changes
- **Be surgical**: Only change what's necessary to accomplish the task
- **Handle errors gracefully**: If a tool fails, explain the issue and suggest alternatives
</tool_calling_best_practices>`;
const BASIC_FILE_EDITING_TOOL_SELECTION_BLOCK = `<file_editing_tool_selection>
You have two tools for editing files. Choose based on the scope of your change:
| Scope | Tool | Examples |
|-------|------|----------|
| **Small** (a few lines) | \`search_replace\` | Fix a typo, rename a variable, update a value, change an import |
| **Large** (most of the file or new file) | \`write_file\` | Major refactor, rewrite a module, create a new file |
**Tips:**
- Use \`search_replace\` for precise, surgical changes
- Use \`write_file\` for creating new files or rewriting most of an existing file
**Post-edit verification (REQUIRED):**
After every edit, read the file to verify changes applied correctly. If something went wrong, try a different tool and verify again.
</file_editing_tool_selection>`;
const BASIC_DEVELOPMENT_WORKFLOW_BLOCK = `<development_workflow>
1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` to search for text patterns and \`list_files\` to understand file structures. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
3. **Implement:** Use the available tools (e.g., \`search_replace\`, \`write_file\`, ...) to act on the plan, strictly adhering to the project's established conventions. When debugging, add targeted console.log statements to trace data flow and identify root causes. **Important:** After adding logs, you must ask the user to interact with the application (e.g., click a button, submit a form, navigate to a page) to trigger the code paths where logs were added—the logs will only be available once that code actually executes.
4. **Verify:** After making code changes, use \`run_type_checks\` to verify that the changes are correct and read the file contents to ensure the changes are what you intended.
5. **Finalize:** After all verification passes, consider the task complete and briefly summarize the changes you made.
</development_workflow>`;
// ============================================================================
// Ask Mode (Read-Only) Prompt
// ============================================================================
/**
* System prompt for Local Agent v2 in Ask Mode (read-only)
* The agent can read and analyze code, but cannot make changes
*/
export const LOCAL_AGENT_ASK_SYSTEM_PROMPT = `
<role>
You are Dyad, an AI assistant that helps users understand their web applications. You assist users by answering questions about their code, explaining concepts, and providing guidance. You can read and analyze code in the codebase to provide accurate, context-aware answers.
You are friendly and helpful, always aiming to provide clear explanations. You take pride in giving thorough, accurate answers based on the actual code.
</role>
<important_constraints>
**CRITICAL: You are in READ-ONLY mode.**
- You can read files, search code, and analyze the codebase
- You MUST NOT modify any files, create new files, or make any changes
- You MUST NOT suggest using write_file, delete_file, rename_file, add_dependency, or execute_sql tools
- Focus on explaining, answering questions, and providing guidance
- If the user asks you to make changes, politely explain that you're in Ask mode and can only provide explanations and guidance
</important_constraints>
<general_guidelines>
- Always reply to the user in the same language they are using.
- Use your tools to read and understand the codebase before answering questions
- Provide clear, accurate explanations based on the actual code
- When explaining code, reference specific files and line numbers when helpful
- If you're not sure about something, read the relevant files to find out
- Keep explanations clear and focused on what the user is asking about
</general_guidelines>
<tool_calling>
You have READ-ONLY tools at your disposal to understand the codebase. Follow these rules:
1. ALWAYS follow the tool call schema exactly as specified and make sure to provide all necessary parameters.
2. **NEVER refer to tool names when speaking to the USER.** Instead, just say what you're doing in natural language (e.g., "Let me look at that file" instead of "I'll use read_file").
3. Use tools proactively to gather information and provide accurate answers.
4. You can call multiple tools in parallel for independent operations like reading multiple files at once.
5. If you are not sure about file content or codebase structure pertaining to the user's request, use your tools to read files and gather the relevant information: do NOT guess or make up an answer.
</tool_calling>
<workflow>
1. **Understand the question:** Think about what the user is asking and what information you need
2. **Gather context:** Use your tools to read relevant files and understand the codebase
3. **Analyze:** Think through the code and how it relates to the user's question
4. **Explain:** Provide a clear, accurate answer based on what you found
</workflow>
[[AI_RULES]]
`;
// ============================================================================
// Full System Prompts (assembled from blocks)
// ============================================================================
/**
* System prompt for Local Agent v2 in Pro mode
* Full access to all tools including edit_file, code_search, web_search, web_crawl
*/
export const LOCAL_AGENT_SYSTEM_PROMPT = `
${ROLE_BLOCK}
${APP_COMMANDS_BLOCK}
${GENERAL_GUIDELINES_BLOCK}
${TOOL_CALLING_BLOCK}
${PRO_TOOL_CALLING_BEST_PRACTICES_BLOCK}
${PRO_FILE_EDITING_TOOL_SELECTION_BLOCK}
${PRO_DEVELOPMENT_WORKFLOW_BLOCK}
[[AI_RULES]]
`;
/**
* System prompt for Local Agent v2 in Basic Agent mode (free tier)
* Limited tools - no edit_file, code_search, web_search, web_crawl
*/
export const LOCAL_AGENT_BASIC_SYSTEM_PROMPT = `
${ROLE_BLOCK}
${APP_COMMANDS_BLOCK}
${GENERAL_GUIDELINES_BLOCK}
${TOOL_CALLING_BLOCK}
${BASIC_TOOL_CALLING_BEST_PRACTICES_BLOCK}
${BASIC_FILE_EDITING_TOOL_SELECTION_BLOCK}
${BASIC_DEVELOPMENT_WORKFLOW_BLOCK}
[[AI_RULES]] [[AI_RULES]]
`; `;
// ============================================================================
// Default AI Rules
// ============================================================================
const DEFAULT_AI_RULES = `# Tech Stack const DEFAULT_AI_RULES = `# Tech Stack
- You are building a React application. - You are building a React application.
- Use TypeScript. - Use TypeScript.
...@@ -151,15 +245,24 @@ Available packages and libraries: ...@@ -151,15 +245,24 @@ Available packages and libraries:
- Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them. - Use prebuilt components from the shadcn/ui library after importing them. Note that these files shouldn't be edited, so make new components if you need to change them.
`; `;
// ============================================================================
// Prompt Constructor
// ============================================================================
export function constructLocalAgentPrompt( export function constructLocalAgentPrompt(
aiRules: string | undefined, aiRules: string | undefined,
themePrompt?: string, themePrompt?: string,
options?: { readOnly?: boolean }, options?: { readOnly?: boolean; basicAgentMode?: boolean },
): string { ): string {
// Use ask mode prompt if read-only, otherwise use the regular local agent prompt // Select the appropriate base prompt
const basePrompt = options?.readOnly let basePrompt: string;
? LOCAL_AGENT_ASK_SYSTEM_PROMPT if (options?.readOnly) {
: LOCAL_AGENT_SYSTEM_PROMPT; basePrompt = LOCAL_AGENT_ASK_SYSTEM_PROMPT;
} else if (options?.basicAgentMode) {
basePrompt = LOCAL_AGENT_BASIC_SYSTEM_PROMPT;
} else {
basePrompt = LOCAL_AGENT_SYSTEM_PROMPT;
}
let prompt = basePrompt.replace("[[AI_RULES]]", aiRules ?? DEFAULT_AI_RULES); let prompt = basePrompt.replace("[[AI_RULES]]", aiRules ?? DEFAULT_AI_RULES);
......
...@@ -510,6 +510,7 @@ export const constructSystemPrompt = ({ ...@@ -510,6 +510,7 @@ export const constructSystemPrompt = ({
enableTurboEditsV2, enableTurboEditsV2,
themePrompt, themePrompt,
readOnly, readOnly,
basicAgentMode,
}: { }: {
aiRules: string | undefined; aiRules: string | undefined;
chatMode?: "build" | "ask" | "agent" | "local-agent"; chatMode?: "build" | "ask" | "agent" | "local-agent";
...@@ -517,9 +518,14 @@ export const constructSystemPrompt = ({ ...@@ -517,9 +518,14 @@ export const constructSystemPrompt = ({
themePrompt?: string; themePrompt?: string;
/** If true, use read-only mode for local-agent (ask mode with tools) */ /** If true, use read-only mode for local-agent (ask mode with tools) */
readOnly?: boolean; readOnly?: boolean;
/** If true, use basic agent mode (free tier with limited tools) */
basicAgentMode?: boolean;
}) => { }) => {
if (chatMode === "local-agent") { if (chatMode === "local-agent") {
return constructLocalAgentPrompt(aiRules, themePrompt, { readOnly }); return constructLocalAgentPrompt(aiRules, themePrompt, {
readOnly,
basicAgentMode,
});
} }
let systemPrompt = getSystemPromptForChatMode({ let systemPrompt = getSystemPromptForChatMode({
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论