From 6bf307154055cce250b9ecafba20ca372e4ee717 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 4 Feb 2026 11:16:19 +0800 Subject: [PATCH 1/5] chore: set skills path for mcp host --- electron/main/constant.ts | 1 + electron/main/service.ts | 3 +++ src-tauri/src/host.rs | 3 ++- src-tauri/src/shared.rs | 2 ++ 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/electron/main/constant.ts b/electron/main/constant.ts index ffd692d7..da5f4b83 100644 --- a/electron/main/constant.ts +++ b/electron/main/constant.ts @@ -35,6 +35,7 @@ export const scriptsDir = path.join(appDir, "scripts") export const configDir = app.isPackaged ? path.join(appDir, "config") : path.join(process.cwd(), ".config") export const hostCacheDir = path.join(appDir, "host_cache") export const logDir = path.join(appDir, "log") +export const skillsDir = path.join(appDir, "skills") export const binDirList = [ path.join(process.resourcesPath, "node"), diff --git a/electron/main/service.ts b/electron/main/service.ts index 641529c3..a322fae8 100644 --- a/electron/main/service.ts +++ b/electron/main/service.ts @@ -17,6 +17,8 @@ import { DEF_MCP_SERVER_NAME, getDefMcpBinPath, binDir, + appDir, + skillsDir, } from "./constant.js" import spawn from "cross-spawn" import { ChildProcess, SpawnOptions, StdioOptions } from "node:child_process" @@ -185,6 +187,7 @@ async function startHostService() { ...process.env, DIVE_CONFIG_DIR: baseConfigDir, RESOURCE_DIR: hostCacheDir, + DIVE_SKILL_DIR: skillsDir, DIVE_USER_AGENT: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Dive/${packageJson.version} (+https://github.com/OpenAgentPlatform/Dive)`, } diff --git a/src-tauri/src/host.rs b/src-tauri/src/host.rs index e6f13e7c..70de45f0 100644 --- a/src-tauri/src/host.rs +++ b/src-tauri/src/host.rs @@ -11,7 +11,7 @@ use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}, }; -use crate::{dependency::{NODEJS_BIN_DIR, UV_BIN_DIR}, process::command::Command, shared::{DEF_MCP_BIN_NAME, VERSION}}; +use crate::{dependency::NODEJS_BIN_DIR, process::command::Command, shared::{DEF_MCP_BIN_NAME, VERSION}}; pub const COMMAND_ALIAS_FILE: &str = "command_alias.json"; pub const CUSTOM_RULES_FILE: &str = "customrules"; @@ -134,6 +134,7 @@ impl HostProcess { .env("PATH", crate::util::get_system_path().await) .env("DIVE_CONFIG_DIR", dirs.config) .env("RESOURCE_DIR", dirs.cache) + .env("DIVE_SKILL_DIR", dirs.skills) .env("DIVE_USER_AGENT", format!("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Dive/{} (+https://github.com/OpenAgentPlatform/Dive)", VERSION)) .current_dir(dunce::simplified(cwd)) .stderr(Stdio::piped()) diff --git a/src-tauri/src/shared.rs b/src-tauri/src/shared.rs index 6e9a1c5d..297781ea 100644 --- a/src-tauri/src/shared.rs +++ b/src-tauri/src/shared.rs @@ -38,6 +38,7 @@ pub static PROJECT_DIRS: LazyLock = LazyLock::new(|| { bus: home.join(".dive/host_cache/bus"), log: home.join(".dive/log"), bin: home.join(".dive/bin"), + skills: home.join(".dive/skills"), #[cfg(debug_assertions)] config: std::env::current_dir().unwrap().join("../.config"), @@ -54,4 +55,5 @@ pub struct Dirs { pub bus: PathBuf, pub log: PathBuf, pub bin: PathBuf, + pub skills: PathBuf, } From f37a28320d978f6a4f1bdeae8067f8b43d94db31 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 4 Feb 2026 11:21:29 +0800 Subject: [PATCH 2/5] feat: add slach command in chat input --- src/atoms/skillState.ts | 16 ++++ src/components/ChatInput.tsx | 123 +++++++++++++++++++++++++- src/ipc/index.ts | 1 + src/ipc/skills.ts | 26 ++++++ src/styles/components/_ChatInput.scss | 11 +++ 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/atoms/skillState.ts create mode 100644 src/ipc/skills.ts diff --git a/src/atoms/skillState.ts b/src/atoms/skillState.ts new file mode 100644 index 00000000..d5cd4d08 --- /dev/null +++ b/src/atoms/skillState.ts @@ -0,0 +1,16 @@ +import { atom } from "jotai" +import { fetchSkills, type Skill } from "../ipc/skills" + +export type { Skill } from "../ipc/skills" + +export const skillsAtom = atom([]) + +export const loadSkillsAtom = atom(null, async (_get, set) => { + try { + const skills = await fetchSkills() + set(skillsAtom, skills) + } catch (error) { + console.error("Failed to load skills:", error) + set(skillsAtom, []) + } +}) diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 5766540d..1c79a0bd 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -21,6 +21,7 @@ import ToolDropDown from "./ToolDropDown" import { historiesAtom } from "../atoms/historyState" import { searchPath, fuzzySearchPath, type PathEntry } from "../ipc/path" import SuggestionMenu from "./SuggestionMenu" +import { skillsAtom, loadSkillsAtom, type Skill } from "../atoms/skillState" interface Props { page: "welcome" | "chat" @@ -65,6 +66,8 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) const currentChatId = useAtomValue(currentChatIdAtom) const histories = useAtomValue(historiesAtom) const tools = useAtomValue(toolsAtom) + const skills = useAtomValue(skillsAtom) + const loadSkills = useSetAtom(loadSkillsAtom) // Tool mention states const [showToolMenu, setShowToolMenu] = useState(false) @@ -85,6 +88,12 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) const [fuzzyBasePath, setFuzzyBasePath] = useState("") const [fuzzyQuery, setFuzzyQuery] = useState("") + // Skill menu states + const [showSkillMenu, setShowSkillMenu] = useState(false) + const [skillSearchQuery, setSkillSearchQuery] = useState("") + const [selectedSkillIndex, setSelectedSkillIndex] = useState(0) + const [skillStartPos, setSkillStartPos] = useState(0) + // Recent paths for quick access const [recentPaths, setRecentPaths] = useState(() => { try { @@ -108,6 +117,8 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) useEffect(() => { loadTools() + loadSkills() + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoggedInOAP]) // Load draft message and files when chatKey changes @@ -453,6 +464,20 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) return [...filteredBuiltIn, ...filteredTools] }, [toolSearchQuery, getToolOptions, builtInOptions]) + // Filter skill options based on search query + const getFilteredSkillOptions = useCallback(() => { + const query = skillSearchQuery.toLowerCase() + + if (!skillSearchQuery) { + return skills + } + + return skills.filter((skill) => + skill.name.toLowerCase().includes(query) || + skill.description.toLowerCase().includes(query) + ) + }, [skillSearchQuery, skills]) + // Determine if we should show recent paths (initial state when entering path search mode) const isInitialPathSearch = useMemo(() => { // Check if path query is just "@/" or "@X:/" (initial trigger) @@ -630,6 +655,37 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) // Hide menu if no valid @ mention found setShowToolMenu(false) + + // Check if / was just typed at the beginning of input or after space/newline (skill trigger) + const lastSlashIndex = textBeforeCursor.lastIndexOf("/") + + if (lastSlashIndex !== -1) { + // Check if there's a space/newline before / or it's at the start + const charBeforeSlash = lastSlashIndex > 0 ? textBeforeCursor[lastSlashIndex - 1] : " " + const isValidSkillTrigger = charBeforeSlash === " " || charBeforeSlash === "\n" || lastSlashIndex === 0 + + if (isValidSkillTrigger) { + const searchText = textBeforeCursor.substring(lastSlashIndex + 1) + + // Show menu if / is followed by no space or only alphanumeric/dash characters + if (!searchText.includes(" ") && !searchText.includes("\n") && /^[a-z0-9-]*$/i.test(searchText)) { + // Check if there are any available skills before showing menu + if (skills.length === 0) { + setShowSkillMenu(false) + return + } + + setSkillSearchQuery(searchText) + setSkillStartPos(lastSlashIndex) + setShowSkillMenu(true) + setSelectedSkillIndex(0) + return + } + } + } + + // Hide skill menu if no valid / trigger found + setShowSkillMenu(false) } // Handle tool selection @@ -694,6 +750,26 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) }, 0) }, [message, pathStartPos, pathSearchQuery, searchPathDebounced, saveRecentPath]) + // Handle skill selection + const selectSkill = useCallback((skill: Skill) => { + const beforeSlash = message.substring(0, skillStartPos) + const afterSlash = message.substring(skillStartPos + skillSearchQuery.length + 1) // +1 for / + const newMessage = beforeSlash + "/" + skill.name + " " + afterSlash + + setMessage(newMessage) + setShowSkillMenu(false) + setSkillSearchQuery("") + + // Focus back to textarea and set cursor position + setTimeout(() => { + if (textareaRef.current) { + const newCursorPos = beforeSlash.length + skill.name.length + 2 // +2 for / and space + textareaRef.current.focus() + textareaRef.current.setSelectionRange(newCursorPos, newCursorPos) + } + }, 0) + }, [message, skillStartPos, skillSearchQuery]) + const saveMessageToHistory = (msg: string) => { if (!msg.trim()) { return @@ -850,9 +926,37 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) return } + // Handle skill menu keyboard navigation + if (showSkillMenu) { + const filteredOptions = getFilteredSkillOptions() + if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { + e.preventDefault() + setSelectedSkillIndex(prev => Math.min(prev + 1, filteredOptions.length - 1)) + return + } + if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { + e.preventDefault() + setSelectedSkillIndex(prev => Math.max(prev - 1, 0)) + return + } + if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey && !e.altKey)) { + e.preventDefault() + if (filteredOptions[selectedSkillIndex]) { + selectSkill(filteredOptions[selectedSkillIndex]) + } + return + } + if (e.key === "Escape") { + e.preventDefault() + setShowSkillMenu(false) + return + } + return + } + // chat-input:history-up // Handle message history navigation with ArrowUp/ArrowDown - if (e.key === "ArrowUp" && !showToolMenu && !showPathMenu) { + if (e.key === "ArrowUp" && !showToolMenu && !showPathMenu && !showSkillMenu) { const textarea = e.currentTarget const cursorPosition = textarea.selectionStart // Only trigger if cursor is at the beginning of the textarea @@ -877,7 +981,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) } } - if (e.key === "ArrowDown" && !showToolMenu && !showPathMenu) { + if (e.key === "ArrowDown" && !showToolMenu && !showPathMenu && !showSkillMenu) { const textarea = e.currentTarget const cursorPosition = textarea.selectionStart const textLength = textarea.value.length @@ -1069,6 +1173,21 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) )} /> + 0} + items={getFilteredSkillOptions().map(skill => ({ key: skill.name, ...skill }))} + selectedIndex={selectedSkillIndex} + onSelectedIndexChange={setSelectedSkillIndex} + onSelect={(item) => selectSkill(item as Skill)} + onClose={() => setShowSkillMenu(false)} + textareaRef={textareaRef} + renderItem={(item) => ( + <> + /{item.name} + {item.description} + + )} + /> {previews.length > 0 && (
diff --git a/src/ipc/index.ts b/src/ipc/index.ts index a6933924..3e84338a 100644 --- a/src/ipc/index.ts +++ b/src/ipc/index.ts @@ -10,6 +10,7 @@ export * from "./config" export * from "./llm" export * from "./lipc" export * from "./path" +export * from "./skills" export function listenIPC(event: string, listener: (...args: any[]) => void): () => void { if (isElectron) { diff --git a/src/ipc/skills.ts b/src/ipc/skills.ts new file mode 100644 index 00000000..d12c7c81 --- /dev/null +++ b/src/ipc/skills.ts @@ -0,0 +1,26 @@ +export interface Skill { + name: string + description: string + license?: string + compatibility?: string + metadata?: Record + allowed_tools?: string +} + +interface SkillsResponse { + success: boolean + message?: string + data: Skill[] +} + +export async function fetchSkills(): Promise { + const response = await fetch("/api/skills/") + if (!response.ok) { + throw new Error(`Failed to fetch skills: ${response.statusText}`) + } + const result: SkillsResponse = await response.json() + if (!result.success) { + throw new Error(result.message || "Failed to fetch skills") + } + return result.data +} diff --git a/src/styles/components/_ChatInput.scss b/src/styles/components/_ChatInput.scss index f794c2e7..69147401 100644 --- a/src/styles/components/_ChatInput.scss +++ b/src/styles/components/_ChatInput.scss @@ -478,5 +478,16 @@ text-overflow: ellipsis; white-space: nowrap; } + + .chat-suggestion-description { + font-size: 11px; + color: var(--text-weak); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: 8px; + flex: 1; + min-width: 0; + } } } From 97833249d7264a456daaec8b53ae63d4be531180 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 9 Feb 2026 17:43:20 +0800 Subject: [PATCH 3/5] feat: add more slash cmd --- src/atoms/configState.ts | 22 ++- src/atoms/modelState.ts | 25 ++- src/components/ChatInput.tsx | 275 +++++++++++++++++++++++--- src/components/ModelSelect.tsx | 59 +----- src/helper/model.ts | 19 ++ src/locales/de/translation.json | 13 +- src/locales/en/translation.json | 13 +- src/locales/es/translation.json | 13 +- src/locales/fi/translation.json | 13 +- src/locales/fil/translation.json | 13 +- src/locales/fr/translation.json | 13 +- src/locales/id/translation.json | 13 +- src/locales/it/translation.json | 13 +- src/locales/ja/translation.json | 16 +- src/locales/ko/translation.json | 13 +- src/locales/lo/translation.json | 13 +- src/locales/no/translation.json | 13 +- src/locales/pl/translation.json | 13 +- src/locales/pt/translation.json | 13 +- src/locales/ru/translation.json | 13 +- src/locales/sv/translation.json | 13 +- src/locales/th/translation.json | 13 +- src/locales/tr/translation.json | 13 +- src/locales/uk/translation.json | 13 +- src/locales/vi/translation.json | 13 +- src/locales/zh-CN/translation.json | 13 +- src/locales/zh-TW/translation.json | 13 +- src/styles/components/_ChatInput.scss | 28 +++ 28 files changed, 607 insertions(+), 110 deletions(-) diff --git a/src/atoms/configState.ts b/src/atoms/configState.ts index 66c0022c..68383065 100644 --- a/src/atoms/configState.ts +++ b/src/atoms/configState.ts @@ -5,7 +5,7 @@ import { isLoggedInOAPAtom, OAPLevelAtom } from "./oapState" import { OAP_PROXY_URL } from "../../shared/oap" import { ModelGroupSetting, ModelProvider, ModelVerifyStatus } from "../../types/model" import { modelSettingsAtom } from "./modelState" -import { defaultBaseModel, defaultModelGroup, intoRawModelConfig, intoRawModelConfigWithQuery, reverseQueryGroup } from "../helper/model" +import { defaultBaseModel, defaultModelGroup, GroupTerm, intoRawModelConfig, intoRawModelConfigWithQuery, ModelTerm, queryGroup, queryModel, reverseQueryGroup } from "../helper/model" import { getVerifyKeyFromModelConfig } from "../helper/verify" import { oapGetToken } from "../ipc" import { fetchModels } from "../ipc/llm" @@ -385,6 +385,26 @@ export const writeOapConfigAtom = atom( } ) +export const selectModelAtom = atom( + null, + async (get, set, value: { group: GroupTerm, model: ModelTerm }) => { + const settings = get(modelSettingsAtom) + const group = queryGroup(value.group, settings.groups) + if (group.length === 0) { + throw new Error("Group not found") + } + + const model = queryModel(value.model, group[0]) + if (model.length === 0) { + throw new Error("Model not found") + } + + const data = await set(writeRawConfigAtom, intoRawModelConfig(settings, group[0], model[0])!) + localStorage.setItem("selectedModel", JSON.stringify(value)) + return data + } +) + export const reloadOapConfigAtom = atom( null, async (get, set) => { diff --git a/src/atoms/modelState.ts b/src/atoms/modelState.ts index f39f891b..672cea80 100644 --- a/src/atoms/modelState.ts +++ b/src/atoms/modelState.ts @@ -1,6 +1,6 @@ import { atom } from "jotai" -import { LLMGroup, ModelGroupSetting } from "../../types/model" -import { defaultModelGroupSetting, getGroupTerm, removeGroup, updateGroup } from "../helper/model" +import { LLMGroup, ModelGroupSetting, ModelProvider } from "../../types/model" +import { defaultModelGroupSetting, getGroupTerm, getModelNamePrefix, getModelTerm, GroupTerm, ModelTerm, removeGroup, updateGroup } from "../helper/model" export const modelSettingsAtom = atom(defaultModelGroupSetting()) @@ -24,6 +24,27 @@ export const disableModelGroupAtom = atom( } ) +export interface ModelOption { + provider: ModelProvider + name: string + value: { group: GroupTerm, model: ModelTerm } +} + +export const modelListAtom = atom((get) => { + const settings = get(modelSettingsAtom) + return settings.groups + .filter(group => group.active) + .flatMap(group => + group.models + .filter(m => m.active && m.verifyStatus !== "unSupportModel") + .map(m => ({ + provider: group.modelProvider, + name: `${getModelNamePrefix(group) ?? ""}/${m.model}`, + value: { group: getGroupTerm(group), model: getModelTerm(m) } + })) + ) +}) + export const removeModelGroupAtom = atom( null, (get, set, group: LLMGroup) => { diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 1c79a0bd..f8cc3c17 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -7,12 +7,12 @@ import useHotkeyEvent from "../hooks/useHotkeyEvent" import Textarea from "./WrappedTextarea" import { currentChatIdAtom, draftMessagesAtom, lastMessageAtom, type FilePreview } from "../atoms/chatState" import { useAtom, useAtomValue, useSetAtom } from "jotai" -import { activeConfigAtom, configAtom, configDictAtom, currentModelSupportToolsAtom, isConfigActiveAtom, writeRawConfigAtom } from "../atoms/configState" +import { activeConfigAtom, configAtom, configDictAtom, currentModelSupportToolsAtom, isConfigActiveAtom, selectModelAtom, writeRawConfigAtom } from "../atoms/configState" import { loadToolsAtom, toolsAtom, type Tool, type SubTool } from "../atoms/toolState" import { useNavigate } from "react-router-dom" import { showToastAtom } from "../atoms/toastState" import { getTermFromModelConfig, queryGroup, queryModel, updateGroup, updateModel } from "../helper/model" -import { modelSettingsAtom } from "../atoms/modelState" +import { modelListAtom, modelSettingsAtom, type ModelOption } from "../atoms/modelState" import { fileToBase64, getFileFromImageUrl } from "../util" import { isLoggedInOAPAtom } from "../atoms/oapState" import Button from "./Button" @@ -21,7 +21,10 @@ import ToolDropDown from "./ToolDropDown" import { historiesAtom } from "../atoms/historyState" import { searchPath, fuzzySearchPath, type PathEntry } from "../ipc/path" import SuggestionMenu from "./SuggestionMenu" -import { skillsAtom, loadSkillsAtom, type Skill } from "../atoms/skillState" +import { skillsAtom, loadSkillsAtom } from "../atoms/skillState" +import { openOverlayAtom } from "../atoms/layerState" +import { isProviderIconNoFilter, PROVIDER_ICONS } from "../atoms/interfaceState" +import { systemThemeAtom, userThemeAtom } from "../atoms/themeState" interface Props { page: "welcome" | "chat" @@ -68,6 +71,11 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) const tools = useAtomValue(toolsAtom) const skills = useAtomValue(skillsAtom) const loadSkills = useSetAtom(loadSkillsAtom) + const openOverlay = useSetAtom(openOverlayAtom) + const allModels = useAtomValue(modelListAtom) + const selectModel = useSetAtom(selectModelAtom) + const systemTheme = useAtomValue(systemThemeAtom) + const userTheme = useAtomValue(userThemeAtom) // Tool mention states const [showToolMenu, setShowToolMenu] = useState(false) @@ -94,6 +102,11 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) const [selectedSkillIndex, setSelectedSkillIndex] = useState(0) const [skillStartPos, setSkillStartPos] = useState(0) + // Model menu states (for /model command) + const [showModelMenu, setShowModelMenu] = useState(false) + const [modelSearchQuery, setModelSearchQuery] = useState("") + const [selectedModelIndex, setSelectedModelIndex] = useState(0) + // Recent paths for quick access const [recentPaths, setRecentPaths] = useState(() => { try { @@ -322,6 +335,10 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) } }, []) + useEffect(() => { + textareaRef.current?.focus() + }, []) + useEffect(() => { if (prevDisabled.current && !disabled) { textareaRef.current?.focus() @@ -464,19 +481,71 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) return [...filteredBuiltIn, ...filteredTools] }, [toolSearchQuery, getToolOptions, builtInOptions]) - // Filter skill options based on search query - const getFilteredSkillOptions = useCallback(() => { + // Built-in slash commands + type BuiltInCommandType = "navigation" | "model" + interface BuiltInCommand { + name: string + description: string + type: BuiltInCommandType + } + + const builtInCommands = useMemo(() => [ + { name: "new-session", description: t("chat.commands.newSession"), type: "navigation" }, + { name: "copy", description: t("chat.commands.copy"), type: "navigation" }, + { name: "models", description: t("chat.commands.models"), type: "navigation" }, + { name: "settings", description: t("chat.commands.settings"), type: "navigation" }, + { name: "mcp", description: t("chat.commands.mcp"), type: "navigation" }, + { name: "model", description: t("chat.commands.model"), type: "model" }, + ], [t]) + + // Unified slash item type for the combined menu + interface SlashItem { + key: string + name: string + description: string + isBuiltIn: boolean + commandType?: BuiltInCommandType + [key: string]: unknown + } + + // Filter combined slash items (built-in commands + skills) + const getFilteredSlashItems = useCallback((): SlashItem[] => { const query = skillSearchQuery.toLowerCase() - if (!skillSearchQuery) { - return skills + const builtInItems: SlashItem[] = builtInCommands + .filter(cmd => !query || cmd.name.toLowerCase().includes(query) || cmd.description.toLowerCase().includes(query)) + .map(cmd => ({ + key: `builtin-${cmd.name}`, + name: cmd.name, + description: cmd.description, + isBuiltIn: true, + commandType: cmd.type, + })) + + const skillItems: SlashItem[] = skills + .filter(skill => !query || skill.name.toLowerCase().includes(query) || skill.description.toLowerCase().includes(query)) + .map(skill => ({ + key: `skill-${skill.name}`, + name: skill.name, + description: skill.description, + isBuiltIn: false, + })) + + return [...builtInItems, ...skillItems] + }, [skillSearchQuery, skills, builtInCommands]) + + // Filter model options based on search query + const getFilteredModelOptions = useCallback(() => { + const query = modelSearchQuery.toLowerCase() + + if (!query) { + return allModels } - return skills.filter((skill) => - skill.name.toLowerCase().includes(query) || - skill.description.toLowerCase().includes(query) + return allModels.filter(model => + model.name.toLowerCase().includes(query) ) - }, [skillSearchQuery, skills]) + }, [modelSearchQuery, allModels]) // Determine if we should show recent paths (initial state when entering path search mode) const isInitialPathSearch = useMemo(() => { @@ -656,7 +725,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) // Hide menu if no valid @ mention found setShowToolMenu(false) - // Check if / was just typed at the beginning of input or after space/newline (skill trigger) + // Check if / was just typed at the beginning of input or after space/newline (skill/command trigger) const lastSlashIndex = textBeforeCursor.lastIndexOf("/") if (lastSlashIndex !== -1) { @@ -667,25 +736,32 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) if (isValidSkillTrigger) { const searchText = textBeforeCursor.substring(lastSlashIndex + 1) + // Check for /model pattern (model command with space and query) + const modelMatch = searchText.match(/^model\s(.*)$/i) + if (modelMatch) { + setModelSearchQuery(modelMatch[1]) + setShowModelMenu(true) + setSelectedModelIndex(0) + setShowSkillMenu(false) + setSkillStartPos(lastSlashIndex) + return + } + // Show menu if / is followed by no space or only alphanumeric/dash characters if (!searchText.includes(" ") && !searchText.includes("\n") && /^[a-z0-9-]*$/i.test(searchText)) { - // Check if there are any available skills before showing menu - if (skills.length === 0) { - setShowSkillMenu(false) - return - } - setSkillSearchQuery(searchText) setSkillStartPos(lastSlashIndex) setShowSkillMenu(true) setSelectedSkillIndex(0) + setShowModelMenu(false) return } } } - // Hide skill menu if no valid / trigger found + // Hide skill menu and model menu if no valid / trigger found setShowSkillMenu(false) + setShowModelMenu(false) } // Handle tool selection @@ -750,11 +826,61 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) }, 0) }, [message, pathStartPos, pathSearchQuery, searchPathDebounced, saveRecentPath]) - // Handle skill selection - const selectSkill = useCallback((skill: Skill) => { + // Handle slash item selection (unified for built-in commands and skills) + const selectSlashItem = useCallback((item: SlashItem) => { + if (item.isBuiltIn) { + // Handle built-in navigation commands + if (item.commandType === "navigation") { + switch (item.name) { + case "new-session": + navigate("/") + break + case "copy": + if (lastMessage) { + navigator.clipboard.writeText(lastMessage) + showToast({ message: t("chat.copied"), type: "success" }) + } + break + case "models": + openOverlay({ page: "Setting", tab: "Model" }) + break + case "settings": + openOverlay({ page: "Setting", tab: "System" }) + break + case "mcp": + openOverlay({ page: "Setting", tab: "Tools" }) + break + } + setMessage("") + setShowSkillMenu(false) + setSkillSearchQuery("") + return + } + + // Handle /model command - switch to model menu mode + if (item.commandType === "model") { + const newMessage = "/model " + setMessage(newMessage) + setShowSkillMenu(false) + setSkillSearchQuery("") + setShowModelMenu(true) + setModelSearchQuery("") + setSelectedModelIndex(0) + + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.focus() + textareaRef.current.setSelectionRange(newMessage.length, newMessage.length) + } + }, 0) + return + } + } + + // Handle regular skill selection (insert as text) const beforeSlash = message.substring(0, skillStartPos) const afterSlash = message.substring(skillStartPos + skillSearchQuery.length + 1) // +1 for / - const newMessage = beforeSlash + "/" + skill.name + " " + afterSlash + const newMessage = beforeSlash + "/" + item.name + " " + afterSlash setMessage(newMessage) setShowSkillMenu(false) @@ -763,12 +889,39 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) // Focus back to textarea and set cursor position setTimeout(() => { if (textareaRef.current) { - const newCursorPos = beforeSlash.length + skill.name.length + 2 // +2 for / and space + const newCursorPos = beforeSlash.length + item.name.length + 2 // +2 for / and space textareaRef.current.focus() textareaRef.current.setSelectionRange(newCursorPos, newCursorPos) } }, 0) - }, [message, skillStartPos, skillSearchQuery]) + }, [message, skillStartPos, skillSearchQuery, openOverlay]) + + // Handle model selection from model menu + const selectModelFromMenu = useCallback(async (option: ModelOption) => { + setShowModelMenu(false) + setModelSearchQuery("") + setMessage("") + + try { + await selectModel(option.value) + showToast({ + message: t("chat.commands.modelChanged", { model: option.value.model.model }), + type: "success" + }) + } catch (error) { + console.error(error) + showToast({ + message: t("setup.saveFailed"), + type: "error" + }) + } + + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.focus() + } + }, 0) + }, [selectModel, showToast, t]) const saveMessageToHistory = (msg: string) => { if (!msg.trim()) { @@ -926,9 +1079,37 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) return } + // Handle model menu keyboard navigation + if (showModelMenu) { + const filteredOptions = getFilteredModelOptions() + if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { + e.preventDefault() + setSelectedModelIndex(prev => Math.min(prev + 1, filteredOptions.length - 1)) + return + } + if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { + e.preventDefault() + setSelectedModelIndex(prev => Math.max(prev - 1, 0)) + return + } + if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey && !e.altKey)) { + e.preventDefault() + if (filteredOptions[selectedModelIndex]) { + selectModelFromMenu(filteredOptions[selectedModelIndex]) + } + return + } + if (e.key === "Escape") { + e.preventDefault() + setShowModelMenu(false) + return + } + return + } + // Handle skill menu keyboard navigation if (showSkillMenu) { - const filteredOptions = getFilteredSkillOptions() + const filteredOptions = getFilteredSlashItems() if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { e.preventDefault() setSelectedSkillIndex(prev => Math.min(prev + 1, filteredOptions.length - 1)) @@ -942,7 +1123,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey && !e.altKey)) { e.preventDefault() if (filteredOptions[selectedSkillIndex]) { - selectSkill(filteredOptions[selectedSkillIndex]) + selectSlashItem(filteredOptions[selectedSkillIndex]) } return } @@ -956,7 +1137,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) // chat-input:history-up // Handle message history navigation with ArrowUp/ArrowDown - if (e.key === "ArrowUp" && !showToolMenu && !showPathMenu && !showSkillMenu) { + if (e.key === "ArrowUp" && !showToolMenu && !showPathMenu && !showSkillMenu && !showModelMenu) { const textarea = e.currentTarget const cursorPosition = textarea.selectionStart // Only trigger if cursor is at the beginning of the textarea @@ -981,7 +1162,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) } } - if (e.key === "ArrowDown" && !showToolMenu && !showPathMenu && !showSkillMenu) { + if (e.key === "ArrowDown" && !showToolMenu && !showPathMenu && !showSkillMenu && !showModelMenu) { const textarea = e.currentTarget const cursorPosition = textarea.selectionStart const textLength = textarea.value.length @@ -1173,21 +1354,51 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) )} /> - 0} - items={getFilteredSkillOptions().map(skill => ({ key: skill.name, ...skill }))} + + show={showSkillMenu && getFilteredSlashItems().length > 0} + items={getFilteredSlashItems()} selectedIndex={selectedSkillIndex} onSelectedIndexChange={setSelectedSkillIndex} - onSelect={(item) => selectSkill(item as Skill)} + onSelect={(item) => selectSlashItem(item)} onClose={() => setShowSkillMenu(false)} textareaRef={textareaRef} renderItem={(item) => ( <> /{item.name} {item.description} + {item.isBuiltIn && ( + Built-in + )} )} /> + ({ key: m.name, ...m }))} + selectedIndex={selectedModelIndex} + onSelectedIndexChange={setSelectedModelIndex} + onSelect={(item) => { + const modelItem = item as unknown as ModelOption & { key: string } + selectModelFromMenu(modelItem) + }} + onClose={() => setShowModelMenu(false)} + textareaRef={textareaRef} + emptyContent={t("chat.commands.noModelsFound")} + headerContent={<>{t("chat.commands.selectModel")}} + renderItem={(item) => { + const modelItem = item as unknown as ModelOption & { key: string } + return ( +
+ {modelItem.provider} + {modelItem.name} +
+ ) + }} + />
{previews.length > 0 && (
diff --git a/src/components/ModelSelect.tsx b/src/components/ModelSelect.tsx index bbbb962a..e9d09ba3 100644 --- a/src/components/ModelSelect.tsx +++ b/src/components/ModelSelect.tsx @@ -4,13 +4,13 @@ import Select from "./Select" import { useCallback, useEffect, useMemo, useState } from "react" import { isProviderIconNoFilter, PROVIDER_ICONS } from "../atoms/interfaceState" import { useAtomValue, useSetAtom } from "jotai" -import { configAtom, writeRawConfigAtom } from "../atoms/configState" +import { configAtom, selectModelAtom } from "../atoms/configState" import { openOverlayAtom } from "../atoms/layerState" import { showToastAtom } from "../atoms/toastState" import Tooltip from "./Tooltip" import { systemThemeAtom, userThemeAtom } from "../atoms/themeState" -import { modelSettingsAtom } from "../atoms/modelState" -import { getGroupTerm, getModelTerm, getTermFromRawModelConfig, GroupTerm, intoRawModelConfig, matchOpenaiCompatible, ModelTerm, queryGroup, queryModel } from "../helper/model" +import { modelListAtom } from "../atoms/modelState" +import { getTermFromRawModelConfig, GroupTerm, matchOpenaiCompatible, ModelTerm } from "../helper/model" import isEqual from "lodash/isEqual" import { cloneDeep } from "lodash" @@ -19,32 +19,13 @@ const DEFAULT_MODEL = {group: {}, model: {}} const ModelSelect = () => { const { t } = useTranslation() const config = useAtomValue(configAtom) - const saveAllConfig = useSetAtom(writeRawConfigAtom) + const selectModel = useSetAtom(selectModelAtom) const [model, setModel] = useState<{group: GroupTerm, model: ModelTerm}>(DEFAULT_MODEL) const openOverlay = useSetAtom(openOverlayAtom) const showToast = useSetAtom(showToastAtom) const systemTheme = useAtomValue(systemThemeAtom) const userTheme = useAtomValue(userThemeAtom) - const settings = useAtomValue(modelSettingsAtom) - - const getModelNamePrefix = (group: GroupTerm) => { - switch (group.modelProvider) { - case "oap": - return "OAP" - case "bedrock": - return `***${group.extra?.credentials?.accessKeyId?.slice(-4)}` - case "lmstudio": - return "LMStudio" - default: - if (group.apiKey) { - return `***${group.apiKey.slice(-4)}` - } - - if (group.baseURL) { - return `***${group.baseURL.slice(-4)}` - } - } - } + const allModels = useAtomValue(modelListAtom) const equalCustomizer = useCallback((a: {group: GroupTerm, model: ModelTerm}, b: {group: GroupTerm, model: ModelTerm}) => { a = cloneDeep(a) @@ -76,17 +57,7 @@ const ModelSelect = () => { }, []) const modelList = useMemo(() => { - const data = Object.values(settings.groups) - .filter((group) => group.active) - .flatMap((group) => - group.models - .filter((model) => model.active && model.verifyStatus != "unSupportModel") - .map((model) => ({ - provider: group.modelProvider, - name: `${getModelNamePrefix(group)}/${model.model}`, - value: {group: getGroupTerm(group), model: getModelTerm(model)}, - }) - )) + const data = [...allModels] data.sort((a, b) => { if (equalCustomizer(a.value, model)) { @@ -101,7 +72,7 @@ const ModelSelect = () => { }) return data - }, [settings, model]) + }, [allModels, model]) useEffect(() => { setModel(getTermFromRawModelConfig(config) ?? DEFAULT_MODEL) @@ -111,21 +82,7 @@ const ModelSelect = () => { const _model = model setModel(value) try { - const group = queryGroup(value.group, settings.groups) - if (group.length === 0) { - throw new Error("Group not found") - } - - const model = queryModel(value.model, group[0]) - if (model.length === 0) { - throw new Error("Model not found") - } - - const data = await saveAllConfig(intoRawModelConfig(settings, group[0], model[0])!) - localStorage.setItem("selectedModel", JSON.stringify({group: value.group, model: value.model})) - if (data.success) { - console.log(data) - } + await selectModel(value) } catch (error) { console.error(error) showToast({ diff --git a/src/helper/model.ts b/src/helper/model.ts index dfe9bea4..f6a31d2c 100644 --- a/src/helper/model.ts +++ b/src/helper/model.ts @@ -433,6 +433,25 @@ export function getTermFromRawModelConfig(config: RawModelConfig): { group: Grou return getTermFromModelConfig(configs[activeProvider]) } +export function getModelNamePrefix(group: GroupTerm): string | undefined { + switch (group.modelProvider) { + case "oap": + return "OAP" + case "bedrock": + return `***${group.extra?.credentials?.accessKeyId?.slice(-4)}` + case "lmstudio": + return "LMStudio" + default: + if (group.apiKey) { + return `***${group.apiKey.slice(-4)}` + } + + if (group.baseURL) { + return `***${group.baseURL.slice(-4)}` + } + } +} + export function fieldsToLLMGroup(provider: ModelProvider, obj: Record) { const mutGroup = defaultModelGroup() mutGroup.modelProvider = provider diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 402cef96..a63f3745 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Erste Verzögerung: Zeit bis die KI das erste Zeichen generiert", "generationRate": "{{time}} Tokens pro Sekunde", "generationRateDesc": "Generierungsrate: Anzahl der Tokens, die die KI pro Sekunde generieren kann" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -570,4 +581,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 24a0c4a8..ad026290 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "First delay: Time for AI to start generating the first character", "generationRate": "{{time}} tokens per second", "generationRateDesc": "Generation rate: Number of tokens AI can generate per second" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -592,4 +603,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 251d9367..27f8c392 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Retraso inicial: Tiempo para que la IA comience a generar el primer carácter", "generationRate": "{{time}} tokens por segundo", "generationRateDesc": "Velocidad de generación: Número de tokens que la IA puede generar por segundo" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -592,4 +603,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/fi/translation.json b/src/locales/fi/translation.json index e7d48ee6..1c436836 100644 --- a/src/locales/fi/translation.json +++ b/src/locales/fi/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Ensimmäinen viive: Aika, joka tekoälyltä kuluu ensimmäisen merkin luomiseen", "generationRate": "{{time}} tokenia sekunnissa", "generationRateDesc": "Luomisnopeus: Tokenien määrä, jonka tekoäly voi luoda sekunnissa" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -574,4 +585,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/fil/translation.json b/src/locales/fil/translation.json index 5bbd2c88..725f5b4f 100644 --- a/src/locales/fil/translation.json +++ b/src/locales/fil/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Unang pagkaantala: Oras para magsimulang lumikha ang AI ng unang karakter", "generationRate": "{{time}} tokens bawat segundo", "generationRateDesc": "Rate ng paggawa: Bilang ng tokens na maaaring likhain ng AI bawat segundo" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -574,4 +585,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 1f717df7..bf80834b 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Premier délai : Temps pour que l'IA commence à générer le premier caractère", "generationRate": "{{time}} tokens par seconde", "generationRateDesc": "Taux de génération : Nombre de tokens que l'IA peut générer par seconde" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -570,4 +581,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index 3f30a702..1b853ee0 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Penundaan pertama: Waktu yang dibutuhkan AI untuk mulai menghasilkan karakter pertama", "generationRate": "{{time}} token per detik", "generationRateDesc": "Tingkat generasi: Jumlah token yang dapat dihasilkan AI per detik" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -574,4 +585,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 0f3600c5..1cfa0cce 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Primo ritardo: Tempo impiegato dall'IA per iniziare a generare il primo carattere", "generationRate": "{{time}} token al secondo", "generationRateDesc": "Velocità di generazione: Numero di token che l'IA può generare al secondo" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -570,4 +581,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index bd840f5e..cb4b2e4a 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "初回遅延: AIが最初の文字を生成するまでの時間", "generationRate": "毎秒 {{time}} トークン", "generationRateDesc": "生成速度: AIが1秒あたりに生成できるトークン数" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -442,7 +453,7 @@ "streamingModeTooltip": "ストリーミングモードは、モデルが応答するときに、一度にすべて表示するのではなく、徐々にコンテンツを表示するモードです。ボタンをオフにすると、非ストリーミングモードになり、モデルがコンテンツを完全に生成するのを待ってから一度に表示するため、モデルでのツール呼び出しとの互換性の問題を回避できます。", "streamingModeDescription": "モデルの応答表示方法を制御します", "streamingModeAlert": "注意:一部のモデルは、ストリーミングモードまたは非ストリーミングモードでのみツール呼び出しをサポートしています。ニーズを十分に理解した上で、このパラメータを調整することをお勧めします。", - "parameterVerify": "パラメータ設定検証", + "parameterVerify": "パラメータ検証", "modelSetting": "{{name}} モデル設定", "customInput": "カスタムパラメータ", "addCustomParameter": "カスタムパラメータを追加", @@ -462,7 +473,6 @@ "reasoningLevelDescription": "モデルの思考深度を設定", "reasoningLevelTooltip": "モデルの思考と推論の深度を制御します。Lowは素早い応答で、結論に重点を置き、詳細は少なく、推論は単純直接です。Mediumは適度な論理説明を含み、重要な情報をカバーします。Highは深い推論で、構造化された分析と説明を提供します。", "tokenBudgetDescription": "推論値を設定 ({{min}}~{{max}})", - "parameterVerify": "パラメータ検証", "expired": "このモデルは公式から削除されました", "expiredInfo": "このモデルは公式リストにありません。ローカル履歴としてのみ表示されています。正常に動作または更新されない可能性があります。チェックを外すと削除され、リストから再度見つけることはできません。", "verifyErrorInfo": "検証エラーの詳細情報", @@ -593,4 +603,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json index 695b7463..e86a6261 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "첫 지연: AI가 첫 번째 문자를 생성하는 데 걸리는 시간", "generationRate": "초당 {{time}} 토큰", "generationRateDesc": "생성 속도: AI가 초당 생성할 수 있는 토큰 수" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -592,4 +603,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/lo/translation.json b/src/locales/lo/translation.json index 1e418723..93aa771f 100644 --- a/src/locales/lo/translation.json +++ b/src/locales/lo/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "ການຊັກຊ້າຄັ້ງທຳອິດ: ເວລາທີ່ AI ໃຊ້ເພື່ອເລີ່ມສ້າງຕົວອັກສອນທຳອິດ", "generationRate": "{{time}} tokens ຕໍ່ວິນາທີ", "generationRateDesc": "ອັດຕາການສ້າງ: ຈຳນວນ tokens ທີ່ AI ສາມາດສ້າງໄດ້ຕໍ່ວິນາທີ" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -574,4 +585,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/no/translation.json b/src/locales/no/translation.json index af63e4bc..0ef34c4b 100644 --- a/src/locales/no/translation.json +++ b/src/locales/no/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Første forsinkelse: Tid for AI å begynne å generere første tegn", "generationRate": "{{time}} tokens per sekund", "generationRateDesc": "Genereringsrate: Antall tokens AI kan generere per sekund" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -574,4 +585,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/pl/translation.json b/src/locales/pl/translation.json index 7e8fc3d4..f5727492 100644 --- a/src/locales/pl/translation.json +++ b/src/locales/pl/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Pierwsze opóźnienie: Czas potrzebny AI do rozpoczęcia generowania pierwszego znaku", "generationRate": "{{time}} tokenów na sekundę", "generationRateDesc": "Szybkość generowania: Liczba tokenów, które AI może wygenerować na sekundę" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -574,4 +585,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 4d482b82..d853c070 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Primeiro atraso: Tempo para a IA começar a gerar o primeiro caractere", "generationRate": "{{time}} tokens por segundo", "generationRateDesc": "Taxa de geração: Número de tokens que a IA pode gerar por segundo" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -570,4 +581,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 3c5e2977..fb71b6af 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Первая задержка: время, необходимое ИИ для начала генерации первого символа", "generationRate": "{{time}} токенов в секунду", "generationRateDesc": "Скорость генерации: количество токенов, которые ИИ может генерировать в секунду" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -570,4 +581,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/sv/translation.json b/src/locales/sv/translation.json index cb8b4b5b..9e06f02e 100644 --- a/src/locales/sv/translation.json +++ b/src/locales/sv/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Första fördröjning: Tid för AI att börja generera första tecknet", "generationRate": "{{time}} tokens per sekund", "generationRateDesc": "Genereringshastighet: Antal tokens AI kan generera per sekund" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -574,4 +585,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/th/translation.json b/src/locales/th/translation.json index 4492e3db..726ea766 100644 --- a/src/locales/th/translation.json +++ b/src/locales/th/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "ความล่าช้าครั้งแรก: เวลาที่ AI ใช้ในการเริ่มสร้างอักขระแรก", "generationRate": "{{time}} โทเค็นต่อวินาที", "generationRateDesc": "อัตราการสร้าง: จำนวนโทเค็นที่ AI สามารถสร้างได้ต่อวินาที" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -570,4 +581,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index f3fe6adf..61aca5cd 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "İlk gecikme: AI'nın ilk karakteri oluşturmaya başlaması için geçen süre", "generationRate": "Saniyede {{time}} token", "generationRateDesc": "Üretim hızı: AI'nın saniyede üretebileceği token sayısı" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -572,4 +583,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index e558adc9..caeac1d4 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Перша затримка: час, необхідний AI для початку генерації першого символу", "generationRate": "{{time}} токенів на секунду", "generationRateDesc": "Швидкість генерації: кількість токенів, які AI може генерувати за секунду" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -572,4 +583,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/vi/translation.json b/src/locales/vi/translation.json index 581b3f95..8906cb8b 100644 --- a/src/locales/vi/translation.json +++ b/src/locales/vi/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "Độ trễ đầu tiên: Thời gian để AI bắt đầu tạo ký tự đầu tiên", "generationRate": "{{time}} token mỗi giây", "generationRateDesc": "Tốc độ tạo: Số lượng token mà AI có thể tạo mỗi giây" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -568,4 +579,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index a1b09212..a217d203 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "首字延迟 : AI 开始生成第一个字符所需的时间", "generationRate": "每秒 {{time}} tokens", "generationRateDesc": "生成速率 : AI 每秒可以生成的 token 数量" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -592,4 +603,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index b54f6052..aaab6b9d 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -95,6 +95,17 @@ "firstDelayDesc": "首字延遲 : AI 開始生成第一個字元所需的時間", "generationRate": "每秒 {{time}} tokens", "generationRateDesc": "生成速率 : AI 每秒可以生成的 token 數量" + }, + "commands": { + "models": "Open model settings", + "settings": "Open system settings", + "mcp": "Open MCP tools settings", + "model": "Switch model", + "selectModel": "Select a model", + "noModelsFound": "No models found", + "modelChanged": "Switched to {{model}}", + "newSession": "Start a new session", + "copy": "Copy last response" } }, "welcome": { @@ -592,4 +603,4 @@ "next": "Next Match", "noResults": "No results" } -} \ No newline at end of file +} diff --git a/src/styles/components/_ChatInput.scss b/src/styles/components/_ChatInput.scss index 69147401..5ca74bec 100644 --- a/src/styles/components/_ChatInput.scss +++ b/src/styles/components/_ChatInput.scss @@ -489,5 +489,33 @@ flex: 1; min-width: 0; } + + .chat-suggestion-badge { + font-size: 9px; + color: var(--text-pri); + background: var(--bg-pri-weak); + padding: 1px 5px; + border-radius: 4px; + margin-left: 8px; + white-space: nowrap; + flex-shrink: 0; + } + + .model-suggestion-item { + display: flex; + align-items: center; + gap: 6px; + + .model-suggestion-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + filter: var(--icon-filter); + + &.no-filter { + filter: none; + } + } + } } } From 673fd83cb87bc14a12fdf7a5a32d550490b97240 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 10 Feb 2026 10:05:44 +0800 Subject: [PATCH 4/5] chore: add tags for skills in list --- src/components/ChatInput.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index f8cc3c17..f8624708 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -1366,9 +1366,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) <> /{item.name} {item.description} - {item.isBuiltIn && ( - Built-in - )} + {item.isBuiltIn ? "Built-in" : "Skill"} )} /> From f5e0cf7a2cbf8a72a1736bb7bd11f068becb52a0 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 23 Feb 2026 13:42:57 +0800 Subject: [PATCH 5/5] fix: release build for tauri --- src-tauri/src/host.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/host.rs b/src-tauri/src/host.rs index 70de45f0..8deaf3ab 100644 --- a/src-tauri/src/host.rs +++ b/src-tauri/src/host.rs @@ -143,6 +143,7 @@ impl HostProcess { // set bin path for builtin tools #[cfg(not(debug_assertions))] { + use crate::dependency::UV_BIN_DIR; let uvx_path = if cfg!(windows) { UV_BIN_DIR.join("uvx.exe") } else {