add command view

This commit is contained in:
duanfuxiang 2025-04-13 22:52:36 +08:00
parent 4a5823721e
commit 43599fca47
4 changed files with 667 additions and 1741 deletions

View File

@ -1,7 +1,7 @@
import * as path from 'path'
import { useMutation } from '@tanstack/react-query'
import { CircleStop, History, Plus } from 'lucide-react'
import { CircleStop, History, Plus, SquareSlash } from 'lucide-react'
import { App, Notice } from 'obsidian'
import {
forwardRef,
@ -52,6 +52,7 @@ import { ModeSelect } from './chat-input/ModeSelect'
import PromptInputWithActions, { ChatUserInputRef } from './chat-input/PromptInputWithActions'
import { editorStateToPlainText } from './chat-input/utils/editor-state-to-plain-text'
import { ChatHistory } from './ChatHistory'
import CommandsView from './CommandsView'
import MarkdownReasoningBlock from './Markdown/MarkdownReasoningBlock'
import QueryProgress, { QueryProgressState } from './QueryProgress'
import ReactMarkdown from './ReactMarkdown'
@ -160,6 +161,8 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
}
}
const [tab, setTab] = useState<'chat' | 'commands'>('commands')
useEffect(() => {
const scrollContainer = chatMessagesRef.current
if (!scrollContainer) return
@ -870,11 +873,15 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
return (
<div className="infio-chat-container">
{/* header view */}
<div className="infio-chat-header">
<ModeSelect />
<div className="infio-chat-header-buttons">
<button
onClick={() => handleNewChat()}
onClick={() => {
setTab('chat')
handleNewChat()
}}
className="infio-chat-list-dropdown"
>
<Plus size={18} />
@ -906,112 +913,134 @@ const Chat = forwardRef<ChatRef, ChatProps>((props, ref) => {
>
<History size={18} />
</ChatHistory>
<button
onClick={() => {
// switch between chat and prompts
if (tab === 'commands') {
setTab('chat')
} else {
setTab('commands')
}
}}
className="infio-chat-list-dropdown"
>
<SquareSlash size={18} color={tab === 'commands' ? 'var(--text-accent)' : 'var(--text-color)'} />
</button>
</div>
</div>
<div className="infio-chat-messages" ref={chatMessagesRef}>
{
// If the chat is empty, show a message to start a new chat
chatMessages.length === 0 && (
<div className="infio-chat-empty-state">
<ShortcutInfo />
</div>
)
}
{chatMessages.map((message, index) =>
message.role === 'user' ? (
message.content &&
<div key={"user-" + message.id} className="infio-chat-messages-user">
<PromptInputWithActions
key={"input-" + message.id}
ref={(ref) => registerChatUserInputRef(message.id, ref)}
initialSerializedEditorState={message.content}
onSubmit={(content, useVaultSearch) => {
if (editorStateToPlainText(content).trim() === '') return
handleSubmit(
[
...chatMessages.slice(0, index),
{
role: 'user',
applyStatus: ApplyStatus.Idle,
content: content,
promptContent: null,
id: message.id,
mentionables: message.mentionables,
},
],
useVaultSearch,
)
chatUserInputRefs.current.get(inputMessage.id)?.focus()
}}
onFocus={() => {
setFocusedMessageId(message.id)
}}
mentionables={message.mentionables}
setMentionables={(mentionables) => {
setChatMessages((prevChatHistory) =>
prevChatHistory.map((msg) =>
msg.id === message.id ? { ...msg, mentionables } : msg,
),
)
}}
/>
{message.similaritySearchResults && (
<SimilaritySearchResults
key={"similarity-search-" + message.id}
similaritySearchResults={message.similaritySearchResults}
/>
)}
</div>
) : (
<div key={"assistant-" + message.id} className="infio-chat-messages-assistant">
<MarkdownReasoningBlock
key={"reasoning-" + message.id}
reasoningContent={message.reasoningContent} />
<ReactMarkdownItem
key={"content-" + message.id}
handleApply={(toolArgs) => handleApply(message.id, toolArgs)}
applyStatus={message.applyStatus}
>
{message.content}
</ReactMarkdownItem>
</div>
),
)}
<QueryProgress state={queryProgress} />
{submitMutation.isPending && (
<button onClick={abortActiveStreams} className="infio-stop-gen-btn">
<CircleStop size={16} />
<div>Stop generation</div>
</button>
)}
</div>
<PromptInputWithActions
key={inputMessage.id}
ref={(ref) => registerChatUserInputRef(inputMessage.id, ref)}
initialSerializedEditorState={inputMessage.content}
onSubmit={(content, useVaultSearch) => {
if (editorStateToPlainText(content).trim() === '') return
handleSubmit(
[...chatMessages, { ...inputMessage, content }],
useVaultSearch,
)
setInputMessage(getNewInputMessage(app, settings.defaultMention))
preventAutoScrollRef.current = false
handleScrollToBottom()
}}
onFocus={() => {
setFocusedMessageId(inputMessage.id)
}}
mentionables={inputMessage.mentionables}
setMentionables={(mentionables) => {
setInputMessage((prevInputMessage) => ({
...prevInputMessage,
mentionables,
}))
}}
autoFocus
addedBlockKey={addedBlockKey}
/>
{/* main view */}
{tab === 'chat' ? (
<>
<div className="infio-chat-messages" ref={chatMessagesRef}>
{
// If the chat is empty, show a message to start a new chat
chatMessages.length === 0 && (
<div className="infio-chat-empty-state">
<ShortcutInfo />
</div>
)
}
{chatMessages.map((message, index) =>
message.role === 'user' ? (
message.content &&
<div key={"user-" + message.id} className="infio-chat-messages-user">
<PromptInputWithActions
key={"input-" + message.id}
ref={(ref) => registerChatUserInputRef(message.id, ref)}
initialSerializedEditorState={message.content}
onSubmit={(content, useVaultSearch) => {
if (editorStateToPlainText(content).trim() === '') return
handleSubmit(
[
...chatMessages.slice(0, index),
{
role: 'user',
applyStatus: ApplyStatus.Idle,
content: content,
promptContent: null,
id: message.id,
mentionables: message.mentionables,
},
],
useVaultSearch,
)
chatUserInputRefs.current.get(inputMessage.id)?.focus()
}}
onFocus={() => {
setFocusedMessageId(message.id)
}}
mentionables={message.mentionables}
setMentionables={(mentionables) => {
setChatMessages((prevChatHistory) =>
prevChatHistory.map((msg) =>
msg.id === message.id ? { ...msg, mentionables } : msg,
),
)
}}
/>
{message.similaritySearchResults && (
<SimilaritySearchResults
key={"similarity-search-" + message.id}
similaritySearchResults={message.similaritySearchResults}
/>
)}
</div>
) : (
<div key={"assistant-" + message.id} className="infio-chat-messages-assistant">
<MarkdownReasoningBlock
key={"reasoning-" + message.id}
reasoningContent={message.reasoningContent} />
<ReactMarkdownItem
key={"content-" + message.id}
handleApply={(toolArgs) => handleApply(message.id, toolArgs)}
applyStatus={message.applyStatus}
>
{message.content}
</ReactMarkdownItem>
</div>
),
)}
<QueryProgress state={queryProgress} />
{submitMutation.isPending && (
<button onClick={abortActiveStreams} className="infio-stop-gen-btn">
<CircleStop size={16} />
<div>Stop generation</div>
</button>
)}
</div>
<PromptInputWithActions
key={inputMessage.id}
ref={(ref) => registerChatUserInputRef(inputMessage.id, ref)}
initialSerializedEditorState={inputMessage.content}
onSubmit={(content, useVaultSearch) => {
if (editorStateToPlainText(content).trim() === '') return
handleSubmit(
[...chatMessages, { ...inputMessage, content }],
useVaultSearch,
)
setInputMessage(getNewInputMessage(app, settings.defaultMention))
preventAutoScrollRef.current = false
handleScrollToBottom()
}}
onFocus={() => {
setFocusedMessageId(inputMessage.id)
}}
mentionables={inputMessage.mentionables}
setMentionables={(mentionables) => {
setInputMessage((prevInputMessage) => ({
...prevInputMessage,
mentionables,
}))
}}
autoFocus
addedBlockKey={addedBlockKey}
/>
</>
) : (
<div className="infio-chat-commands">
<CommandsView />
</div>
)}
</div>
)
})

View File

@ -0,0 +1,243 @@
import { Pencil, Save, Search, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { v4 as uuidv4 } from 'uuid'
export interface Command {
id: string
title: string
content: string
}
const CommandsView = () => {
const [commands, setCommands] = useState<Command[]>([])
const [newCommand, setNewCommand] = useState<Command>({
id: uuidv4(),
title: '',
content: ''
})
const [searchTerm, setSearchTerm] = useState('')
const [editingCommandId, setEditingCommandId] = useState<string | null>(null)
const titleInputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
const contentInputRefs = useRef<Map<string, HTMLTextAreaElement>>(new Map())
// 从本地存储加载commands
useEffect(() => {
const savedCommands = localStorage.getItem('commands')
if (savedCommands) {
try {
const parsedData = JSON.parse(savedCommands)
// 验证解析的数据是否为符合Prompt接口的数组
if (Array.isArray(parsedData) && parsedData.every(isCommand)) {
setCommands(parsedData)
}
} catch (error) {
console.error('无法解析保存的命令', error)
}
}
}, [])
// 类型守卫函数用于验证对象是否符合Command接口
function isCommand(item: unknown): item is Command {
if (!item || typeof item !== 'object') {
return false;
}
// 使用in操作符检查属性存在
if (!('id' in item) || !('title' in item) || !('content' in item)) {
return false;
}
// 使用JavaScript的hasOwnProperty和typeof来检查属性类型
return (
Object.prototype.hasOwnProperty.call(item, 'id') &&
Object.prototype.hasOwnProperty.call(item, 'title') &&
Object.prototype.hasOwnProperty.call(item, 'content') &&
typeof Reflect.get(item, 'id') === 'string' &&
typeof Reflect.get(item, 'title') === 'string' &&
typeof Reflect.get(item, 'content') === 'string'
);
}
// 保存commands到本地存储
useEffect(() => {
localStorage.setItem('commands', JSON.stringify(commands))
}, [commands])
// 处理新command的标题变化
const handleNewCommandTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewCommand({ ...newCommand, title: e.target.value })
}
// 处理新command的内容变化
const handleNewCommandContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNewCommand({ ...newCommand, content: e.target.value })
}
// 添加新command
const handleAddCommand = () => {
if (newCommand.title.trim() === '' || newCommand.content.trim() === '') {
return
}
setCommands([...commands, newCommand])
setNewCommand({
id: uuidv4(),
title: '',
content: ''
})
}
// 删除command
const handleDeleteCommand = (id: string) => {
setCommands(commands.filter(command => command.id !== id))
if (editingCommandId === id) {
setEditingCommandId(null)
}
}
// 编辑command
const handleEditCommand = (command: Command) => {
setEditingCommandId(command.id)
}
// 保存编辑后的command
const handleSaveEdit = (id: string) => {
const titleInput = titleInputRefs.current.get(id)
const contentInput = contentInputRefs.current.get(id)
if (titleInput && contentInput) {
setCommands(
commands.map(command =>
command.id === id
? { ...command, title: titleInput.value, content: contentInput.value }
: command
)
)
setEditingCommandId(null)
}
}
// 处理搜索
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
}
// 过滤commands列表
const filteredCommands = commands.filter(
command =>
command.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
command.content.toLowerCase().includes(searchTerm.toLowerCase())
)
return (
<div className="infio-commands-container">
{/* header */}
<div className="infio-commands-header">
<div className="infio-commands-new">
<h2 className="infio-commands-header-title">Create Quick Command</h2>
<div className="infio-commands-label">Name</div>
<input
type="text"
placeholder="Input Command Name"
value={newCommand.title}
onChange={handleNewCommandTitleChange}
className="infio-commands-input"
/>
<div className="infio-commands-label">Content</div>
<textarea
placeholder="Input Command Content"
value={newCommand.content}
onChange={handleNewCommandContentChange}
className="infio-commands-textarea"
/>
{/* <div className="infio-commands-hint">English identifier (lowercase letters + numbers + hyphens)</div> */}
<button
onClick={handleAddCommand}
className="infio-commands-add-btn"
disabled={!newCommand.title.trim() || !newCommand.content.trim()}
>
<span>Create Command</span>
</button>
</div>
</div>
{/* search bar */}
<div className="infio-commands-search">
<Search size={18} className="infio-commands-search-icon" />
<input
type="text"
placeholder="Search Command..."
value={searchTerm}
onChange={handleSearch}
className="infio-commands-search-input"
/>
</div>
{/* commands list */}
<div className="infio-commands-list">
{filteredCommands.length === 0 ? (
<div className="infio-commands-empty">
<p>No commands found</p>
</div>
) : (
filteredCommands.map(command => (
<div key={command.id} className="infio-commands-item">
{editingCommandId === command.id ? (
// edit mode
<div className="infio-commands-edit-mode">
<input
type="text"
defaultValue={command.title}
className="infio-commands-edit-title"
ref={(el) => {
if (el) titleInputRefs.current.set(command.id, el)
}}
/>
<textarea
defaultValue={command.content}
className="infio-commands-textarea"
ref={(el) => {
if (el) contentInputRefs.current.set(command.id, el)
}}
/>
<div className="infio-commands-actions">
<button
onClick={() => handleSaveEdit(command.id)}
className="infio-commands-btn"
>
<Save size={16} />
</button>
</div>
</div>
) : (
// view mode
<div className="infio-commands-view-mode">
<div className="infio-commands-title">{command.title}</div>
<div className="infio-commands-content">{command.content}</div>
<div className="infio-commands-actions">
<button
onClick={() => handleEditCommand(command)}
className="infio-commands-btn"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDeleteCommand(command.id)}
className="infio-commands-btn"
>
<Trash2 size={16} />
</button>
</div>
</div>
)}
</div>
))
)}
</div>
</div>
)
}
export default CommandsView

File diff suppressed because it is too large Load Diff

View File

@ -1871,3 +1871,292 @@ button.infio-chat-input-model-select {
align-items: center;
gap: 4px;
}
/*
* CommandsView Styles
* - 命令管理界面
*/
.infio-chat-commands {
overflow-y: scroll;
}
.infio-commands-container {
/* background-color: #1e1e1e; */
color: var(--text-normal);
border-radius: var(--radius-m);
padding: var(--size-4-3);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
height: 100%;
margin: 0 auto;
}
.infio-commands-header {
padding-bottom: var(--size-4-2);
border-bottom: 1px solid #333;
margin-bottom: var(--size-4-2);
}
.infio-commands-new {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
.infio-commands-header-title {
color: var(--text-normal);
font-size: 28px;
font-weight: 500;
margin: 0 0 var(--size-4-3) 0;
}
.infio-commands-label {
color: var(--text-normal);
font-size: var(--font-ui-medium);
font-weight: var(--font-medium);
margin: var(--size-2-2) 0;
}
.infio-commands-hint {
color: #999;
font-size: var(--font-ui-smaller);
margin-top: var(--size-2-1);
}
.infio-commands-input {
background-color: #333 !important;
border: none;
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-commands-textarea {
background-color: #333 !important;
border: none;
border-radius: var(--radius-s);
color: var(--text-normal);
padding: var(--size-4-2);
font-size: var(--font-ui-small);
width: 100%;
min-height: 80px;
resize: vertical;
box-sizing: border-box;
}
.infio-commands-add-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--size-2-2);
background-color: transparent;
color: var(--text-accent);
border: 1px solid #555;
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-commands-add-btn:disabled {
background-color: #444;
color: #777;
cursor: not-allowed;
}
.infio-commands-search {
display: flex;
align-items: center;
background-color: #333 !important;
border: 1px solid #444;
border-radius: 6px;
padding: 6px 12px;
margin-bottom: var(--size-4-3);
transition: all 0.2s ease;
height: 36px;
max-width: 100%;
}
.infio-commands-search:focus-within {
border-color: #666;
}
.infio-commands-search-icon {
color: #888;
margin-right: 8px;
opacity: 0.8;
}
.infio-commands-search-input {
background-color: transparent !important;
border: none !important;
color: #ddd;
padding: 4px 0;
font-size: 14px;
width: 100%;
outline: none;
height: 24px;
&:focus {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
}
.infio-commands-search-input::placeholder {
color: #888;
opacity: 0.8;
}
.infio-commands-search-input::placeholder {
color: var(--text-faint);
opacity: 0.7;
}
.infio-commands-list {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
.infio-commands-empty {
display: flex;
justify-content: center;
padding: var(--size-4-3);
color: var(--text-muted);
}
.infio-commands-item {
background-color: #2a2a2a;
border-radius: var(--radius-s);
padding: var(--size-4-2);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.infio-commands-title {
font-weight: var(--font-medium);
margin-bottom: var(--size-2-3);
font-size: var(--font-ui-medium);
color: var(--text-accent);
user-select: text;
}
.infio-commands-content {
color: var(--text-muted);
margin-bottom: var(--size-4-2);
font-size: var(--font-ui-small);
white-space: pre-wrap;
word-break: break-word;
user-select: text;
}
.infio-commands-actions {
display: flex;
justify-content: flex-end;
gap: var(--size-1-2);
position: relative;
z-index: 10;
}
.infio-commands-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-commands-edit-mode {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
.infio-commands-edit-title {
background-color: #333 !important;
border: none;
border-radius: var(--radius-s);
color: var(--text-normal);
padding: var(--size-4-2);
font-size: var(--font-ui-medium);
font-weight: var(--font-medium);
width: 100%;
}
.infio-commands-view-mode {
display: flex;
flex-direction: column;
}
.infio-commands-form {
display: flex;
flex-direction: column;
gap: var(--size-4-3);
}
.infio-commands-form-group {
display: flex;
flex-direction: column;
gap: var(--size-2-2);
}
.infio-commands-section {
margin-bottom: var(--size-4-3);
border-bottom: 1px solid #333;
padding-bottom: var(--size-4-3);
}
.infio-commands-section-title {
color: var(--text-accent);
font-size: var(--font-ui-medium);
font-weight: var(--font-medium);
margin: 0 0 var(--size-4-2) 0;
}
.infio-commands-location {
display: flex;
gap: var(--size-4-4);
margin-bottom: var(--size-4-2);
}
.infio-commands-location-option {
display: flex;
flex-direction: column;
background-color: #2a2a2a;
border-radius: var(--radius-s);
padding: var(--size-4-2);
width: 50%;
}
.infio-commands-location-option input[type="radio"] {
margin-right: var(--size-2-2);
}
.infio-commands-location-option label {
color: var(--text-normal);
font-weight: var(--font-medium);
margin-bottom: var(--size-2-2);
display: flex;
align-items: center;
}
.infio-commands-location-description {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
}