add switch mode tool

This commit is contained in:
duanfuxiang 2025-03-17 09:12:49 +08:00
parent 9a5e5f3880
commit 4aa321dffc
14 changed files with 264 additions and 84 deletions

View File

@ -14,6 +14,7 @@ import {
} from 'react'
import { v4 as uuidv4 } from 'uuid'
import { ModeSelect } from './chat-input/ModeSelect'
import { ApplyViewState } from '../../ApplyView'
import { APPLY_VIEW_TYPE } from '../../constants'
import { useApp } from '../../contexts/AppContext'
@ -90,7 +91,7 @@ export type ChatProps = {
const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
const app = useApp()
const { settings } = useSettings()
const { settings, setSettings } = useSettings()
const { getRAGEngine } = useRAG()
const {
@ -565,6 +566,25 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
mentionables: [],
}
}
} else if (toolArgs.type === 'switch_mode') {
setSettings({
...settings,
mode: toolArgs.mode,
})
const formattedContent = `[switch_mode to ${toolArgs.mode}] Result: successfully switched to ${toolArgs.mode}\n`
return {
type: 'switch_mode',
applyMsgId,
applyStatus: ApplyStatus.Applied,
returnMsg: {
role: 'user',
applyStatus: ApplyStatus.Idle,
content: null,
promptContent: formattedContent,
id: uuidv4(),
mentionables: [],
}
}
}
} catch (error) {
console.error('Failed to apply changes', error)
@ -745,7 +765,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
return (
<div className="infio-chat-container">
<div className="infio-chat-header">
<h1 className="infio-chat-header-title"> CHAT </h1>
<ModeSelect />
<div className="infio-chat-header-buttons">
<button
onClick={() => handleNewChat()}

View File

@ -0,0 +1,82 @@
import { Check, Loader2, Settings2, X } from 'lucide-react'
import { PropsWithChildren, useState } from 'react'
import { useDarkModeContext } from '../../contexts/DarkModeContext'
import { ApplyStatus, ToolArgs } from '../../types/apply'
import { MemoizedSyntaxHighlighterWrapper } from './SyntaxHighlighterWrapper'
export default function MarkdownSwitchModeBlock({
mode,
applyStatus,
onApply,
reason,
finish,
}: PropsWithChildren<{
mode: string
applyStatus: ApplyStatus
onApply: (args: ToolArgs) => void
reason: string
finish: boolean
}>) {
const [applying, setApplying] = useState(false)
const { isDarkMode } = useDarkModeContext()
const handleApply = async () => {
if (applyStatus !== ApplyStatus.Idle) {
return
}
setApplying(true)
onApply({
type: 'switch_mode',
mode: mode,
reason: reason,
finish: finish,
})
}
return (
<div className={`infio-chat-code-block has-filename`}>
<div className={'infio-chat-code-block-header'}>
<div className={'infio-chat-code-block-header-filename'}>
<Settings2 size={10} className="infio-chat-code-block-header-icon" />
Switch to &quot;{mode.charAt(0).toUpperCase() + mode.slice(1)}&quot; mode
</div>
<div className={'infio-chat-code-block-header-button'}>
<button
onClick={handleApply}
style={{ color: '#008000' }}
disabled={applyStatus !== ApplyStatus.Idle || applying}
>
{applyStatus === ApplyStatus.Idle ? (
applying ? (
<>
<Loader2 className="spinner" size={14} /> Allowing...
</>
) : (
'Allow'
)
) : applyStatus === ApplyStatus.Applied ? (
<>
<Check size={14} /> Success
</>
) : (
<>
<X size={14} /> Failed
</>
)}
</button>
</div>
</div>
<MemoizedSyntaxHighlighterWrapper
isDarkMode={isDarkMode}
language="markdown"
hasFilename={true}
wrapLines={true}
isOpen={true}
>
{reason}
</MemoizedSyntaxHighlighterWrapper>
</div>
)
}

View File

@ -16,6 +16,7 @@ import MarkdownRegexSearchFilesBlock from './MarkdownRegexSearchFilesBlock'
import MarkdownSearchAndReplace from './MarkdownSearchAndReplace'
import MarkdownSearchWebBlock from './MarkdownSearchWebBlock'
import MarkdownSemanticSearchFilesBlock from './MarkdownSemanticSearchFilesBlock'
import MarkdownSwitchModeBlock from './MarkdownSwitchModeBlock'
import MarkdownWithIcons from './MarkdownWithIcon'
function ReactMarkdown({
applyStatus,
@ -132,6 +133,15 @@ function ReactMarkdown({
markdownContent={
`<icon name='ask_followup_question' size={14} className="infio-markdown-icon" />
${block.question && block.question.trimStart()}`} />
) : block.type === 'switch_mode' ? (
<MarkdownSwitchModeBlock
key={"switch-mode-" + index}
applyStatus={applyStatus}
onApply={onApply}
mode={block.mode}
reason={block.reason}
finish={block.finish}
/>
) : block.type === 'search_web' ? (
<MarkdownSearchWebBlock
key={"search-web-" + index}

View File

@ -0,0 +1,53 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useSettings } from '../../../contexts/SettingsContext'
import { modes } from '../../../utils/modes'
export function ModeSelect() {
const { settings, setSettings } = useSettings()
const [isOpen, setIsOpen] = useState(false)
const [mode, setMode] = useState(settings.mode)
useEffect(() => {
setMode(settings.mode)
}, [settings.mode])
return (
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenu.Trigger className="infio-chat-input-model-select">
<div className="infio-chat-input-model-select__icon">
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</div>
<div className="infio-chat-input-model-select__model-name">
{modes.find((m) => m.slug === mode)?.name}
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="infio-popover">
<ul>
{modes.map((mode) => (
<DropdownMenu.Item
key={mode.slug}
onSelect={() => {
setMode(mode.slug)
setSettings({
...settings,
mode: mode.slug,
})
}}
asChild
>
<li>{mode.name}</li>
</DropdownMenu.Item>
))}
</ul>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}

View File

@ -18,15 +18,16 @@ export function ModelSelect() {
try {
const models = await GetProviderModelIds(settings.chatModelProvider)
setProviderModels(models)
setChatModelId(settings.chatModelId)
} catch (error) {
console.error('Failed to fetch provider models:', error)
} finally {
setIsLoading(false)
}
}
fetchModels()
}, [settings.chatModelProvider])
}, [settings])
return (
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>

View File

@ -30,6 +30,7 @@ import { ImageUploadButton } from './ImageUploadButton'
import LexicalContentEditable from './LexicalContentEditable'
import MentionableBadge from './MentionableBadge'
import { ModelSelect } from './ModelSelect'
import { ModeSelect } from './ModeSelect'
import { MentionNode } from './plugins/mention/MentionNode'
import { NodeMutations } from './plugins/on-mutation/OnMutationPlugin'
import { SubmitButton } from './SubmitButton'

View File

@ -1,16 +1,16 @@
import { promises as fs } from "fs"
import * as path from "path"
import * as vscode from "vscode"
// import { promises as fs } from "fs"
// import * as path from "path"
// import * as vscode from "vscode"
import { ModeConfig, getAllModesWithPrompts } from "../../../utils/modes"
export async function getModesSection(context: vscode.ExtensionContext): Promise<string> {
const settingsDir = path.join(context.globalStorageUri.fsPath, "settings")
await fs.mkdir(settingsDir, { recursive: true })
const customModesPath = path.join(settingsDir, "cline_custom_modes.json")
export async function getModesSection(): Promise<string> {
// const settingsDir = path.join(context.globalStorageUri.fsPath, "settings")
// await fs.mkdir(settingsDir, { recursive: true })
// const customModesPath = path.join(settingsDir, "cline_custom_modes.json")
// Get all modes with their overrides from extension state
const allModes = await getAllModesWithPrompts(context)
const allModes = await getAllModesWithPrompts()
return `====
@ -18,43 +18,5 @@ MODES
- These are the currently available modes:
${allModes.map((mode: ModeConfig) => ` * "${mode.name}" mode (${mode.slug}) - ${mode.roleDefinition.split(".")[0]}`).join("\n")}
- Custom modes can be configured in two ways:
1. Globally via '${customModesPath}' (created automatically on startup)
2. Per-workspace via '.roomodes' in the workspace root directory
When modes with the same slug exist in both files, the workspace-specific .roomodes version takes precedence. This allows projects to override global modes or define project-specific modes.
If asked to create a project mode, create it in .roomodes in the workspace root. If asked to create a global mode, use the global custom modes file.
- The following fields are required and must not be empty:
* slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better.
* name: The display name for the mode
* roleDefinition: A detailed description of the mode's role and capabilities
* groups: Array of allowed tool groups (can be empty). Each group can be specified either as a string (e.g., "edit" to allow editing any file) or with file restrictions (e.g., ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }] to only allow editing markdown files)
- The customInstructions field is optional.
- For multi-line text, include newline characters in the string like "This is the first line.\\nThis is the next line.\\n\\nThis is a double line break."
Both files should follow this structure:
{
"customModes": [
{
"slug": "designer", // Required: unique slug with lowercase letters, numbers, and hyphens
"name": "Designer", // Required: mode display name
"roleDefinition": "You are Infio, a UI/UX expert specializing in design systems and frontend development. Your expertise includes:\\n- Creating and maintaining design systems\\n- Implementing responsive and accessible web interfaces\\n- Working with CSS, HTML, and modern frontend frameworks\\n- Ensuring consistent user experiences across platforms", // Required: non-empty
"groups": [ // Required: array of tool groups (can be empty)
"read", // Read files group (read_file, search_files, list_files)
"edit", // Edit files group (apply_diff, write_to_file) - allows editing any file
// Or with file restrictions:
// ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }], // Edit group that only allows editing markdown files
"browser", // Browser group (browser_action)
"command", // Command group (execute_command)
"mcp" // MCP group (use_mcp_tool, access_mcp_resource)
],
"customInstructions": "Additional instructions for the Designer mode" // Optional
}
]
}`
`
}

View File

@ -15,7 +15,7 @@ import {
addCustomInstructions,
getCapabilitiesSection,
getMcpServersSection,
// getModesSection,
getModesSection,
getObjectiveSection,
getRulesSection,
getSharedToolUseSection,
@ -44,7 +44,7 @@ async function generatePrompt(
// }
const searchTool = "semantic"
// If diff is disabled, don't pass the diffStrategy
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
@ -52,9 +52,12 @@ async function generatePrompt(
const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0]
const roleDefinition = promptComponent?.roleDefinition || modeConfig.roleDefinition
const mcpServersSection = modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp")
? await getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation)
: ""
const [modesSection, mcpServersSection] = await Promise.all([
getModesSection(),
modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp")
? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation)
: Promise.resolve(""),
])
const basePrompt = `${roleDefinition}
@ -77,19 +80,21 @@ ${getToolUseGuidelinesSection()}
${mcpServersSection}
${getCapabilitiesSection(
mode,
cwd,
searchTool,
)}
mode,
cwd,
searchTool,
)}
${modesSection}
${getRulesSection(
mode,
cwd,
searchTool,
supportsComputerUse,
effectiveDiffStrategy,
experiments,
)}
mode,
cwd,
searchTool,
supportsComputerUse,
effectiveDiffStrategy,
experiments,
)}
${getSystemInfoSection(cwd)}

View File

@ -76,4 +76,11 @@ export type FetchUrlsContentToolArgs = {
finish?: boolean;
}
export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs;
export type SwitchModeToolArgs = {
type: 'switch_mode';
mode: string;
reason: string;
finish?: boolean;
}
export type ToolArgs = ReadFileToolArgs | WriteToFileToolArgs | InsertContentToolArgs | SearchAndReplaceToolArgs | ListFilesToolArgs | RegexSearchFilesToolArgs | SemanticSearchFilesToolArgs | SearchWebToolArgs | FetchUrlsContentToolArgs | SwitchModeToolArgs;

View File

@ -219,6 +219,9 @@ export const InfioSettingsSchema = z.object({
embeddingModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Google),
embeddingModelId: z.string().catch(''),
// Mode
mode: z.string().catch('ask'),
/// [compatible]
// activeModels [compatible]
activeModels: z.array(

View File

@ -248,15 +248,15 @@ export const defaultPrompts: Readonly<CustomModePrompts> = Object.freeze(
)
// Helper function to get all modes with their prompt overrides from extension state
export async function getAllModesWithPrompts(context: vscode.ExtensionContext): Promise<ModeConfig[]> {
const customModes = (await context.globalState.get<ModeConfig[]>("customModes")) || []
const customModePrompts = (await context.globalState.get<CustomModePrompts>("customModePrompts")) || {}
export async function getAllModesWithPrompts(): Promise<ModeConfig[]> {
// const customModes = (await context.globalState.get<ModeConfig[]>("customModes")) || []
// const customModePrompts = (await context.globalState.get<CustomModePrompts>("customModePrompts")) || {}
const allModes = getAllModes(customModes)
const allModes = getAllModes()
return allModes.map((mode) => ({
...mode,
roleDefinition: customModePrompts[mode.slug]?.roleDefinition ?? mode.roleDefinition,
customInstructions: customModePrompts[mode.slug]?.customInstructions ?? mode.customInstructions,
roleDefinition: mode.roleDefinition,
customInstructions: mode.customInstructions,
}))
}

View File

@ -68,6 +68,11 @@ export type ParsedMsgBlock =
type: 'fetch_urls_content'
urls: string[]
finish: boolean
} | {
type: 'switch_mode'
mode: string
reason: string
finish: boolean
}
export function parseMsgBlocks(
@ -399,7 +404,6 @@ export function parseMsgBlocks(
result,
})
lastEndOffset = endOffset
} else if (node.nodeName === 'ask_followup_question') {
if (!node.sourceCodeLocation) {
throw new Error('sourceCodeLocation is undefined')
@ -423,8 +427,40 @@ export function parseMsgBlocks(
question,
})
lastEndOffset = endOffset
}
else if (node.nodeName === 'search_web') {
} else if (node.nodeName === 'switch_mode') {
if (!node.sourceCodeLocation) {
throw new Error('sourceCodeLocation is undefined')
}
const startOffset = node.sourceCodeLocation.startOffset
const endOffset = node.sourceCodeLocation.endOffset
if (startOffset > lastEndOffset) {
parsedResult.push({
type: 'string',
content: input.slice(lastEndOffset, startOffset),
})
}
let mode: string = ''
let reason: string = ''
for (const childNode of node.childNodes) {
if (childNode.nodeName === 'mode_slug' && childNode.childNodes.length > 0) {
// @ts-ignore - 忽略 value 属性的类型错误
mode = childNode.childNodes[0].value
} else if (childNode.nodeName === 'reason' && childNode.childNodes.length > 0) {
// @ts-ignore - 忽略 value 属性的类型错误
reason = childNode.childNodes[0].value
}
}
parsedResult.push({
type: 'switch_mode',
mode,
reason,
finish: node.sourceCodeLocation.endTag !== undefined
})
lastEndOffset = endOffset
} else if (node.nodeName === 'search_web') {
if (!node.sourceCodeLocation) {
throw new Error('sourceCodeLocation is undefined')
}

View File

@ -16,7 +16,7 @@ import {
MentionableVault
} from '../types/mentionable'
import { InfioSettings } from '../types/settings'
import { defaultModeSlug, getFullModeDetails } from "../utils/modes"
import { Mode, defaultModeSlug, getFullModeDetails, getModeBySlug } from "../utils/modes"
import {
readTFileContent
@ -157,8 +157,8 @@ export class PromptGenerator {
similaritySearchResults,
},
]
const systemMessage = await this.getSystemMessageNew()
console.log('this.settings.mode', this.settings.mode)
const systemMessage = await this.getSystemMessageNew(this.settings.mode)
const requestMessages: RequestMessage[] = [
systemMessage,
@ -225,7 +225,7 @@ export class PromptGenerator {
details += `\n\n# Current Time\n${formatter.format(now)} (${timeZone}, UTC${timeZoneOffsetStr})`
// Add current mode details
const currentMode = defaultModeSlug
const currentMode = this.settings.mode
const modeDetails = await getFullModeDetails(currentMode)
details += `\n\n# Current Mode\n`
details += `<slug>${currentMode}</slug>\n`
@ -446,8 +446,8 @@ export class PromptGenerator {
}
}
private async getSystemMessageNew(): Promise<RequestMessage> {
const systemPrompt = await SYSTEM_PROMPT(this.app.vault.getRoot().path, false)
private async getSystemMessageNew(mode: Mode): Promise<RequestMessage> {
const systemPrompt = await SYSTEM_PROMPT(this.app.vault.getRoot().path, false, mode)
return {
role: 'system',

View File

@ -493,7 +493,7 @@ button:not(.clickable-icon).infio-chat-list-dropdown {
/* position: fixed; */
border: 1px solid var(--background-modifier-border);
overflow: hidden;
width: 240px;
min-width: 60px;
max-width: 240px;
}