infio-copilot/src/components/chat-view/CustomModeView.tsx
2025-04-30 18:47:14 +08:00

667 lines
18 KiB
TypeScript

import { ChevronDown, ChevronRight, Plus, Trash2, Undo2 } from 'lucide-react';
import { getLanguage } from 'obsidian';
import React, { useEffect, useMemo, useState } from 'react';
import { PREVIEW_VIEW_TYPE } from '../../constants';
import { useApp } from '../../contexts/AppContext';
import { useDiffStrategy } from '../../contexts/DiffStrategyContext';
import { useRAG } from '../../contexts/RAGContext';
import { useSettings } from '../../contexts/SettingsContext';
import { CustomMode, GroupEntry, ToolGroup } from '../../database/json/custom-mode/types';
import { useCustomModes } from '../../hooks/use-custom-mode';
import { PreviewView, PreviewViewState } from '../../PreviewView';
import { modes as buildinModes } from '../../utils/modes';
import { openOrCreateMarkdownFile } from '../../utils/obsidian';
import { PromptGenerator, getFullLanguageName } from '../../utils/prompt-generator';
const CustomModeView = () => {
const app = useApp()
const {
createCustomMode,
deleteCustomMode,
updateCustomMode,
customModeList,
customModePrompts
} = useCustomModes()
const { settings } = useSettings()
const { getRAGEngine } = useRAG()
const diffStrategy = useDiffStrategy()
const promptGenerator = useMemo(() => {
// @ts-expect-error
return new PromptGenerator(getRAGEngine, app, settings, diffStrategy, customModePrompts, customModeList)
}, [app, settings, diffStrategy, customModePrompts, customModeList])
// Currently selected mode
const [selectedMode, setSelectedMode] = useState<string>('ask')
const [isBuiltinMode, setIsBuiltinMode] = useState<boolean>(true)
const [isAdvancedCollapsed, setIsAdvancedCollapsed] = useState(true);
const isNewMode = React.useMemo(() => selectedMode === "add_new_mode", [selectedMode])
// New mode configuration
const [newMode, setNewMode] = useState<CustomMode>({
id: '',
slug: '',
name: '',
roleDefinition: '',
customInstructions: '',
groups: [],
source: 'global',
updatedAt: 0,
})
// Custom mode ID
const [customModeId, setCustomModeId] = useState<string>('')
// Mode name
const [modeName, setModeName] = useState<string>('')
// Role definition
const [roleDefinition, setRoleDefinition] = useState<string>('')
// Selected tool groups
const [selectedTools, setSelectedTools] = useState<GroupEntry[]>([])
// Custom instructions
const [customInstructions, setCustomInstructions] = useState<string>('')
// Update form data when mode changes
useEffect(() => {
// new mode
if (isNewMode) {
setIsBuiltinMode(false);
setModeName(newMode.name);
setRoleDefinition(newMode.roleDefinition);
setCustomInstructions(newMode.customInstructions || '');
setSelectedTools(newMode.groups as GroupEntry[]);
setCustomModeId('');
return;
}
const builtinMode = buildinModes.find(m => m.slug === selectedMode);
if (builtinMode) {
setIsBuiltinMode(true);
setModeName(builtinMode.slug);
setRoleDefinition(builtinMode.roleDefinition);
setCustomInstructions(builtinMode.customInstructions || '');
setSelectedTools(builtinMode.groups as GroupEntry[]);
setCustomModeId(''); // Built-in modes don't have custom IDs
} else {
setIsBuiltinMode(false);
const customMode = customModeList.find(m => m.slug === selectedMode);
if (customMode) {
setCustomModeId(customMode.id || '');
setModeName(customMode.name);
setRoleDefinition(customMode.roleDefinition);
setCustomInstructions(customMode.customInstructions || '');
setSelectedTools(customMode.groups);
} else {
console.error("custom mode not found")
}
}
}, [selectedMode, customModeList]);
// Handle tool group selection change
const handleToolChange = React.useCallback((tool: ToolGroup) => {
if (isNewMode) {
setNewMode((prev) => ({
...prev,
groups: prev.groups.includes(tool) ? prev.groups.filter(t => t !== tool) : [...prev.groups, tool]
}))
}
setSelectedTools(prev => {
if (prev.includes(tool)) {
return prev.filter(t => t !== tool);
} else {
return [...prev, tool];
}
});
}, [isNewMode])
// Update mode configuration
const handleUpdateMode = React.useCallback(async () => {
if (!isBuiltinMode) {
await updateCustomMode(
customModeId,
modeName,
roleDefinition,
customInstructions,
selectedTools
);
}
}, [isBuiltinMode, customModeId, modeName, roleDefinition, customInstructions, selectedTools])
// Create new mode
const createNewMode = React.useCallback(async () => {
if (!isNewMode) return;
await createCustomMode(
modeName,
roleDefinition,
customInstructions,
selectedTools
);
// reset
setNewMode({
id: '',
slug: '',
name: '',
roleDefinition: '',
customInstructions: '',
groups: [],
source: 'global',
updatedAt: 0,
})
setSelectedMode("add_new_mode")
}, [isNewMode, modeName, roleDefinition, customInstructions, selectedTools])
// Delete mode
const deleteMode = React.useCallback(async () => {
if (isNewMode || isBuiltinMode) return;
await deleteCustomMode(customModeId);
setModeName('')
setRoleDefinition('')
setCustomInstructions('')
setSelectedTools([])
setSelectedMode('add_new_mode')
}, [isNewMode, isBuiltinMode, customModeId])
return (
<div className="infio-custom-modes-container">
{/* Mode configuration title and buttons */}
<div className="infio-custom-modes-header">
<div className="infio-custom-modes-title">
<h2>Mode Configuration</h2>
</div>
{/* <div className="infio-custom-modes-actions">
<button className="infio-custom-modes-btn">
<PlusCircle size={18} />
</button>
<button className="infio-custom-modes-btn">
<Settings size={18} />
</button>
</div> */}
</div>
{/* Create mode tip */}
<div className="infio-custom-modes-tip">
Click + to create a new mode
</div>
{/* Mode selection area */}
<div className="infio-custom-modes-builtin">
{[...buildinModes, ...customModeList].map(mode => (
<button
key={mode.slug}
className={`infio-mode-btn ${selectedMode === mode.slug ? 'active' : ''}`}
onClick={() => { setSelectedMode(mode.slug) }}
>
{mode.name}
</button>
))}
<button
key={"add_new_mode"}
className={`infio-mode-btn ${selectedMode === "add_new_mode" ? 'active' : ''}`}
onClick={() => setSelectedMode("add_new_mode")}
>
<Plus size={18} />
</button>
</div>
{/* Mode name */}
<div className="infio-custom-modes-section">
<div className="infio-section-header">
<h3>Mode Name</h3>
{!isBuiltinMode && !isNewMode && (
<button className="infio-section-btn" onClick={deleteMode}>
<Trash2 size={16} />
</button>
)}
</div>
{
isBuiltinMode ? (
<p className="infio-section-subtitle">Built-in mode names cannot be modified</p>
) : (
<p className="infio-section-subtitle">
Mode names must only contain letters, numbers, and hyphens
</p>
)
}
<input
type="text"
value={modeName}
onChange={(e) => {
if (isNewMode) {
setNewMode((prev) => ({ ...prev, name: e.target.value }))
}
setModeName(e.target.value)
}}
className="infio-custom-modes-input"
placeholder="Enter mode name..."
disabled={isBuiltinMode}
/>
</div>
{/* Role definition */}
<div className="infio-custom-modes-section">
<div className="infio-section-header">
<h3>Role Definition</h3>
{isBuiltinMode && (
<button className="infio-section-btn">
<Undo2 size={16} />
</button>
)}
</div>
<p className="infio-section-subtitle">Set professional domain and response style</p>
<textarea
className="infio-custom-textarea"
value={roleDefinition}
onChange={(e) => {
if (isNewMode) {
setNewMode((prev) => ({ ...prev, roleDefinition: e.target.value }))
}
setRoleDefinition(e.target.value)
}}
placeholder="Enter role definition..."
/>
</div>
{/* Available features */}
<div className="infio-custom-modes-section">
<div className="infio-section-header">
<h3>Available Features</h3>
{/* {!isBuiltinMode && (
<button className="infio-section-btn">
<Undo2 size={16} />
</button>
)} */}
</div>
{
isBuiltinMode && (
<p className="infio-section-subtitle">Available features of built-in modes cannot be modified</p>
)
}
<div className="infio-tools-list">
<div className="infio-tool-item">
<label>
<input
type="checkbox"
disabled={isBuiltinMode}
checked={selectedTools.includes('read')}
onChange={() => handleToolChange('read')}
/>
Read Files
</label>
</div>
<div className="infio-tool-item">
<label>
<input
type="checkbox"
disabled={isBuiltinMode}
checked={selectedTools.includes('edit')}
onChange={() => handleToolChange('edit')}
/>
Edit Files
</label>
</div>
<div className="infio-tool-item">
<label>
<input
type="checkbox"
disabled={isBuiltinMode}
checked={selectedTools.includes('research')}
onChange={() => handleToolChange('research')}
/>
Web Search
</label>
</div>
</div>
</div>
{/* Mode-specific rules */}
<div className="infio-custom-modes-section">
<div className="infio-section-header">
<h3>Mode-Specific Rules (Optional)</h3>
{isBuiltinMode && (
<button className="infio-section-btn">
<Undo2 size={16} />
</button>
)}
</div>
<p className="infio-section-subtitle">Mode-specific rules</p>
<textarea
className="infio-custom-textarea"
value={customInstructions}
onChange={(e) => {
if (isNewMode) {
setNewMode((prev) => ({ ...prev, customInstructions: e.target.value }))
}
setCustomInstructions(e.target.value)
}}
placeholder="Enter mode custom instructions..."
/>
<p className="infio-section-footer">
Support reading configuration from<a href="#" className="infio-link" onClick={() => openOrCreateMarkdownFile(app, `_infio_prompts/${modeName}/rules.md`, 0)}>_infio_prompts/{modeName}/rules</a> file
</p>
</div>
{/* Advanced, override system prompt */}
<div className="infio-custom-modes-section">
<div
className="infio-section-header infio-section-header-collapsible"
onClick={() => setIsAdvancedCollapsed(!isAdvancedCollapsed)}
>
<div className="infio-section-header-title-container">
{isAdvancedCollapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<h6 className="infio-section-header-title">Override System Prompt</h6>
</div>
</div>
{!isAdvancedCollapsed && (
<>
<p className="infio-section-subtitle">
You can completely replace the system prompt for this mode (excluding role definition and custom instructions) by creating a file
<a href="#" className="infio-link" onClick={() => openOrCreateMarkdownFile(app, `_infio_prompts/${modeName}/system_prompt.md`, 0)}>_infio_prompts/{modeName}/system_prompt</a>
. This is a very advanced feature that will override all built-in prompts including tool usage, please use with caution <button
className="infio-preview-btn"
onClick={async () => {
let filesSearchMethod = settings.filesSearchMethod
if (filesSearchMethod === 'auto' && settings.embeddingModelId && settings.embeddingModelId !== '') {
filesSearchMethod = 'semantic'
}
const userLanguage = getFullLanguageName(getLanguage())
const systemPrompt = await promptGenerator.getSystemMessageNew(modeName, filesSearchMethod, userLanguage)
const existingLeaf = app.workspace
.getLeavesOfType(PREVIEW_VIEW_TYPE)
.find(
(leaf) =>
leaf.view instanceof PreviewView && leaf.view.state.title === `${modeName} system prompt`
)
if (existingLeaf) {
app.workspace.setActiveLeaf(existingLeaf, { focus: true })
} else {
app.workspace.getLeaf(true).setViewState({
type: PREVIEW_VIEW_TYPE,
active: true,
state: {
content: systemPrompt.content as string,
title: `${modeName} system prompt`,
} satisfies PreviewViewState,
})
}
}
}
>
Preview System Prompt
</button>
</p></>
)}
</div>
{/* Save */}
<div className="infio-custom-modes-actions">
<button
className="infio-preview-btn"
onClick={() => {
if (isNewMode) {
createNewMode()
} else {
handleUpdateMode()
}
}}
>
Save
</button>
</div>
{/* Styles */}
<style>
{`
.infio-custom-modes-container {
display: flex;
flex-direction: column;
padding: 16px;
gap: 16px;
color: var(--text-normal);
height: 100%;
overflow-y: auto;
}
.infio-custom-modes-input {
background-color: var(--background-primary) !important;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
color: var(--text-normal);
padding: var(--size-4-2);
font-size: var(--font-ui-small);
width: 100%;
box-sizing: border-box;
margin-bottom: var(--size-4-2);
}
.infio-custom-modes-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.infio-custom-modes-title h2 {
margin: 0;
font-size: 24px;
}
.infio-custom-modes-actions {
display: flex;
gap: 8px;
}
.infio-custom-modes-btn {
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid #444;
color: var(--text-normal)
border-radius: 4px;
padding: 6px;
cursor: pointer;
}
.infio-custom-modes-tip {
color: #888;
font-size: 14px;
margin-bottom: 8px;
}
.infio-custom-modes-builtin {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.infio-mode-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--size-2-2);
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
border-radius: var(--radius-s);
padding: var(--size-2-3) var(--size-4-3);
cursor: pointer;
font-size: var(--font-ui-small);
align-self: flex-start;
margin-top: var(--size-4-2);
}
.infio-mode-btn.active {
background-color: var(--text-accent);
}
.infio-custom-modes-custom {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.infio-mode-btn-custom {
background-color: transparent;
border: 1px solid #444;
border-radius: 4px;
padding: 6px 12px;
color: #888;
cursor: pointer;
font-size: 14px;
}
.infio-mode-btn-custom.active {
background-color: var(--text-accent);
border-color: var(--text-accent);
color: var(--text-normal);
}
.infio-custom-modes-section {
margin-bottom: 16px;
}
.infio-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.infio-section-header h3 {
margin: 0;
font-size: 16px;
}
.infio-section-btn {
display: flex;
align-items: center;
justify-content: center;
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
color: var(--text-muted);
padding: 0 !important;
margin: 0 !important;
width: 24px !important;
height: 24px !important;
&:hover {
background-color: var(--background-modifier-hover) !important;
}
}
.infio-section-subtitle {
color: #888;
font-size: 14px;
margin: 4px 0 12px;
}
.infio-custom-textarea {
background-color: var(--background-primary) !important;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
color: var(--text-normal);
padding: var(--size-4-2);
font-size: var(--font-ui-small);
width: 100%;
min-height: 160px;
resize: vertical;
box-sizing: border-box;
}
.infio-select {
width: 100%;
border: 1px solid #444;
border-radius: 4px;
color: var(--text-normal);
padding: 8px 12px;
margin-bottom: 8px;
}
.infio-tools-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.infio-tool-item {
display: flex;
align-items: center;
}
.infio-tool-item label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.infio-code-section {
border: 1px solid #444;
border-radius: 4px;
padding: 8px;
margin-bottom: 12px;
}
.infio-code-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: #888;
}
.infio-section-footer {
margin-top: 0px;
font-size: 14px;
color: #888;
}
.infio-link {
color: var(--text-accent);
text-decoration: none;
}
.infio-preview-btn {
border: 1px solid #444;
color: var(--text-normal);
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
}
.infio-section-header-collapsible {
cursor: pointer;
user-select: none;
}
.infio-section-header-title-container {
display: flex;
align-items: center;
gap: 4px;
}
.infio-section-header-title {
margin: 0;
}
`}
</style>
</div>
)
}
export default CustomModeView