mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
commit
942def2bb0
17 changed files with 2666 additions and 1128 deletions
|
|
@ -141,11 +141,6 @@ Examples: Exa (web search), Twitter/X, ElevenLabs (voice), Slack, Linear/Jira, G
|
||||||
- No proprietary formats or hosted lock-in
|
- No proprietary formats or hosted lock-in
|
||||||
- You can inspect, edit, back up, or delete everything at any time
|
- You can inspect, edit, back up, or delete everything at any time
|
||||||
|
|
||||||
|
|
||||||
## Looking for Rowboat Web Studio?
|
|
||||||
|
|
||||||
If you’re looking for Rowboat web Studio, start [here](https://docs.rowboatlabs.com/).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import * as composioHandler from './composio-handler.js';
|
||||||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||||
|
import { search } from '@x/core/dist/search/search.js';
|
||||||
|
|
||||||
type InvokeChannels = ipc.InvokeChannels;
|
type InvokeChannels = ipc.InvokeChannels;
|
||||||
type IPCChannels = ipc.IPCChannels;
|
type IPCChannels = ipc.IPCChannels;
|
||||||
|
|
@ -497,5 +498,9 @@ export function setupIpcHandlers() {
|
||||||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||||
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
||||||
},
|
},
|
||||||
|
// Search handler
|
||||||
|
'search:query': async (_event, args) => {
|
||||||
|
return search(args.query, args.limit, args.types);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -102,7 +102,7 @@ export const Conversation = ({ className, children, ...props }: ConversationProp
|
||||||
* Must be used inside Conversation component.
|
* Must be used inside Conversation component.
|
||||||
*/
|
*/
|
||||||
export const ScrollPositionPreserver = () => {
|
export const ScrollPositionPreserver = () => {
|
||||||
const { isAtBottom } = useStickToBottomContext();
|
const { isAtBottom, scrollRef } = useStickToBottomContext();
|
||||||
const preservationContext = useContext(ScrollPreservationContext);
|
const preservationContext = useContext(ScrollPreservationContext);
|
||||||
const containerFoundRef = useRef(false);
|
const containerFoundRef = useRef(false);
|
||||||
|
|
||||||
|
|
@ -110,29 +110,13 @@ export const ScrollPositionPreserver = () => {
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (containerFoundRef.current || !preservationContext) return;
|
if (containerFoundRef.current || !preservationContext) return;
|
||||||
|
|
||||||
// Find the scroll container (StickToBottom creates one)
|
// Use the local StickToBottom scroll container for this conversation instance.
|
||||||
// It's the first parent with overflow-y scroll/auto
|
const container = scrollRef.current;
|
||||||
const findScrollContainer = (): HTMLElement | null => {
|
|
||||||
const candidates = document.querySelectorAll('[role="log"]');
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
// The scroll container is a direct child of the role="log" element
|
|
||||||
const children = candidate.children;
|
|
||||||
for (const child of children) {
|
|
||||||
const style = window.getComputedStyle(child);
|
|
||||||
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
|
||||||
return child as HTMLElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const container = findScrollContainer();
|
|
||||||
if (container) {
|
if (container) {
|
||||||
preservationContext.registerScrollContainer(container);
|
preservationContext.registerScrollContainer(container);
|
||||||
containerFoundRef.current = true;
|
containerFoundRef.current = true;
|
||||||
}
|
}
|
||||||
}, [preservationContext]);
|
}, [preservationContext, scrollRef]);
|
||||||
|
|
||||||
// Track engagement based on scroll position
|
// Track engagement based on scroll position
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -931,7 +931,13 @@ export const PromptInputTextarea = ({
|
||||||
if (autoFocus || focusTrigger !== undefined) {
|
if (autoFocus || focusTrigger !== undefined) {
|
||||||
// Small delay to ensure the element is fully mounted and visible
|
// Small delay to ensure the element is fully mounted and visible
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
textareaRef.current?.focus();
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
try {
|
||||||
|
textarea.focus({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
201
apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
Normal file
201
apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { ArrowUp, LoaderIcon, Square } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
type FileMention,
|
||||||
|
type PromptInputMessage,
|
||||||
|
PromptInputProvider,
|
||||||
|
PromptInputTextarea,
|
||||||
|
usePromptInputController,
|
||||||
|
} from '@/components/ai-elements/prompt-input'
|
||||||
|
|
||||||
|
interface ChatInputInnerProps {
|
||||||
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||||
|
onStop?: () => void
|
||||||
|
isProcessing: boolean
|
||||||
|
isStopping?: boolean
|
||||||
|
isActive: boolean
|
||||||
|
presetMessage?: string
|
||||||
|
onPresetMessageConsumed?: () => void
|
||||||
|
runId?: string | null
|
||||||
|
initialDraft?: string
|
||||||
|
onDraftChange?: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatInputInner({
|
||||||
|
onSubmit,
|
||||||
|
onStop,
|
||||||
|
isProcessing,
|
||||||
|
isStopping,
|
||||||
|
isActive,
|
||||||
|
presetMessage,
|
||||||
|
onPresetMessageConsumed,
|
||||||
|
runId,
|
||||||
|
initialDraft,
|
||||||
|
onDraftChange,
|
||||||
|
}: ChatInputInnerProps) {
|
||||||
|
const controller = usePromptInputController()
|
||||||
|
const message = controller.textInput.value
|
||||||
|
const canSubmit = Boolean(message.trim()) && !isProcessing
|
||||||
|
|
||||||
|
// Restore the tab draft when this input mounts.
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialDraft) {
|
||||||
|
controller.textInput.setInput(initialDraft)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDraftChange?.(message)
|
||||||
|
}, [message, onDraftChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (presetMessage) {
|
||||||
|
controller.textInput.setInput(presetMessage)
|
||||||
|
onPresetMessageConsumed?.()
|
||||||
|
}
|
||||||
|
}, [presetMessage, controller.textInput, onPresetMessageConsumed])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!canSubmit) return
|
||||||
|
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions)
|
||||||
|
controller.textInput.clear()
|
||||||
|
controller.mentions.clearMentions()
|
||||||
|
}, [canSubmit, message, onSubmit, controller])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
}, [handleSubmit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return
|
||||||
|
const onDragOver = (e: DragEvent) => {
|
||||||
|
if (e.dataTransfer?.types?.includes('Files')) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
if (e.dataTransfer?.types?.includes('Files')) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
||||||
|
const paths = Array.from(e.dataTransfer.files)
|
||||||
|
.map((file) => window.electronUtils?.getPathForFile(file))
|
||||||
|
.filter(Boolean)
|
||||||
|
if (paths.length > 0) {
|
||||||
|
const currentText = controller.textInput.value
|
||||||
|
const pathText = paths.join(' ')
|
||||||
|
controller.textInput.setInput(currentText ? `${currentText} ${pathText}` : pathText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('dragover', onDragOver)
|
||||||
|
document.addEventListener('drop', onDrop)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('dragover', onDragOver)
|
||||||
|
document.removeEventListener('drop', onDrop)
|
||||||
|
}
|
||||||
|
}, [controller, isActive])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-4 shadow-none">
|
||||||
|
<PromptInputTextarea
|
||||||
|
placeholder="Type your message..."
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus={isActive}
|
||||||
|
focusTrigger={isActive ? runId : undefined}
|
||||||
|
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
{isProcessing ? (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onClick={onStop}
|
||||||
|
title={isStopping ? 'Click again to force stop' : 'Stop generation'}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||||
|
isStopping
|
||||||
|
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||||
|
: 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isStopping ? (
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Square className="h-3 w-3 fill-current" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||||
|
canSubmit
|
||||||
|
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
: 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatInputWithMentionsProps {
|
||||||
|
knowledgeFiles: string[]
|
||||||
|
recentFiles: string[]
|
||||||
|
visibleFiles: string[]
|
||||||
|
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||||
|
onStop?: () => void
|
||||||
|
isProcessing: boolean
|
||||||
|
isStopping?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
presetMessage?: string
|
||||||
|
onPresetMessageConsumed?: () => void
|
||||||
|
runId?: string | null
|
||||||
|
initialDraft?: string
|
||||||
|
onDraftChange?: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatInputWithMentions({
|
||||||
|
knowledgeFiles,
|
||||||
|
recentFiles,
|
||||||
|
visibleFiles,
|
||||||
|
onSubmit,
|
||||||
|
onStop,
|
||||||
|
isProcessing,
|
||||||
|
isStopping,
|
||||||
|
isActive = true,
|
||||||
|
presetMessage,
|
||||||
|
onPresetMessageConsumed,
|
||||||
|
runId,
|
||||||
|
initialDraft,
|
||||||
|
onDraftChange,
|
||||||
|
}: ChatInputWithMentionsProps) {
|
||||||
|
return (
|
||||||
|
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||||
|
<ChatInputInner
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onStop={onStop}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
isStopping={isStopping}
|
||||||
|
isActive={isActive}
|
||||||
|
presetMessage={presetMessage}
|
||||||
|
onPresetMessageConsumed={onPresetMessageConsumed}
|
||||||
|
runId={runId}
|
||||||
|
initialDraft={initialDraft}
|
||||||
|
onDraftChange={onDraftChange}
|
||||||
|
/>
|
||||||
|
</PromptInputProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
208
apps/x/apps/renderer/src/components/search-dialog.tsx
Normal file
208
apps/x/apps/renderer/src/components/search-dialog.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { FileTextIcon, MessageSquareIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
} from '@/components/ui/command'
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce'
|
||||||
|
import { useSidebarSection, type ActiveSection } from '@/contexts/sidebar-context'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
type: 'knowledge' | 'chat'
|
||||||
|
title: string
|
||||||
|
preview: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchType = 'knowledge' | 'chat'
|
||||||
|
|
||||||
|
function activeTabToTypes(section: ActiveSection): SearchType[] {
|
||||||
|
if (section === 'knowledge') return ['knowledge']
|
||||||
|
return ['chat'] // "tasks" tab maps to chat
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSelectFile: (path: string) => void
|
||||||
|
onSelectRun: (runId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchDialog({ open, onOpenChange, onSelectFile, onSelectRun }: SearchDialogProps) {
|
||||||
|
const { activeSection } = useSidebarSection()
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([])
|
||||||
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
|
const [activeTypes, setActiveTypes] = useState<Set<SearchType>>(
|
||||||
|
() => new Set(activeTabToTypes(activeSection))
|
||||||
|
)
|
||||||
|
const debouncedQuery = useDebounce(query, 250)
|
||||||
|
|
||||||
|
// Sync filter preselection when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setActiveTypes(new Set(activeTabToTypes(activeSection)))
|
||||||
|
}
|
||||||
|
}, [open, activeSection])
|
||||||
|
|
||||||
|
const toggleType = useCallback((type: SearchType) => {
|
||||||
|
setActiveTypes(new Set([type]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debouncedQuery.trim()) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setIsSearching(true)
|
||||||
|
|
||||||
|
const types = Array.from(activeTypes) as ('knowledge' | 'chat')[]
|
||||||
|
window.ipc.invoke('search:query', { query: debouncedQuery, limit: 20, types })
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults(res.results)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Search failed:', err)
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsSearching(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [debouncedQuery, activeTypes])
|
||||||
|
|
||||||
|
// Reset state when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setQuery('')
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleSelect = useCallback((result: SearchResult) => {
|
||||||
|
onOpenChange(false)
|
||||||
|
if (result.type === 'knowledge') {
|
||||||
|
onSelectFile(result.path)
|
||||||
|
} else {
|
||||||
|
onSelectRun(result.path)
|
||||||
|
}
|
||||||
|
}, [onOpenChange, onSelectFile, onSelectRun])
|
||||||
|
|
||||||
|
const knowledgeResults = results.filter(r => r.type === 'knowledge')
|
||||||
|
const chatResults = results.filter(r => r.type === 'chat')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Search"
|
||||||
|
description="Search across knowledge and chats"
|
||||||
|
showCloseButton={false}
|
||||||
|
className="top-[20%] translate-y-0"
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search..."
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-2 border-b">
|
||||||
|
<FilterToggle
|
||||||
|
active={activeTypes.has('knowledge')}
|
||||||
|
onClick={() => toggleType('knowledge')}
|
||||||
|
icon={<FileTextIcon className="size-3" />}
|
||||||
|
label="Knowledge"
|
||||||
|
/>
|
||||||
|
<FilterToggle
|
||||||
|
active={activeTypes.has('chat')}
|
||||||
|
onClick={() => toggleType('chat')}
|
||||||
|
icon={<MessageSquareIcon className="size-3" />}
|
||||||
|
label="Chats"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CommandList>
|
||||||
|
{!query.trim() && (
|
||||||
|
<CommandEmpty>Type to search...</CommandEmpty>
|
||||||
|
)}
|
||||||
|
{query.trim() && !isSearching && results.length === 0 && (
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
)}
|
||||||
|
{knowledgeResults.length > 0 && (
|
||||||
|
<CommandGroup heading="Knowledge">
|
||||||
|
{knowledgeResults.map((result) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`knowledge-${result.path}`}
|
||||||
|
value={`knowledge-${result.title}-${result.path}`}
|
||||||
|
onSelect={() => handleSelect(result)}
|
||||||
|
>
|
||||||
|
<FileTextIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<span className="truncate font-medium">{result.title}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{chatResults.length > 0 && (
|
||||||
|
<CommandGroup heading="Chats">
|
||||||
|
{chatResults.map((result) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`chat-${result.path}`}
|
||||||
|
value={`chat-${result.title}-${result.path}`}
|
||||||
|
onSelect={() => handleSelect(result)}
|
||||||
|
>
|
||||||
|
<MessageSquareIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<span className="truncate font-medium">{result.title}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{result.preview}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterToggle({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Copy,
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|
@ -105,6 +106,7 @@ type KnowledgeActions = {
|
||||||
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
rename: (path: string, newName: string, isDir: boolean) => Promise<void>
|
||||||
remove: (path: string) => Promise<void>
|
remove: (path: string) => Promise<void>
|
||||||
copyPath: (path: string) => void
|
copyPath: (path: string) => void
|
||||||
|
onOpenInNewTab?: (path: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunListItem = {
|
type RunListItem = {
|
||||||
|
|
@ -149,6 +151,7 @@ type TasksActions = {
|
||||||
onNewChat: () => void
|
onNewChat: () => void
|
||||||
onSelectRun: (runId: string) => void
|
onSelectRun: (runId: string) => void
|
||||||
onDeleteRun: (runId: string) => void
|
onDeleteRun: (runId: string) => void
|
||||||
|
onOpenInNewTab?: (runId: string) => void
|
||||||
onSelectBackgroundTask?: (taskName: string) => void
|
onSelectBackgroundTask?: (taskName: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -981,6 +984,15 @@ function Tree({
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!isDir && actions.onOpenInNewTab && (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(item.path)}>
|
||||||
|
<ExternalLink className="mr-2 size-4" />
|
||||||
|
Open in new tab
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ContextMenuItem onClick={handleCopyPath}>
|
<ContextMenuItem onClick={handleCopyPath}>
|
||||||
<Copy className="mr-2 size-4" />
|
<Copy className="mr-2 size-4" />
|
||||||
Copy Path
|
Copy Path
|
||||||
|
|
@ -1033,12 +1045,20 @@ function Tree({
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem className="group/file-item">
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={isSelected}
|
isActive={isSelected}
|
||||||
onClick={() => onSelect(item.path, item.kind)}
|
onClick={(e) => {
|
||||||
|
if (e.metaKey && actions.onOpenInNewTab) {
|
||||||
|
actions.onOpenInNewTab(item.path)
|
||||||
|
} else {
|
||||||
|
onSelect(item.path, item.kind)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>{item.name}</span>
|
<div className="flex w-full items-center gap-1 min-w-0">
|
||||||
|
<span className="min-w-0 flex-1 truncate">{item.name}</span>
|
||||||
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
|
|
@ -1162,37 +1182,54 @@ function TasksSection({
|
||||||
</div>
|
</div>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{runs.map((run) => (
|
{runs.map((run) => (
|
||||||
<SidebarMenuItem key={run.id} className="group/chat-item">
|
<ContextMenu key={run.id}>
|
||||||
<SidebarMenuButton
|
<ContextMenuTrigger asChild>
|
||||||
isActive={currentRunId === run.id}
|
<SidebarMenuItem className="group/chat-item">
|
||||||
onClick={() => actions?.onSelectRun(run.id)}
|
<SidebarMenuButton
|
||||||
>
|
isActive={currentRunId === run.id}
|
||||||
<div className="flex w-full items-center gap-2 min-w-0">
|
onClick={(e) => {
|
||||||
{processingRunIds?.has(run.id) ? (
|
if (e.metaKey && actions?.onOpenInNewTab) {
|
||||||
<span className="size-2 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
actions.onOpenInNewTab(run.id)
|
||||||
) : null}
|
} else {
|
||||||
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
|
actions?.onSelectRun(run.id)
|
||||||
{run.createdAt ? (
|
}
|
||||||
<span className={`shrink-0 text-[10px] text-muted-foreground${processingRunIds?.has(run.id) ? '' : ' group-hover/chat-item:hidden'}`}>
|
}}
|
||||||
{formatRunTime(run.createdAt)}
|
>
|
||||||
</span>
|
<div className="flex w-full items-center gap-2 min-w-0">
|
||||||
) : null}
|
{processingRunIds?.has(run.id) ? (
|
||||||
{!processingRunIds?.has(run.id) && (
|
<span className="size-2 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
<button
|
) : null}
|
||||||
type="button"
|
<span className="min-w-0 flex-1 truncate text-sm">{run.title || '(Untitled chat)'}</span>
|
||||||
className="shrink-0 hidden group-hover/chat-item:flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
|
{run.createdAt ? (
|
||||||
onClick={(e) => {
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
e.stopPropagation()
|
{formatRunTime(run.createdAt)}
|
||||||
setPendingDeleteRunId(run.id)
|
</span>
|
||||||
}}
|
) : null}
|
||||||
aria-label="Delete chat"
|
</div>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-48">
|
||||||
|
{actions?.onOpenInNewTab && (
|
||||||
|
<ContextMenuItem onClick={() => actions.onOpenInNewTab!(run.id)}>
|
||||||
|
<ExternalLink className="mr-2 size-4" />
|
||||||
|
Open in new tab
|
||||||
|
</ContextMenuItem>
|
||||||
|
)}
|
||||||
|
{!processingRunIds?.has(run.id) && (
|
||||||
|
<>
|
||||||
|
{actions?.onOpenInNewTab && <ContextMenuSeparator />}
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setPendingDeleteRunId(run.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="mr-2 size-4" />
|
||||||
</button>
|
Delete
|
||||||
)}
|
</ContextMenuItem>
|
||||||
</div>
|
</>
|
||||||
</SidebarMenuButton>
|
)}
|
||||||
</SidebarMenuItem>
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
95
apps/x/apps/renderer/src/components/tab-bar.tsx
Normal file
95
apps/x/apps/renderer/src/components/tab-bar.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type ChatTab = {
|
||||||
|
id: string
|
||||||
|
runId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileTab = {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabBarProps<T> {
|
||||||
|
tabs: T[]
|
||||||
|
activeTabId: string
|
||||||
|
getTabTitle: (tab: T) => string
|
||||||
|
getTabId: (tab: T) => string
|
||||||
|
isProcessing?: (tab: T) => boolean
|
||||||
|
onSwitchTab: (tabId: string) => void
|
||||||
|
onCloseTab: (tabId: string) => void
|
||||||
|
layout?: 'fill' | 'scroll'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabBar<T>({
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
getTabTitle,
|
||||||
|
getTabId,
|
||||||
|
isProcessing,
|
||||||
|
onSwitchTab,
|
||||||
|
onCloseTab,
|
||||||
|
layout = 'fill',
|
||||||
|
}: TabBarProps<T>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 self-stretch min-w-0',
|
||||||
|
layout === 'scroll'
|
||||||
|
? 'overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||||
|
: 'overflow-hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tabs.map((tab, index) => {
|
||||||
|
const tabId = getTabId(tab)
|
||||||
|
const isActive = tabId === activeTabId
|
||||||
|
const processing = isProcessing?.(tab) ?? false
|
||||||
|
const title = getTabTitle(tab)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={tabId}>
|
||||||
|
{index > 0 && (
|
||||||
|
<div className="self-stretch w-px bg-border/70 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSwitchTab(tabId)}
|
||||||
|
className={cn(
|
||||||
|
'titlebar-no-drag group/tab relative flex items-center gap-1.5 px-3 self-stretch text-xs transition-colors',
|
||||||
|
layout === 'scroll' ? 'min-w-[140px] max-w-[240px]' : 'min-w-0 max-w-[220px]',
|
||||||
|
isActive
|
||||||
|
? 'bg-background text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||||
|
)}
|
||||||
|
style={layout === 'scroll' ? { flex: '0 0 auto' } : { flex: '1 1 0px' }}
|
||||||
|
>
|
||||||
|
{processing && (
|
||||||
|
<span className="size-1.5 shrink-0 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
)}
|
||||||
|
<span className="truncate flex-1 text-left">{title}</span>
|
||||||
|
{tabs.length > 1 && (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
className="shrink-0 flex items-center justify-center rounded-sm p-0.5 opacity-0 group-hover/tab:opacity-60 hover:opacity-100! hover:bg-foreground/10 transition-all"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCloseTab(tabId)
|
||||||
|
}}
|
||||||
|
aria-label="Close tab"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{/* Right edge divider after last tab to close off the section */}
|
||||||
|
{index === tabs.length - 1 && (
|
||||||
|
<div className="self-stretch w-px bg-border/70 shrink-0" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
177
apps/x/apps/renderer/src/lib/chat-conversation.ts
Normal file
177
apps/x/apps/renderer/src/lib/chat-conversation.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import type { ToolUIPart } from 'ai'
|
||||||
|
import z from 'zod'
|
||||||
|
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
input: ToolUIPart['input']
|
||||||
|
result?: ToolUIPart['output']
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'error'
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorMessage {
|
||||||
|
id: string
|
||||||
|
kind: 'error'
|
||||||
|
message: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConversationItem = ChatMessage | ToolCall | ErrorMessage
|
||||||
|
export type PermissionResponse = 'approve' | 'deny'
|
||||||
|
|
||||||
|
export type ChatTabViewState = {
|
||||||
|
runId: string | null
|
||||||
|
conversation: ConversationItem[]
|
||||||
|
currentAssistantMessage: string
|
||||||
|
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||||
|
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
||||||
|
permissionResponses: Map<string, PermissionResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createEmptyChatTabViewState = (): ChatTabViewState => ({
|
||||||
|
runId: null,
|
||||||
|
conversation: [],
|
||||||
|
currentAssistantMessage: '',
|
||||||
|
pendingAskHumanRequests: new Map(),
|
||||||
|
allPermissionRequests: new Map(),
|
||||||
|
permissionResponses: new Map(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ToolState = 'input-streaming' | 'input-available' | 'output-available' | 'output-error'
|
||||||
|
|
||||||
|
export const isChatMessage = (item: ConversationItem): item is ChatMessage => 'role' in item
|
||||||
|
export const isToolCall = (item: ConversationItem): item is ToolCall => 'name' in item
|
||||||
|
export const isErrorMessage = (item: ConversationItem): item is ErrorMessage =>
|
||||||
|
'kind' in item && item.kind === 'error'
|
||||||
|
|
||||||
|
export const toToolState = (status: ToolCall['status']): ToolState => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'input-streaming'
|
||||||
|
case 'running':
|
||||||
|
return 'input-available'
|
||||||
|
case 'completed':
|
||||||
|
return 'output-available'
|
||||||
|
case 'error':
|
||||||
|
return 'output-error'
|
||||||
|
default:
|
||||||
|
return 'input-available'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeToolInput = (
|
||||||
|
input: ToolCall['input'] | string | undefined
|
||||||
|
): ToolCall['input'] => {
|
||||||
|
if (input === undefined || input === null) return {}
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed) return {}
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed)
|
||||||
|
} catch {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeToolOutput = (
|
||||||
|
output: ToolCall['result'] | undefined,
|
||||||
|
status: ToolCall['status']
|
||||||
|
) => {
|
||||||
|
if (output === undefined || output === null) {
|
||||||
|
return status === 'completed' ? 'No output returned.' : null
|
||||||
|
}
|
||||||
|
if (output === '') return '(empty output)'
|
||||||
|
if (typeof output === 'boolean' || typeof output === 'number') return String(output)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebSearchCardResult = { title: string; url: string; description: string }
|
||||||
|
|
||||||
|
export type WebSearchCardData = {
|
||||||
|
query: string
|
||||||
|
results: WebSearchCardResult[]
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null => {
|
||||||
|
if (tool.name === 'web-search') {
|
||||||
|
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||||
|
const result = tool.result as Record<string, unknown> | undefined
|
||||||
|
return {
|
||||||
|
query: (input?.query as string) || '',
|
||||||
|
results: (result?.results as WebSearchCardResult[]) || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tool.name === 'research-search') {
|
||||||
|
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||||
|
const result = tool.result as Record<string, unknown> | undefined
|
||||||
|
const rawResults = (result?.results as Array<{
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
highlights?: string[]
|
||||||
|
text?: string
|
||||||
|
}>) || []
|
||||||
|
const mapped = rawResults.map((entry) => ({
|
||||||
|
title: entry.title,
|
||||||
|
url: entry.url,
|
||||||
|
description: entry.highlights?.[0] || (entry.text ? entry.text.slice(0, 200) : ''),
|
||||||
|
}))
|
||||||
|
const category = input?.category as string | undefined
|
||||||
|
return {
|
||||||
|
query: (input?.query as string) || '',
|
||||||
|
results: mapped,
|
||||||
|
title: category
|
||||||
|
? `${category.charAt(0).toUpperCase() + category.slice(1)} search`
|
||||||
|
: 'Researched the web',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse attached files from message content and return clean message + file paths.
|
||||||
|
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
|
||||||
|
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
|
||||||
|
const match = content.match(attachedFilesRegex)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return { message: content, files: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesXml = match[1]
|
||||||
|
const filePathRegex = /<file path="([^"]+)">/g
|
||||||
|
const files: string[] = []
|
||||||
|
let fileMatch
|
||||||
|
while ((fileMatch = filePathRegex.exec(filesXml)) !== null) {
|
||||||
|
files.push(fileMatch[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanMessage = content.replace(attachedFilesRegex, '').trim()
|
||||||
|
for (const filePath of files) {
|
||||||
|
const fileName = filePath.split('/').pop()?.replace(/\.md$/i, '') || ''
|
||||||
|
if (!fileName) continue
|
||||||
|
const mentionRegex = new RegExp(`@${fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
|
||||||
|
cleanMessage = cleanMessage.replace(mentionRegex, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: cleanMessage.trim(), files }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inferRunTitleFromMessage = (content: string): string | undefined => {
|
||||||
|
const { message } = parseAttachedFiles(content)
|
||||||
|
const normalized = message.replace(/\s+/g, ' ').trim()
|
||||||
|
if (!normalized) return undefined
|
||||||
|
return normalized.length > 100 ? normalized.substring(0, 100) : normalized
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,17 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notion-like base typography */
|
||||||
.tiptap-editor .ProseMirror {
|
.tiptap-editor .ProseMirror {
|
||||||
padding: 1rem;
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
|
"Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif,
|
||||||
|
"Segoe UI Emoji", "Segoe UI Symbol";
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgb(55, 53, 47);
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 4rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,47 +36,45 @@
|
||||||
|
|
||||||
/* Placeholder */
|
/* Placeholder */
|
||||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||||
color: var(--muted-foreground);
|
color: rgba(55, 53, 47, 0.4);
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
height: 0;
|
height: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Typography */
|
/* Paragraphs */
|
||||||
.tiptap-editor .ProseMirror {
|
.tiptap-editor .ProseMirror p {
|
||||||
font-size: 1rem;
|
margin: 1px 0;
|
||||||
line-height: 1.75;
|
padding: 3px 2px;
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror > * + * {
|
|
||||||
margin-top: 0.75em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Headings */
|
/* Headings */
|
||||||
.tiptap-editor .ProseMirror h1 {
|
.tiptap-editor .ProseMirror h1 {
|
||||||
font-size: 2em;
|
font-size: 1.875em;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.3;
|
||||||
margin-top: 1.5em;
|
margin-top: 2em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 4px;
|
||||||
|
padding: 3px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror h2 {
|
.tiptap-editor .ProseMirror h2 {
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
margin-top: 1.25em;
|
margin-top: 1.1em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 1px;
|
||||||
|
padding: 3px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror h3 {
|
.tiptap-editor .ProseMirror h3 {
|
||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.3;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 1px;
|
||||||
|
padding: 3px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror h1:first-child,
|
.tiptap-editor .ProseMirror h1:first-child,
|
||||||
|
|
@ -76,16 +83,11 @@
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Paragraphs */
|
|
||||||
.tiptap-editor .ProseMirror p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lists */
|
/* Lists */
|
||||||
.tiptap-editor .ProseMirror ul,
|
.tiptap-editor .ProseMirror ul,
|
||||||
.tiptap-editor .ProseMirror ol {
|
.tiptap-editor .ProseMirror ol {
|
||||||
padding-left: 1.5em;
|
padding-left: 1.625em;
|
||||||
margin: 0.5em 0;
|
margin: 1px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror ul {
|
.tiptap-editor .ProseMirror ul {
|
||||||
|
|
@ -97,7 +99,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror li {
|
.tiptap-editor .ProseMirror li {
|
||||||
margin: 0.25em 0;
|
padding: 3px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror li p {
|
.tiptap-editor .ProseMirror li p {
|
||||||
|
|
@ -106,50 +108,56 @@
|
||||||
|
|
||||||
/* Blockquote */
|
/* Blockquote */
|
||||||
.tiptap-editor .ProseMirror blockquote {
|
.tiptap-editor .ProseMirror blockquote {
|
||||||
border-left: 3px solid var(--border);
|
border-left: 3px solid rgb(55, 53, 47);
|
||||||
padding-left: 1em;
|
padding-left: 14px;
|
||||||
|
margin: 4px 0;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Code */
|
/* Code blocks */
|
||||||
.tiptap-editor .ProseMirror code {
|
|
||||||
background-color: var(--muted);
|
|
||||||
border-radius: 0.25em;
|
|
||||||
padding: 0.15em 0.3em;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Code Block */
|
|
||||||
.tiptap-editor .ProseMirror pre {
|
.tiptap-editor .ProseMirror pre {
|
||||||
background-color: var(--muted);
|
background: rgb(247, 246, 243);
|
||||||
border-radius: 0.5em;
|
border-radius: 4px;
|
||||||
padding: 1em;
|
padding: 2rem;
|
||||||
|
font-family: "SFMono-Regular", Menlo, Consolas, "PT Mono",
|
||||||
|
"Liberation Mono", Courier, monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 8px 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 0.75em 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror pre code {
|
.tiptap-editor .ProseMirror pre code {
|
||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.875em;
|
font-size: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Horizontal Rule */
|
/* Inline code */
|
||||||
|
.tiptap-editor .ProseMirror code {
|
||||||
|
background: rgba(135, 131, 120, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #eb5757;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
.tiptap-editor .ProseMirror hr {
|
.tiptap-editor .ProseMirror hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid rgba(55, 53, 47, 0.16);
|
||||||
margin: 1.5em 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Links */
|
/* Links */
|
||||||
.tiptap-editor .ProseMirror a {
|
.tiptap-editor .ProseMirror a {
|
||||||
color: var(--primary);
|
color: inherit;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
text-decoration-color: rgba(55, 53, 47, 0.4);
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
@ -175,14 +183,13 @@
|
||||||
.tiptap-editor .ProseMirror ul[data-type="taskList"] {
|
.tiptap-editor .ProseMirror ul[data-type="taskList"] {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
margin: 0.5em 0;
|
margin: 1px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror ul[data-type="taskList"] li {
|
.tiptap-editor .ProseMirror ul[data-type="taskList"] li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
margin: 0.25em 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror ul[data-type="taskList"] li > label {
|
.tiptap-editor .ProseMirror ul[data-type="taskList"] li > label {
|
||||||
|
|
@ -238,14 +245,16 @@
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content area centering */
|
/* Keep knowledge text width readable while margins collapse on narrow panes. */
|
||||||
.tiptap-editor .ProseMirror {
|
.tiptap-editor .ProseMirror {
|
||||||
margin-left: 20%;
|
width: 100%;
|
||||||
margin-right: 20%;
|
max-width: min(56rem, calc(100% - clamp(0.5rem, 2.5vw, 2rem)));
|
||||||
padding-left: 1rem;
|
margin-left: auto;
|
||||||
padding-right: 1rem;
|
margin-right: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-left: clamp(0.5rem, 1.5vw, 1rem);
|
||||||
|
padding-right: clamp(0.5rem, 1.5vw, 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wiki-link-anchor {
|
.wiki-link-anchor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|
@ -327,3 +336,33 @@
|
||||||
background-color: var(--primary);
|
background-color: var(--primary);
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
.dark .tiptap-editor .ProseMirror {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror a {
|
||||||
|
text-decoration-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror blockquote {
|
||||||
|
border-left-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror hr {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror pre {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror code {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ff7b72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -226,13 +226,14 @@ export class StreamStepMessageBuilder {
|
||||||
private textBuffer: string = "";
|
private textBuffer: string = "";
|
||||||
private reasoningBuffer: string = "";
|
private reasoningBuffer: string = "";
|
||||||
private providerOptions: z.infer<typeof ProviderOptions> | undefined = undefined;
|
private providerOptions: z.infer<typeof ProviderOptions> | undefined = undefined;
|
||||||
|
private reasoningProviderOptions: z.infer<typeof ProviderOptions> | undefined = undefined;
|
||||||
|
|
||||||
flushBuffers() {
|
flushBuffers() {
|
||||||
// skip reasoning
|
if (this.reasoningBuffer || this.reasoningProviderOptions) {
|
||||||
// if (this.reasoningBuffer) {
|
this.parts.push({ type: "reasoning", text: this.reasoningBuffer, providerOptions: this.reasoningProviderOptions });
|
||||||
// this.parts.push({ type: "reasoning", text: this.reasoningBuffer });
|
this.reasoningBuffer = "";
|
||||||
// this.reasoningBuffer = "";
|
this.reasoningProviderOptions = undefined;
|
||||||
// }
|
}
|
||||||
if (this.textBuffer) {
|
if (this.textBuffer) {
|
||||||
this.parts.push({ type: "text", text: this.textBuffer });
|
this.parts.push({ type: "text", text: this.textBuffer });
|
||||||
this.textBuffer = "";
|
this.textBuffer = "";
|
||||||
|
|
@ -242,7 +243,11 @@ export class StreamStepMessageBuilder {
|
||||||
ingest(event: z.infer<typeof LlmStepStreamEvent>) {
|
ingest(event: z.infer<typeof LlmStepStreamEvent>) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "reasoning-start":
|
case "reasoning-start":
|
||||||
|
break;
|
||||||
case "reasoning-end":
|
case "reasoning-end":
|
||||||
|
this.reasoningProviderOptions = event.providerOptions;
|
||||||
|
this.flushBuffers();
|
||||||
|
break;
|
||||||
case "text-start":
|
case "text-start":
|
||||||
case "text-end":
|
case "text-end":
|
||||||
this.flushBuffers();
|
this.flushBuffers();
|
||||||
|
|
@ -827,10 +832,6 @@ export async function* streamAgent({
|
||||||
tools,
|
tools,
|
||||||
signal,
|
signal,
|
||||||
)) {
|
)) {
|
||||||
// Only log significant events (not text-delta to reduce noise)
|
|
||||||
if (event.type !== 'text-delta') {
|
|
||||||
loopLogger.log('got llm-stream-event:', event.type);
|
|
||||||
}
|
|
||||||
messageBuilder.ingest(event);
|
messageBuilder.ingest(event);
|
||||||
yield* processEvent({
|
yield* processEvent({
|
||||||
runId,
|
runId,
|
||||||
|
|
@ -924,9 +925,11 @@ async function* streamLlm(
|
||||||
tools: ToolSet,
|
tools: ToolSet,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
|
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
|
||||||
|
const converted = convertFromMessages(messages);
|
||||||
|
console.log(`! SENDING payload to model: `, JSON.stringify(converted))
|
||||||
const { fullStream } = streamText({
|
const { fullStream } = streamText({
|
||||||
model,
|
model,
|
||||||
messages: convertFromMessages(messages),
|
messages: converted,
|
||||||
system: instructions,
|
system: instructions,
|
||||||
tools,
|
tools,
|
||||||
stopWhen: stepCountIs(1),
|
stopWhen: stepCountIs(1),
|
||||||
|
|
@ -935,7 +938,7 @@ async function* streamLlm(
|
||||||
for await (const event of fullStream) {
|
for await (const event of fullStream) {
|
||||||
// Check abort on every chunk for responsiveness
|
// Check abort on every chunk for responsiveness
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
// console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
|
console.log("-> \t\tstream event", JSON.stringify(event));
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "error":
|
case "error":
|
||||||
yield {
|
yield {
|
||||||
|
|
@ -968,6 +971,12 @@ async function* streamLlm(
|
||||||
providerOptions: event.providerMetadata,
|
providerOptions: event.providerMetadata,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case "text-end":
|
||||||
|
yield {
|
||||||
|
type: "text-end",
|
||||||
|
providerOptions: event.providerMetadata,
|
||||||
|
};
|
||||||
|
break;
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
yield {
|
yield {
|
||||||
type: "text-delta",
|
type: "text-delta",
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,18 @@ Always consult this catalog first so you load the right skills before taking act
|
||||||
- Summarize completed work and suggest logical next steps at the end of a task.
|
- Summarize completed work and suggest logical next steps at the end of a task.
|
||||||
- Always ask for confirmation before taking destructive actions.
|
- Always ask for confirmation before taking destructive actions.
|
||||||
|
|
||||||
|
## Output Formatting
|
||||||
|
- Use **H3** (###) for section headers in longer responses. Never use H1 or H2 — they're too large for chat.
|
||||||
|
- Use **bold** for key terms, names, or concepts the user should notice.
|
||||||
|
- Keep bullet points short (1-2 lines each). Use them for lists of 3+ items, not for general prose.
|
||||||
|
- Use numbered lists only when order matters (steps, rankings).
|
||||||
|
- For short answers (1-3 sentences), just use plain prose. No headers, no bullets.
|
||||||
|
- Use code blocks with language tags (\`\`\`python, \`\`\`json, etc.) for any code or config.
|
||||||
|
- Use inline \`code\` for file names, commands, variable names, or short technical references.
|
||||||
|
- Add a blank line between sections for breathing room.
|
||||||
|
- Never start a response with a heading. Lead with a sentence or two of context first.
|
||||||
|
- Avoid deeply nested bullets. If nesting beyond 2 levels, restructure.
|
||||||
|
|
||||||
## MCP Tool Discovery (CRITICAL)
|
## MCP Tool Discovery (CRITICAL)
|
||||||
|
|
||||||
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
|
**ALWAYS check for MCP tools BEFORE saying you can't do something.**
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@ export async function executeAction(
|
||||||
try {
|
try {
|
||||||
const client = getComposioClient();
|
const client = getComposioClient();
|
||||||
const result = await client.tools.execute(actionSlug, {
|
const result = await client.tools.execute(actionSlug, {
|
||||||
userId: connectedAccountId,
|
userId: 'rowboat-user',
|
||||||
arguments: input,
|
arguments: input,
|
||||||
connectedAccountId,
|
connectedAccountId,
|
||||||
dangerouslySkipVersionCheck: true,
|
dangerouslySkipVersionCheck: true,
|
||||||
|
|
@ -352,8 +352,8 @@ export async function executeAction(
|
||||||
console.log(`[Composio] Action completed successfully`);
|
console.log(`[Composio] Action completed successfully`);
|
||||||
return { success: true, data: result.data };
|
return { success: true, data: result.data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[Composio] Action execution failed:`, error);
|
console.error(`[Composio] Action execution failed:`, JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}), 2));
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : (typeof error === 'object' ? JSON.stringify(error) : 'Unknown error');
|
||||||
return { success: false, data: null, error: message };
|
return { success: false, data: null, error: message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
375
apps/x/packages/core/src/search/search.ts
Normal file
375
apps/x/packages/core/src/search/search.ts
Normal file
|
|
@ -0,0 +1,375 @@
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import fsp from 'fs/promises';
|
||||||
|
import readline from 'readline';
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import { WorkDir } from '../config/config.js';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
type: 'knowledge' | 'chat';
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||||
|
const RUNS_DIR = path.join(WorkDir, 'runs');
|
||||||
|
|
||||||
|
type SearchType = 'knowledge' | 'chat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search across knowledge files and chat history.
|
||||||
|
* @param types - optional filter to search only specific types (default: both)
|
||||||
|
*/
|
||||||
|
export async function search(query: string, limit = 20, types?: SearchType[]): Promise<{ results: SearchResult[] }> {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { results: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchKnowledgeEnabled = !types || types.includes('knowledge');
|
||||||
|
const searchChatsEnabled = !types || types.includes('chat');
|
||||||
|
|
||||||
|
const [knowledgeResults, chatResults] = await Promise.all([
|
||||||
|
searchKnowledgeEnabled ? searchKnowledge(trimmed, limit) : Promise.resolve([]),
|
||||||
|
searchChatsEnabled ? searchChats(trimmed, limit) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = [...knowledgeResults, ...chatResults].slice(0, limit);
|
||||||
|
return { results };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search knowledge markdown files by content and filename.
|
||||||
|
*/
|
||||||
|
async function searchKnowledge(query: string, limit: number): Promise<SearchResult[]> {
|
||||||
|
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
const seenPaths = new Set<string>();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
// Content search via grep
|
||||||
|
try {
|
||||||
|
const grepMatches = await grepFiles(query, KNOWLEDGE_DIR, '*.md');
|
||||||
|
for (const match of grepMatches) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
const relPath = path.relative(WorkDir, match.file);
|
||||||
|
if (seenPaths.has(relPath)) continue;
|
||||||
|
seenPaths.add(relPath);
|
||||||
|
|
||||||
|
const title = path.basename(match.file, '.md');
|
||||||
|
results.push({
|
||||||
|
type: 'knowledge',
|
||||||
|
title,
|
||||||
|
preview: match.line.trim().substring(0, 150),
|
||||||
|
path: relPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// grep failed (no matches or dir issue) — continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filename search — check files whose name matches the query
|
||||||
|
try {
|
||||||
|
const allFiles = await listMarkdownFiles(KNOWLEDGE_DIR);
|
||||||
|
for (const file of allFiles) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
const relPath = path.relative(WorkDir, file);
|
||||||
|
if (seenPaths.has(relPath)) continue;
|
||||||
|
|
||||||
|
const basename = path.basename(file, '.md');
|
||||||
|
if (basename.toLowerCase().includes(lowerQuery)) {
|
||||||
|
seenPaths.add(relPath);
|
||||||
|
const preview = await readFirstLines(file, 2);
|
||||||
|
results.push({
|
||||||
|
type: 'knowledge',
|
||||||
|
title: basename,
|
||||||
|
preview,
|
||||||
|
path: relPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search chat history by title and message content.
|
||||||
|
*/
|
||||||
|
async function searchChats(query: string, limit: number): Promise<SearchResult[]> {
|
||||||
|
if (!fs.existsSync(RUNS_DIR)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
// Content search via grep on JSONL files
|
||||||
|
try {
|
||||||
|
const grepMatches = await grepFiles(query, RUNS_DIR, '*.jsonl');
|
||||||
|
for (const match of grepMatches) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
const runId = path.basename(match.file, '.jsonl');
|
||||||
|
if (seenIds.has(runId)) continue;
|
||||||
|
|
||||||
|
const meta = await readRunMetadata(match.file);
|
||||||
|
if (meta.agentName !== 'copilot') {
|
||||||
|
seenIds.add(runId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenIds.add(runId);
|
||||||
|
|
||||||
|
// Extract a content preview from the matching line
|
||||||
|
let preview = '';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(match.line);
|
||||||
|
if (parsed.message?.content && typeof parsed.message.content === 'string') {
|
||||||
|
preview = parsed.message.content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '').trim().substring(0, 150);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
preview = match.line.substring(0, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
type: 'chat',
|
||||||
|
title: meta.title || runId,
|
||||||
|
preview,
|
||||||
|
path: runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// grep failed — continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title search — scan run files for matching titles
|
||||||
|
try {
|
||||||
|
const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true });
|
||||||
|
const jsonlFiles = entries
|
||||||
|
.filter(e => e.isFile() && e.name.endsWith('.jsonl'))
|
||||||
|
.map(e => e.name)
|
||||||
|
.sort()
|
||||||
|
.reverse(); // newest first
|
||||||
|
|
||||||
|
for (const name of jsonlFiles) {
|
||||||
|
if (results.length >= limit) break;
|
||||||
|
const runId = path.basename(name, '.jsonl');
|
||||||
|
if (seenIds.has(runId)) continue;
|
||||||
|
|
||||||
|
const filePath = path.join(RUNS_DIR, name);
|
||||||
|
const meta = await readRunMetadata(filePath);
|
||||||
|
if (meta.agentName !== 'copilot') {
|
||||||
|
seenIds.add(runId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (meta.title && meta.title.toLowerCase().includes(lowerQuery)) {
|
||||||
|
seenIds.add(runId);
|
||||||
|
results.push({
|
||||||
|
type: 'chat',
|
||||||
|
title: meta.title,
|
||||||
|
preview: meta.title,
|
||||||
|
path: runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use grep to find files matching a query.
|
||||||
|
*/
|
||||||
|
function grepFiles(query: string, dir: string, includeGlob: string): Promise<Array<{ file: string; line: string }>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
'grep',
|
||||||
|
['-ril', '--include=' + includeGlob, query, dir],
|
||||||
|
{ maxBuffer: 1024 * 1024 },
|
||||||
|
(error, stdout) => {
|
||||||
|
if (error) {
|
||||||
|
// Exit code 1 = no matches
|
||||||
|
if (error.code === 1) {
|
||||||
|
resolve([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = stdout.trim().split('\n').filter(Boolean);
|
||||||
|
// For each matching file, get the first matching line
|
||||||
|
const promises = files.map(file =>
|
||||||
|
getFirstMatchingLine(file, query).then(line => ({ file, line }))
|
||||||
|
);
|
||||||
|
Promise.all(promises).then(resolve).catch(reject);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the first line in a file that matches the query (case-insensitive).
|
||||||
|
*/
|
||||||
|
function getFirstMatchingLine(filePath: string, query: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let resolved = false;
|
||||||
|
const done = (value: string) => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
if (line.toLowerCase().includes(lowerQuery)) {
|
||||||
|
done(line);
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => done(''));
|
||||||
|
stream.on('error', () => done(''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunMetadata {
|
||||||
|
title: string | undefined;
|
||||||
|
agentName: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read metadata from a run JSONL file (agent name from start event, title from first user message).
|
||||||
|
*/
|
||||||
|
function readRunMetadata(filePath: string): Promise<RunMetadata> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let resolved = false;
|
||||||
|
const done = (value: RunMetadata) => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
|
let lineIndex = 0;
|
||||||
|
let agentName: string | undefined;
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
if (resolved) return;
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (lineIndex === 0) {
|
||||||
|
// Start event — extract agentName
|
||||||
|
const start = JSON.parse(trimmed);
|
||||||
|
agentName = start.agentName;
|
||||||
|
lineIndex++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = JSON.parse(trimmed);
|
||||||
|
if (event.type === 'message') {
|
||||||
|
const msg = event.message;
|
||||||
|
if (msg?.role === 'user') {
|
||||||
|
const content = msg.content;
|
||||||
|
if (typeof content === 'string' && content.trim()) {
|
||||||
|
let cleaned = content.replace(/<attached-files>[\s\S]*?<\/attached-files>/g, '');
|
||||||
|
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
||||||
|
if (cleaned) {
|
||||||
|
done({ title: cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned, agentName });
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done({ title: undefined, agentName });
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
return;
|
||||||
|
} else if (msg?.role === 'assistant') {
|
||||||
|
done({ title: undefined, agentName });
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lineIndex++;
|
||||||
|
} catch {
|
||||||
|
lineIndex++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => done({ title: undefined, agentName }));
|
||||||
|
rl.on('error', () => done({ title: undefined, agentName: undefined }));
|
||||||
|
stream.on('error', () => {
|
||||||
|
rl.close();
|
||||||
|
done({ title: undefined, agentName: undefined });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively list all .md files in a directory.
|
||||||
|
*/
|
||||||
|
async function listMarkdownFiles(dir: string): Promise<string[]> {
|
||||||
|
const results: string[] = [];
|
||||||
|
try {
|
||||||
|
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const nested = await listMarkdownFiles(fullPath);
|
||||||
|
results.push(...nested);
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the first N non-empty lines of a file for preview.
|
||||||
|
*/
|
||||||
|
async function readFirstLines(filePath: string, n: number): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed && !trimmed.startsWith('#')) {
|
||||||
|
lines.push(trimmed);
|
||||||
|
}
|
||||||
|
if (lines.length >= n) {
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => {
|
||||||
|
resolve(lines.join(' ').substring(0, 150));
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', () => {
|
||||||
|
resolve('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -396,6 +396,22 @@ const ipcSchemas = {
|
||||||
req: z.object({ path: z.string() }),
|
req: z.object({ path: z.string() }),
|
||||||
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
|
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
|
||||||
},
|
},
|
||||||
|
// Search channels
|
||||||
|
'search:query': {
|
||||||
|
req: z.object({
|
||||||
|
query: z.string(),
|
||||||
|
limit: z.number().optional(),
|
||||||
|
types: z.array(z.enum(['knowledge', 'chat'])).optional(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
results: z.array(z.object({
|
||||||
|
type: z.enum(['knowledge', 'chat']),
|
||||||
|
title: z.string(),
|
||||||
|
preview: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue