mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 00:02:38 +02:00
can set a work directory in assistant chats (#534)
This commit is contained in:
parent
d6651c4bf8
commit
a48887da61
4 changed files with 159 additions and 8 deletions
|
|
@ -685,6 +685,19 @@ 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 };
|
||||||
},
|
},
|
||||||
|
'dialog:openDirectory': async (event, args) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
const defaultPath = args.defaultPath ? resolveShellPath(args.defaultPath) : os.homedir();
|
||||||
|
const result = await dialog.showOpenDialog(win!, {
|
||||||
|
title: args.title ?? 'Choose work directory',
|
||||||
|
defaultPath,
|
||||||
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
|
});
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return { path: null };
|
||||||
|
}
|
||||||
|
return { path: result.filePaths[0] ?? null };
|
||||||
|
},
|
||||||
// Knowledge version history handlers
|
// Knowledge version history handlers
|
||||||
'knowledge:history': async (_event, args) => {
|
'knowledge:history': async (_event, args) => {
|
||||||
const commits = await versionHistory.getFileHistory(args.path);
|
const commits = await versionHistory.getFileHistory(args.path);
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ import {
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
FileText,
|
FileText,
|
||||||
FileVideo,
|
FileVideo,
|
||||||
|
FolderCog,
|
||||||
Globe,
|
Globe,
|
||||||
Headphones,
|
Headphones,
|
||||||
|
ImagePlus,
|
||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
Mic,
|
Mic,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -23,8 +25,10 @@ import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
DropdownMenuRadioItem,
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
|
|
@ -169,6 +173,7 @@ function ChatInputInner({
|
||||||
const [searchEnabled, setSearchEnabled] = useState(false)
|
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||||
|
const [workDir, setWorkDir] = useState<string | null>(null)
|
||||||
|
|
||||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -251,6 +256,55 @@ function ChatInputInner({
|
||||||
return () => window.removeEventListener('models-config-changed', handler)
|
return () => window.removeEventListener('models-config-changed', handler)
|
||||||
}, [loadModelConfig])
|
}, [loadModelConfig])
|
||||||
|
|
||||||
|
// Load currently configured work directory
|
||||||
|
const loadWorkDir = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/workdir.json' })
|
||||||
|
const parsed = JSON.parse(result.data)
|
||||||
|
const value = typeof parsed?.path === 'string' ? parsed.path.trim() : ''
|
||||||
|
setWorkDir(value || null)
|
||||||
|
} catch {
|
||||||
|
setWorkDir(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkDir()
|
||||||
|
}, [isActive, loadWorkDir])
|
||||||
|
|
||||||
|
const handleSetWorkDir = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { path: chosen } = await window.ipc.invoke('dialog:openDirectory', {
|
||||||
|
title: 'Choose work directory',
|
||||||
|
defaultPath: workDir ?? undefined,
|
||||||
|
})
|
||||||
|
if (!chosen) return
|
||||||
|
await window.ipc.invoke('workspace:writeFile', {
|
||||||
|
path: 'config/workdir.json',
|
||||||
|
data: JSON.stringify({ path: chosen }, null, 2),
|
||||||
|
})
|
||||||
|
setWorkDir(chosen)
|
||||||
|
toast.success(`Work directory set: ${chosen}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set work directory', err)
|
||||||
|
toast.error('Failed to set work directory')
|
||||||
|
}
|
||||||
|
}, [workDir])
|
||||||
|
|
||||||
|
const handleClearWorkDir = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke('workspace:writeFile', {
|
||||||
|
path: 'config/workdir.json',
|
||||||
|
data: JSON.stringify({}, null, 2),
|
||||||
|
})
|
||||||
|
setWorkDir(null)
|
||||||
|
toast.success('Work directory cleared')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to clear work directory', err)
|
||||||
|
toast.error('Failed to clear work directory')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Check search tool availability (exa or signed-in via gateway)
|
// Check search tool availability (exa or signed-in via gateway)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkSearch = async () => {
|
const checkSearch = async () => {
|
||||||
|
|
@ -484,14 +538,53 @@ function ChatInputInner({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-4 pb-3">
|
<div className="flex items-center gap-2 px-4 pb-3">
|
||||||
<button
|
<DropdownMenu>
|
||||||
type="button"
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={() => fileInputRef.current?.click()}
|
<button
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
type="button"
|
||||||
aria-label="Attach files"
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
>
|
aria-label="Add"
|
||||||
<Plus className="h-4 w-4" />
|
>
|
||||||
</button>
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="min-w-56">
|
||||||
|
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
|
||||||
|
<ImagePlus className="size-4" />
|
||||||
|
<span>Add files or photos</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => { void handleSetWorkDir() }}>
|
||||||
|
<FolderCog className="size-4" />
|
||||||
|
<span>{workDir ? 'Change work directory' : 'Set work directory'}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{workDir && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onSelect={() => { void handleClearWorkDir() }}>
|
||||||
|
<X className="size-4" />
|
||||||
|
<span>Clear work directory</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{workDir && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSetWorkDir}
|
||||||
|
className="flex h-7 max-w-[180px] shrink-0 items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<FolderCog className="h-3.5 w-3.5" />
|
||||||
|
<span className="truncate">{workDir.split('/').pop() || workDir}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
Work directory: {workDir}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{searchAvailable && (
|
{searchAvailable && (
|
||||||
searchEnabled ? (
|
searchEnabled ? (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,19 @@ import { getRaw as getInlineTaskAgentRaw } from "../knowledge/inline_task_agent.
|
||||||
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.js";
|
||||||
|
|
||||||
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
|
||||||
|
const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json');
|
||||||
|
|
||||||
|
function loadUserWorkDir(): string | null {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
|
||||||
|
const raw = fs.readFileSync(WORKDIR_CONFIG_FILE, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw) as { path?: unknown };
|
||||||
|
const value = typeof parsed.path === 'string' ? parsed.path.trim() : '';
|
||||||
|
return value || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadAgentNotesContext(): string | null {
|
function loadAgentNotesContext(): string | null {
|
||||||
const sections: string[] = [];
|
const sections: string[] = [];
|
||||||
|
|
@ -1094,6 +1107,28 @@ export async function* streamAgent({
|
||||||
if (agentNotesContext) {
|
if (agentNotesContext) {
|
||||||
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
||||||
}
|
}
|
||||||
|
const userWorkDir = loadUserWorkDir();
|
||||||
|
if (userWorkDir) {
|
||||||
|
loopLogger.log('injecting user work directory', userWorkDir);
|
||||||
|
instructionsWithDateTime += `\n\n# User Work Directory
|
||||||
|
The user has chosen the following directory as their current **work directory**:
|
||||||
|
|
||||||
|
\`${userWorkDir}\`
|
||||||
|
|
||||||
|
Treat this as the **default location** for file operations whenever the user refers to files generically:
|
||||||
|
- "list the files", "show me what's in here", "what's the latest report" — list or look in the work directory.
|
||||||
|
- "save this", "export it", "write that to a file" — write the output into the work directory unless the user names another location.
|
||||||
|
- "open the file I was just working on", "the doc from earlier" — assume the work directory first.
|
||||||
|
|
||||||
|
Use absolute paths rooted at this directory. On macOS/Linux call \`executeCommand\` with POSIX commands (\`ls\`, \`cat\`, \`cp\`, etc.) operating on \`${userWorkDir}\`. On Windows use the equivalent cmd syntax. For reading file contents use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
|
||||||
|
|
||||||
|
**Exceptions — these ALWAYS take precedence over the work directory default:**
|
||||||
|
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use the workspace tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
|
||||||
|
2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request.
|
||||||
|
3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory.
|
||||||
|
|
||||||
|
Do not announce the work directory unless it's relevant. Just use it.`;
|
||||||
|
}
|
||||||
// Always inject a Middle Pane section so the LLM has a clear, up-to-date signal
|
// Always inject a Middle Pane section so the LLM has a clear, up-to-date signal
|
||||||
// that supersedes any earlier middle-pane mention in the conversation history.
|
// that supersedes any earlier middle-pane mention in the conversation history.
|
||||||
const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`;
|
const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`;
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,16 @@ 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() }),
|
||||||
},
|
},
|
||||||
|
// Native dialog channels
|
||||||
|
'dialog:openDirectory': {
|
||||||
|
req: z.object({
|
||||||
|
defaultPath: z.string().optional(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
}),
|
||||||
|
res: z.object({
|
||||||
|
path: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
// Knowledge version history channels
|
// Knowledge version history channels
|
||||||
'knowledge:history': {
|
'knowledge:history': {
|
||||||
req: z.object({ path: RelPath }),
|
req: z.object({ path: RelPath }),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue