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' } from 'react'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { ModeSelect } from './chat-input/ModeSelect'
import { ApplyViewState } from '../../ApplyView' import { ApplyViewState } from '../../ApplyView'
import { APPLY_VIEW_TYPE } from '../../constants' import { APPLY_VIEW_TYPE } from '../../constants'
import { useApp } from '../../contexts/AppContext' import { useApp } from '../../contexts/AppContext'
@ -90,7 +91,7 @@ export type ChatProps = {
const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => { const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
const app = useApp() const app = useApp()
const { settings } = useSettings() const { settings, setSettings } = useSettings()
const { getRAGEngine } = useRAG() const { getRAGEngine } = useRAG()
const { const {
@ -565,6 +566,25 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
mentionables: [], 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) { } catch (error) {
console.error('Failed to apply changes', error) console.error('Failed to apply changes', error)
@ -745,7 +765,7 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
return ( return (
<div className="infio-chat-container"> <div className="infio-chat-container">
<div className="infio-chat-header"> <div className="infio-chat-header">
<h1 className="infio-chat-header-title"> CHAT </h1> <ModeSelect />
<div className="infio-chat-header-buttons"> <div className="infio-chat-header-buttons">
<button <button
onClick={() => handleNewChat()} 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 MarkdownSearchAndReplace from './MarkdownSearchAndReplace'
import MarkdownSearchWebBlock from './MarkdownSearchWebBlock' import MarkdownSearchWebBlock from './MarkdownSearchWebBlock'
import MarkdownSemanticSearchFilesBlock from './MarkdownSemanticSearchFilesBlock' import MarkdownSemanticSearchFilesBlock from './MarkdownSemanticSearchFilesBlock'
import MarkdownSwitchModeBlock from './MarkdownSwitchModeBlock'
import MarkdownWithIcons from './MarkdownWithIcon' import MarkdownWithIcons from './MarkdownWithIcon'
function ReactMarkdown({ function ReactMarkdown({
applyStatus, applyStatus,
@ -132,6 +133,15 @@ function ReactMarkdown({
markdownContent={ markdownContent={
`<icon name='ask_followup_question' size={14} className="infio-markdown-icon" /> `<icon name='ask_followup_question' size={14} className="infio-markdown-icon" />
${block.question && block.question.trimStart()}`} /> ${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' ? ( ) : block.type === 'search_web' ? (
<MarkdownSearchWebBlock <MarkdownSearchWebBlock
key={"search-web-" + index} 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,6 +18,7 @@ export function ModelSelect() {
try { try {
const models = await GetProviderModelIds(settings.chatModelProvider) const models = await GetProviderModelIds(settings.chatModelProvider)
setProviderModels(models) setProviderModels(models)
setChatModelId(settings.chatModelId)
} catch (error) { } catch (error) {
console.error('Failed to fetch provider models:', error) console.error('Failed to fetch provider models:', error)
} finally { } finally {
@ -26,7 +27,7 @@ export function ModelSelect() {
} }
fetchModels() fetchModels()
}, [settings.chatModelProvider]) }, [settings])
return ( return (
<DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}> <DropdownMenu.Root open={isOpen} onOpenChange={setIsOpen}>

View File

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

View File

@ -1,16 +1,16 @@
import { promises as fs } from "fs" // import { promises as fs } from "fs"
import * as path from "path" // import * as path from "path"
import * as vscode from "vscode" // import * as vscode from "vscode"
import { ModeConfig, getAllModesWithPrompts } from "../../../utils/modes" import { ModeConfig, getAllModesWithPrompts } from "../../../utils/modes"
export async function getModesSection(context: vscode.ExtensionContext): Promise<string> { export async function getModesSection(): Promise<string> {
const settingsDir = path.join(context.globalStorageUri.fsPath, "settings") // const settingsDir = path.join(context.globalStorageUri.fsPath, "settings")
await fs.mkdir(settingsDir, { recursive: true }) // await fs.mkdir(settingsDir, { recursive: true })
const customModesPath = path.join(settingsDir, "cline_custom_modes.json") // const customModesPath = path.join(settingsDir, "cline_custom_modes.json")
// Get all modes with their overrides from extension state // Get all modes with their overrides from extension state
const allModes = await getAllModesWithPrompts(context) const allModes = await getAllModesWithPrompts()
return `==== return `====
@ -18,43 +18,5 @@ MODES
- These are the currently available modes: - These are the currently available modes:
${allModes.map((mode: ModeConfig) => ` * "${mode.name}" mode (${mode.slug}) - ${mode.roleDefinition.split(".")[0]}`).join("\n")} ${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, addCustomInstructions,
getCapabilitiesSection, getCapabilitiesSection,
getMcpServersSection, getMcpServersSection,
// getModesSection, getModesSection,
getObjectiveSection, getObjectiveSection,
getRulesSection, getRulesSection,
getSharedToolUseSection, getSharedToolUseSection,
@ -52,9 +52,12 @@ async function generatePrompt(
const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0] const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0]
const roleDefinition = promptComponent?.roleDefinition || modeConfig.roleDefinition const roleDefinition = promptComponent?.roleDefinition || modeConfig.roleDefinition
const mcpServersSection = modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp") const [modesSection, mcpServersSection] = await Promise.all([
? await getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation) getModesSection(),
: "" modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp")
? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation)
: Promise.resolve(""),
])
const basePrompt = `${roleDefinition} const basePrompt = `${roleDefinition}
@ -77,19 +80,21 @@ ${getToolUseGuidelinesSection()}
${mcpServersSection} ${mcpServersSection}
${getCapabilitiesSection( ${getCapabilitiesSection(
mode, mode,
cwd, cwd,
searchTool, searchTool,
)} )}
${modesSection}
${getRulesSection( ${getRulesSection(
mode, mode,
cwd, cwd,
searchTool, searchTool,
supportsComputerUse, supportsComputerUse,
effectiveDiffStrategy, effectiveDiffStrategy,
experiments, experiments,
)} )}
${getSystemInfoSection(cwd)} ${getSystemInfoSection(cwd)}

View File

@ -76,4 +76,11 @@ export type FetchUrlsContentToolArgs = {
finish?: boolean; 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), embeddingModelProvider: z.nativeEnum(ApiProvider).catch(ApiProvider.Google),
embeddingModelId: z.string().catch(''), embeddingModelId: z.string().catch(''),
// Mode
mode: z.string().catch('ask'),
/// [compatible] /// [compatible]
// activeModels [compatible] // activeModels [compatible]
activeModels: z.array( 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 // Helper function to get all modes with their prompt overrides from extension state
export async function getAllModesWithPrompts(context: vscode.ExtensionContext): Promise<ModeConfig[]> { export async function getAllModesWithPrompts(): Promise<ModeConfig[]> {
const customModes = (await context.globalState.get<ModeConfig[]>("customModes")) || [] // const customModes = (await context.globalState.get<ModeConfig[]>("customModes")) || []
const customModePrompts = (await context.globalState.get<CustomModePrompts>("customModePrompts")) || {} // const customModePrompts = (await context.globalState.get<CustomModePrompts>("customModePrompts")) || {}
const allModes = getAllModes(customModes) const allModes = getAllModes()
return allModes.map((mode) => ({ return allModes.map((mode) => ({
...mode, ...mode,
roleDefinition: customModePrompts[mode.slug]?.roleDefinition ?? mode.roleDefinition, roleDefinition: mode.roleDefinition,
customInstructions: customModePrompts[mode.slug]?.customInstructions ?? mode.customInstructions, customInstructions: mode.customInstructions,
})) }))
} }

View File

@ -68,6 +68,11 @@ export type ParsedMsgBlock =
type: 'fetch_urls_content' type: 'fetch_urls_content'
urls: string[] urls: string[]
finish: boolean finish: boolean
} | {
type: 'switch_mode'
mode: string
reason: string
finish: boolean
} }
export function parseMsgBlocks( export function parseMsgBlocks(
@ -399,7 +404,6 @@ export function parseMsgBlocks(
result, result,
}) })
lastEndOffset = endOffset lastEndOffset = endOffset
} else if (node.nodeName === 'ask_followup_question') { } else if (node.nodeName === 'ask_followup_question') {
if (!node.sourceCodeLocation) { if (!node.sourceCodeLocation) {
throw new Error('sourceCodeLocation is undefined') throw new Error('sourceCodeLocation is undefined')
@ -423,8 +427,40 @@ export function parseMsgBlocks(
question, question,
}) })
lastEndOffset = endOffset lastEndOffset = endOffset
} } else if (node.nodeName === 'switch_mode') {
else if (node.nodeName === 'search_web') { 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) { if (!node.sourceCodeLocation) {
throw new Error('sourceCodeLocation is undefined') throw new Error('sourceCodeLocation is undefined')
} }

View File

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

View File

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