提交 7ac9c84a authored 作者: Vittorio's avatar Vittorio

支持每次对话选择连接器

上级 427a845b
ALTER TABLE `chats` ADD `connector_ids_json` text;
\ No newline at end of file
{
"version": "6",
"dialect": "sqlite",
"id": "daf2663f-2e61-47a1-8fc9-1ed3127a8ab8",
"prevId": "c2295e0b-3101-4472-957e-8e51672c7ec1",
"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
},
"neon_active_branch_id": {
"name": "neon_active_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_id": {
"name": "vercel_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_name": {
"name": "vercel_project_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_team_id": {
"name": "vercel_team_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_deployment_url": {
"name": "vercel_deployment_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"install_command": {
"name": "install_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"start_command": {
"name": "start_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"chat_context": {
"name": "chat_context",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_favorite": {
"name": "is_favorite",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
},
"theme_id": {
"name": "theme_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"initial_commit_hash": {
"name": "initial_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"compacted_at": {
"name": "compacted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"compaction_backup_path": {
"name": "compaction_backup_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pending_compaction": {
"name": "pending_compaction",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"chat_mode": {
"name": "chat_mode",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"connector_ids_json": {
"name": "connector_ids_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"chats_app_id_apps_id_fk": {
"name": "chats_app_id_apps_id_fk",
"tableFrom": "chats",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"connectors": {
"name": "connectors",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"spec_version": {
"name": "spec_version",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_spec": {
"name": "raw_spec",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"endpoints_json": {
"name": "endpoints_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_synced_at": {
"name": "last_synced_at",
"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": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"custom_themes": {
"name": "custom_themes",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_model_providers": {
"name": "language_model_providers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_base_url": {
"name": "api_base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"env_var_name": {
"name": "env_var_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_models": {
"name": "language_models",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_name": {
"name": "api_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"builtin_provider_id": {
"name": "builtin_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_provider_id": {
"name": "custom_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"language_models_custom_provider_id_language_model_providers_id_fk": {
"name": "language_models_custom_provider_id_language_model_providers_id_fk",
"tableFrom": "language_models",
"tableTo": "language_model_providers",
"columnsFrom": [
"custom_provider_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_servers": {
"name": "mcp_servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transport": {
"name": "transport",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"args": {
"name": "args",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"env_json": {
"name": "env_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"headers_json": {
"name": "headers_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_tool_consents": {
"name": "mcp_tool_consents",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tool_name": {
"name": "tool_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consent": {
"name": "consent",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ask'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"uniq_mcp_consent": {
"name": "uniq_mcp_consent",
"columns": [
"server_id",
"tool_name"
],
"isUnique": true
}
},
"foreignKeys": {
"mcp_tool_consents_server_id_mcp_servers_id_fk": {
"name": "mcp_tool_consents_server_id_mcp_servers_id_fk",
"tableFrom": "mcp_tool_consents",
"tableTo": "mcp_servers",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approval_state": {
"name": "approval_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_commit_hash": {
"name": "source_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"request_id": {
"name": "request_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_tokens_used": {
"name": "max_tokens_used",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ai_messages_json": {
"name": "ai_messages_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"using_free_agent_mode_quota": {
"name": "using_free_agent_mode_quota",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_compaction_summary": {
"name": "is_compaction_summary",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"messages_chat_id_chats_id_fk": {
"name": "messages_chat_id_chats_id_fk",
"tableFrom": "messages",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"prompts": {
"name": "prompts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"prompts_slug_unique": {
"name": "prompts_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"versions": {
"name": "versions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"neon_db_timestamp": {
"name": "neon_db_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"versions_app_commit_unique": {
"name": "versions_app_commit_unique",
"columns": [
"app_id",
"commit_hash"
],
"isUnique": true
}
},
"foreignKeys": {
"versions_app_id_apps_id_fk": {
"name": "versions_app_id_apps_id_fk",
"tableFrom": "versions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
\ No newline at end of file
......@@ -211,6 +211,13 @@
"when": 1777625433944,
"tag": "0029_elite_khan",
"breakpoints": true
},
{
"idx": 30,
"version": "6",
"when": 1777630917274,
"tag": "0030_large_dracula",
"breakpoints": true
}
]
}
\ No newline at end of file
......@@ -3,6 +3,8 @@ import type {
Message,
AgentTodo,
ComponentSelection,
ConnectorSelectionMode,
ConnectorSummary,
} from "@/ipc/types";
import type { ListedApp } from "@/ipc/types/app";
import type { Getter, Setter } from "jotai";
......@@ -39,6 +41,13 @@ export const chatInputValueAtom = atom(
);
export const homeChatInputValueAtom = atom<string>("");
export const homeSelectedAppAtom = atom<ListedApp | null>(null);
export const homeSelectedConnectorAtom = atom<ConnectorSummary | null>(null);
export const turnConnectorByChatIdAtom = atom<
Map<number, ConnectorSummary | null>
>(new Map());
export const turnConnectorModeByChatIdAtom = atom<
Map<number, ConnectorSelectionMode>
>(new Map());
// Used for scrolling to the bottom of the chat messages (per chat)
export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
......@@ -248,6 +257,8 @@ export interface QueuedMessageItem {
prompt: string;
attachments?: FileAttachment[];
selectedComponents?: ComponentSelection[];
connectorIds?: number[];
connectorSelectionMode?: ConnectorSelectionMode;
}
// Map<chatId, QueuedMessageItem[]>
......
import { ChevronsUpDown, Globe } from "lucide-react";
import type { ConnectorSummary } from "@/ipc/types";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface ConnectorPickerProps {
connectors: ConnectorSummary[];
selectedConnector: ConnectorSummary | null;
onSelectConnector: (connector: ConnectorSummary | null) => void;
emptyLabel: string;
menuLabel: string;
dataTestId: string;
className?: string;
selectedClassName?: string;
unselectedClassName?: string;
}
export function ConnectorPicker({
connectors,
selectedConnector,
onSelectConnector,
emptyLabel,
menuLabel,
dataTestId,
className,
selectedClassName,
unselectedClassName,
}: ConnectorPickerProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"cursor-pointer px-2 py-1 text-xs font-medium rounded-lg transition-colors flex items-center gap-1.5 max-w-[200px]",
selectedConnector ? selectedClassName : unselectedClassName,
className,
)}
data-testid={dataTestId}
>
<Globe size={14} />
<span className="truncate">
{selectedConnector ? selectedConnector.name : emptyLabel}
</span>
<ChevronsUpDown size={12} className="opacity-60 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
<DropdownMenuLabel>{menuLabel}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={selectedConnector ? String(selectedConnector.id) : "none"}
onValueChange={(value) => {
if (value === "none") {
onSelectConnector(null);
return;
}
onSelectConnector(
connectors.find((connector) => connector.id === Number(value)) ??
null,
);
}}
>
<DropdownMenuRadioItem value="none">
{emptyLabel}
</DropdownMenuRadioItem>
{connectors.map((connector) => (
<DropdownMenuRadioItem
key={connector.id}
value={String(connector.id)}
>
<span className="truncate">{connector.name}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
......@@ -13,12 +13,20 @@ import {
} from "@/components/ui/dialog";
import { useCreateApp } from "@/hooks/useCreateApp";
import { useCheckName } from "@/hooks/useCheckName";
import { useLoadConnectors } from "@/hooks/useLoadConnectors";
import { NEON_TEMPLATE_IDS, Template } from "@/shared/templates";
import { useSelectChat } from "@/hooks/useSelectChat";
import { Loader2 } from "lucide-react";
import { neonTemplateHook } from "@/client_logic/template_hook";
import { showError } from "@/lib/toast";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface CreateAppDialogProps {
open: boolean;
......@@ -33,8 +41,10 @@ export function CreateAppDialog({
}: CreateAppDialogProps) {
const { t } = useTranslation(["home", "common"]);
const [appName, setAppName] = useState("");
const [connectorValue, setConnectorValue] = useState("none");
const [isSubmitting, setIsSubmitting] = useState(false);
const { createApp } = useCreateApp();
const { connectors } = useLoadConnectors();
const { data: nameCheckResult } = useCheckName(appName);
const { selectChat } = useSelectChat();
const handleSubmit = async (e: React.FormEvent) => {
......@@ -50,7 +60,11 @@ export function CreateAppDialog({
setIsSubmitting(true);
try {
const result = await createApp({ name: appName.trim() });
const result = await createApp({
name: appName.trim(),
initialConnectorIds:
connectorValue === "none" ? [] : [Number(connectorValue)],
});
if (template && NEON_TEMPLATE_IDS.has(template.id)) {
await neonTemplateHook({
appId: result.app.id,
......@@ -60,6 +74,7 @@ export function CreateAppDialog({
// Selecting the new chat seeds recent tab order immediately.
selectChat({ chatId: result.chatId, appId: result.app.id });
setAppName("");
setConnectorValue("none");
onOpenChange(false);
} catch (error) {
showError(error as any);
......@@ -102,6 +117,37 @@ export function CreateAppDialog({
</p>
)}
</div>
{connectors.length > 0 && (
<div className="grid gap-2">
<Label htmlFor="appConnector">
{t("home:connectorOptional")}
</Label>
<Select
value={connectorValue}
onValueChange={(value) => setConnectorValue(value ?? "none")}
disabled={isSubmitting}
>
<SelectTrigger id="appConnector">
<SelectValue
placeholder={t("home:selectConnectorForApp")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("home:noConnectorSelected")}
</SelectItem>
{connectors.map((connector) => (
<SelectItem
key={connector.id}
value={String(connector.id)}
>
{connector.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter>
......
......@@ -24,7 +24,7 @@ import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSettings } from "@/hooks/useSettings";
import { ipc } from "@/ipc/types";
import { ipc, type Chat } from "@/ipc/types";
import {
chatInputValuesByIdAtom,
chatMessagesByIdAtom,
......@@ -32,6 +32,8 @@ import {
pendingAgentConsentsAtom,
agentTodosByChatIdAtom,
needsFreshPlanChatAtom,
turnConnectorByChatIdAtom,
turnConnectorModeByChatIdAtom,
} from "@/atoms/chatAtoms";
import { atom, useAtom, useSetAtom, useAtomValue } from "jotai";
import { useStreamChat } from "@/hooks/useStreamChat";
......@@ -107,6 +109,8 @@ import { useVoiceToText } from "@/hooks/useVoiceToText";
import { isDyadProEnabled } from "@/lib/schemas";
import { useChatMode } from "@/hooks/useChatMode";
import { useInitialChatMode } from "@/hooks/useInitialChatMode";
import { useLoadConnectors } from "@/hooks/useLoadConnectors";
import { ConnectorPicker } from "@/components/ConnectorPicker";
const showTokenBarAtom = atom(false);
......@@ -168,6 +172,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const [showTokenBar, setShowTokenBar] = useAtom(showTokenBarAtom);
const queryClient = useQueryClient();
const { connectors } = useLoadConnectors();
const toggleShowTokenBar = useCallback(() => {
setShowTokenBar((prev) => !prev);
queryClient.invalidateQueries({ queryKey: queryKeys.tokenCount.all });
......@@ -291,6 +296,30 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const [needsFreshPlanChat, setNeedsFreshPlanChat] = useAtom(
needsFreshPlanChatAtom,
);
const [turnConnectorByChatId, setTurnConnectorByChatId] = useAtom(
turnConnectorByChatIdAtom,
);
const [turnConnectorModeByChatId, setTurnConnectorModeByChatId] = useAtom(
turnConnectorModeByChatIdAtom,
);
const selectedTurnConnector = chatId
? (turnConnectorByChatId.get(chatId) ?? null)
: null;
const selectedTurnConnectorMode = chatId
? (turnConnectorModeByChatId.get(chatId) ?? "append")
: "append";
const currentChat = chatId
? (queryClient.getQueryData(queryKeys.chats.detail({ chatId })) as
| Chat
| undefined)
: undefined;
const defaultConnectorIds = currentChat?.connectorIds ?? [];
const defaultConnectorNames = connectors
.filter((connector) => defaultConnectorIds.includes(connector.id))
.map((connector) => connector.name);
const turnConnectorIds = selectedTurnConnector
? [selectedTurnConnector.id]
: [];
// Detect transition to plan mode from another mode in a chat with messages
const prevModeRef = useRef(chatMode);
......@@ -326,10 +355,46 @@ export function ChatInput({ chatId }: { chatId?: number }) {
setNeedsFreshPlanChat,
]);
const setSelectedTurnConnector = useCallback(
(connector: typeof selectedTurnConnector | null) => {
if (!chatId) return;
setTurnConnectorByChatId((prev) => {
const next = new Map(prev);
next.set(chatId, connector);
return next;
});
},
[chatId, setTurnConnectorByChatId],
);
const toggleTurnConnectorMode = useCallback(() => {
if (!chatId) return;
setTurnConnectorModeByChatId((prev) => {
const next = new Map(prev);
const current = next.get(chatId) ?? "append";
next.set(chatId, current === "append" ? "replace" : "append");
return next;
});
}, [chatId, setTurnConnectorModeByChatId]);
const clearTurnConnectorSelection = useCallback(() => {
setSelectedTurnConnector(null);
if (!chatId) return;
setTurnConnectorModeByChatId((prev) => {
const next = new Map(prev);
next.set(chatId, "append");
return next;
});
}, [chatId, setSelectedTurnConnector, setTurnConnectorModeByChatId]);
// Token counting for context limit banner
const { result: tokenCountResult } = useCountTokens(
!isStreaming ? (chatId ?? null) : null,
"",
{
connectorIds: turnConnectorIds,
connectorSelectionMode: selectedTurnConnectorMode,
},
);
const showBanner =
......@@ -364,6 +429,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
setInputValue("");
clearAttachments();
setSelectedComponents([]);
clearTurnConnectorSelection();
setVisualEditingSelectedComponent(null);
if (previewIframeRef?.contentWindow) {
previewIframeRef.contentWindow.postMessage(
......@@ -375,6 +441,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
setInputValue,
clearAttachments,
setSelectedComponents,
clearTurnConnectorSelection,
setVisualEditingSelectedComponent,
previewIframeRef,
]);
......@@ -429,6 +496,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
prompt: inputValue,
attachments,
selectedComponents: componentsToSave,
connectorIds: selectedTurnConnector ? [selectedTurnConnector.id] : [],
connectorSelectionMode: selectedTurnConnectorMode,
});
}
// Load the message content into the input
......@@ -437,6 +506,18 @@ export function ChatInput({ chatId }: { chatId?: number }) {
replaceAttachments(msg.attachments ?? []);
setIsRestoringQueuedSelection(true);
setSelectedComponents(msg.selectedComponents ?? []);
setSelectedTurnConnector(
connectors.find(
(connector) => connector.id === msg.connectorIds?.[0],
) ?? null,
);
if (chatId) {
setTurnConnectorModeByChatId((prev) => {
const next = new Map(prev);
next.set(chatId, msg.connectorSelectionMode ?? "append");
return next;
});
}
// Reset visual editing target to avoid stale toolbar state
setVisualEditingSelectedComponent(null);
// Set editing mode
......@@ -448,9 +529,15 @@ export function ChatInput({ chatId }: { chatId?: number }) {
inputValue,
attachments,
selectedComponents,
selectedTurnConnector,
selectedTurnConnectorMode,
connectors,
chatId,
setInputValue,
replaceAttachments,
setSelectedComponents,
setSelectedTurnConnector,
setTurnConnectorModeByChatId,
setVisualEditingSelectedComponent,
setIsRestoringQueuedSelection,
updateQueuedMessage,
......@@ -533,6 +620,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const newChatId = await ipc.chat.createChat({
appId,
initialChatMode: "plan",
initialConnectorIds: defaultConnectorIds,
});
setSelectedChatId(newChatId);
navigate({ to: "/chat", search: { id: newChatId } });
......@@ -544,8 +632,11 @@ export function ChatInput({ chatId }: { chatId?: number }) {
chatId: newChatId,
attachments,
redo: false,
connectorIds: turnConnectorIds,
connectorSelectionMode: selectedTurnConnectorMode,
requestedChatMode: "plan",
});
clearTurnConnectorSelection();
clearAttachments();
posthog.capture("chat:submit", { chatMode });
return;
......@@ -565,6 +656,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
prompt: currentInput,
attachments,
selectedComponents: componentsToSend,
connectorIds: turnConnectorIds,
connectorSelectionMode: selectedTurnConnectorMode,
});
resetEditingState();
return;
......@@ -577,12 +670,15 @@ export function ChatInput({ chatId }: { chatId?: number }) {
prompt: currentInput,
attachments,
selectedComponents: componentsToSend,
connectorIds: turnConnectorIds,
connectorSelectionMode: selectedTurnConnectorMode,
});
if (queued) {
// Only clear input, attachments, and components on successful queue
setInputValue("");
clearAttachments();
setSelectedComponents([]);
clearTurnConnectorSelection();
setVisualEditingSelectedComponent(null);
// Clear overlays in the preview iframe
if (previewIframeRef?.contentWindow) {
......@@ -600,6 +696,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
// Clear input and components before sending
setInputValue("");
setSelectedComponents([]);
clearTurnConnectorSelection();
setVisualEditingSelectedComponent(null);
// Clear overlays in the preview iframe
if (previewIframeRef?.contentWindow) {
......@@ -616,6 +713,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
attachments,
redo: false,
selectedComponents: componentsToSend,
connectorIds: turnConnectorIds,
connectorSelectionMode: selectedTurnConnectorMode,
requestedChatMode: isChatModeLoading ? null : chatMode,
});
clearAttachments();
......@@ -649,6 +748,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const newChatId = await ipc.chat.createChat({
appId,
initialChatMode,
initialConnectorIds: defaultConnectorIds,
});
setSelectedChatId(newChatId);
navigate({
......@@ -1055,6 +1155,81 @@ export function ChatInput({ chatId }: { chatId?: number }) {
<div className="px-2 flex items-center justify-between pb-0.5 pt-0.5">
<div className="flex items-center">
<ChatInputControls showContextFilesPicker={false} />
{defaultConnectorNames.length > 0 && (
<div className="ml-1.5 px-2 py-1 text-xs rounded-lg bg-muted/70 text-muted-foreground max-w-[220px] truncate">
{t("defaultConnectorLabel", "Chat connectors")}:{" "}
{defaultConnectorNames.join(", ")}
</div>
)}
{connectors.length > 0 && (
<>
<div className="ml-1.5">
<ConnectorPicker
connectors={connectors}
selectedConnector={selectedTurnConnector}
onSelectConnector={setSelectedTurnConnector}
emptyLabel={t("turnConnectorLabel", "Turn connector")}
menuLabel={t(
"selectTurnConnector",
"Select a temporary connector for this turn",
)}
dataTestId="chat-turn-connector-selector"
selectedClassName="bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300"
unselectedClassName="text-foreground/80 hover:text-foreground hover:bg-muted/60"
/>
</div>
{selectedTurnConnector && (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
onClick={clearTurnConnectorSelection}
className="cursor-pointer px-1.5 py-1 ml-1 text-muted-foreground rounded-lg transition-colors hover:text-foreground hover:bg-muted/60"
aria-label={t(
"clearTurnConnector",
"Clear turn connector",
)}
/>
}
>
<X size={12} />
</TooltipTrigger>
<TooltipContent>
{t("clearTurnConnector", "Clear turn connector")}
</TooltipContent>
</Tooltip>
)}
{selectedTurnConnector && (
<Tooltip>
<TooltipTrigger
render={
<button
onClick={toggleTurnConnectorMode}
className="cursor-pointer px-2 py-1 ml-1 text-xs rounded-lg transition-colors bg-muted/60 hover:bg-muted"
data-testid="chat-turn-connector-mode"
/>
}
>
{selectedTurnConnectorMode === "append"
? t("connectorModeAppend", "Append")
: t("connectorModeReplace", "Replace")}
</TooltipTrigger>
<TooltipContent>
{selectedTurnConnectorMode === "append"
? t(
"connectorModeAppendHelp",
"Add this connector on top of the chat's default connectors",
)
: t(
"connectorModeReplaceHelp",
"Use only this connector for this turn",
)}
</TooltipContent>
</Tooltip>
)}
</>
)}
</div>
<AuxiliaryActionsMenu
......@@ -1066,7 +1241,13 @@ export function ChatInput({ chatId }: { chatId?: number }) {
/>
</div>
{/* TokenBar is only displayed when showTokenBar is true */}
{showTokenBar && <TokenBar chatId={chatId} />}
{showTokenBar && (
<TokenBar
chatId={chatId}
connectorIds={turnConnectorIds}
connectorSelectionMode={selectedTurnConnectorMode}
/>
)}
</div>
</div>
......
......@@ -15,7 +15,11 @@ import {
} from "@/components/ui/tooltip";
import { useSettings } from "@/hooks/useSettings";
import { homeChatInputValueAtom, homeSelectedAppAtom } from "@/atoms/chatAtoms";
import {
homeChatInputValueAtom,
homeSelectedAppAtom,
homeSelectedConnectorAtom,
} from "@/atoms/chatAtoms";
import { useAtom } from "jotai";
import { useState } from "react";
import { useStreamChat } from "@/hooks/useStreamChat";
......@@ -32,6 +36,7 @@ import { useTypingPlaceholder } from "@/hooks/useTypingPlaceholder";
import { AuxiliaryActionsMenu } from "./AuxiliaryActionsMenu";
import { cn } from "@/lib/utils";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useLoadConnectors } from "@/hooks/useLoadConnectors";
import { AppSearchDialog } from "../AppSearchDialog";
import { useVoiceToText } from "@/hooks/useVoiceToText";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
......@@ -39,6 +44,7 @@ import { ipc } from "@/ipc/types";
import { useCallback, useEffect } from "react";
import { showError } from "@/lib/toast";
import { useTranslation } from "react-i18next";
import { ConnectorPicker } from "@/components/ConnectorPicker";
export function HomeChatInput({
onSubmit,
......@@ -49,6 +55,9 @@ export function HomeChatInput({
const posthog = usePostHog();
const [inputValue, setInputValue] = useAtom(homeChatInputValueAtom);
const [selectedApp, setSelectedApp] = useAtom(homeSelectedAppAtom);
const [selectedConnector, setSelectedConnector] = useAtom(
homeSelectedConnectorAtom,
);
const { settings } = useSettings();
const { isStreaming } = useStreamChat({
hasChatId: false,
......@@ -72,6 +81,7 @@ export function HomeChatInput({
const [appSearchOpen, setAppSearchOpen] = useState(false);
const { apps } = useLoadApps();
const { connectors } = useLoadConnectors();
// Clear selected app when the experiment flag is disabled
useEffect(() => {
......@@ -131,14 +141,17 @@ export function HomeChatInput({
onSubmit({
attachments,
selectedApp: selectedApp ?? undefined,
selectedConnector: selectedConnector ?? undefined,
});
// Clear attachments and selected app as part of submission process
clearAttachments();
setSelectedApp(null);
setSelectedConnector(null);
posthog.capture("chat:home_submit", {
chatMode: settings?.selectedChatMode,
existingApp: !!selectedApp,
connectorId: selectedConnector?.id ?? null,
});
};
......@@ -288,6 +301,20 @@ export function HomeChatInput({
<div className="px-2 flex items-center justify-between pb-0.5 pt-0.5">
<div className="flex items-center">
<ChatInputControls showContextFilesPicker={false} />
{connectors.length > 0 && (
<div className="ml-1.5">
<ConnectorPicker
connectors={connectors}
selectedConnector={selectedConnector}
onSelectConnector={setSelectedConnector}
emptyLabel={t("home:noConnectorSelected")}
menuLabel={t("home:selectConnector")}
dataTestId="home-connector-selector"
selectedClassName="bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300"
unselectedClassName="text-foreground/80 hover:text-foreground hover:bg-muted/60"
/>
</div>
)}
{settings?.enableSelectAppFromHomeChatInput && (
<Tooltip>
<TooltipTrigger
......
......@@ -16,16 +16,25 @@ import {
import { chatInputValueAtom } from "@/atoms/chatAtoms";
import { useAtom } from "jotai";
import { useSettings } from "@/hooks/useSettings";
import { ipc } from "@/ipc/types";
import { ipc, type ConnectorSelectionMode } from "@/ipc/types";
interface TokenBarProps {
chatId?: number;
connectorIds?: number[];
connectorSelectionMode?: ConnectorSelectionMode;
}
export function TokenBar({ chatId }: TokenBarProps) {
export function TokenBar({
chatId,
connectorIds,
connectorSelectionMode,
}: TokenBarProps) {
const [inputValue] = useAtom(chatInputValueAtom);
const { settings } = useSettings();
const { result, error } = useCountTokens(chatId ?? null, inputValue);
const { result, error } = useCountTokens(chatId ?? null, inputValue, {
connectorIds,
connectorSelectionMode,
});
if (!chatId || !result) {
return null;
......
......@@ -84,6 +84,9 @@ export const chats = sqliteTable("chats", {
compactionBackupPath: text("compaction_backup_path"),
pendingCompaction: integer("pending_compaction", { mode: "boolean" }),
chatMode: text("chat_mode").$type<StoredChatMode | null>(),
connectorIdsJson: text("connector_ids_json", {
mode: "json",
}).$type<number[] | null>(),
});
export const connectors = sqliteTable("connectors", {
......
......@@ -6,9 +6,19 @@ import {
import { ipc, type TokenCountResult } from "@/ipc/types";
import { useCallback, useEffect, useState } from "react";
import { queryKeys } from "@/lib/queryKeys";
import type { ConnectorSelectionMode } from "@/ipc/types";
export function useCountTokens(chatId: number | null, input: string = "") {
export function useCountTokens(
chatId: number | null,
input: string = "",
options?: {
connectorIds?: number[];
connectorSelectionMode?: ConnectorSelectionMode;
},
) {
const queryClient = useQueryClient();
const connectorIds = options?.connectorIds ?? [];
const connectorSelectionMode = options?.connectorSelectionMode;
// Debounce input so we don't call the token counting IPC on every keystroke.
const [debouncedInput, setDebouncedInput] = useState(input);
......@@ -33,12 +43,19 @@ export function useCountTokens(chatId: number | null, input: string = "") {
error,
refetch,
} = useQuery<TokenCountResult | null>({
queryKey: queryKeys.tokenCount.forChat({ chatId, input: debouncedInput }),
queryKey: queryKeys.tokenCount.forChat({
chatId,
input: debouncedInput,
connectorIds,
connectorSelectionMode,
}),
queryFn: async () => {
if (chatId === null) return null;
return ipc.chat.countTokens({
chatId,
input: debouncedInput,
connectorIds,
connectorSelectionMode,
});
},
placeholderData: keepPreviousData,
......
......@@ -82,6 +82,8 @@ export function useQueueProcessor() {
redo: false,
attachments: messageToSend.attachments,
selectedComponents: messageToSend.selectedComponents,
connectorIds: messageToSend.connectorIds,
connectorSelectionMode: messageToSend.connectorSelectionMode,
requestedChatMode: chatMode,
});
......
......@@ -3,6 +3,7 @@ import type {
ComponentSelection,
FileAttachment,
ChatAttachment,
ConnectorSelectionMode,
} from "@/ipc/types";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
......@@ -94,6 +95,8 @@ export function useStreamChat({
attachments,
selectedComponents,
requestedChatMode,
connectorIds,
connectorSelectionMode,
onSettled,
}: {
prompt: string;
......@@ -103,6 +106,8 @@ export function useStreamChat({
attachments?: FileAttachment[];
selectedComponents?: ComponentSelection[];
requestedChatMode?: Chat["chatMode"] | null;
connectorIds?: number[];
connectorSelectionMode?: ConnectorSelectionMode;
onSettled?: (result: { success: boolean }) => void;
}) => {
if (
......@@ -213,6 +218,8 @@ export function useStreamChat({
requestedChatMode === null
? undefined
: (requestedChatMode ?? cachedChat?.chatMode ?? undefined),
connectorIds,
connectorSelectionMode,
},
{
onChunk: ({
......@@ -512,7 +519,14 @@ export function useStreamChat({
(
id: string,
updates: Partial<
Pick<QueuedMessageItem, "prompt" | "attachments" | "selectedComponents">
Pick<
QueuedMessageItem,
| "prompt"
| "attachments"
| "selectedComponents"
| "connectorIds"
| "connectorSelectionMode"
>
>,
) => {
if (chatId === undefined) return;
......
......@@ -140,6 +140,15 @@
"descriptionPlural": "Choose how the files should be used."
},
"selectedComponents": "Selected Components ({{count}})",
"defaultConnectorLabel": "Chat connectors",
"turnConnectorLabel": "Turn connector",
"clearTurnConnector": "Clear turn connector",
"changeTurnConnector": "Change the connector for this turn",
"selectTurnConnector": "Select a temporary connector for this turn",
"connectorModeAppend": "Append",
"connectorModeReplace": "Replace",
"connectorModeAppendHelp": "Add this connector on top of the chat's default connectors",
"connectorModeReplaceHelp": "Use only this connector for this turn",
"clearAllComponents": "Clear all selected components",
"deselectComponent": "Deselect component",
"chatMode": {
......
......@@ -24,9 +24,15 @@
"clearSelection": "Clear selection",
"copyToDyadApps": "Copy to the dyad-apps folder",
"noAppSelected": "No app selected",
"noConnectorSelected": "No connector selected",
"deselectApp": "Deselect app",
"deselectConnector": "Deselect connector",
"changeSelectedApp": "Change selected app",
"changeSelectedConnector": "Change selected connector",
"selectExistingApp": "Select an existing app",
"selectConnector": "Select a connector for this chat",
"selectConnectorForApp": "Select a connector",
"connectorOptional": "API connector (optional)",
"appNameExists": "An app with this name already exists. Please choose a different name:",
"appName": "App name",
"appNameOptional": "App name (optional)",
......
......@@ -140,6 +140,15 @@
"descriptionPlural": "选择文件的使用方式。"
},
"selectedComponents": "已选择的组件 ({{count}})",
"defaultConnectorLabel": "对话连接器",
"turnConnectorLabel": "本轮连接器",
"clearTurnConnector": "清除本轮连接器",
"changeTurnConnector": "更换本轮消息使用的连接器",
"selectTurnConnector": "为本轮消息选择一个临时连接器",
"connectorModeAppend": "追加",
"connectorModeReplace": "覆盖",
"connectorModeAppendHelp": "在当前对话默认连接器的基础上追加这个连接器",
"connectorModeReplaceHelp": "本轮消息只使用这个连接器",
"clearAllComponents": "清除所有已选择的组件",
"deselectComponent": "取消选择组件",
"chatMode": {
......
......@@ -21,9 +21,15 @@
"clearSelection": "清除选择",
"copyToDyadApps": "复制到 dyad-apps 文件夹",
"noAppSelected": "未选择应用",
"noConnectorSelected": "未选择连接器",
"deselectApp": "取消选择应用",
"deselectConnector": "取消选择连接器",
"changeSelectedApp": "更换已选择的应用",
"changeSelectedConnector": "更换已选择的连接器",
"selectExistingApp": "选择现有应用",
"selectConnector": "为这个对话选择一个连接器",
"selectConnectorForApp": "选择连接器",
"connectorOptional": "API 连接器(可选)",
"appNameExists": "此名称的应用已存在。请选择其他名称:",
"appName": "应用名称",
"appNameOptional": "应用名称(可选)",
......
import { ipcMain, app, dialog } from "electron";
import { db, getDatabasePath } from "../../db";
import { apps, chats, messages } from "../../db/schema";
import { apps, chats, connectors, messages } from "../../db/schema";
import { desc, eq, inArray, like } from "drizzle-orm";
import { createTypedHandler } from "./base";
import { appContracts } from "../types/app";
......@@ -1217,6 +1217,22 @@ export function registerAppHandlers() {
const appPath = params.name;
const fullAppPath = getDyadAppPath(appPath);
const sanitizedConnectorIds = Array.from(
new Set((params.initialConnectorIds ?? []).filter(Number.isFinite)),
);
if (sanitizedConnectorIds.length > 0) {
const foundConnectors = await db.query.connectors.findMany({
where: inArray(connectors.id, sanitizedConnectorIds),
columns: { id: true },
});
if (foundConnectors.length !== sanitizedConnectorIds.length) {
throw new DyadError(
"One or more selected connectors were not found",
DyadErrorKind.NotFound,
);
}
}
if (!isAppLocationAccessible(fullAppPath)) {
throw new Error(
`The path ${fullAppPath} is inaccessible. Please check your custom apps folder setting.`,
......@@ -1249,6 +1265,8 @@ export function registerAppHandlers() {
.values({
appId: app.id,
chatMode: initialChatMode,
connectorIdsJson:
sanitizedConnectorIds.length > 0 ? sanitizedConnectorIds : null,
})
.returning();
......
import { db } from "../../db";
import { apps, chats, messages } from "../../db/schema";
import { desc, eq, and, like } from "drizzle-orm";
import { apps, chats, connectors, messages } from "../../db/schema";
import { desc, eq, and, like, inArray } from "drizzle-orm";
import type { ChatSearchResult, ChatSummary } from "../../lib/schemas";
import log from "electron-log";
......@@ -18,9 +18,9 @@ const logger = log.scope("chat_handlers");
export function registerChatHandlers() {
createTypedHandler(chatContracts.createChat, async (_, input) => {
const { appId, initialChatMode } =
const { appId, initialChatMode, initialConnectorIds } =
typeof input === "number"
? { appId: input, initialChatMode: undefined }
? { appId: input, initialChatMode: undefined, initialConnectorIds: [] }
: input;
// Get the app's path first
......@@ -48,6 +48,22 @@ export function registerChatHandlers() {
const chatMode = await getInitialChatModeForNewChat(initialChatMode);
const sanitizedConnectorIds = Array.from(
new Set((initialConnectorIds ?? []).filter(Number.isFinite)),
);
if (sanitizedConnectorIds.length > 0) {
const foundConnectors = await db.query.connectors.findMany({
where: inArray(connectors.id, sanitizedConnectorIds),
columns: { id: true },
});
if (foundConnectors.length !== sanitizedConnectorIds.length) {
throw new DyadError(
"One or more selected connectors were not found",
DyadErrorKind.NotFound,
);
}
}
// Create a new chat
const [chat] = await db
.insert(chats)
......@@ -55,6 +71,8 @@ export function registerChatHandlers() {
appId,
initialCommitHash,
chatMode,
connectorIdsJson:
sanitizedConnectorIds.length > 0 ? sanitizedConnectorIds : null,
})
.returning();
logger.info(
......@@ -86,6 +104,7 @@ export function registerChatHandlers() {
...chat,
title: chat.title ?? "",
chatMode: normalizeStoredChatMode(chat.chatMode),
connectorIds: chat.connectorIdsJson ?? [],
messages: chat.messages.map((m) => ({
...m,
role: m.role as "user" | "assistant",
......@@ -104,6 +123,7 @@ export function registerChatHandlers() {
createdAt: true,
appId: true,
chatMode: true,
connectorIdsJson: true,
},
orderBy: [desc(chats.createdAt)],
})
......@@ -114,6 +134,7 @@ export function registerChatHandlers() {
createdAt: true,
appId: true,
chatMode: true,
connectorIdsJson: true,
},
orderBy: [desc(chats.createdAt)],
});
......@@ -122,6 +143,7 @@ export function registerChatHandlers() {
return allChats.map((chat) => ({
...chat,
chatMode: normalizeStoredChatMode(chat.chatMode),
connectorIds: chat.connectorIdsJson ?? [],
})) satisfies ChatSummary[];
});
......
......@@ -15,7 +15,7 @@ import {
} from "ai";
import { db } from "../../db";
import { chats, messages } from "../../db/schema";
import { chats, connectors, messages } from "../../db/schema";
import { and, eq, isNull } from "drizzle-orm";
import type { SmartContextMode } from "../../lib/schemas";
import {
......@@ -118,6 +118,8 @@ import {
} from "./free_agent_quota_handlers";
import { AI_STREAMING_ERROR_MESSAGE_PREFIX } from "@/shared/texts";
import { getCurrentCommitHash } from "../utils/git_utils";
import { formatOpenApiConnectorSystemPrompt } from "../utils/openapi_utils";
import { resolveEffectiveConnectorIds } from "../utils/connector_selection";
import {
processChatMessagesWithVersionedFiles as getVersionedFiles,
VersionedFiles,
......@@ -906,6 +908,42 @@ ${componentSnippet}
) {
systemPrompt += "\n\n" + SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT;
}
const effectiveConnectorIds = resolveEffectiveConnectorIds({
chatConnectorIds: updatedChat.connectorIdsJson,
requestConnectorIds: req.connectorIds,
requestSelectionMode: req.connectorSelectionMode,
});
if (effectiveConnectorIds.length > 0) {
const matchedConnectors = await db.query.connectors.findMany({
where: inArray(connectors.id, effectiveConnectorIds),
});
const connectorsById = new Map(
matchedConnectors.map((connector) => [connector.id, connector]),
);
for (const connectorId of effectiveConnectorIds) {
const connector = connectorsById.get(connectorId);
if (
connector?.type === "openapi" &&
connector.endpointsJson &&
connector.endpointsJson.length > 0
) {
systemPrompt +=
"\n\n" +
formatOpenApiConnectorSystemPrompt({
name: connector.name,
sourceUrl: connector.sourceUrl,
description: connector.description,
specVersion: connector.specVersion,
endpoints: connector.endpointsJson,
});
}
}
}
const isSummarizeIntent = req.prompt.startsWith(
"Summarize from chat-id=",
);
......
import { db } from "../../db";
import { chats } from "../../db/schema";
import { eq } from "drizzle-orm";
import { chats, connectors } from "../../db/schema";
import { eq, inArray } from "drizzle-orm";
import {
constructSystemPrompt,
readAiRules,
......@@ -30,6 +30,8 @@ import { parseAppMentions } from "@/shared/parse_mention_apps";
import { isLocalAgentBackedMode, isTurboEditsV2Enabled } from "@/lib/schemas";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { resolveChatModeForTurn } from "./chat_mode_resolution";
import { resolveEffectiveConnectorIds } from "../utils/connector_selection";
import { formatOpenApiConnectorSystemPrompt } from "../utils/openapi_utils";
const logger = log.scope("token_count_handlers");
......@@ -119,6 +121,40 @@ export function registerTokenCountHandlers() {
systemPrompt += "\n\n" + SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT;
}
const effectiveConnectorIds = resolveEffectiveConnectorIds({
chatConnectorIds: chat.connectorIdsJson,
requestConnectorIds: req.connectorIds,
requestSelectionMode: req.connectorSelectionMode,
});
if (effectiveConnectorIds.length > 0) {
const matchedConnectors = await db.query.connectors.findMany({
where: inArray(connectors.id, effectiveConnectorIds),
});
const connectorsById = new Map(
matchedConnectors.map((connector) => [connector.id, connector]),
);
for (const connectorId of effectiveConnectorIds) {
const connector = connectorsById.get(connectorId);
if (
connector?.type === "openapi" &&
connector.endpointsJson &&
connector.endpointsJson.length > 0
) {
systemPrompt +=
"\n\n" +
formatOpenApiConnectorSystemPrompt({
name: connector.name,
sourceUrl: connector.sourceUrl,
description: connector.description,
specVersion: connector.specVersion,
endpoints: connector.endpointsJson,
});
}
}
}
const systemPromptTokens = estimateTokens(systemPrompt + supabaseContext);
// Extract codebase information if app is associated with the chat
......
......@@ -56,6 +56,7 @@ export type App = z.infer<typeof AppSchema>;
export const CreateAppParamsSchema = z.object({
name: z.string().min(1),
initialChatMode: ChatModeSchema.optional(),
initialConnectorIds: z.array(z.number()).optional(),
});
/**
......
......@@ -49,10 +49,16 @@ export const ChatSchema = z.object({
initialCommitHash: z.string().nullable().optional(),
dbTimestamp: z.string().nullable().optional(),
chatMode: NullableChatModeSchema,
connectorIds: z.array(z.number()).optional(),
});
export type Chat = z.infer<typeof ChatSchema>;
export const ConnectorSelectionModeSchema = z.enum(["append", "replace"]);
export type ConnectorSelectionMode = z.infer<
typeof ConnectorSelectionModeSchema
>;
/**
* Schema for component selection (used in chat context).
*/
......@@ -98,6 +104,8 @@ export const ChatStreamParamsSchema = z.object({
attachments: z.array(ChatAttachmentSchema).optional(),
selectedComponents: z.array(ComponentSelectionSchema).optional(),
requestedChatMode: ChatModeSchema.optional(),
connectorIds: z.array(z.number()).optional(),
connectorSelectionMode: ConnectorSelectionModeSchema.optional(),
});
export type ChatStreamParams = z.infer<typeof ChatStreamParamsSchema>;
......@@ -173,6 +181,8 @@ export type UpdateChatParams = z.infer<typeof UpdateChatParamsSchema>;
export const TokenCountParamsSchema = z.object({
chatId: z.number(),
input: z.string(),
connectorIds: z.array(z.number()).optional(),
connectorSelectionMode: ConnectorSelectionModeSchema.optional(),
});
export type TokenCountParams = z.infer<typeof TokenCountParamsSchema>;
......@@ -214,6 +224,7 @@ export const chatContracts = {
title: z.string().nullable(),
createdAt: z.date(),
chatMode: NullableChatModeSchema,
connectorIds: z.array(z.number()).optional(),
}),
),
}),
......@@ -225,6 +236,7 @@ export const chatContracts = {
z.object({
appId: z.number(),
initialChatMode: ChatModeSchema.optional(),
initialConnectorIds: z.array(z.number()).optional(),
}),
]),
output: CreateChatResultSchema,
......
......@@ -128,6 +128,7 @@ export type {
FileAttachment,
ChatAttachment,
ChatStreamParams,
ConnectorSelectionMode,
ChatResponseChunk,
ChatResponseEnd,
UpdateChatParams,
......
import { describe, expect, it } from "vitest";
import { resolveEffectiveConnectorIds } from "./connector_selection";
describe("resolveEffectiveConnectorIds", () => {
it("falls back to the chat defaults when the turn does not specify any connectors", () => {
expect(
resolveEffectiveConnectorIds({
chatConnectorIds: [3, 1, 1],
requestConnectorIds: [],
}),
).toEqual([3, 1]);
});
it("appends temporary turn connectors by default", () => {
expect(
resolveEffectiveConnectorIds({
chatConnectorIds: [1, 2],
requestConnectorIds: [2, 5],
requestSelectionMode: "append",
}),
).toEqual([1, 2, 5]);
});
it("replaces the chat defaults when the turn requests replace mode", () => {
expect(
resolveEffectiveConnectorIds({
chatConnectorIds: [1, 2],
requestConnectorIds: [4, 4, 9],
requestSelectionMode: "replace",
}),
).toEqual([4, 9]);
});
});
import type { ConnectorSelectionMode } from "@/ipc/types";
type ResolveEffectiveConnectorIdsParams = {
chatConnectorIds: number[] | null | undefined;
requestConnectorIds: number[] | null | undefined;
requestSelectionMode?: ConnectorSelectionMode;
};
export function resolveEffectiveConnectorIds({
chatConnectorIds,
requestConnectorIds,
requestSelectionMode,
}: ResolveEffectiveConnectorIdsParams): number[] {
const normalizedChatConnectorIds = Array.from(
new Set((chatConnectorIds ?? []).filter(Number.isFinite)),
);
const normalizedRequestConnectorIds = Array.from(
new Set((requestConnectorIds ?? []).filter(Number.isFinite)),
);
if (normalizedRequestConnectorIds.length === 0) {
return normalizedChatConnectorIds;
}
if (requestSelectionMode === "replace") {
return normalizedRequestConnectorIds;
}
return Array.from(
new Set([...normalizedChatConnectorIds, ...normalizedRequestConnectorIds]),
);
}
import { describe, expect, it } from "vitest";
import { extractOpenApiEndpoints } from "./openapi_utils";
import {
extractOpenApiEndpoints,
formatOpenApiConnectorSystemPrompt,
} from "./openapi_utils";
describe("extractOpenApiEndpoints", () => {
it("extracts endpoints from a JSON OpenAPI document", () => {
......@@ -70,4 +73,31 @@ describe("extractOpenApiEndpoints", () => {
},
]);
});
it("formats a connector summary for the system prompt", () => {
const prompt = formatOpenApiConnectorSystemPrompt({
name: "Weather API",
sourceUrl: "https://example.com/openapi.json",
description: "Forecast data",
specVersion: "3.1.0",
endpoints: [
{
id: "getForecast",
method: "GET",
path: "/forecast",
operationId: "getForecast",
summary: "Get forecast",
description: null,
tags: ["weather"],
},
],
});
expect(prompt).toContain("Connector: Weather API");
expect(prompt).toContain("Spec URL: https://example.com/openapi.json");
expect(prompt).toContain("- GET /forecast (getForecast) - Get forecast");
expect(prompt).toContain(
"Prefer these endpoints over inventing new API routes.",
);
});
});
......@@ -165,3 +165,42 @@ export async function fetchAndParseOpenApiDocument(url: string): Promise<{
endpoints,
};
}
export function formatOpenApiConnectorSystemPrompt(input: {
name: string;
sourceUrl: string;
description: string | null;
specVersion: string | null;
endpoints: ConnectorEndpoint[];
}): string {
const endpointLines = input.endpoints
.slice(0, 60)
.map((endpoint) => {
const summary = endpoint.summary?.trim()
? ` - ${endpoint.summary.trim()}`
: "";
const operationId = endpoint.operationId
? ` (${endpoint.operationId})`
: "";
return `- ${endpoint.method} ${endpoint.path}${operationId}${summary}`;
})
.join("\n");
const remainingCount = Math.max(input.endpoints.length - 60, 0);
const descriptionBlock =
input.description && input.description.trim().length > 0
? `Description: ${input.description.trim()}\n`
: "";
return `# API Connector
This app is connected to an external API specification that you should use as the source of truth when generating API calls, client helpers, request shapes, and integration code.
Connector: ${input.name}
Spec URL: ${input.sourceUrl}
Spec Version: ${input.specVersion ?? "unknown"}
${descriptionBlock}Available endpoints:
${endpointLines}
${remainingCount > 0 ? `- ...and ${remainingCount} more endpoints` : ""}
Prefer these endpoints over inventing new API routes. If you need to wire this API into the app, generate code that calls these external endpoints rather than creating fake local endpoints unless the user explicitly asks for a mock or proxy layer.`;
}
......@@ -131,8 +131,24 @@ export const queryKeys = {
// ─────────────────────────────────────────────────────────────────────────────
tokenCount: {
all: ["tokenCount"] as const,
forChat: ({ chatId, input }: { chatId: number | null; input: string }) =>
["tokenCount", chatId, input] as const,
forChat: ({
chatId,
input,
connectorIds,
connectorSelectionMode,
}: {
chatId: number | null;
input: string;
connectorIds?: number[];
connectorSelectionMode?: "append" | "replace";
}) =>
[
"tokenCount",
chatId,
input,
connectorIds ?? [],
connectorSelectionMode ?? "append",
] as const,
},
// ─────────────────────────────────────────────────────────────────────────────
......
......@@ -16,6 +16,7 @@ export const ChatSummarySchema = z.object({
title: z.string().nullable(),
createdAt: z.date(),
chatMode: z.enum(["build", "ask", "local-agent", "plan"]).nullable(),
connectorIds: z.array(z.number()).optional(),
});
/**
......
......@@ -32,6 +32,7 @@ import { ForceCloseDialog } from "@/components/ForceCloseDialog";
import { useSelectChat } from "@/hooks/useSelectChat";
import type { FileAttachment } from "@/ipc/types";
import type { ConnectorSummary } from "@/ipc/types";
import type { ListedApp } from "@/ipc/types/app";
import { NEON_TEMPLATE_IDS } from "@/shared/templates";
import { neonTemplateHook } from "@/client_logic/template_hook";
......@@ -47,6 +48,7 @@ let hasCheckedReleaseNotes = false;
export interface HomeSubmitOptions {
attachments?: FileAttachment[];
selectedApp?: ListedApp;
selectedConnector?: ConnectorSummary;
}
export default function HomePage() {
......@@ -154,6 +156,7 @@ export default function HomePage() {
const handleSubmit = async (options?: HomeSubmitOptions) => {
const attachments = options?.attachments || [];
const selectedApp = options?.selectedApp;
const selectedConnector = options?.selectedConnector;
if (!inputValue.trim() && attachments.length === 0) return;
......@@ -168,6 +171,7 @@ export default function HomePage() {
chatId = await ipc.chat.createChat({
appId: selectedApp.id,
initialChatMode,
initialConnectorIds: selectedConnector ? [selectedConnector.id] : [],
});
appId = selectedApp.id;
} else {
......@@ -175,6 +179,7 @@ export default function HomePage() {
const result = await ipc.app.createApp({
name: generateCuteAppName(),
initialChatMode,
initialConnectorIds: selectedConnector ? [selectedConnector.id] : [],
});
chatId = result.chatId;
appId = result.app.id;
......@@ -216,7 +221,10 @@ export default function HomePage() {
await invalidateAppQuery(queryClient, { appId });
// Invalidate chats so ChatTabs picks up the new chat immediately.
await queryClient.invalidateQueries({ queryKey: queryKeys.chats.all });
posthog.capture("home:chat-submit", { existingApp: !!selectedApp });
posthog.capture("home:chat-submit", {
existingApp: !!selectedApp,
hasConnector: !!selectedConnector,
});
// Select newly created first chat so it appears first in tabs.
selectChat({ chatId, appId });
} catch (error) {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论