Add file attachments with @ mentions and drag & drop support
- Create attachment type system with file, text, symbol, and agent sources - Implement file picker with SDK integration (find.files, file.status) - Add @ detection in prompt input to trigger file search - Create attachment chips UI for managing attached files - Add attachment state management per session - Update message submission to include attachments - Implement drag & drop support for adding files - Show git-modified files first in file picker with +/- indicators - Filter files as user types after @ - Clear attachments after successful message send
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { Component, onMount, onCleanup, Show, createMemo, createEffect } from "solid-js"
|
import { Component, onMount, onCleanup, Show, createMemo, createEffect } from "solid-js"
|
||||||
import type { Session } from "./types/session"
|
import type { Session } from "./types/session"
|
||||||
|
import type { Attachment } from "./types/attachment"
|
||||||
import EmptyState from "./components/empty-state"
|
import EmptyState from "./components/empty-state"
|
||||||
import SessionPicker from "./components/session-picker"
|
import SessionPicker from "./components/session-picker"
|
||||||
import CommandPalette from "./components/command-palette"
|
import CommandPalette from "./components/command-palette"
|
||||||
@@ -70,8 +71,8 @@ const SessionView: Component<{
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleSendMessage(prompt: string) {
|
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
||||||
await sendMessage(props.instanceId, props.sessionId, prompt)
|
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAgentChange(agent: string) {
|
async function handleAgentChange(agent: string) {
|
||||||
|
|||||||
27
src/components/attachment-chip.tsx
Normal file
27
src/components/attachment-chip.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Component } from "solid-js"
|
||||||
|
import type { Attachment } from "../types/attachment"
|
||||||
|
|
||||||
|
interface AttachmentChipProps {
|
||||||
|
attachment: Attachment
|
||||||
|
onRemove: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttachmentChip: Component<AttachmentChipProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||||
|
title={props.attachment.source.type === "file" ? props.attachment.source.path : undefined}
|
||||||
|
>
|
||||||
|
<span class="font-mono">{props.attachment.display}</span>
|
||||||
|
<button
|
||||||
|
onClick={props.onRemove}
|
||||||
|
class="flex h-4 w-4 items-center justify-center rounded hover:bg-blue-200 dark:hover:bg-blue-800"
|
||||||
|
aria-label="Remove attachment"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttachmentChip
|
||||||
208
src/components/file-picker.tsx
Normal file
208
src/components/file-picker.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { Component, createSignal, createEffect, For, Show, onMount } from "solid-js"
|
||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
path: string
|
||||||
|
added?: number
|
||||||
|
removed?: number
|
||||||
|
isGitFile: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePickerProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSelect: (path: string) => void
|
||||||
|
instanceId: string
|
||||||
|
instanceClient: any
|
||||||
|
searchQuery?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilePicker: Component<FilePickerProps> = (props) => {
|
||||||
|
const [files, setFiles] = createSignal<FileItem[]>([])
|
||||||
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
|
const [query, setQuery] = createSignal(props.searchQuery || "")
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
|
||||||
|
let inputRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
async function fetchFiles(searchQuery: string) {
|
||||||
|
if (!props.instanceClient) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const gitFilesPromise = props.instanceClient.file.status()
|
||||||
|
const searchFilesPromise = searchQuery
|
||||||
|
? props.instanceClient.find.files({ query: { query: searchQuery } })
|
||||||
|
: Promise.resolve({ data: [] })
|
||||||
|
|
||||||
|
const [gitResponse, searchResponse] = await Promise.all([gitFilesPromise, searchFilesPromise])
|
||||||
|
|
||||||
|
const gitFiles: FileItem[] = (gitResponse.data || []).map((file: any) => ({
|
||||||
|
path: file.path,
|
||||||
|
added: file.added,
|
||||||
|
removed: file.removed,
|
||||||
|
isGitFile: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const searchFiles: FileItem[] = (searchResponse.data || [])
|
||||||
|
.filter((path: string) => !gitFiles.some((gf) => gf.path === path))
|
||||||
|
.map((path: string) => ({
|
||||||
|
path,
|
||||||
|
isGitFile: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const allFiles = searchQuery
|
||||||
|
? [...gitFiles.filter((f) => f.path.includes(searchQuery)), ...searchFiles]
|
||||||
|
: gitFiles
|
||||||
|
|
||||||
|
setFiles(allFiles)
|
||||||
|
setSelectedIndex(0)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch files:", error)
|
||||||
|
setFiles([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.open) {
|
||||||
|
fetchFiles(query())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.searchQuery !== undefined) {
|
||||||
|
setQuery(props.searchQuery)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (props.open && inputRef) {
|
||||||
|
setTimeout(() => inputRef?.focus(), 50)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
const fileList = files()
|
||||||
|
if (fileList.length === 0) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => Math.min(prev + 1, fileList.length - 1))
|
||||||
|
scrollToSelected()
|
||||||
|
break
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||||
|
scrollToSelected()
|
||||||
|
break
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault()
|
||||||
|
if (fileList[selectedIndex()]) {
|
||||||
|
handleSelect(fileList[selectedIndex()].path)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault()
|
||||||
|
props.onClose()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToSelected() {
|
||||||
|
setTimeout(() => {
|
||||||
|
const selectedElement = document.querySelector('[data-file-selected="true"]')
|
||||||
|
if (selectedElement) {
|
||||||
|
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(path: string) {
|
||||||
|
props.onSelect(path)
|
||||||
|
props.onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQueryChange(value: string) {
|
||||||
|
setQuery(value)
|
||||||
|
fetchFiles(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="fixed inset-0 z-50 bg-black/50" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
|
||||||
|
<Dialog.Content
|
||||||
|
class="w-full max-w-2xl rounded-lg border border-gray-300 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div class="border-b border-gray-200 p-4 dark:border-gray-700">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search files..."
|
||||||
|
value={query()}
|
||||||
|
onInput={(e) => handleQueryChange(e.currentTarget.value)}
|
||||||
|
class="w-full border-0 bg-transparent text-base outline-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-96 overflow-y-auto">
|
||||||
|
<Show
|
||||||
|
when={!loading()}
|
||||||
|
fallback={
|
||||||
|
<div class="p-8 text-center text-sm text-gray-500">
|
||||||
|
<div class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
|
||||||
|
<span class="ml-2">Loading files...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={files().length > 0}
|
||||||
|
fallback={<div class="p-8 text-center text-sm text-gray-500">No matching files</div>}
|
||||||
|
>
|
||||||
|
<For each={files()}>
|
||||||
|
{(file, index) => (
|
||||||
|
<div
|
||||||
|
data-file-selected={index() === selectedIndex()}
|
||||||
|
class={`cursor-pointer border-b border-gray-100 px-4 py-2 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-800 ${
|
||||||
|
index() === selectedIndex() ? "bg-blue-50 dark:bg-blue-900/20" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelect(file.path)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-mono text-sm text-gray-900 dark:text-gray-100">{file.path}</span>
|
||||||
|
<Show when={file.isGitFile && (file.added || file.removed)}>
|
||||||
|
<div class="flex gap-2 text-xs">
|
||||||
|
<Show when={file.added}>
|
||||||
|
<span class="text-green-600 dark:text-green-400">+{file.added}</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={file.removed}>
|
||||||
|
<span class="text-red-600 dark:text-red-400">-{file.removed}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 p-2 text-xs text-gray-500 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between px-2">
|
||||||
|
<span>↑↓ Navigate • Enter Select • Esc Close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilePicker
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
import { createSignal, Show, onMount, createEffect } from "solid-js"
|
import { createSignal, Show, onMount, createEffect, For } from "solid-js"
|
||||||
import AgentSelector from "./agent-selector"
|
import AgentSelector from "./agent-selector"
|
||||||
import ModelSelector from "./model-selector"
|
import ModelSelector from "./model-selector"
|
||||||
|
import FilePicker from "./file-picker"
|
||||||
|
import AttachmentChip from "./attachment-chip"
|
||||||
import { addToHistory, getHistory } from "../stores/message-history"
|
import { addToHistory, getHistory } from "../stores/message-history"
|
||||||
|
import { getAttachments, addAttachment, removeAttachment, clearAttachments } from "../stores/attachments"
|
||||||
|
import { createFileAttachment } from "../types/attachment"
|
||||||
|
import type { Attachment } from "../types/attachment"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import HintRow from "./hint-row"
|
import HintRow from "./hint-row"
|
||||||
import { isMac } from "../lib/keyboard-utils"
|
import { isMac } from "../lib/keyboard-utils"
|
||||||
|
import { getActiveInstance } from "../stores/instances"
|
||||||
|
|
||||||
interface PromptInputProps {
|
interface PromptInputProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
instanceFolder: string
|
instanceFolder: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
onSend: (prompt: string) => Promise<void>
|
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
agent: string
|
agent: string
|
||||||
model: { providerId: string; modelId: string }
|
model: { providerId: string; modelId: string }
|
||||||
@@ -24,7 +30,14 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const [history, setHistory] = createSignal<string[]>([])
|
const [history, setHistory] = createSignal<string[]>([])
|
||||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||||
const [isFocused, setIsFocused] = createSignal(false)
|
const [isFocused, setIsFocused] = createSignal(false)
|
||||||
|
const [showFilePicker, setShowFilePicker] = createSignal(false)
|
||||||
|
const [fileSearchQuery, setFileSearchQuery] = createSignal("")
|
||||||
|
const [atPosition, setAtPosition] = createSignal<number | null>(null)
|
||||||
|
const [isDragging, setIsDragging] = createSignal(false)
|
||||||
let textareaRef: HTMLTextAreaElement | undefined
|
let textareaRef: HTMLTextAreaElement | undefined
|
||||||
|
let containerRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
const attachments = () => getAttachments(props.instanceId, props.sessionId)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const loaded = await getHistory(props.instanceFolder)
|
const loaded = await getHistory(props.instanceFolder)
|
||||||
@@ -32,6 +45,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (showFilePicker()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSend()
|
handleSend()
|
||||||
@@ -76,6 +93,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const text = prompt().trim()
|
const text = prompt().trim()
|
||||||
|
const currentAttachments = attachments()
|
||||||
if (!text || sending() || props.disabled) return
|
if (!text || sending() || props.disabled) return
|
||||||
|
|
||||||
setSending(true)
|
setSending(true)
|
||||||
@@ -86,8 +104,9 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
setHistory(updated)
|
setHistory(updated)
|
||||||
setHistoryIndex(-1)
|
setHistoryIndex(-1)
|
||||||
|
|
||||||
await props.onSend(text)
|
await props.onSend(text, currentAttachments)
|
||||||
setPrompt("")
|
setPrompt("")
|
||||||
|
clearAttachments(props.instanceId, props.sessionId)
|
||||||
|
|
||||||
if (textareaRef) {
|
if (textareaRef) {
|
||||||
textareaRef.style.height = "auto"
|
textareaRef.style.height = "auto"
|
||||||
@@ -103,22 +122,116 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
|
|
||||||
function handleInput(e: Event) {
|
function handleInput(e: Event) {
|
||||||
const target = e.target as HTMLTextAreaElement
|
const target = e.target as HTMLTextAreaElement
|
||||||
setPrompt(target.value)
|
const value = target.value
|
||||||
|
setPrompt(value)
|
||||||
setHistoryIndex(-1)
|
setHistoryIndex(-1)
|
||||||
|
|
||||||
target.style.height = "auto"
|
target.style.height = "auto"
|
||||||
target.style.height = Math.min(target.scrollHeight, 200) + "px"
|
target.style.height = Math.min(target.scrollHeight, 200) + "px"
|
||||||
|
|
||||||
|
const cursorPos = target.selectionStart
|
||||||
|
const lastAtIndex = value.lastIndexOf("@", cursorPos)
|
||||||
|
|
||||||
|
if (lastAtIndex !== -1 && lastAtIndex < cursorPos) {
|
||||||
|
const textAfterAt = value.substring(lastAtIndex + 1, cursorPos)
|
||||||
|
const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n")
|
||||||
|
|
||||||
|
if (!hasSpace) {
|
||||||
|
setAtPosition(lastAtIndex)
|
||||||
|
setFileSearchQuery(textAfterAt)
|
||||||
|
setShowFilePicker(true)
|
||||||
|
} else {
|
||||||
|
setShowFilePicker(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setShowFilePicker(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSend = () => prompt().trim().length > 0 && !sending() && !props.disabled
|
function handleFileSelect(path: string) {
|
||||||
|
const instance = getActiveInstance()
|
||||||
|
if (!instance) return
|
||||||
|
|
||||||
|
const filename = path.split("/").pop() || path
|
||||||
|
const attachment = createFileAttachment(path, filename)
|
||||||
|
addAttachment(props.instanceId, props.sessionId, attachment)
|
||||||
|
|
||||||
|
const currentPrompt = prompt()
|
||||||
|
const pos = atPosition()
|
||||||
|
if (pos !== null) {
|
||||||
|
const before = currentPrompt.substring(0, pos)
|
||||||
|
const after = currentPrompt.substring(textareaRef?.selectionStart || pos)
|
||||||
|
setPrompt(before + after)
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowFilePicker(false)
|
||||||
|
setAtPosition(null)
|
||||||
|
setFileSearchQuery("")
|
||||||
|
|
||||||
|
setTimeout(() => textareaRef?.focus(), 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveAttachment(attachmentId: string) {
|
||||||
|
removeAttachment(props.instanceId, props.sessionId, attachmentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
|
||||||
|
const files = e.dataTransfer?.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i]
|
||||||
|
const path = (file as any).path || file.name
|
||||||
|
const filename = file.name
|
||||||
|
const mime = file.type || "text/plain"
|
||||||
|
|
||||||
|
const attachment = createFileAttachment(path, filename, mime)
|
||||||
|
addAttachment(props.instanceId, props.sessionId, attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
textareaRef?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSend = () => (prompt().trim().length > 0 || attachments().length > 0) && !sending() && !props.disabled
|
||||||
|
|
||||||
|
const instance = () => getActiveInstance()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="prompt-input-container">
|
<div class="prompt-input-container">
|
||||||
<div class="prompt-input-wrapper">
|
<Show when={attachments().length > 0}>
|
||||||
|
<div class="flex flex-wrap gap-2 border-b border-gray-200 p-2 dark:border-gray-700">
|
||||||
|
<For each={attachments()}>
|
||||||
|
{(att) => <AttachmentChip attachment={att} onRemove={() => handleRemoveAttachment(att.id)} />}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
class={`prompt-input-wrapper ${isDragging() ? "border-2 border-blue-500 bg-blue-50 dark:bg-blue-900/10" : ""}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
class="prompt-input"
|
class="prompt-input"
|
||||||
placeholder="Type your message or /command..."
|
placeholder="Type your message, @file, or /command..."
|
||||||
value={prompt()}
|
value={prompt()}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -135,8 +248,8 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="prompt-input-hints">
|
<div class="prompt-input-hints">
|
||||||
<HintRow>
|
<HintRow>
|
||||||
<Kbd>Enter</Kbd> to send • <Kbd>Shift+Enter</Kbd> for new line • <Kbd>↑↓</Kbd> for history •{" "}
|
<Kbd>Enter</Kbd> to send • <Kbd>Shift+Enter</Kbd> for new line • <Kbd>@</Kbd> for files • <Kbd>↑↓</Kbd> for
|
||||||
<Kbd shortcut="cmd+p" /> to focus
|
history
|
||||||
</HintRow>
|
</HintRow>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
@@ -153,6 +266,21 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={showFilePicker() && instance()}>
|
||||||
|
<FilePicker
|
||||||
|
open={showFilePicker()}
|
||||||
|
onClose={() => {
|
||||||
|
setShowFilePicker(false)
|
||||||
|
setAtPosition(null)
|
||||||
|
setFileSearchQuery("")
|
||||||
|
}}
|
||||||
|
onSelect={handleFileSelect}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
instanceClient={instance()!.client}
|
||||||
|
searchQuery={fileSearchQuery()}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/stores/attachments.ts
Normal file
47
src/stores/attachments.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import type { Attachment } from "../types/attachment"
|
||||||
|
|
||||||
|
const [attachments, setAttachments] = createSignal<Map<string, Attachment[]>>(new Map())
|
||||||
|
|
||||||
|
function getSessionKey(instanceId: string, sessionId: string): string {
|
||||||
|
return `${instanceId}:${sessionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAttachments(instanceId: string, sessionId: string): Attachment[] {
|
||||||
|
const key = getSessionKey(instanceId, sessionId)
|
||||||
|
return attachments().get(key) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAttachment(instanceId: string, sessionId: string, attachment: Attachment) {
|
||||||
|
const key = getSessionKey(instanceId, sessionId)
|
||||||
|
setAttachments((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const existing = next.get(key) || []
|
||||||
|
next.set(key, [...existing, attachment])
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachment(instanceId: string, sessionId: string, attachmentId: string) {
|
||||||
|
const key = getSessionKey(instanceId, sessionId)
|
||||||
|
setAttachments((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const existing = next.get(key) || []
|
||||||
|
next.set(
|
||||||
|
key,
|
||||||
|
existing.filter((a) => a.id !== attachmentId),
|
||||||
|
)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAttachments(instanceId: string, sessionId: string) {
|
||||||
|
const key = getSessionKey(instanceId, sessionId)
|
||||||
|
setAttachments((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getAttachments, addAttachment, removeAttachment, clearAttachments }
|
||||||
@@ -626,7 +626,7 @@ async function sendMessage(
|
|||||||
instanceId: string,
|
instanceId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
attachments: string[] = [],
|
attachments: any[] = [],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const instance = instances().get(instanceId)
|
const instance = instances().get(instanceId)
|
||||||
if (!instance || !instance.client) {
|
if (!instance || !instance.client) {
|
||||||
@@ -639,13 +639,33 @@ async function sendMessage(
|
|||||||
throw new Error("Session not found")
|
throw new Error("Session not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parts: any[] = [
|
||||||
|
{
|
||||||
|
type: "text" as const,
|
||||||
|
text: prompt,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
for (const att of attachments) {
|
||||||
|
const source = att.source
|
||||||
|
if (source.type === "file") {
|
||||||
|
parts.push({
|
||||||
|
type: "file" as const,
|
||||||
|
path: source.path,
|
||||||
|
mime: source.mime,
|
||||||
|
})
|
||||||
|
} else if (source.type === "text") {
|
||||||
|
parts.push({
|
||||||
|
type: "text" as const,
|
||||||
|
text: source.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
parts: [
|
parts,
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: prompt,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
...(session.agent && { agent: session.agent }),
|
...(session.agent && { agent: session.agent }),
|
||||||
...(session.model.providerId &&
|
...(session.model.providerId &&
|
||||||
session.model.modelId && {
|
session.model.modelId && {
|
||||||
|
|||||||
87
src/types/attachment.ts
Normal file
87
src/types/attachment.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
export interface Attachment {
|
||||||
|
id: string
|
||||||
|
type: AttachmentType
|
||||||
|
display: string
|
||||||
|
url: string
|
||||||
|
filename: string
|
||||||
|
mediaType: string
|
||||||
|
source: AttachmentSource
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttachmentType = "file" | "text" | "symbol" | "agent"
|
||||||
|
|
||||||
|
export type AttachmentSource = FileSource | TextSource | SymbolSource | AgentSource
|
||||||
|
|
||||||
|
export interface FileSource {
|
||||||
|
type: "file"
|
||||||
|
path: string
|
||||||
|
mime: string
|
||||||
|
data?: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextSource {
|
||||||
|
type: "text"
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolSource {
|
||||||
|
type: "symbol"
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
kind: number
|
||||||
|
range: SymbolRange
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolRange {
|
||||||
|
start: Position
|
||||||
|
end: Position
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
line: number
|
||||||
|
char: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSource {
|
||||||
|
type: "agent"
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFileAttachment(
|
||||||
|
path: string,
|
||||||
|
filename: string,
|
||||||
|
mime: string = "text/plain",
|
||||||
|
data?: Uint8Array,
|
||||||
|
): Attachment {
|
||||||
|
const absolutePath = path.startsWith("/") ? path : `/${path}`
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "file",
|
||||||
|
display: `@${filename}`,
|
||||||
|
url: `file://${absolutePath}`,
|
||||||
|
filename,
|
||||||
|
mediaType: mime,
|
||||||
|
source: {
|
||||||
|
type: "file",
|
||||||
|
path: absolutePath,
|
||||||
|
mime,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTextAttachment(value: string, display: string, filename: string): Attachment {
|
||||||
|
const base64 = btoa(value)
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "text",
|
||||||
|
display,
|
||||||
|
url: `data:text/plain;base64,${base64}`,
|
||||||
|
filename,
|
||||||
|
mediaType: "text/plain",
|
||||||
|
source: {
|
||||||
|
type: "text",
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user