mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
commit
983a4c578f
32 changed files with 2049 additions and 242 deletions
|
|
@ -1,5 +1,8 @@
|
|||
import { skillCatalog } from "./skills/index.js";
|
||||
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
||||
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
||||
|
||||
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||
|
||||
export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}. You can also help the user with general tasks.
|
||||
|
||||
|
|
@ -39,6 +42,8 @@ When a user asks for ANY task that might require external capabilities (web sear
|
|||
- Use relative paths (no \${BASE_DIR} prefixes) when running commands or referencing files.
|
||||
- Keep user data safe—double-check before editing or deleting important resources.
|
||||
|
||||
${runtimeContextPrompt}
|
||||
|
||||
## Workspace access & scope
|
||||
- You have full read/write access inside \`${BASE_DIR}\` (this resolves to the user's \`~/.rowboat\` directory). Create folders, files, and agents there using builtin tools or allowed shell commands—don't wait for the user to do it manually.
|
||||
- If a user mentions a different root (e.g., \`~/.rowboatx\` or another path), clarify whether they meant the Rowboat workspace and propose the equivalent path you can act on. Only refuse if they explicitly insist on an inaccessible location.
|
||||
|
|
|
|||
69
apps/cli/src/application/assistant/runtime-context.ts
Normal file
69
apps/cli/src/application/assistant/runtime-context.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh';
|
||||
export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
|
||||
|
||||
export interface RuntimeContext {
|
||||
platform: NodeJS.Platform;
|
||||
osName: RuntimeOsName;
|
||||
shellDialect: RuntimeShellDialect;
|
||||
shellExecutable: string;
|
||||
}
|
||||
|
||||
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
|
||||
}
|
||||
|
||||
export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
|
||||
if (platform === 'win32') {
|
||||
return {
|
||||
platform,
|
||||
osName: 'Windows',
|
||||
shellDialect: 'windows-cmd',
|
||||
shellExecutable: getExecutionShell(platform),
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return {
|
||||
platform,
|
||||
osName: 'macOS',
|
||||
shellDialect: 'posix-sh',
|
||||
shellExecutable: getExecutionShell(platform),
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
return {
|
||||
platform,
|
||||
osName: 'Linux',
|
||||
shellDialect: 'posix-sh',
|
||||
shellExecutable: getExecutionShell(platform),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
platform,
|
||||
osName: 'Unknown',
|
||||
shellDialect: 'posix-sh',
|
||||
shellExecutable: getExecutionShell(platform),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeContextPrompt(runtime: RuntimeContext): string {
|
||||
if (runtime.shellDialect === 'windows-cmd') {
|
||||
return `## Runtime Platform (CRITICAL)
|
||||
- Detected platform: **${runtime.platform}**
|
||||
- Detected OS: **${runtime.osName}**
|
||||
- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax)
|
||||
- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`).
|
||||
- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`).
|
||||
- Do not assume macOS/Linux command syntax when the runtime is Windows.`;
|
||||
}
|
||||
|
||||
return `## Runtime Platform (CRITICAL)
|
||||
- Detected platform: **${runtime.platform}**
|
||||
- Detected OS: **${runtime.osName}**
|
||||
- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax)
|
||||
- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`).
|
||||
- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux).
|
||||
- Do not assume Windows command syntax when the runtime is POSIX.`;
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { exec, execSync } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js';
|
||||
import { getExecutionShell } from '../assistant/runtime-context.js';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
|
||||
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
||||
const EXECUTION_SHELL = getExecutionShell();
|
||||
|
||||
function sanitizeToken(token: string): string {
|
||||
return token.trim().replace(/^['"]+|['"]+$/g, '');
|
||||
|
|
@ -91,7 +93,7 @@ export async function executeCommand(
|
|||
cwd: options?.cwd,
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||
shell: '/bin/sh', // use sh for cross-platform compatibility
|
||||
shell: EXECUTION_SHELL,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -125,7 +127,7 @@ export function executeCommandSync(
|
|||
cwd: options?.cwd,
|
||||
timeout: options?.timeout,
|
||||
encoding: 'utf-8',
|
||||
shell: '/bin/sh',
|
||||
shell: EXECUTION_SHELL,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/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 { search } from '@x/core/dist/search/search.js';
|
||||
import { versionHistory } from '@x/core';
|
||||
|
||||
type InvokeChannels = ipc.InvokeChannels;
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
|
@ -105,6 +106,18 @@ let watcher: FSWatcher | null = null;
|
|||
const changeQueue = new Set<string>();
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Emit knowledge commit event to all renderer windows
|
||||
*/
|
||||
function emitKnowledgeCommitEvent(): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('knowledge:didCommit', {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit workspace change event to all renderer windows
|
||||
*/
|
||||
|
|
@ -283,6 +296,9 @@ export function stopServicesWatcher(): void {
|
|||
* Add new handlers here as you add channels to IPCChannels
|
||||
*/
|
||||
export function setupIpcHandlers() {
|
||||
// Forward knowledge commit events to renderer for panel refresh
|
||||
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
|
||||
|
||||
registerIpcHandlers({
|
||||
'app:getVersions': async () => {
|
||||
// args is null for this channel (no request payload)
|
||||
|
|
@ -498,6 +514,19 @@ export function setupIpcHandlers() {
|
|||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
||||
},
|
||||
// Knowledge version history handlers
|
||||
'knowledge:history': async (_event, args) => {
|
||||
const commits = await versionHistory.getFileHistory(args.path);
|
||||
return { commits };
|
||||
},
|
||||
'knowledge:fileAtCommit': async (_event, args) => {
|
||||
const content = await versionHistory.getFileAtCommit(args.path, args.oid);
|
||||
return { content };
|
||||
},
|
||||
'knowledge:restore': async (_event, args) => {
|
||||
await versionHistory.restoreFile(args.path, args.oid);
|
||||
return { ok: true };
|
||||
},
|
||||
// Search handler
|
||||
'search:query': async (_event, args) => {
|
||||
return search(args.query, args.limit, args.types);
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
|||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||
import './App.css'
|
||||
import z from 'zod';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon, HistoryIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor } from './components/markdown-editor';
|
||||
import { ChatSidebar } from './components/chat-sidebar';
|
||||
import { ChatInputWithMentions } from './components/chat-input-with-mentions';
|
||||
import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions';
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view';
|
||||
import { useDebounce } from './hooks/use-debounce';
|
||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||
|
|
@ -48,10 +49,12 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
|
|||
import { OnboardingModal } from '@/components/onboarding-modal'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
|
||||
import {
|
||||
type ChatMessage,
|
||||
type ChatTabViewState,
|
||||
type ConversationItem,
|
||||
type ToolCall,
|
||||
|
|
@ -452,6 +455,7 @@ function ContentHeader({
|
|||
|
||||
function App() {
|
||||
type ShortcutPane = 'left' | 'right'
|
||||
type MarkdownHistoryHandlers = { undo: () => boolean; redo: () => boolean }
|
||||
|
||||
// File browser state (for Knowledge section)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
|
|
@ -503,6 +507,13 @@ function App() {
|
|||
const initialContentRef = useRef<string>('')
|
||||
const renameInProgressRef = useRef(false)
|
||||
|
||||
// Version history state
|
||||
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
|
||||
const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{
|
||||
oid: string
|
||||
content: string
|
||||
} | null>(null)
|
||||
|
||||
// Chat state
|
||||
const [, setMessage] = useState<string>('')
|
||||
const [conversation, setConversation] = useState<ConversationItem[]>([])
|
||||
|
|
@ -596,6 +607,8 @@ function App() {
|
|||
// File tab state
|
||||
const [fileTabs, setFileTabs] = useState<FileTab[]>([])
|
||||
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null)
|
||||
const [editorSessionByTabId, setEditorSessionByTabId] = useState<Record<string, number>>({})
|
||||
const fileHistoryHandlersRef = useRef<Map<string, MarkdownHistoryHandlers>>(new Map())
|
||||
const fileTabIdCounterRef = useRef(0)
|
||||
const newFileTabId = () => `file-tab-${++fileTabIdCounterRef.current}`
|
||||
|
||||
|
|
@ -1067,6 +1080,14 @@ function App() {
|
|||
saveFile()
|
||||
}, [debouncedContent, setHistory])
|
||||
|
||||
// Close version history panel when switching files
|
||||
useEffect(() => {
|
||||
if (versionHistoryPath && selectedPath !== versionHistoryPath) {
|
||||
setVersionHistoryPath(null)
|
||||
setViewingHistoricalVersion(null)
|
||||
}
|
||||
}, [selectedPath, versionHistoryPath])
|
||||
|
||||
// Load runs list (all pages)
|
||||
const loadRuns = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -1168,19 +1189,41 @@ function App() {
|
|||
if (msg.role === 'user' || msg.role === 'assistant') {
|
||||
// Extract text content from message
|
||||
let textContent = ''
|
||||
let msgAttachments: ChatMessage['attachments'] = undefined
|
||||
if (typeof msg.content === 'string') {
|
||||
textContent = msg.content
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
// Extract text parts
|
||||
textContent = msg.content
|
||||
.filter((part: { type: string }) => part.type === 'text')
|
||||
.map((part: { type: string; text?: string }) => part.text || '')
|
||||
const contentParts = msg.content as Array<{
|
||||
type: string
|
||||
text?: string
|
||||
path?: string
|
||||
filename?: string
|
||||
mimeType?: string
|
||||
size?: number
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
arguments?: ToolUIPart['input']
|
||||
}>
|
||||
|
||||
textContent = contentParts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text || '')
|
||||
.join('')
|
||||
|
||||
|
||||
const attachmentParts = contentParts.filter((part) => part.type === 'attachment' && part.path)
|
||||
if (attachmentParts.length > 0) {
|
||||
msgAttachments = attachmentParts.map((part) => ({
|
||||
path: part.path!,
|
||||
filename: part.filename || part.path!.split('/').pop() || part.path!,
|
||||
mimeType: part.mimeType || 'application/octet-stream',
|
||||
size: part.size,
|
||||
}))
|
||||
}
|
||||
|
||||
// Also extract tool-call parts from assistant messages
|
||||
if (msg.role === 'assistant') {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === 'tool-call') {
|
||||
for (const part of contentParts) {
|
||||
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
|
||||
const toolCall: ToolCall = {
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
|
|
@ -1194,11 +1237,12 @@ function App() {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (textContent) {
|
||||
if (textContent || msgAttachments) {
|
||||
items.push({
|
||||
id: event.messageId,
|
||||
role: msg.role,
|
||||
content: textContent,
|
||||
attachments: msgAttachments,
|
||||
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
|
||||
})
|
||||
}
|
||||
|
|
@ -1615,20 +1659,35 @@ function App() {
|
|||
return cleanup
|
||||
}, [handleRunEvent])
|
||||
|
||||
const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => {
|
||||
const handlePromptSubmit = async (
|
||||
message: PromptInputMessage,
|
||||
mentions?: FileMention[],
|
||||
stagedAttachments: StagedAttachment[] = []
|
||||
) => {
|
||||
if (isProcessing) return
|
||||
|
||||
const { text } = message;
|
||||
const { text } = message
|
||||
const userMessage = text.trim()
|
||||
if (!userMessage) return
|
||||
const hasAttachments = stagedAttachments.length > 0
|
||||
if (!userMessage && !hasAttachments) return
|
||||
|
||||
setMessage('')
|
||||
|
||||
const userMessageId = `user-${Date.now()}`
|
||||
setConversation(prev => [...prev, {
|
||||
const displayAttachments: ChatMessage['attachments'] = hasAttachments
|
||||
? stagedAttachments.map((attachment) => ({
|
||||
path: attachment.path,
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
thumbnailUrl: attachment.thumbnailUrl,
|
||||
}))
|
||||
: undefined
|
||||
setConversation((prev) => [...prev, {
|
||||
id: userMessageId,
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
attachments: displayAttachments,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
|
||||
|
|
@ -1644,42 +1703,98 @@ function App() {
|
|||
newRunCreatedAt = run.createdAt
|
||||
setRunId(currentRunId)
|
||||
// Update active chat tab's runId to the new run
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: currentRunId } : t))
|
||||
setChatTabs((prev) => prev.map((tab) => (
|
||||
tab.id === activeChatTabId
|
||||
? { ...tab, runId: currentRunId }
|
||||
: tab
|
||||
)))
|
||||
isNewRun = true
|
||||
}
|
||||
|
||||
// Read mentioned file contents and format message with XML context
|
||||
let formattedMessage = userMessage
|
||||
if (mentions && mentions.length > 0) {
|
||||
const attachedFiles = await Promise.all(
|
||||
mentions.map(async (m) => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: m.path })
|
||||
return { path: m.path, content: result.data as string }
|
||||
} catch (err) {
|
||||
console.error('Failed to read mentioned file:', m.path, err)
|
||||
return { path: m.path, content: `[Error reading file: ${m.path}]` }
|
||||
}
|
||||
})
|
||||
)
|
||||
let titleSource = userMessage
|
||||
|
||||
if (attachedFiles.length > 0) {
|
||||
const filesXml = attachedFiles
|
||||
.map(f => `<file path="${f.path}">\n${f.content}\n</file>`)
|
||||
.join('\n')
|
||||
formattedMessage = `<attached-files>\n${filesXml}\n</attached-files>\n\n${userMessage}`
|
||||
if (hasAttachments) {
|
||||
type ContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
| {
|
||||
type: 'attachment'
|
||||
path: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
const contentParts: ContentPart[] = []
|
||||
|
||||
if (mentions && mentions.length > 0) {
|
||||
for (const mention of mentions) {
|
||||
contentParts.push({
|
||||
type: 'attachment',
|
||||
path: mention.path,
|
||||
filename: mention.displayName || mention.path.split('/').pop() || mention.path,
|
||||
mimeType: 'text/markdown',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const attachment of stagedAttachments) {
|
||||
contentParts.push({
|
||||
type: 'attachment',
|
||||
path: attachment.path,
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
})
|
||||
}
|
||||
|
||||
if (userMessage) {
|
||||
contentParts.push({ type: 'text', text: userMessage })
|
||||
} else {
|
||||
titleSource = stagedAttachments[0]?.filename ?? ''
|
||||
}
|
||||
|
||||
// Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema.
|
||||
const attachmentPayload = contentParts as unknown as string
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: currentRunId,
|
||||
message: attachmentPayload,
|
||||
})
|
||||
} else {
|
||||
// Legacy path: plain string with optional XML-formatted @mentions.
|
||||
let formattedMessage = userMessage
|
||||
if (mentions && mentions.length > 0) {
|
||||
const attachedFiles = await Promise.all(
|
||||
mentions.map(async (mention) => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: mention.path })
|
||||
return { path: mention.path, content: result.data as string }
|
||||
} catch (err) {
|
||||
console.error('Failed to read mentioned file:', mention.path, err)
|
||||
return { path: mention.path, content: `[Error reading file: ${mention.path}]` }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (attachedFiles.length > 0) {
|
||||
const filesXml = attachedFiles
|
||||
.map((file) => `<file path="${file.path}">\n${file.content}\n</file>`)
|
||||
.join('\n')
|
||||
formattedMessage = `<attached-files>\n${filesXml}\n</attached-files>\n\n${userMessage}`
|
||||
}
|
||||
}
|
||||
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: currentRunId,
|
||||
message: formattedMessage,
|
||||
})
|
||||
|
||||
titleSource = formattedMessage
|
||||
}
|
||||
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: currentRunId,
|
||||
message: formattedMessage,
|
||||
})
|
||||
|
||||
if (isNewRun) {
|
||||
const inferredTitle = inferRunTitleFromMessage(formattedMessage)
|
||||
setRuns(prev => {
|
||||
const withoutCurrent = prev.filter(run => run.id !== currentRunId)
|
||||
const inferredTitle = inferRunTitleFromMessage(titleSource)
|
||||
setRuns((prev) => {
|
||||
const withoutCurrent = prev.filter((run) => run.id !== currentRunId)
|
||||
return [{
|
||||
id: currentRunId!,
|
||||
title: inferredTitle,
|
||||
|
|
@ -2036,6 +2151,13 @@ function App() {
|
|||
}
|
||||
return next
|
||||
})
|
||||
setEditorSessionByTabId((prev) => {
|
||||
if (!(tabId in prev)) return prev
|
||||
const next = { ...prev }
|
||||
delete next[tabId]
|
||||
return next
|
||||
})
|
||||
fileHistoryHandlersRef.current.delete(tabId)
|
||||
}, [activeFileTabId, fileTabs, removeEditorCacheForPath])
|
||||
|
||||
const handleNewChatTab = useCallback(() => {
|
||||
|
|
@ -2136,6 +2258,11 @@ function App() {
|
|||
setFileTabs((prev) => prev.map((tab) => (
|
||||
tab.id === activeFileTabId ? { ...tab, path } : tab
|
||||
)))
|
||||
// Rebinds this tab to a different note path: reset editor session to clear undo history.
|
||||
setEditorSessionByTabId((prev) => ({
|
||||
...prev,
|
||||
[activeFileTabId]: (prev[activeFileTabId] ?? 0) + 1,
|
||||
}))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -2352,6 +2479,46 @@ function App() {
|
|||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Route undo/redo to the active markdown tab only (prevents cross-tab browser undo behavior).
|
||||
useEffect(() => {
|
||||
const handleHistoryKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod || e.altKey) return
|
||||
|
||||
const key = e.key.toLowerCase()
|
||||
const wantsUndo = key === 'z' && !e.shiftKey
|
||||
const wantsRedo = (key === 'z' && e.shiftKey) || (!isMac && key === 'y')
|
||||
if (!wantsUndo && !wantsRedo) return
|
||||
|
||||
if (!selectedPath || !selectedPath.endsWith('.md') || !activeFileTabId) return
|
||||
|
||||
const target = e.target as EventTarget | null
|
||||
if (target instanceof HTMLElement) {
|
||||
const inTipTapEditor = Boolean(target.closest('.tiptap-editor'))
|
||||
const inOtherTextInput = (
|
||||
target instanceof HTMLInputElement
|
||||
|| target instanceof HTMLTextAreaElement
|
||||
|| target.isContentEditable
|
||||
) && !inTipTapEditor
|
||||
if (inOtherTextInput) return
|
||||
}
|
||||
|
||||
const handlers = fileHistoryHandlersRef.current.get(activeFileTabId)
|
||||
if (!handlers) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (wantsUndo) {
|
||||
handlers.undo()
|
||||
} else {
|
||||
handlers.redo()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleHistoryKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', handleHistoryKeyDown, true)
|
||||
}, [activeFileTabId, isMac, selectedPath])
|
||||
|
||||
// Keyboard shortcuts for tab management
|
||||
useEffect(() => {
|
||||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||
|
|
@ -2794,6 +2961,18 @@ function App() {
|
|||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||
if (isChatMessage(item)) {
|
||||
if (item.role === 'user') {
|
||||
if (item.attachments && item.attachments.length > 0) {
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<MessageContent>{item.content}</MessageContent>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
|
|
@ -3050,6 +3229,31 @@ function App() {
|
|||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{selectedPath && selectedPath.startsWith('knowledge/') && selectedPath.endsWith('.md') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (versionHistoryPath) {
|
||||
setVersionHistoryPath(null)
|
||||
setViewingHistoricalVersion(null)
|
||||
} else {
|
||||
setVersionHistoryPath(selectedPath)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"titlebar-no-drag flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors self-center shrink-0",
|
||||
versionHistoryPath && "bg-accent text-foreground"
|
||||
)}
|
||||
aria-label="Version history"
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !selectedTask && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -3113,33 +3317,80 @@ function App() {
|
|||
</div>
|
||||
) : selectedPath ? (
|
||||
selectedPath.endsWith('.md') ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{openMarkdownTabs.map((tab) => {
|
||||
const isActive = activeFileTabId
|
||||
? tab.id === activeFileTabId || tab.path === selectedPath
|
||||
: tab.path === selectedPath
|
||||
const tabContent = editorContentByPath[tab.path]
|
||||
?? (isActive && editorPathRef.current === tab.path ? editorContent : '')
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'min-h-0 flex-1 flex-col overflow-hidden',
|
||||
isActive ? 'flex' : 'hidden'
|
||||
)}
|
||||
data-file-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<MarkdownEditor
|
||||
content={tabContent}
|
||||
onChange={(markdown) => handleEditorChange(tab.path, markdown)}
|
||||
placeholder="Start writing..."
|
||||
wikiLinks={wikiLinkConfig}
|
||||
onImageUpload={handleImageUpload}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex-1 min-h-0 flex flex-row overflow-hidden">
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{openMarkdownTabs.map((tab) => {
|
||||
const isActive = activeFileTabId
|
||||
? tab.id === activeFileTabId || tab.path === selectedPath
|
||||
: tab.path === selectedPath
|
||||
const isViewingHistory = viewingHistoricalVersion && isActive && versionHistoryPath === tab.path
|
||||
const tabContent = isViewingHistory
|
||||
? viewingHistoricalVersion.content
|
||||
: editorContentByPath[tab.path]
|
||||
?? (isActive && editorPathRef.current === tab.path ? editorContent : '')
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'min-h-0 flex-1 flex-col overflow-hidden',
|
||||
isActive ? 'flex' : 'hidden'
|
||||
)}
|
||||
data-file-tab-panel={tab.id}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
<MarkdownEditor
|
||||
content={tabContent}
|
||||
onChange={(markdown) => { if (!isViewingHistory) handleEditorChange(tab.path, markdown) }}
|
||||
placeholder="Start writing..."
|
||||
wikiLinks={wikiLinkConfig}
|
||||
onImageUpload={handleImageUpload}
|
||||
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
|
||||
onHistoryHandlersChange={(handlers) => {
|
||||
if (handlers) {
|
||||
fileHistoryHandlersRef.current.set(tab.id, handlers)
|
||||
} else {
|
||||
fileHistoryHandlersRef.current.delete(tab.id)
|
||||
}
|
||||
}}
|
||||
editable={!isViewingHistory}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{versionHistoryPath && (
|
||||
<VersionHistoryPanel
|
||||
path={versionHistoryPath}
|
||||
onClose={() => {
|
||||
setVersionHistoryPath(null)
|
||||
setViewingHistoricalVersion(null)
|
||||
}}
|
||||
onSelectVersion={(oid, content) => {
|
||||
if (oid === null) {
|
||||
setViewingHistoricalVersion(null)
|
||||
} else {
|
||||
setViewingHistoricalVersion({ oid, content })
|
||||
}
|
||||
}}
|
||||
onRestore={async (oid) => {
|
||||
try {
|
||||
await window.ipc.invoke('knowledge:restore', {
|
||||
path: versionHistoryPath.startsWith('knowledge/')
|
||||
? versionHistoryPath.slice('knowledge/'.length)
|
||||
: versionHistoryPath,
|
||||
oid,
|
||||
})
|
||||
// Reload file content
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: versionHistoryPath })
|
||||
handleEditorChange(versionHistoryPath, result.data)
|
||||
setViewingHistoricalVersion(null)
|
||||
setVersionHistoryPath(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to restore version:', err)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,28 @@
|
|||
import { useCallback, useEffect } from 'react'
|
||||
import { ArrowUp, LoaderIcon, Square } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ArrowUp,
|
||||
AudioLines,
|
||||
FileArchive,
|
||||
FileCode2,
|
||||
FileIcon,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
LoaderIcon,
|
||||
Plus,
|
||||
Square,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
type AttachmentIconKind,
|
||||
getAttachmentDisplayName,
|
||||
getAttachmentIconKind,
|
||||
getAttachmentToneClass,
|
||||
getAttachmentTypeLabel,
|
||||
} from '@/lib/attachment-presentation'
|
||||
import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
type FileMention,
|
||||
|
|
@ -10,9 +31,41 @@ import {
|
|||
PromptInputTextarea,
|
||||
usePromptInputController,
|
||||
} from '@/components/ai-elements/prompt-input'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export type StagedAttachment = {
|
||||
id: string
|
||||
path: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
isImage: boolean
|
||||
size: number
|
||||
thumbnailUrl?: string
|
||||
}
|
||||
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||
switch (kind) {
|
||||
case 'audio':
|
||||
return AudioLines
|
||||
case 'video':
|
||||
return FileVideo
|
||||
case 'spreadsheet':
|
||||
return FileSpreadsheet
|
||||
case 'archive':
|
||||
return FileArchive
|
||||
case 'code':
|
||||
return FileCode2
|
||||
case 'text':
|
||||
return FileText
|
||||
default:
|
||||
return FileIcon
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatInputInnerProps {
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||
onStop?: () => void
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
|
|
@ -38,7 +91,10 @@ function ChatInputInner({
|
|||
}: ChatInputInnerProps) {
|
||||
const controller = usePromptInputController()
|
||||
const message = controller.textInput.value
|
||||
const canSubmit = Boolean(message.trim()) && !isProcessing
|
||||
const [attachments, setAttachments] = useState<StagedAttachment[]>([])
|
||||
const [focusNonce, setFocusNonce] = useState(0)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing
|
||||
|
||||
// Restore the tab draft when this input mounts.
|
||||
useEffect(() => {
|
||||
|
|
@ -59,12 +115,48 @@ function ChatInputInner({
|
|||
}
|
||||
}, [presetMessage, controller.textInput, onPresetMessageConsumed])
|
||||
|
||||
const addFiles = useCallback(async (paths: string[]) => {
|
||||
const newAttachments: StagedAttachment[] = []
|
||||
for (const filePath of paths) {
|
||||
try {
|
||||
const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath })
|
||||
if (result.size > MAX_ATTACHMENT_SIZE) {
|
||||
toast.error(`File too large: ${getFileDisplayName(filePath)} (max 10MB)`)
|
||||
continue
|
||||
}
|
||||
const mime = result.mimeType || getMimeFromExtension(getExtension(filePath))
|
||||
const image = isImageMime(mime)
|
||||
newAttachments.push({
|
||||
id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
path: filePath,
|
||||
filename: getFileDisplayName(filePath),
|
||||
mimeType: mime,
|
||||
isImage: image,
|
||||
size: result.size,
|
||||
thumbnailUrl: image ? `data:${mime};base64,${result.data}` : undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to read file:', filePath, err)
|
||||
toast.error(`Failed to read: ${getFileDisplayName(filePath)}`)
|
||||
}
|
||||
}
|
||||
if (newAttachments.length > 0) {
|
||||
setAttachments((prev) => [...prev, ...newAttachments])
|
||||
setFocusNonce((value) => value + 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeAttachment = useCallback((id: string) => {
|
||||
setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!canSubmit) return
|
||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions)
|
||||
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments)
|
||||
controller.textInput.clear()
|
||||
controller.mentions.clearMentions()
|
||||
}, [canSubmit, message, onSubmit, controller])
|
||||
setAttachments([])
|
||||
}, [attachments, canSubmit, controller, message, onSubmit])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
|
|
@ -88,11 +180,9 @@ function ChatInputInner({
|
|||
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
||||
const paths = Array.from(e.dataTransfer.files)
|
||||
.map((file) => window.electronUtils?.getPathForFile(file))
|
||||
.filter(Boolean)
|
||||
.filter(Boolean) as string[]
|
||||
if (paths.length > 0) {
|
||||
const currentText = controller.textInput.value
|
||||
const pathText = paths.join(' ')
|
||||
controller.textInput.setInput(currentText ? `${currentText} ${pathText}` : pathText)
|
||||
void addFiles(paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -103,50 +193,119 @@ function ChatInputInner({
|
|||
document.removeEventListener('dragover', onDragOver)
|
||||
document.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [controller, isActive])
|
||||
}, [addFiles, 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 className="rounded-lg border border-border bg-background shadow-none">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||
{attachments.map((attachment) => {
|
||||
const attachmentType = getAttachmentTypeLabel(attachment)
|
||||
const attachmentName = getAttachmentDisplayName(attachment)
|
||||
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
|
||||
|
||||
return (
|
||||
<span
|
||||
key={attachment.id}
|
||||
className="group relative inline-flex min-w-[230px] max-w-[320px] items-center gap-2 rounded-xl border border-border/50 bg-muted/80 px-2.5 py-2"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg',
|
||||
attachment.isImage && attachment.thumbnailUrl
|
||||
? 'bg-muted'
|
||||
: getAttachmentToneClass(attachmentType)
|
||||
)}
|
||||
>
|
||||
{attachment.isImage && attachment.thumbnailUrl ? (
|
||||
<img src={attachment.thumbnailUrl} alt="" className="size-full object-cover" />
|
||||
) : (
|
||||
<Icon className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
|
||||
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(attachment.id)}
|
||||
className="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full border border-border/70 bg-background/70 text-muted-foreground opacity-0 transition-[opacity,color] duration-150 hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-4 py-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
const paths = Array.from(files)
|
||||
.map((file) => window.electronUtils?.getPathForFile(file))
|
||||
.filter(Boolean) as string[]
|
||||
if (paths.length > 0) {
|
||||
void addFiles(paths)
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
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="Attach files"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<PromptInputTextarea
|
||||
placeholder="Type your message..."
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={isActive}
|
||||
focusTrigger={isActive ? `${runId ?? 'new'}:${focusNonce}` : 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -155,7 +314,7 @@ export interface ChatInputWithMentionsProps {
|
|||
knowledgeFiles: string[]
|
||||
recentFiles: string[]
|
||||
visibleFiles: string[]
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||
onStop?: () => void
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
|
|
|
|||
137
apps/x/apps/renderer/src/components/chat-message-attachments.tsx
Normal file
137
apps/x/apps/renderer/src/components/chat-message-attachments.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import {
|
||||
AudioLines,
|
||||
FileArchive,
|
||||
FileCode2,
|
||||
FileIcon,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileVideo,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { MessageAttachment } from '@/lib/chat-conversation'
|
||||
import {
|
||||
type AttachmentIconKind,
|
||||
getAttachmentDisplayName,
|
||||
getAttachmentIconKind,
|
||||
getAttachmentToneClass,
|
||||
getAttachmentTypeLabel,
|
||||
} from '@/lib/attachment-presentation'
|
||||
import { isImageMime, toFileUrl } from '@/lib/file-utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||
switch (kind) {
|
||||
case 'audio':
|
||||
return AudioLines
|
||||
case 'video':
|
||||
return FileVideo
|
||||
case 'spreadsheet':
|
||||
return FileSpreadsheet
|
||||
case 'archive':
|
||||
return FileArchive
|
||||
case 'code':
|
||||
return FileCode2
|
||||
case 'text':
|
||||
return FileText
|
||||
default:
|
||||
return FileIcon
|
||||
}
|
||||
}
|
||||
|
||||
function ImageAttachmentPreview({ attachment }: { attachment: MessageAttachment }) {
|
||||
const fallbackFileUrl = useMemo(() => toFileUrl(attachment.path), [attachment.path])
|
||||
const [src, setSrc] = useState(attachment.thumbnailUrl || fallbackFileUrl)
|
||||
const [triedBase64, setTriedBase64] = useState(Boolean(attachment.thumbnailUrl))
|
||||
|
||||
useEffect(() => {
|
||||
const nextSrc = attachment.thumbnailUrl || fallbackFileUrl
|
||||
setSrc(nextSrc)
|
||||
setTriedBase64(Boolean(attachment.thumbnailUrl))
|
||||
}, [attachment.thumbnailUrl, fallbackFileUrl])
|
||||
|
||||
const loadBase64 = useMemo(
|
||||
() => async () => {
|
||||
try {
|
||||
const result = await window.ipc.invoke('shell:readFileBase64', { path: attachment.path })
|
||||
const mimeType = result.mimeType || attachment.mimeType || 'image/*'
|
||||
setSrc(`data:${mimeType};base64,${result.data}`)
|
||||
} catch {
|
||||
// Keep current src; fallback rendering (broken image icon) is better than crashing.
|
||||
}
|
||||
},
|
||||
[attachment.mimeType, attachment.path]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (attachment.thumbnailUrl || triedBase64) return
|
||||
setTriedBase64(true)
|
||||
void loadBase64()
|
||||
}, [attachment.thumbnailUrl, loadBase64, triedBase64])
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="Image attachment"
|
||||
className="h-44 w-auto max-w-[300px] rounded-2xl border border-border/70 bg-muted object-cover"
|
||||
onError={() => {
|
||||
if (triedBase64) return
|
||||
setTriedBase64(true)
|
||||
void loadBase64()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChatMessageAttachmentsProps {
|
||||
attachments: MessageAttachment[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) {
|
||||
if (attachments.length === 0) return null
|
||||
|
||||
const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType))
|
||||
const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType))
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-end gap-2', className)}>
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{imageAttachments.map((attachment, index) => (
|
||||
<ImageAttachmentPreview key={`${attachment.path}-${index}`} attachment={attachment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{fileAttachments.map((attachment, index) => {
|
||||
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
|
||||
const attachmentName = getAttachmentDisplayName(attachment)
|
||||
const attachmentType = getAttachmentTypeLabel(attachment)
|
||||
return (
|
||||
<span
|
||||
key={`${attachment.path}-${index}`}
|
||||
className="inline-flex min-w-[240px] max-w-[440px] items-center gap-3 rounded-2xl border border-border/50 bg-muted/75 px-3 py-2.5 text-sm text-foreground"
|
||||
title={attachmentName}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-12 shrink-0 items-center justify-center rounded-xl',
|
||||
getAttachmentToneClass(attachmentType)
|
||||
)}
|
||||
>
|
||||
<Icon className="size-6 shrink-0" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
|
||||
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -25,7 +25,8 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme
|
|||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions } from '@/components/chat-input-with-mentions'
|
||||
import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions'
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import {
|
||||
type ChatTabViewState,
|
||||
|
|
@ -89,7 +90,7 @@ interface ChatSidebarProps {
|
|||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
onStop?: () => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
|
||||
knowledgeFiles?: string[]
|
||||
recentFiles?: string[]
|
||||
visibleFiles?: string[]
|
||||
|
|
@ -256,6 +257,18 @@ export function ChatSidebar({
|
|||
const renderConversationItem = (item: ConversationItem, tabId: string) => {
|
||||
if (isChatMessage(item)) {
|
||||
if (item.role === 'user') {
|
||||
if (item.attachments && item.attachments.length > 0) {
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<MessageContent>{item.content}</MessageContent>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
|
|
|
|||
|
|
@ -195,6 +195,9 @@ interface MarkdownEditorProps {
|
|||
placeholder?: string
|
||||
wikiLinks?: WikiLinkConfig
|
||||
onImageUpload?: (file: File) => Promise<string | null>
|
||||
editorSessionKey?: number
|
||||
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -278,6 +281,9 @@ export function MarkdownEditor({
|
|||
placeholder = 'Start writing...',
|
||||
wikiLinks,
|
||||
onImageUpload,
|
||||
editorSessionKey = 0,
|
||||
onHistoryHandlersChange,
|
||||
editable = true,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -299,6 +305,7 @@ export function MarkdownEditor({
|
|||
)
|
||||
|
||||
const editor = useEditor({
|
||||
editable,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
|
|
@ -400,7 +407,7 @@ export function MarkdownEditor({
|
|||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
}, [editorSessionKey])
|
||||
|
||||
const orderedFiles = useMemo(() => {
|
||||
if (!wikiLinks) return []
|
||||
|
|
@ -489,12 +496,37 @@ export function MarkdownEditor({
|
|||
isInternalUpdate.current = true
|
||||
// Pre-process to preserve blank lines
|
||||
const preprocessed = preprocessMarkdown(content)
|
||||
editor.commands.setContent(preprocessed)
|
||||
// Treat tab-open content as baseline: do not add hydration to undo history.
|
||||
editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()
|
||||
isInternalUpdate.current = false
|
||||
}
|
||||
}
|
||||
}, [editor, content])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onHistoryHandlersChange) return
|
||||
if (!editor) {
|
||||
onHistoryHandlersChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
onHistoryHandlersChange({
|
||||
undo: () => editor.chain().focus().undo().run(),
|
||||
redo: () => editor.chain().focus().redo().run(),
|
||||
})
|
||||
|
||||
return () => {
|
||||
onHistoryHandlersChange(null)
|
||||
}
|
||||
}, [editor, onHistoryHandlersChange])
|
||||
|
||||
// Update editable state when prop changes
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setEditable(editable)
|
||||
}
|
||||
}, [editor, editable])
|
||||
|
||||
// Force re-render decorations when selection highlight changes
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
|
|
|
|||
|
|
@ -57,14 +57,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
|
|
@ -87,7 +87,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [slackConnecting, setSlackConnecting] = useState(false)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates },
|
||||
|
|
@ -287,6 +287,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const apiKey = activeConfig.apiKey.trim() || undefined
|
||||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const model = activeConfig.model.trim()
|
||||
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
|
|
@ -294,6 +295,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
baseURL,
|
||||
},
|
||||
model,
|
||||
knowledgeGraphModel,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -657,39 +659,74 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading models...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.model}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.model}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.model}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.model}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.knowledgeGraphModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showApiKey && (
|
||||
|
|
|
|||
|
|
@ -167,14 +167,14 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
|||
|
||||
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "" },
|
||||
})
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
|
|
@ -199,7 +199,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
|
||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], ...updates },
|
||||
|
|
@ -229,6 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
apiKey: parsed.provider.apiKey || "",
|
||||
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
||||
model: parsed.model,
|
||||
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
@ -296,6 +297,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
baseURL: activeConfig.baseURL.trim() || undefined,
|
||||
},
|
||||
model: activeConfig.model.trim(),
|
||||
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -362,40 +364,75 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Model selection */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading models...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.model}
|
||||
onChange={(e) => updateConfig(provider, { model: e.target.value })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.model}
|
||||
onValueChange={(value) => updateConfig(provider, { model: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
{/* Model selection - side by side */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.model}
|
||||
onChange={(e) => updateConfig(provider, { model: e.target.value })}
|
||||
placeholder="Enter model"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.model}
|
||||
onValueChange={(value) => updateConfig(provider, { model: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.knowledgeGraphModel}
|
||||
onChange={(e) => updateConfig(provider, { knowledgeGraphModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.knowledgeGraphModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
|
|
|
|||
177
apps/x/apps/renderer/src/components/version-history-panel.tsx
Normal file
177
apps/x/apps/renderer/src/components/version-history-panel.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { X, Clock } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface CommitInfo {
|
||||
oid: string
|
||||
message: string
|
||||
timestamp: number
|
||||
author: string
|
||||
}
|
||||
|
||||
interface VersionHistoryPanelProps {
|
||||
path: string // knowledge-relative file path (e.g. "knowledge/People/John.md")
|
||||
onClose: () => void
|
||||
onSelectVersion: (oid: string | null, content: string) => void // null = current
|
||||
onRestore: (oid: string) => void
|
||||
}
|
||||
|
||||
function formatTimestamp(unixSeconds: number): { date: string; time: string } {
|
||||
const d = new Date(unixSeconds * 1000)
|
||||
const date = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
|
||||
const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
|
||||
return { date, time }
|
||||
}
|
||||
|
||||
export function VersionHistoryPanel({
|
||||
path,
|
||||
onClose,
|
||||
onSelectVersion,
|
||||
onRestore,
|
||||
}: VersionHistoryPanelProps) {
|
||||
const [commits, setCommits] = useState<CommitInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedOid, setSelectedOid] = useState<string | null>(null) // null = current/latest
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Strip "knowledge/" prefix for IPC calls
|
||||
const relPath = path.startsWith('knowledge/') ? path.slice('knowledge/'.length) : path
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.ipc.invoke('knowledge:history', { path: relPath })
|
||||
setCommits(result.commits)
|
||||
} catch (err) {
|
||||
console.error('Failed to load version history:', err)
|
||||
setError('Failed to load history')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [relPath])
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory()
|
||||
}, [loadHistory])
|
||||
|
||||
// Refresh when new commits land
|
||||
useEffect(() => {
|
||||
return window.ipc.on('knowledge:didCommit', () => {
|
||||
loadHistory()
|
||||
})
|
||||
}, [loadHistory])
|
||||
|
||||
const handleSelectCommit = useCallback(async (oid: string, isLatest: boolean) => {
|
||||
if (isLatest) {
|
||||
setSelectedOid(null)
|
||||
// Read current file content
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path })
|
||||
onSelectVersion(null, result.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to read current file:', err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedOid(oid)
|
||||
try {
|
||||
const result = await window.ipc.invoke('knowledge:fileAtCommit', { path: relPath, oid })
|
||||
onSelectVersion(oid, result.content)
|
||||
} catch (err) {
|
||||
console.error('Failed to load file at commit:', err)
|
||||
}
|
||||
}, [path, relPath, onSelectVersion])
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (selectedOid) {
|
||||
onRestore(selectedOid)
|
||||
}
|
||||
}, [selectedOid, onRestore])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-[280px] shrink-0 border-l border-border bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
|
||||
<span className="text-sm font-medium text-foreground">Version history</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
aria-label="Close version history"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Commit list */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
{error}
|
||||
</div>
|
||||
) : commits.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
No history available
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{commits.map((commit, index) => {
|
||||
const isLatest = index === 0
|
||||
const isSelected = isLatest ? selectedOid === null : selectedOid === commit.oid
|
||||
const { date, time } = formatTimestamp(commit.timestamp)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={commit.oid}
|
||||
type="button"
|
||||
onClick={() => handleSelectCommit(commit.oid, isLatest)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 transition-colors',
|
||||
isSelected
|
||||
? 'bg-accent'
|
||||
: 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isLatest && (
|
||||
<Clock className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-sm text-foreground">
|
||||
{date} · {time}
|
||||
</span>
|
||||
</div>
|
||||
{isLatest && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Current version
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{selectedOid && (
|
||||
<div className="shrink-0 border-t border-border p-3">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleRestore}
|
||||
>
|
||||
Restore this version
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
apps/x/apps/renderer/src/lib/attachment-presentation.ts
Normal file
107
apps/x/apps/renderer/src/lib/attachment-presentation.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { getExtension } from '@/lib/file-utils'
|
||||
|
||||
export type AttachmentLike = {
|
||||
filename?: string
|
||||
path: string
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export type AttachmentIconKind =
|
||||
| 'audio'
|
||||
| 'video'
|
||||
| 'spreadsheet'
|
||||
| 'archive'
|
||||
| 'code'
|
||||
| 'text'
|
||||
| 'file'
|
||||
|
||||
const ARCHIVE_EXTENSIONS = new Set([
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
|
||||
])
|
||||
|
||||
const SPREADSHEET_EXTENSIONS = new Set([
|
||||
'csv', 'tsv', 'xls', 'xlsx',
|
||||
])
|
||||
|
||||
const CODE_EXTENSIONS = new Set([
|
||||
'js', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'toml', 'xml',
|
||||
'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'cpp', 'h', 'hpp',
|
||||
'cs', 'php', 'swift', 'sh', 'sql', 'html', 'css', 'scss', 'md',
|
||||
])
|
||||
|
||||
export function getAttachmentDisplayName(attachment: AttachmentLike): string {
|
||||
if (attachment.filename) return attachment.filename
|
||||
const fromPath = attachment.path.split(/[\\/]/).pop()
|
||||
return fromPath || attachment.path
|
||||
}
|
||||
|
||||
export function getAttachmentTypeLabel(attachment: AttachmentLike): string {
|
||||
const ext = getExtension(getAttachmentDisplayName(attachment))
|
||||
if (ext) return ext.toUpperCase()
|
||||
|
||||
const mediaType = attachment.mimeType.toLowerCase()
|
||||
if (mediaType.startsWith('audio/')) return 'AUDIO'
|
||||
if (mediaType.startsWith('video/')) return 'VIDEO'
|
||||
if (mediaType.startsWith('text/')) return 'TEXT'
|
||||
if (mediaType.startsWith('image/')) return 'IMAGE'
|
||||
|
||||
const [, subtypeRaw = 'file'] = mediaType.split('/')
|
||||
const subtype = subtypeRaw.split(';')[0].split('+').pop() || 'file'
|
||||
const cleaned = subtype.replace(/[^a-z0-9]/gi, '')
|
||||
return cleaned ? cleaned.toUpperCase() : 'FILE'
|
||||
}
|
||||
|
||||
export function getAttachmentIconKind(attachment: AttachmentLike): AttachmentIconKind {
|
||||
const mediaType = attachment.mimeType.toLowerCase()
|
||||
const ext = getExtension(attachment.filename || attachment.path)
|
||||
|
||||
if (mediaType.startsWith('audio/')) return 'audio'
|
||||
if (mediaType.startsWith('video/')) return 'video'
|
||||
if (mediaType.includes('spreadsheet') || SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet'
|
||||
if (mediaType.includes('zip') || mediaType.includes('compressed') || ARCHIVE_EXTENSIONS.has(ext)) return 'archive'
|
||||
if (
|
||||
mediaType.includes('json')
|
||||
|| mediaType.includes('javascript')
|
||||
|| mediaType.includes('typescript')
|
||||
|| mediaType.includes('xml')
|
||||
|| CODE_EXTENSIONS.has(ext)
|
||||
) {
|
||||
return 'code'
|
||||
}
|
||||
if (mediaType.startsWith('text/') || mediaType.includes('pdf') || mediaType.includes('document')) {
|
||||
return 'text'
|
||||
}
|
||||
|
||||
return 'file'
|
||||
}
|
||||
|
||||
export function getAttachmentToneClass(typeLabel: string): string {
|
||||
switch (typeLabel) {
|
||||
case 'PDF':
|
||||
return 'bg-red-500 text-white'
|
||||
case 'CSV':
|
||||
case 'XLS':
|
||||
case 'XLSX':
|
||||
case 'TSV':
|
||||
return 'bg-emerald-500 text-white'
|
||||
case 'ZIP':
|
||||
case 'RAR':
|
||||
case '7Z':
|
||||
case 'TAR':
|
||||
case 'GZ':
|
||||
return 'bg-amber-500 text-white'
|
||||
case 'MP3':
|
||||
case 'WAV':
|
||||
case 'M4A':
|
||||
case 'FLAC':
|
||||
case 'AAC':
|
||||
return 'bg-fuchsia-500 text-white'
|
||||
case 'MP4':
|
||||
case 'MOV':
|
||||
case 'AVI':
|
||||
case 'WEBM':
|
||||
return 'bg-violet-500 text-white'
|
||||
default:
|
||||
return 'bg-primary/85 text-primary-foreground'
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,19 @@ import type { ToolUIPart } from 'ai'
|
|||
import z from 'zod'
|
||||
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
|
||||
|
||||
export interface MessageAttachment {
|
||||
path: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size?: number
|
||||
thumbnailUrl?: string
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
attachments?: MessageAttachment[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
|
|
|
|||
61
apps/x/apps/renderer/src/lib/file-utils.ts
Normal file
61
apps/x/apps/renderer/src/lib/file-utils.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
const IMAGE_MIMES = new Set([
|
||||
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
||||
'image/svg+xml', 'image/bmp', 'image/tiff', 'image/ico', 'image/avif',
|
||||
]);
|
||||
|
||||
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
// Images
|
||||
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
||||
webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/ico',
|
||||
avif: 'image/avif', tiff: 'image/tiff',
|
||||
// Text / code
|
||||
txt: 'text/plain', md: 'text/markdown', html: 'text/html', css: 'text/css',
|
||||
csv: 'text/csv', xml: 'text/xml',
|
||||
js: 'text/javascript', ts: 'text/typescript', jsx: 'text/javascript',
|
||||
tsx: 'text/typescript', json: 'application/json', yaml: 'text/yaml',
|
||||
yml: 'text/yaml', toml: 'text/toml',
|
||||
py: 'text/x-python', rb: 'text/x-ruby', rs: 'text/x-rust',
|
||||
go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',
|
||||
h: 'text/x-c', hpp: 'text/x-c++', sh: 'text/x-shellscript',
|
||||
// Documents
|
||||
pdf: 'application/pdf',
|
||||
// Archives
|
||||
zip: 'application/zip',
|
||||
};
|
||||
|
||||
export function isImageMime(mimeType: string): boolean {
|
||||
return IMAGE_MIMES.has(mimeType) || mimeType.startsWith('image/');
|
||||
}
|
||||
|
||||
export function getMimeFromExtension(ext: string): string {
|
||||
const normalized = ext.toLowerCase().replace(/^\./, '');
|
||||
return EXTENSION_TO_MIME[normalized] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function getFileDisplayName(filePath: string): string {
|
||||
return filePath.split('/').pop() || filePath;
|
||||
}
|
||||
|
||||
export function getExtension(filePath: string): string {
|
||||
const name = filePath.split('/').pop() || '';
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function toFileUrl(filePath: string): string {
|
||||
if (!filePath) return filePath;
|
||||
if (
|
||||
filePath.startsWith('data:') ||
|
||||
filePath.startsWith('file://') ||
|
||||
filePath.startsWith('http://') ||
|
||||
filePath.startsWith('https://')
|
||||
) {
|
||||
return filePath;
|
||||
}
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const encoded = encodeURI(normalized);
|
||||
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||
return `file:///${encoded}`;
|
||||
}
|
||||
return `file://${encoded}`;
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
"cron-parser": "^5.5.0",
|
||||
"glob": "^13.0.0",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"isomorphic-git": "^1.29.0",
|
||||
"googleapis": "^169.0.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"node-html-markdown": "^2.0.0",
|
||||
|
|
|
|||
|
|
@ -357,6 +357,12 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
return await repo.fetch(id);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
||||
const result: ModelMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
|
|
@ -400,11 +406,37 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
|
|||
});
|
||||
break;
|
||||
case "user":
|
||||
result.push({
|
||||
role: "user",
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
if (typeof msg.content === 'string') {
|
||||
// Legacy string — pass through unchanged
|
||||
result.push({
|
||||
role: "user",
|
||||
content: msg.content,
|
||||
providerOptions,
|
||||
});
|
||||
} else {
|
||||
// New content parts array — collapse to text for LLM
|
||||
const textSegments: string[] = [];
|
||||
const attachmentLines: string[] = [];
|
||||
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "attachment") {
|
||||
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
|
||||
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`);
|
||||
} else {
|
||||
textSegments.push(part.text);
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentLines.length > 0) {
|
||||
textSegments.unshift("User has attached the following files:", ...attachmentLines, "");
|
||||
}
|
||||
|
||||
result.push({
|
||||
role: "user",
|
||||
content: textSegments.join("\n"),
|
||||
providerOptions,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "tool":
|
||||
result.push({
|
||||
|
|
@ -674,7 +706,12 @@ export async function* streamAgent({
|
|||
|
||||
// set up provider + model
|
||||
const provider = createProvider(modelConfig.provider);
|
||||
const model = provider.languageModel(modelConfig.model);
|
||||
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep"];
|
||||
const modelId = (knowledgeGraphAgents.includes(state.agentName!) && modelConfig.knowledgeGraphModel)
|
||||
? modelConfig.knowledgeGraphModel
|
||||
: modelConfig.model;
|
||||
const model = provider.languageModel(modelId);
|
||||
logger.log(`using model: ${modelId}`);
|
||||
|
||||
let loopCounter = 0;
|
||||
while (true) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { skillCatalog } from "./skills/index.js";
|
||||
import { WorkDir as BASE_DIR } from "../../config/config.js";
|
||||
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
||||
|
||||
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||
|
||||
export const CopilotInstructions = `You are Rowboat Copilot - an AI assistant for everyday work. You help users with anything they want. For instance, drafting emails, prepping for meetings, tracking projects, or answering questions - with memory that compounds from their emails, calendar, and notes. Everything runs locally on the user's machine. The nerdy coworker who remembers everything.
|
||||
|
||||
|
|
@ -150,18 +153,22 @@ When a user asks for ANY task that might require external capabilities (web sear
|
|||
- Use relative paths (no \`\${BASE_DIR}\` prefixes) when running commands or referencing files.
|
||||
- Keep user data safe—double-check before editing or deleting important resources.
|
||||
|
||||
${runtimeContextPrompt}
|
||||
|
||||
## Workspace Access & Scope
|
||||
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
|
||||
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
|
||||
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
|
||||
|
||||
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
|
||||
- The user is on **macOS**. Use macOS paths and commands (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` command).
|
||||
- Follow the detected runtime platform above for shell syntax and filesystem path style.
|
||||
- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS).
|
||||
- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\<name>\\Desktop\`).
|
||||
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
|
||||
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
|
||||
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
|
||||
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
|
||||
- NEVER ask what OS the user is on - they are on macOS.
|
||||
- NEVER ask what OS the user is on if runtime platform is already available.
|
||||
- Load the \`organize-files\` skill for guidance on file organization tasks.
|
||||
|
||||
## Builtin Tools vs Shell Commands
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
export type RuntimeShellDialect = 'windows-cmd' | 'posix-sh';
|
||||
export type RuntimeOsName = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
|
||||
|
||||
export interface RuntimeContext {
|
||||
platform: NodeJS.Platform;
|
||||
osName: RuntimeOsName;
|
||||
shellDialect: RuntimeShellDialect;
|
||||
shellExecutable: string;
|
||||
}
|
||||
|
||||
export function getExecutionShell(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh';
|
||||
}
|
||||
|
||||
export function getRuntimeContext(platform: NodeJS.Platform = process.platform): RuntimeContext {
|
||||
if (platform === 'win32') {
|
||||
return {
|
||||
platform,
|
||||
osName: 'Windows',
|
||||
shellDialect: 'windows-cmd',
|
||||
shellExecutable: getExecutionShell(platform),
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return {
|
||||
platform,
|
||||
osName: 'macOS',
|
||||
shellDialect: 'posix-sh',
|
||||
shellExecutable: getExecutionShell(platform),
|
||||
};
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
return {
|
||||
platform,
|
||||
osName: 'Linux',
|
||||
shellDialect: 'posix-sh',
|
||||
shellExecutable: getExecutionShell(platform),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
platform,
|
||||
osName: 'Unknown',
|
||||
shellDialect: 'posix-sh',
|
||||
shellExecutable: getExecutionShell(platform),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeContextPrompt(runtime: RuntimeContext): string {
|
||||
if (runtime.shellDialect === 'windows-cmd') {
|
||||
return `## Runtime Platform (CRITICAL)
|
||||
- Detected platform: **${runtime.platform}**
|
||||
- Detected OS: **${runtime.osName}**
|
||||
- Shell used by executeCommand: **${runtime.shellExecutable}** (Windows Command Prompt / cmd syntax)
|
||||
- Use Windows command syntax for executeCommand (for example: \`dir\`, \`type\`, \`copy\`, \`move\`, \`del\`, \`rmdir\`).
|
||||
- Use Windows-style absolute paths when outside workspace (for example: \`C:\\Users\\...\`).
|
||||
- Do not assume macOS/Linux command syntax when the runtime is Windows.`;
|
||||
}
|
||||
|
||||
return `## Runtime Platform (CRITICAL)
|
||||
- Detected platform: **${runtime.platform}**
|
||||
- Detected OS: **${runtime.osName}**
|
||||
- Shell used by executeCommand: **${runtime.shellExecutable}** (POSIX sh syntax)
|
||||
- Use POSIX command syntax for executeCommand (for example: \`ls\`, \`cat\`, \`cp\`, \`mv\`, \`rm\`).
|
||||
- Use POSIX paths when outside workspace (for example: \`~/Desktop\`, \`/Users/.../\` on macOS, \`/home/.../\` on Linux).
|
||||
- Do not assume Windows command syntax when the runtime is POSIX.`;
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { exec, execSync, spawn, ChildProcess } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getSecurityAllowList } from '../../config/security.js';
|
||||
import { getExecutionShell } from '../assistant/runtime-context.js';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/;
|
||||
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/;
|
||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']);
|
||||
const EXECUTION_SHELL = getExecutionShell();
|
||||
|
||||
function sanitizeToken(token: string): string {
|
||||
return token.trim().replace(/^['"()]+|['"()]+$/g, '');
|
||||
|
|
@ -85,7 +87,7 @@ export async function executeCommand(
|
|||
cwd: options?.cwd,
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
|
||||
shell: '/bin/sh', // use sh for cross-platform compatibility
|
||||
shell: EXECUTION_SHELL,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -145,7 +147,7 @@ export function executeCommandAbortable(
|
|||
// Check if already aborted before spawning
|
||||
if (options?.signal?.aborted) {
|
||||
// Return a dummy process and a resolved result
|
||||
const dummyProc = spawn('true', { shell: true });
|
||||
const dummyProc = spawn(process.execPath, ['-e', 'process.exit(0)']);
|
||||
dummyProc.kill();
|
||||
return {
|
||||
process: dummyProc,
|
||||
|
|
@ -159,7 +161,7 @@ export function executeCommandAbortable(
|
|||
}
|
||||
|
||||
const proc = spawn(command, [], {
|
||||
shell: '/bin/sh',
|
||||
shell: EXECUTION_SHELL,
|
||||
cwd: options?.cwd,
|
||||
detached: process.platform !== 'win32', // Create process group on Unix
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
|
|
@ -273,7 +275,7 @@ export function executeCommandSync(
|
|||
cwd: options?.cwd,
|
||||
timeout: options?.timeout,
|
||||
encoding: 'utf-8',
|
||||
shell: '/bin/sh',
|
||||
shell: EXECUTION_SHELL,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
|
||||
import { UserMessageContent } from "@x/shared/dist/message.js";
|
||||
import z from "zod";
|
||||
|
||||
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
|
||||
|
||||
type EnqueuedMessage = {
|
||||
messageId: string;
|
||||
message: string;
|
||||
message: UserMessageContentType;
|
||||
};
|
||||
|
||||
export interface IMessageQueue {
|
||||
enqueue(runId: string, message: string): Promise<string>;
|
||||
enqueue(runId: string, message: UserMessageContentType): Promise<string>;
|
||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||
}
|
||||
|
||||
|
|
@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async enqueue(runId: string, message: string): Promise<string> {
|
||||
async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
|
||||
if (!this.store[runId]) {
|
||||
this.store[runId] = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,4 +91,9 @@ function ensureWelcomeFile() {
|
|||
|
||||
ensureDirs();
|
||||
ensureDefaultConfigs();
|
||||
ensureWelcomeFile();
|
||||
ensureWelcomeFile();
|
||||
|
||||
// Initialize version history repo (async, fire-and-forget on startup)
|
||||
import('../knowledge/version_history.js').then(m => m.initRepo()).catch(err => {
|
||||
console.error('[VersionHistory] Failed to init repo:', err);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,4 +5,7 @@ export * as workspace from './workspace/workspace.js';
|
|||
export * as watcher from './workspace/watcher.js';
|
||||
|
||||
// Config initialization
|
||||
export { initConfigs } from './config/initConfigs.js';
|
||||
export { initConfigs } from './config/initConfigs.js';
|
||||
|
||||
// Knowledge version history
|
||||
export * as versionHistory from './knowledge/version_history.js';
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from './graph_state.js';
|
||||
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
|
||||
import { limitEventItems } from './limit_event_items.js';
|
||||
import { commitAll } from './version_history.js';
|
||||
|
||||
/**
|
||||
* Build obsidian-style knowledge graph by running topic extraction
|
||||
|
|
@ -320,6 +321,13 @@ async function buildGraphWithFiles(
|
|||
// Save state after each successful batch
|
||||
// This ensures partial progress is saved even if later batches fail
|
||||
saveState(state);
|
||||
|
||||
// Commit knowledge changes to version history
|
||||
try {
|
||||
await commitAll('Knowledge update', 'Rowboat');
|
||||
} catch (err) {
|
||||
console.error(`[GraphBuilder] Failed to commit version history:`, err);
|
||||
}
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error(`Error processing batch ${batchNumber}:`, error);
|
||||
|
|
@ -467,6 +475,13 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
|
|||
|
||||
// Save state after each batch
|
||||
saveState(state);
|
||||
|
||||
// Commit knowledge changes to version history
|
||||
try {
|
||||
await commitAll('Knowledge update', 'Rowboat');
|
||||
} catch (err) {
|
||||
console.error(`[GraphBuilder] Failed to commit version history:`, err);
|
||||
}
|
||||
} catch (error) {
|
||||
hadError = true;
|
||||
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);
|
||||
|
|
|
|||
243
apps/x/packages/core/src/knowledge/version_history.ts
Normal file
243
apps/x/packages/core/src/knowledge/version_history.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import git from 'isomorphic-git';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
|
||||
// Simple promise-based mutex to serialize commits
|
||||
let commitLock: Promise<void> = Promise.resolve();
|
||||
|
||||
// Commit listeners for notifying other layers (e.g. renderer refresh)
|
||||
type CommitListener = () => void;
|
||||
const commitListeners: CommitListener[] = [];
|
||||
|
||||
export function onCommit(listener: CommitListener): () => void {
|
||||
commitListeners.push(listener);
|
||||
return () => {
|
||||
const idx = commitListeners.indexOf(listener);
|
||||
if (idx >= 0) commitListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a git repo in the knowledge directory if one doesn't exist.
|
||||
* Stages all existing .md files and makes an initial commit.
|
||||
*/
|
||||
export async function initRepo(): Promise<void> {
|
||||
const gitDir = path.join(KNOWLEDGE_DIR, '.git');
|
||||
if (fs.existsSync(gitDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure knowledge dir exists
|
||||
if (!fs.existsSync(KNOWLEDGE_DIR)) {
|
||||
fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
await git.init({ fs, dir: KNOWLEDGE_DIR });
|
||||
|
||||
// Stage all existing .md files
|
||||
const files = getAllMdFiles(KNOWLEDGE_DIR, '');
|
||||
for (const file of files) {
|
||||
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath: file });
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
await git.commit({
|
||||
fs,
|
||||
dir: KNOWLEDGE_DIR,
|
||||
message: 'Initial snapshot',
|
||||
author: { name: 'Rowboat', email: 'local' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all .md files relative to the knowledge dir.
|
||||
*/
|
||||
function getAllMdFiles(baseDir: string, relDir: string): string[] {
|
||||
const results: string[] = [];
|
||||
const absDir = relDir ? path.join(baseDir, relDir) : baseDir;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(absDir);
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry === '.git' || entry.startsWith('.')) continue;
|
||||
const fullPath = path.join(absDir, entry);
|
||||
const relPath = relDir ? `${relDir}/${entry}` : entry;
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
results.push(...getAllMdFiles(baseDir, relPath));
|
||||
} else if (entry.endsWith('.md')) {
|
||||
results.push(relPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage all changes to .md files and commit. No-op if nothing changed.
|
||||
* Serialized via a promise lock to prevent concurrent git index corruption.
|
||||
*/
|
||||
export async function commitAll(message: string, authorName: string): Promise<void> {
|
||||
const prev = commitLock;
|
||||
let resolve: () => void;
|
||||
commitLock = new Promise(r => { resolve = r; });
|
||||
|
||||
await prev;
|
||||
try {
|
||||
await commitAllInner(message, authorName);
|
||||
} finally {
|
||||
resolve!();
|
||||
}
|
||||
}
|
||||
|
||||
async function commitAllInner(message: string, authorName: string): Promise<void> {
|
||||
const matrix = await git.statusMatrix({ fs, dir: KNOWLEDGE_DIR });
|
||||
|
||||
let hasChanges = false;
|
||||
for (const [filepath, head, workdir, stage] of matrix) {
|
||||
// Skip non-md files
|
||||
if (!filepath.endsWith('.md')) continue;
|
||||
|
||||
// [filepath, HEAD, WORKDIR, STAGE]
|
||||
// Unchanged: [f, 1, 1, 1]
|
||||
if (head === 1 && workdir === 1 && stage === 1) continue;
|
||||
|
||||
hasChanges = true;
|
||||
|
||||
if (workdir === 0) {
|
||||
// File deleted from workdir
|
||||
await git.remove({ fs, dir: KNOWLEDGE_DIR, filepath });
|
||||
} else {
|
||||
// File added or modified
|
||||
await git.add({ fs, dir: KNOWLEDGE_DIR, filepath });
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChanges) return;
|
||||
|
||||
await git.commit({
|
||||
fs,
|
||||
dir: KNOWLEDGE_DIR,
|
||||
message,
|
||||
author: { name: authorName, email: 'local' },
|
||||
});
|
||||
|
||||
for (const listener of commitListeners) {
|
||||
try { listener(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommitInfo {
|
||||
oid: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
author: string;
|
||||
}
|
||||
|
||||
const MAX_FILE_HISTORY = 50;
|
||||
|
||||
/**
|
||||
* Get commit history for a specific file.
|
||||
* Returns commits where the file content changed, most recent first.
|
||||
* Capped at MAX_FILE_HISTORY entries.
|
||||
*/
|
||||
export async function getFileHistory(knowledgeRelPath: string): Promise<CommitInfo[]> {
|
||||
// Normalize path separators for git (always forward slashes)
|
||||
const filepath = knowledgeRelPath.replace(/\\/g, '/');
|
||||
|
||||
let commits: Awaited<ReturnType<typeof git.log>>;
|
||||
try {
|
||||
commits = await git.log({ fs, dir: KNOWLEDGE_DIR });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (commits.length === 0) return [];
|
||||
|
||||
const result: CommitInfo[] = [];
|
||||
|
||||
// Walk through commits and check if file changed between consecutive commits
|
||||
for (let i = 0; i < commits.length; i++) {
|
||||
if (result.length >= MAX_FILE_HISTORY) break;
|
||||
|
||||
const commit = commits[i]!;
|
||||
const parentCommit = commits[i + 1]; // undefined for the first (oldest) commit
|
||||
|
||||
const currentOid = await getBlobOidAtCommit(commit.oid, filepath);
|
||||
const parentOid = parentCommit
|
||||
? await getBlobOidAtCommit(parentCommit.oid, filepath)
|
||||
: null;
|
||||
|
||||
// Include this commit if:
|
||||
// - The file existed and changed from parent
|
||||
// - The file was added (parentOid is null but currentOid exists)
|
||||
// - The file was deleted (currentOid is null but parentOid exists)
|
||||
if (currentOid !== parentOid) {
|
||||
result.push({
|
||||
oid: commit.oid,
|
||||
message: commit.commit.message.trim(),
|
||||
timestamp: commit.commit.author.timestamp,
|
||||
author: commit.commit.author.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blob OID for a file at a specific commit, or null if not found.
|
||||
*/
|
||||
async function getBlobOidAtCommit(commitOid: string, filepath: string): Promise<string | null> {
|
||||
try {
|
||||
const result = await git.readBlob({
|
||||
fs,
|
||||
dir: KNOWLEDGE_DIR,
|
||||
oid: commitOid,
|
||||
filepath,
|
||||
});
|
||||
// Compute a content hash from the blob to compare
|
||||
return result.oid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content at a specific commit.
|
||||
*/
|
||||
export async function getFileAtCommit(knowledgeRelPath: string, oid: string): Promise<string> {
|
||||
const filepath = knowledgeRelPath.replace(/\\/g, '/');
|
||||
const result = await git.readBlob({
|
||||
fs,
|
||||
dir: KNOWLEDGE_DIR,
|
||||
oid,
|
||||
filepath,
|
||||
});
|
||||
return Buffer.from(result.blob).toString('utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a file to its content at a given commit, then commit the restoration.
|
||||
*/
|
||||
export async function restoreFile(knowledgeRelPath: string, oid: string): Promise<void> {
|
||||
const content = await getFileAtCommit(knowledgeRelPath, oid);
|
||||
const absPath = path.join(KNOWLEDGE_DIR, knowledgeRelPath);
|
||||
|
||||
// Ensure parent directory exists
|
||||
const dir = path.dirname(absPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(absPath, content, 'utf-8');
|
||||
|
||||
const filename = path.basename(knowledgeRelPath);
|
||||
await commitAll(`Restored ${filename}`, 'You');
|
||||
}
|
||||
|
|
@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
const messageEvent = event as z.infer<typeof MessageEvent>;
|
||||
if (messageEvent.message.role === 'user') {
|
||||
const content = messageEvent.message.content;
|
||||
if (typeof content === 'string' && content.trim()) {
|
||||
// Clean attached-files XML and @mentions, then truncate to 100 chars
|
||||
const cleaned = cleanContentForTitle(content);
|
||||
if (!cleaned) continue; // Skip if only attached files/mentions
|
||||
let textContent: string | undefined;
|
||||
if (typeof content === 'string') {
|
||||
textContent = content;
|
||||
} else {
|
||||
textContent = content
|
||||
.filter(p => p.type === 'text')
|
||||
.map(p => p.text)
|
||||
.join('');
|
||||
}
|
||||
if (textContent && textContent.trim()) {
|
||||
const cleaned = cleanContentForTitle(textContent);
|
||||
if (!cleaned) continue;
|
||||
return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
||||
}
|
||||
}
|
||||
|
|
@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
if (msg.role === 'user') {
|
||||
// Found first user message - use as title
|
||||
const content = msg.content;
|
||||
if (typeof content === 'string' && content.trim()) {
|
||||
// Clean attached-files XML and @mentions, then truncate
|
||||
const cleaned = cleanContentForTitle(content);
|
||||
let textContent: string | undefined;
|
||||
if (typeof content === 'string') {
|
||||
textContent = content;
|
||||
} else {
|
||||
textContent = content
|
||||
.filter(p => p.type === 'text')
|
||||
.map(p => p.text)
|
||||
.join('');
|
||||
}
|
||||
if (textContent && textContent.trim()) {
|
||||
const cleaned = cleanContentForTitle(textContent);
|
||||
if (cleaned) {
|
||||
title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import z from "zod";
|
||||
import container from "../di/container.js";
|
||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { IMessageQueue, UserMessageContentType } from "../application/lib/message-queue.js";
|
||||
import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js";
|
||||
import { IRunsRepo } from "./repo.js";
|
||||
import { IAgentRuntime } from "../agents/runtime.js";
|
||||
|
|
@ -19,7 +19,7 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
|
|||
return run;
|
||||
}
|
||||
|
||||
export async function createMessage(runId: string, message: string): Promise<string> {
|
||||
export async function createMessage(runId: string, message: UserMessageContentType): Promise<string> {
|
||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||
const id = await queue.enqueue(runId, message);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { z } from 'zod';
|
|||
import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
|
||||
import { commitAll } from '../knowledge/version_history.js';
|
||||
|
||||
// ============================================================================
|
||||
// Path Utilities
|
||||
|
|
@ -218,6 +219,21 @@ export async function readFile(
|
|||
};
|
||||
}
|
||||
|
||||
// Debounced commit for knowledge file edits
|
||||
let knowledgeCommitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleKnowledgeCommit(filename: string): void {
|
||||
if (knowledgeCommitTimer) {
|
||||
clearTimeout(knowledgeCommitTimer);
|
||||
}
|
||||
knowledgeCommitTimer = setTimeout(() => {
|
||||
knowledgeCommitTimer = null;
|
||||
commitAll(`Edit ${filename}`, 'You').catch(err => {
|
||||
console.error('[VersionHistory] Failed to commit after edit:', err);
|
||||
});
|
||||
}, 3 * 60 * 1000);
|
||||
}
|
||||
|
||||
export async function writeFile(
|
||||
relPath: string,
|
||||
data: string,
|
||||
|
|
@ -266,6 +282,11 @@ export async function writeFile(
|
|||
const stat = statToSchema(stats, 'file');
|
||||
const etag = computeEtag(stats.size, stats.mtimeMs);
|
||||
|
||||
// Schedule a debounced version history commit for knowledge files
|
||||
if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) {
|
||||
scheduleKnowledgeCommit(path.basename(relPath));
|
||||
}
|
||||
|
||||
return {
|
||||
path: relPath,
|
||||
stat,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js';
|
|||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||
import { ServiceEvent } from './service-events.js';
|
||||
import { UserMessageContent } from './message.js';
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Validation Schemas (Single Source of Truth)
|
||||
|
|
@ -128,7 +129,7 @@ const ipcSchemas = {
|
|||
'runs:createMessage': {
|
||||
req: z.object({
|
||||
runId: z.string(),
|
||||
message: z.string(),
|
||||
message: UserMessageContent,
|
||||
}),
|
||||
res: z.object({
|
||||
messageId: z.string(),
|
||||
|
|
@ -396,6 +397,30 @@ const ipcSchemas = {
|
|||
req: z.object({ path: z.string() }),
|
||||
res: z.object({ data: z.string(), mimeType: z.string(), size: z.number() }),
|
||||
},
|
||||
// Knowledge version history channels
|
||||
'knowledge:history': {
|
||||
req: z.object({ path: RelPath }),
|
||||
res: z.object({
|
||||
commits: z.array(z.object({
|
||||
oid: z.string(),
|
||||
message: z.string(),
|
||||
timestamp: z.number(),
|
||||
author: z.string(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
'knowledge:fileAtCommit': {
|
||||
req: z.object({ path: RelPath, oid: z.string() }),
|
||||
res: z.object({ content: z.string() }),
|
||||
},
|
||||
'knowledge:restore': {
|
||||
req: z.object({ path: RelPath, oid: z.string() }),
|
||||
res: z.object({ ok: z.literal(true) }),
|
||||
},
|
||||
'knowledge:didCommit': {
|
||||
req: z.object({}),
|
||||
res: z.null(),
|
||||
},
|
||||
// Search channels
|
||||
'search:query': {
|
||||
req: z.object({
|
||||
|
|
|
|||
|
|
@ -28,9 +28,30 @@ export const AssistantContentPart = z.union([
|
|||
ToolCallPart,
|
||||
]);
|
||||
|
||||
// A piece of user-typed text within a content array
|
||||
export const UserTextPart = z.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
// An attachment within a content array
|
||||
export const UserAttachmentPart = z.object({
|
||||
type: z.literal("attachment"),
|
||||
path: z.string(), // absolute file path
|
||||
filename: z.string(), // display name ("photo.png")
|
||||
mimeType: z.string(), // MIME type ("image/png", "text/plain")
|
||||
size: z.number().optional(), // bytes
|
||||
});
|
||||
|
||||
// Any single part of a user message (text or attachment)
|
||||
export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
|
||||
|
||||
// Named type for user message content — used everywhere instead of repeating the union
|
||||
export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
|
||||
|
||||
export const UserMessage = z.object({
|
||||
role: z.literal("user"),
|
||||
content: z.string(),
|
||||
content: UserMessageContent,
|
||||
providerOptions: ProviderOptions.optional(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,4 +10,5 @@ export const LlmProvider = z.object({
|
|||
export const LlmModelConfig = z.object({
|
||||
provider: LlmProvider,
|
||||
model: z.string(),
|
||||
knowledgeGraphModel: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
216
apps/x/pnpm-lock.yaml
generated
216
apps/x/pnpm-lock.yaml
generated
|
|
@ -359,6 +359,9 @@ importers:
|
|||
googleapis:
|
||||
specifier: ^169.0.0
|
||||
version: 169.0.0
|
||||
isomorphic-git:
|
||||
specifier: ^1.29.0
|
||||
version: 1.37.2
|
||||
mammoth:
|
||||
specifier: ^1.11.0
|
||||
version: 1.11.0
|
||||
|
|
@ -3501,6 +3504,10 @@ packages:
|
|||
abbrev@1.1.1:
|
||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
abs-svg-path@0.1.1:
|
||||
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
||||
|
||||
|
|
@ -3627,6 +3634,9 @@ packages:
|
|||
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
async-lock@1.4.1:
|
||||
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
|
||||
|
||||
async@1.5.2:
|
||||
resolution: {integrity: sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==}
|
||||
|
||||
|
|
@ -3641,6 +3651,10 @@ packages:
|
|||
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
awilix@12.0.5:
|
||||
resolution: {integrity: sha512-Qf/V/hRo6DK0FoBKJ9QiObasRxHAhcNi0mV6kW2JMawxS3zq6Un+VsZmVAZDUfvB+MjTEiJ2tUJUl4cr0JiUAw==}
|
||||
engines: {node: '>=16.3.0'}
|
||||
|
|
@ -3742,6 +3756,9 @@ packages:
|
|||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -3762,6 +3779,10 @@ packages:
|
|||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bind@1.0.8:
|
||||
resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -3825,6 +3846,9 @@ packages:
|
|||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
clean-git-ref@2.0.1:
|
||||
resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==}
|
||||
|
||||
clean-stack@2.2.0:
|
||||
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -4256,6 +4280,9 @@ packages:
|
|||
dfa@1.2.0:
|
||||
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
|
||||
|
||||
diff3@0.0.3:
|
||||
resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==}
|
||||
|
||||
dingbat-to-unicode@1.0.1:
|
||||
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
|
||||
|
||||
|
|
@ -4496,6 +4523,10 @@ packages:
|
|||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
eventemitter3@5.0.1:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
|
||||
|
|
@ -4640,6 +4671,10 @@ packages:
|
|||
fontkit@2.0.4:
|
||||
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
|
||||
|
||||
for-each@0.3.5:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
|
@ -5104,6 +5139,10 @@ packages:
|
|||
is-arrayish@0.3.4:
|
||||
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
||||
|
||||
is-callable@1.2.7:
|
||||
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -5170,6 +5209,10 @@ packages:
|
|||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-typed-array@1.1.15:
|
||||
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-unicode-supported@0.1.0:
|
||||
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -5184,6 +5227,9 @@ packages:
|
|||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
isarray@2.0.5:
|
||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||
|
||||
isbinaryfile@4.0.10:
|
||||
resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
|
|
@ -5191,6 +5237,11 @@ packages:
|
|||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
isomorphic-git@1.37.2:
|
||||
resolution: {integrity: sha512-HCQBBKmXIMPdHgYGstSBNp6MNmVcMQBbUqJF8xfywFmlpNseO4KKex59YlXqNxhRxmv3fUZwvNWvMyOdc1VvhA==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
|
|
@ -5762,6 +5813,9 @@ packages:
|
|||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minimisted@2.0.1:
|
||||
resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==}
|
||||
|
||||
minipass-collect@1.0.2:
|
||||
resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -6169,6 +6223,10 @@ packages:
|
|||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
pify@4.0.1:
|
||||
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pkce-challenge@5.0.1:
|
||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
|
|
@ -6186,6 +6244,10 @@ packages:
|
|||
points-on-path@0.2.1:
|
||||
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
|
|
@ -6220,6 +6282,10 @@ packages:
|
|||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
process@0.11.10:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
progress@2.0.3:
|
||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
|
@ -6434,6 +6500,10 @@ packages:
|
|||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readable-stream@4.7.0:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
|
@ -6649,12 +6719,21 @@ packages:
|
|||
server-destroy@1.0.1:
|
||||
resolution: {integrity: sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
sha.js@2.4.12:
|
||||
resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
|
||||
engines: {node: '>= 0.10'}
|
||||
hasBin: true
|
||||
|
||||
shebang-command@1.2.0:
|
||||
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -6701,6 +6780,12 @@ packages:
|
|||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-concat@1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||
|
||||
|
|
@ -6928,6 +7013,10 @@ packages:
|
|||
resolution: {integrity: sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
to-buffer@1.2.2:
|
||||
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
to-data-view@1.1.0:
|
||||
resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==}
|
||||
|
||||
|
|
@ -6998,6 +7087,10 @@ packages:
|
|||
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
typescript-eslint@8.50.1:
|
||||
resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
|
@ -7272,6 +7365,10 @@ packages:
|
|||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
which-typed-array@1.1.20:
|
||||
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
which@1.3.1:
|
||||
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
|
||||
hasBin: true
|
||||
|
|
@ -11316,6 +11413,10 @@ snapshots:
|
|||
|
||||
abbrev@1.1.1: {}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
abs-svg-path@0.1.1: {}
|
||||
|
||||
accepts@2.0.0:
|
||||
|
|
@ -11440,6 +11541,8 @@ snapshots:
|
|||
|
||||
arrify@2.0.1: {}
|
||||
|
||||
async-lock@1.4.1: {}
|
||||
|
||||
async@1.5.2:
|
||||
optional: true
|
||||
|
||||
|
|
@ -11449,6 +11552,10 @@ snapshots:
|
|||
|
||||
author-regex@1.0.0: {}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
awilix@12.0.5:
|
||||
dependencies:
|
||||
camel-case: 4.1.2
|
||||
|
|
@ -11568,6 +11675,11 @@ snapshots:
|
|||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
buffer@6.0.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
cacache@16.1.3:
|
||||
|
|
@ -11610,6 +11722,13 @@ snapshots:
|
|||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
call-bind@1.0.8:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
get-intrinsic: 1.3.0
|
||||
set-function-length: 1.2.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
|
|
@ -11672,6 +11791,8 @@ snapshots:
|
|||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
clean-git-ref@2.0.1: {}
|
||||
|
||||
clean-stack@2.2.0: {}
|
||||
|
||||
cli-cursor@3.1.0:
|
||||
|
|
@ -12061,7 +12182,6 @@ snapshots:
|
|||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
optional: true
|
||||
|
||||
define-properties@1.2.1:
|
||||
dependencies:
|
||||
|
|
@ -12095,6 +12215,8 @@ snapshots:
|
|||
|
||||
dfa@1.2.0: {}
|
||||
|
||||
diff3@0.0.3: {}
|
||||
|
||||
dingbat-to-unicode@1.0.1: {}
|
||||
|
||||
dir-compare@4.2.0:
|
||||
|
|
@ -12451,6 +12573,8 @@ snapshots:
|
|||
|
||||
etag@1.8.1: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
|
@ -12638,6 +12762,10 @@ snapshots:
|
|||
unicode-properties: 1.4.1
|
||||
unicode-trie: 2.0.0
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
|
|
@ -12983,7 +13111,6 @@ snapshots:
|
|||
has-property-descriptors@1.0.2:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
optional: true
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
|
|
@ -13251,6 +13378,8 @@ snapshots:
|
|||
|
||||
is-arrayish@0.3.4: {}
|
||||
|
||||
is-callable@1.2.7: {}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
dependencies:
|
||||
hasown: 2.0.2
|
||||
|
|
@ -13300,6 +13429,10 @@ snapshots:
|
|||
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-typed-array@1.1.15:
|
||||
dependencies:
|
||||
which-typed-array: 1.1.20
|
||||
|
||||
is-unicode-supported@0.1.0: {}
|
||||
|
||||
is-url@1.2.4: {}
|
||||
|
|
@ -13310,10 +13443,26 @@ snapshots:
|
|||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isarray@2.0.5: {}
|
||||
|
||||
isbinaryfile@4.0.10: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isomorphic-git@1.37.2:
|
||||
dependencies:
|
||||
async-lock: 1.4.1
|
||||
clean-git-ref: 2.0.1
|
||||
crc-32: 1.2.2
|
||||
diff3: 0.0.3
|
||||
ignore: 5.3.2
|
||||
minimisted: 2.0.1
|
||||
pako: 1.0.11
|
||||
pify: 4.0.1
|
||||
readable-stream: 4.7.0
|
||||
sha.js: 2.4.12
|
||||
simple-get: 4.0.1
|
||||
|
||||
jackspeak@3.4.3:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
|
|
@ -14139,6 +14288,10 @@ snapshots:
|
|||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minimisted@2.0.1:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
minipass-collect@1.0.2:
|
||||
dependencies:
|
||||
minipass: 3.3.6
|
||||
|
|
@ -14506,6 +14659,8 @@ snapshots:
|
|||
|
||||
pify@2.3.0: {}
|
||||
|
||||
pify@4.0.1: {}
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
|
|
@ -14527,6 +14682,8 @@ snapshots:
|
|||
path-data-parser: 0.1.0
|
||||
points-on-curve: 0.2.0
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.5.6:
|
||||
|
|
@ -14565,6 +14722,8 @@ snapshots:
|
|||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
progress@2.0.3: {}
|
||||
|
||||
promise-inflight@1.0.1: {}
|
||||
|
|
@ -14887,6 +15046,14 @@ snapshots:
|
|||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@4.7.0:
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
buffer: 6.0.3
|
||||
events: 3.3.0
|
||||
process: 0.11.10
|
||||
string_decoder: 1.3.0
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
rechoir@0.8.0:
|
||||
|
|
@ -15175,10 +15342,25 @@ snapshots:
|
|||
|
||||
server-destroy@1.0.1: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
get-intrinsic: 1.3.0
|
||||
gopd: 1.2.0
|
||||
has-property-descriptors: 1.0.2
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
sha.js@2.4.12:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
safe-buffer: 5.2.1
|
||||
to-buffer: 1.2.2
|
||||
|
||||
shebang-command@1.2.0:
|
||||
dependencies:
|
||||
shebang-regex: 1.0.0
|
||||
|
|
@ -15236,6 +15418,14 @@ snapshots:
|
|||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
|
||||
simple-get@4.0.1:
|
||||
dependencies:
|
||||
decompress-response: 6.0.0
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.3.4
|
||||
|
|
@ -15493,6 +15683,12 @@ snapshots:
|
|||
unorm: 1.6.0
|
||||
optional: true
|
||||
|
||||
to-buffer@1.2.2:
|
||||
dependencies:
|
||||
isarray: 2.0.5
|
||||
safe-buffer: 5.2.1
|
||||
typed-array-buffer: 1.0.3
|
||||
|
||||
to-data-view@1.1.0:
|
||||
optional: true
|
||||
|
||||
|
|
@ -15550,6 +15746,12 @@ snapshots:
|
|||
media-typer: 1.1.0
|
||||
mime-types: 3.0.2
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
is-typed-array: 1.1.15
|
||||
|
||||
typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
|
|
@ -15827,6 +16029,16 @@ snapshots:
|
|||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
which-typed-array@1.1.20:
|
||||
dependencies:
|
||||
available-typed-arrays: 1.0.7
|
||||
call-bind: 1.0.8
|
||||
call-bound: 1.0.4
|
||||
for-each: 0.3.5
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-tostringtag: 1.0.2
|
||||
|
||||
which@1.3.1:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue