mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
Compare commits
43 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f14f3b0347 | ||
|
|
d42fb26bcc | ||
|
|
caf00fae0c | ||
|
|
bdf270b7a1 | ||
|
|
0bb256879c | ||
|
|
75842fa06b | ||
|
|
f4dbb58a77 | ||
|
|
5c4aa77255 | ||
|
|
51f2ad6e8a | ||
|
|
15567cd1dd | ||
|
|
c81d3cb27b | ||
|
|
32b6b2f1c0 | ||
|
|
0f051ea467 | ||
|
|
7ad1a91ea8 | ||
|
|
ae296c7723 | ||
|
|
fbbaeea1df | ||
|
|
a86f555cbb | ||
|
|
a80ef4d320 | ||
|
|
dc3e25c98b | ||
|
|
93054066fa | ||
|
|
4c46bf4c25 | ||
|
|
e8a7cd59c1 | ||
|
|
1306b7f442 | ||
|
|
56edc5a730 | ||
|
|
5d65616cfb | ||
|
|
9f776ce526 | ||
|
|
8e0a3e2991 | ||
|
|
0d71ad33f5 | ||
|
|
5aedec2db9 | ||
|
|
acc655172d | ||
|
|
1f58c1f6cb | ||
|
|
eaab438666 | ||
|
|
e9cdd3f6eb | ||
|
|
50df9ed178 | ||
|
|
933df9c4a8 | ||
|
|
ebc56b5312 | ||
|
|
e71107320c | ||
|
|
a240ff777f | ||
|
|
efe2a93d8a | ||
|
|
2133d7226f | ||
|
|
c5e984e4c4 | ||
|
|
41bbec6296 | ||
|
|
70ca18a7fa |
66 changed files with 4410 additions and 698 deletions
|
|
@ -44,6 +44,7 @@ export async function isConfigured(): Promise<{ configured: boolean }> {
|
|||
export function setApiKey(apiKey: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
composioClient.setApiKey(apiKey);
|
||||
invalidateCopilotInstructionsCache();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -455,7 +455,7 @@ export function setupIpcHandlers() {
|
|||
return runsCore.createRun(args);
|
||||
},
|
||||
'runs:createMessage': async (_event, args) => {
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled) };
|
||||
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) };
|
||||
},
|
||||
'runs:authorizePermission': async (_event, args) => {
|
||||
await runsCore.authorizePermission(args.runId, args.authorization);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
|||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
|
||||
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
|
||||
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
|
||||
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
|
|
@ -116,7 +117,7 @@ protocol.registerSchemesAsPrivileged([
|
|||
},
|
||||
]);
|
||||
|
||||
const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture"]);
|
||||
const ALLOWED_SESSION_PERMISSIONS = new Set(["media", "display-capture", "clipboard-read", "clipboard-sanitized-write"]);
|
||||
|
||||
function configureSessionPermissions(targetSession: Session): void {
|
||||
targetSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
|
|
@ -291,6 +292,11 @@ app.whenReady().then(async () => {
|
|||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
// start local sites server for iframe dashboards and other mini apps
|
||||
initLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to start:', error);
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
|
|
@ -309,4 +315,7 @@ app.on("before-quit", () => {
|
|||
stopWorkspaceWatcher();
|
||||
stopRunsWatcher();
|
||||
stopServicesWatcher();
|
||||
shutdownLocalSites().catch((error) => {
|
||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"@tiptap/extension-image": "^3.16.0",
|
||||
"@tiptap/extension-link": "^3.15.3",
|
||||
"@tiptap/extension-placeholder": "^3.15.3",
|
||||
"@tiptap/extension-table": "^3.22.4",
|
||||
"@tiptap/extension-task-item": "^3.15.3",
|
||||
"@tiptap/extension-task-list": "^3.15.3",
|
||||
"@tiptap/pm": "^3.15.3",
|
||||
|
|
@ -48,6 +49,7 @@
|
|||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"recharts": "^3.8.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^1.6.10",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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, HistoryIcon, RadioIcon, SquareIcon, Globe } from 'lucide-react';
|
||||
import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, HistoryIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||
import { ChatSidebar } from './components/chat-sidebar';
|
||||
|
|
@ -15,6 +15,7 @@ import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-vi
|
|||
import { BasesView, type BaseConfig, DEFAULT_BASE_CONFIG } from '@/components/bases-view';
|
||||
import { useDebounce } from './hooks/use-debounce';
|
||||
import { SidebarContentPanel } from '@/components/sidebar-content';
|
||||
import { SuggestedTopicsView } from '@/components/suggested-topics-view';
|
||||
import { SidebarSectionProvider } from '@/contexts/sidebar-context';
|
||||
import {
|
||||
Conversation,
|
||||
|
|
@ -61,6 +62,8 @@ import { BrowserPane } from '@/components/browser-pane/BrowserPane'
|
|||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { defaultRemarkPlugins } from 'streamdown'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar'
|
||||
import {
|
||||
type ChatMessage,
|
||||
|
|
@ -88,7 +91,7 @@ import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
|
|||
import { toast } from "sonner"
|
||||
import { useVoiceMode } from '@/hooks/useVoiceMode'
|
||||
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
|
||||
import { useMeetingTranscription, type MeetingTranscriptionState, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
|
||||
import { useMeetingTranscription, type CalendarEventMeta } from '@/hooks/useMeetingTranscription'
|
||||
import { useAnalyticsIdentity } from '@/hooks/useAnalyticsIdentity'
|
||||
import * as analytics from '@/lib/analytics'
|
||||
|
||||
|
|
@ -103,6 +106,11 @@ interface TreeNode extends DirEntry {
|
|||
|
||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||
|
||||
// Render user messages with markdown so bullets, bold, links, etc. survive the
|
||||
// round-trip from the input textarea. `remarkBreaks` turns single newlines
|
||||
// into <br> so typed line breaks are preserved without requiring blank lines.
|
||||
const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks]
|
||||
|
||||
function SmoothStreamingMessage({ text, components }: { text: string; components: typeof streamdownComponents }) {
|
||||
const smoothText = useSmoothedText(text)
|
||||
return <MessageResponse components={components}>{smoothText}</MessageResponse>
|
||||
|
|
@ -126,9 +134,10 @@ const TITLEBAR_BUTTON_PX = 32
|
|||
const TITLEBAR_BUTTON_GAP_PX = 4
|
||||
const TITLEBAR_HEADER_GAP_PX = 8
|
||||
const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
|
||||
const TITLEBAR_BUTTONS_COLLAPSED = 4
|
||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
|
||||
const TITLEBAR_BUTTONS_COLLAPSED = 1
|
||||
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 0
|
||||
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
|
||||
const SUGGESTED_TOPICS_TAB_PATH = '__rowboat_suggested_topics__'
|
||||
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
|
||||
|
||||
const clampNumber = (value: number, min: number, max: number) =>
|
||||
|
|
@ -257,8 +266,63 @@ const getAncestorDirectoryPaths = (path: string): string[] => {
|
|||
}
|
||||
|
||||
const isGraphTabPath = (path: string) => path === GRAPH_TAB_PATH
|
||||
const isSuggestedTopicsTabPath = (path: string) => path === SUGGESTED_TOPICS_TAB_PATH
|
||||
const isBaseFilePath = (path: string) => path.endsWith('.base') || path === BASES_DEFAULT_TAB_PATH
|
||||
|
||||
const getSuggestedTopicTargetFolder = (category?: string) => {
|
||||
const normalized = category?.trim().toLowerCase()
|
||||
switch (normalized) {
|
||||
case 'people':
|
||||
case 'person':
|
||||
return 'People'
|
||||
case 'organizations':
|
||||
case 'organization':
|
||||
return 'Organizations'
|
||||
case 'projects':
|
||||
case 'project':
|
||||
return 'Projects'
|
||||
case 'meetings':
|
||||
case 'meeting':
|
||||
return 'Meetings'
|
||||
case 'topics':
|
||||
case 'topic':
|
||||
default:
|
||||
return 'Topics'
|
||||
}
|
||||
}
|
||||
|
||||
const buildSuggestedTopicExplorePrompt = ({
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
category?: string
|
||||
}) => {
|
||||
const folder = getSuggestedTopicTargetFolder(category)
|
||||
const categoryLabel = category?.trim() || 'Topics'
|
||||
return [
|
||||
'I am exploring a suggested topic card from the Suggested Topics panel.',
|
||||
'This card may represent a person, organization, topic, or project.',
|
||||
'',
|
||||
'Card context:',
|
||||
`- Title: ${title}`,
|
||||
`- Category: ${categoryLabel}`,
|
||||
`- Description: ${description}`,
|
||||
`- Target folder if we set this up: knowledge/${folder}/`,
|
||||
'',
|
||||
`Please start by telling me that you can set up a tracking note for "${title}" under knowledge/${folder}/.`,
|
||||
'Then briefly explain what that tracking note would monitor or refresh and ask me if you should set it up.',
|
||||
'Do not create or modify anything yet.',
|
||||
'Treat a clear confirmation from me as explicit approval to proceed.',
|
||||
`If I confirm later, load the \`tracks\` skill first, check whether a matching note already exists under knowledge/${folder}/, and update it instead of creating a duplicate.`,
|
||||
`If no matching note exists, create a new note under knowledge/${folder}/ with an appropriate filename.`,
|
||||
'Use a track block in that note rather than only writing static content, and keep any surrounding note scaffolding short and useful.',
|
||||
'Do not ask me to choose a note path unless there is a real ambiguity you cannot resolve from the card.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const normalizeUsage = (usage?: Partial<LanguageModelUsage> | null): LanguageModelUsage | null => {
|
||||
if (!usage) return null
|
||||
const hasNumbers = Object.values(usage).some((value) => typeof value === 'number')
|
||||
|
|
@ -439,6 +503,7 @@ type ViewState =
|
|||
| { type: 'file'; path: string }
|
||||
| { type: 'graph' }
|
||||
| { type: 'task'; name: string }
|
||||
| { type: 'suggested-topics' }
|
||||
|
||||
function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
||||
if (a.type !== b.type) return false
|
||||
|
|
@ -448,38 +513,13 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
|
|||
return true // both graph
|
||||
}
|
||||
|
||||
/** Sidebar toggle + utility buttons (fixed position, top-left) */
|
||||
/** Sidebar toggle (fixed position, top-left) */
|
||||
function FixedSidebarToggle({
|
||||
onNavigateBack,
|
||||
onNavigateForward,
|
||||
canNavigateBack,
|
||||
canNavigateForward,
|
||||
onNewChat,
|
||||
onOpenSearch,
|
||||
meetingState,
|
||||
meetingSummarizing,
|
||||
meetingAvailable,
|
||||
onToggleMeeting,
|
||||
isBrowserOpen,
|
||||
onToggleBrowser,
|
||||
leftInsetPx,
|
||||
}: {
|
||||
onNavigateBack: () => void
|
||||
onNavigateForward: () => void
|
||||
canNavigateBack: boolean
|
||||
canNavigateForward: boolean
|
||||
onNewChat: () => void
|
||||
onOpenSearch: () => void
|
||||
meetingState: MeetingTranscriptionState
|
||||
meetingSummarizing: boolean
|
||||
meetingAvailable: boolean
|
||||
onToggleMeeting: () => void
|
||||
isBrowserOpen: boolean
|
||||
onToggleBrowser: () => void
|
||||
leftInsetPx: number
|
||||
}) {
|
||||
const { toggleSidebar, state } = useSidebar()
|
||||
const isCollapsed = state === "collapsed"
|
||||
const { toggleSidebar } = useSidebar()
|
||||
return (
|
||||
<div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
<div aria-hidden="true" className="h-10 shrink-0" style={{ width: leftInsetPx }} />
|
||||
|
|
@ -493,98 +533,6 @@ function FixedSidebarToggle({
|
|||
>
|
||||
<PanelLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
aria-label="New chat"
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSearch}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
aria-label="Search"
|
||||
>
|
||||
<SearchIcon className="size-5" />
|
||||
</button>
|
||||
{meetingAvailable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMeeting}
|
||||
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md transition-colors disabled:pointer-events-none",
|
||||
meetingSummarizing
|
||||
? "text-muted-foreground"
|
||||
: meetingState === 'recording'
|
||||
? "text-red-500 hover:bg-accent"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
>
|
||||
{meetingSummarizing || meetingState === 'connecting' ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : meetingState === 'recording' ? (
|
||||
<SquareIcon className="size-4 animate-pulse" />
|
||||
) : (
|
||||
<RadioIcon className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{meetingSummarizing ? 'Generating meeting notes...' : meetingState === 'connecting' ? 'Starting transcription...' : meetingState === 'recording' ? 'Stop meeting notes' : 'Take new meeting notes'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleBrowser}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md transition-colors",
|
||||
isBrowserOpen
|
||||
? "bg-accent text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
aria-label={isBrowserOpen ? "Close browser" : "Open browser"}
|
||||
>
|
||||
<Globe className="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{isBrowserOpen ? 'Close browser' : 'Open browser'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Back / Forward navigation */}
|
||||
{isCollapsed && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateBack}
|
||||
disabled={!canNavigateBack}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateForward}
|
||||
disabled={!canNavigateForward}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
|
||||
aria-label="Go forward"
|
||||
>
|
||||
<ChevronRightIcon className="size-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -664,7 +612,8 @@ function App() {
|
|||
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
|
||||
const [isGraphOpen, setIsGraphOpen] = useState(false)
|
||||
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
|
||||
const [isSuggestedTopicsOpen, setIsSuggestedTopicsOpen] = useState(false)
|
||||
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean; suggestedTopics: boolean } | null>(null)
|
||||
const [baseConfigByPath, setBaseConfigByPath] = useState<Record<string, BaseConfig>>({})
|
||||
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
|
||||
nodes: [],
|
||||
|
|
@ -875,6 +824,7 @@ function App() {
|
|||
const chatTabIdCounterRef = useRef(0)
|
||||
const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}`
|
||||
const chatDraftsRef = useRef(new Map<string, string>())
|
||||
const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: string }>())
|
||||
const chatScrollTopByTabRef = useRef(new Map<string, number>())
|
||||
const [toolOpenByTab, setToolOpenByTab] = useState<Record<string, Record<string, boolean>>>({})
|
||||
const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState<Record<string, ChatViewportAnchorState>>({})
|
||||
|
|
@ -959,6 +909,7 @@ function App() {
|
|||
|
||||
const getFileTabTitle = useCallback((tab: FileTab) => {
|
||||
if (isGraphTabPath(tab.path)) return 'Graph View'
|
||||
if (isSuggestedTopicsTabPath(tab.path)) return 'Suggested Topics'
|
||||
if (tab.path === BASES_DEFAULT_TAB_PATH) return 'Bases'
|
||||
if (tab.path.endsWith('.base')) return tab.path.split('/').pop()?.replace(/\.base$/i, '') || 'Base'
|
||||
return tab.path.split('/').pop()?.replace(/\.md$/i, '') || tab.path
|
||||
|
|
@ -2154,6 +2105,34 @@ function App() {
|
|||
return cleanup
|
||||
}, [handleRunEvent])
|
||||
|
||||
type MiddlePaneContextPayload =
|
||||
| { kind: 'note'; path: string; content: string }
|
||||
| { kind: 'browser'; url: string; title: string }
|
||||
const buildMiddlePaneContext = async (): Promise<MiddlePaneContextPayload | undefined> => {
|
||||
// Nothing visible in the middle pane when the right pane is maximized.
|
||||
if (isRightPaneMaximized) return undefined
|
||||
|
||||
// Browser is an overlay on top of any note — when it's open, it's what the user is looking at.
|
||||
if (isBrowserOpen) {
|
||||
try {
|
||||
const state = await window.ipc.invoke('browser:getState', null)
|
||||
const activeTab = state.tabs.find((t) => t.id === state.activeTabId)
|
||||
if (activeTab) {
|
||||
return { kind: 'browser', url: activeTab.url, title: activeTab.title }
|
||||
}
|
||||
} catch {
|
||||
// fall through to no-context if browser state is unavailable
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Note case: only markdown files are meaningfully readable as context.
|
||||
const path = selectedPathRef.current
|
||||
if (!path || !path.endsWith('.md')) return undefined
|
||||
const content = editorContentRef.current ?? ''
|
||||
return { kind: 'note', path, content }
|
||||
}
|
||||
|
||||
const handlePromptSubmit = async (
|
||||
message: PromptInputMessage,
|
||||
mentions?: FileMention[],
|
||||
|
|
@ -2194,8 +2173,10 @@ function App() {
|
|||
let isNewRun = false
|
||||
let newRunCreatedAt: string | null = null
|
||||
if (!currentRunId) {
|
||||
const selected = selectedModelByTabRef.current.get(submitTabId)
|
||||
const run = await window.ipc.invoke('runs:create', {
|
||||
agentId,
|
||||
...(selected ? { model: selected.model, provider: selected.provider } : {}),
|
||||
})
|
||||
currentRunId = run.id
|
||||
newRunCreatedAt = run.createdAt
|
||||
|
|
@ -2257,12 +2238,14 @@ function App() {
|
|||
|
||||
// Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema.
|
||||
const attachmentPayload = contentParts as unknown as string
|
||||
const middlePaneContext = await buildMiddlePaneContext()
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: currentRunId,
|
||||
message: attachmentPayload,
|
||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||
searchEnabled: searchEnabled || undefined,
|
||||
middlePaneContext,
|
||||
})
|
||||
analytics.chatMessageSent({
|
||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||
|
|
@ -2270,12 +2253,14 @@ function App() {
|
|||
searchEnabled: searchEnabled || undefined,
|
||||
})
|
||||
} else {
|
||||
const middlePaneContext = await buildMiddlePaneContext()
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: currentRunId,
|
||||
message: userMessage,
|
||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||
searchEnabled: searchEnabled || undefined,
|
||||
middlePaneContext,
|
||||
})
|
||||
analytics.chatMessageSent({
|
||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||
|
|
@ -2496,6 +2481,7 @@ function App() {
|
|||
return next
|
||||
})
|
||||
chatDraftsRef.current.delete(tabId)
|
||||
selectedModelByTabRef.current.delete(tabId)
|
||||
chatScrollTopByTabRef.current.delete(tabId)
|
||||
setToolOpenByTab((prev) => {
|
||||
if (!(tabId in prev)) return prev
|
||||
|
|
@ -2622,9 +2608,17 @@ function App() {
|
|||
if (isGraphTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
return
|
||||
}
|
||||
if (isSuggestedTopicsTabPath(tab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
return
|
||||
}
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setSelectedPath(tab.path)
|
||||
}, [fileTabs, isRightPaneMaximized])
|
||||
|
||||
|
|
@ -2652,6 +2646,7 @@ function App() {
|
|||
setActiveFileTabId(null)
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
return []
|
||||
}
|
||||
const idx = prev.findIndex(t => t.id === tabId)
|
||||
|
|
@ -2664,8 +2659,14 @@ function App() {
|
|||
if (isGraphTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
} else if (isSuggestedTopicsTabPath(newActiveTab.path)) {
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
} else {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setSelectedPath(newActiveTab.path)
|
||||
}
|
||||
}
|
||||
|
|
@ -2695,15 +2696,16 @@ function App() {
|
|||
}
|
||||
handleNewChat()
|
||||
// Left-pane "new chat" should always open full chat view.
|
||||
if (selectedPath || isGraphOpen) {
|
||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
||||
} else {
|
||||
setExpandedFrom(null)
|
||||
}
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
}, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen])
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
}, [chatTabs, activeChatTabId, handleNewChat, selectedPath, isGraphOpen, isSuggestedTopicsOpen])
|
||||
|
||||
// Sidebar variant: create/switch chat tab without leaving file/graph context.
|
||||
const handleNewChatTabInSidebar = useCallback(() => {
|
||||
|
|
@ -2767,6 +2769,27 @@ function App() {
|
|||
return () => window.removeEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
|
||||
}, [submitFromPalette])
|
||||
|
||||
// Listener for prompt-block "Run" events
|
||||
// (dispatched by apps/renderer/src/extensions/prompt-block.tsx)
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<{
|
||||
instruction?: string
|
||||
filePath?: string
|
||||
label?: string
|
||||
}>
|
||||
const instruction = ev.detail?.instruction
|
||||
const filePath = ev.detail?.filePath
|
||||
if (!instruction) return
|
||||
const mention = filePath
|
||||
? { path: filePath, displayName: filePath.split('/').pop() ?? filePath }
|
||||
: null
|
||||
submitFromPalette(instruction, mention)
|
||||
}
|
||||
window.addEventListener('rowboat:open-copilot-prompt', handler as EventListener)
|
||||
return () => window.removeEventListener('rowboat:open-copilot-prompt', handler as EventListener)
|
||||
}, [submitFromPalette])
|
||||
|
||||
const toggleKnowledgePane = useCallback(() => {
|
||||
setIsRightPaneMaximized(false)
|
||||
setIsChatSidebarOpen(prev => !prev)
|
||||
|
|
@ -2797,19 +2820,26 @@ function App() {
|
|||
|
||||
const handleOpenFullScreenChat = useCallback(() => {
|
||||
// Remember where we came from so the close button can return
|
||||
if (selectedPath || isGraphOpen) {
|
||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen })
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen) {
|
||||
setExpandedFrom({ path: selectedPath, graph: isGraphOpen, suggestedTopics: isSuggestedTopicsOpen })
|
||||
}
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
}, [selectedPath, isGraphOpen])
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen])
|
||||
|
||||
const handleCloseFullScreenChat = useCallback(() => {
|
||||
if (expandedFrom) {
|
||||
if (expandedFrom.graph) {
|
||||
setIsGraphOpen(true)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
} else if (expandedFrom.suggestedTopics) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
} else if (expandedFrom.path) {
|
||||
setIsGraphOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setSelectedPath(expandedFrom.path)
|
||||
}
|
||||
setExpandedFrom(null)
|
||||
|
|
@ -2819,10 +2849,11 @@ function App() {
|
|||
|
||||
const currentViewState = React.useMemo<ViewState>(() => {
|
||||
if (selectedBackgroundTask) return { type: 'task', name: selectedBackgroundTask }
|
||||
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
|
||||
if (selectedPath) return { type: 'file', path: selectedPath }
|
||||
if (isGraphOpen) return { type: 'graph' }
|
||||
return { type: 'chat', runId }
|
||||
}, [selectedBackgroundTask, selectedPath, isGraphOpen, runId])
|
||||
}, [selectedBackgroundTask, isSuggestedTopicsOpen, selectedPath, isGraphOpen, runId])
|
||||
|
||||
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
|
||||
const last = stack[stack.length - 1]
|
||||
|
|
@ -2868,6 +2899,17 @@ function App() {
|
|||
setActiveFileTabId(id)
|
||||
}, [fileTabs])
|
||||
|
||||
const ensureSuggestedTopicsFileTab = useCallback(() => {
|
||||
const existing = fileTabs.find((tab) => isSuggestedTopicsTabPath(tab.path))
|
||||
if (existing) {
|
||||
setActiveFileTabId(existing.id)
|
||||
return
|
||||
}
|
||||
const id = newFileTabId()
|
||||
setFileTabs((prev) => [...prev, { id, path: SUGGESTED_TOPICS_TAB_PATH }])
|
||||
setActiveFileTabId(id)
|
||||
}, [fileTabs])
|
||||
|
||||
const applyViewState = useCallback(async (view: ViewState) => {
|
||||
switch (view.type) {
|
||||
case 'file':
|
||||
|
|
@ -2876,6 +2918,7 @@ function App() {
|
|||
// Navigating to a file dismisses the browser overlay so the file is
|
||||
// visible in the middle pane.
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setExpandedFrom(null)
|
||||
// Preserve split vs knowledge-max mode when navigating knowledge files.
|
||||
// Only exit chat-only maximize, because that would hide the selected file.
|
||||
|
|
@ -2889,6 +2932,7 @@ function App() {
|
|||
setSelectedBackgroundTask(null)
|
||||
setSelectedPath(null)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsGraphOpen(true)
|
||||
ensureGraphFileTab()
|
||||
|
|
@ -2900,10 +2944,21 @@ function App() {
|
|||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(view.name)
|
||||
return
|
||||
case 'suggested-topics':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
setIsBrowserOpen(false)
|
||||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(true)
|
||||
ensureSuggestedTopicsFileTab()
|
||||
return
|
||||
case 'chat':
|
||||
setSelectedPath(null)
|
||||
setIsGraphOpen(false)
|
||||
|
|
@ -2912,6 +2967,7 @@ function App() {
|
|||
setExpandedFrom(null)
|
||||
setIsRightPaneMaximized(false)
|
||||
setSelectedBackgroundTask(null)
|
||||
setIsSuggestedTopicsOpen(false)
|
||||
if (view.runId) {
|
||||
await loadRun(view.runId)
|
||||
} else {
|
||||
|
|
@ -2919,7 +2975,7 @@ function App() {
|
|||
}
|
||||
return
|
||||
}
|
||||
}, [ensureFileTabForPath, ensureGraphFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
}, [ensureFileTabForPath, ensureGraphFileTab, ensureSuggestedTopicsFileTab, handleNewChat, isRightPaneMaximized, loadRun])
|
||||
|
||||
const navigateToView = useCallback(async (nextView: ViewState) => {
|
||||
const current = currentViewState
|
||||
|
|
@ -3184,7 +3240,7 @@ function App() {
|
|||
}, [])
|
||||
|
||||
// Keyboard shortcut: Ctrl+L to toggle main chat view
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||
const isFullScreenChat = !selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask && !isBrowserOpen
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
||||
|
|
@ -3262,12 +3318,16 @@ function App() {
|
|||
const handleTabKeyDown = (e: KeyboardEvent) => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod) return
|
||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen) && isChatSidebarOpen)
|
||||
const rightPaneAvailable = Boolean((selectedPath || isGraphOpen || isSuggestedTopicsOpen) && isChatSidebarOpen)
|
||||
const targetPane: ShortcutPane = rightPaneAvailable
|
||||
? (isRightPaneMaximized ? 'right' : activeShortcutPane)
|
||||
: 'left'
|
||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen)
|
||||
const selectedKnowledgePath = isGraphOpen ? GRAPH_TAB_PATH : selectedPath
|
||||
const inFileView = targetPane === 'left' && Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen)
|
||||
const selectedKnowledgePath = isGraphOpen
|
||||
? GRAPH_TAB_PATH
|
||||
: isSuggestedTopicsOpen
|
||||
? SUGGESTED_TOPICS_TAB_PATH
|
||||
: selectedPath
|
||||
const targetFileTabId = activeFileTabId ?? (
|
||||
selectedKnowledgePath
|
||||
? (fileTabs.find((tab) => tab.path === selectedKnowledgePath)?.id ?? null)
|
||||
|
|
@ -3321,7 +3381,7 @@ function App() {
|
|||
}
|
||||
document.addEventListener('keydown', handleTabKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleTabKeyDown)
|
||||
}, [selectedPath, isGraphOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
||||
}, [selectedPath, isGraphOpen, isSuggestedTopicsOpen, isChatSidebarOpen, isRightPaneMaximized, activeShortcutPane, chatTabs, fileTabs, activeChatTabId, activeFileTabId, closeChatTab, closeFileTab, switchChatTab, switchFileTab])
|
||||
|
||||
const toggleExpand = (path: string, kind: 'file' | 'dir') => {
|
||||
if (kind === 'file') {
|
||||
|
|
@ -3329,9 +3389,9 @@ function App() {
|
|||
return
|
||||
}
|
||||
|
||||
// Top-level knowledge folders (except Notes) open as a bases view with folder filter
|
||||
// Top-level knowledge folders open as a bases view with folder filter
|
||||
const parts = path.split('/')
|
||||
if (parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes') {
|
||||
if (parts.length === 2 && parts[0] === 'knowledge') {
|
||||
const folderName = parts[1]
|
||||
const folderCfg = FOLDER_BASE_CONFIGS[folderName]
|
||||
setBaseConfigByPath((prev) => ({
|
||||
|
|
@ -3346,7 +3406,7 @@ function App() {
|
|||
}),
|
||||
},
|
||||
}))
|
||||
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
|
|
@ -3468,14 +3528,14 @@ function App() {
|
|||
},
|
||||
openGraph: () => {
|
||||
// From chat-only landing state, open graph directly in full knowledge view.
|
||||
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
void navigateToView({ type: 'graph' })
|
||||
},
|
||||
openBases: () => {
|
||||
if (!selectedPath && !isGraphOpen && !selectedBackgroundTask) {
|
||||
if (!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedBackgroundTask) {
|
||||
setIsChatSidebarOpen(false)
|
||||
setIsRightPaneMaximized(false)
|
||||
}
|
||||
|
|
@ -3921,7 +3981,14 @@ function App() {
|
|||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<MessageContent>{item.content}</MessageContent>
|
||||
<MessageContent>
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{item.content}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
|
|
@ -3942,7 +4009,12 @@ function App() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{message}
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{message}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
|
|
@ -4047,7 +4119,7 @@ function App() {
|
|||
const selectedTask = selectedBackgroundTask
|
||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||
: null
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isBrowserOpen)
|
||||
const isRightPaneContext = Boolean(selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen)
|
||||
const isRightPaneOnlyMode = isRightPaneContext && isChatSidebarOpen && isRightPaneMaximized
|
||||
const shouldCollapseLeftPane = isRightPaneOnlyMode
|
||||
const openMarkdownTabs = React.useMemo(() => {
|
||||
|
|
@ -4080,6 +4152,14 @@ function App() {
|
|||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={toggleExpand}
|
||||
onToggleFolder={(path) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(path)) next.delete(path)
|
||||
else next.add(path)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
knowledgeActions={knowledgeActions}
|
||||
onVoiceNoteCreated={handleVoiceNoteCreated}
|
||||
runs={runs}
|
||||
|
|
@ -4089,7 +4169,7 @@ function App() {
|
|||
onNewChat: handleNewChatTab,
|
||||
onSelectRun: (runIdToLoad) => {
|
||||
cancelRecordingIfActive()
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
setIsChatSidebarOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -4100,7 +4180,7 @@ function App() {
|
|||
return
|
||||
}
|
||||
// In two-pane mode (file/graph/browser), keep the middle pane and just swap chat context in the right sidebar.
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: runIdToLoad } : t))
|
||||
loadRun(runIdToLoad)
|
||||
return
|
||||
|
|
@ -4124,14 +4204,14 @@ function App() {
|
|||
} else {
|
||||
// Only one tab, reset it to new chat
|
||||
setChatTabs([{ id: tabForRun.id, runId: null }])
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
handleNewChat()
|
||||
} else {
|
||||
void navigateToView({ type: 'chat', runId: null })
|
||||
}
|
||||
}
|
||||
} else if (runId === runIdToDelete) {
|
||||
if (selectedPath || isGraphOpen || isBrowserOpen) {
|
||||
if (selectedPath || isGraphOpen || isSuggestedTopicsOpen || isBrowserOpen) {
|
||||
setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: null } : t))
|
||||
handleNewChat()
|
||||
} else {
|
||||
|
|
@ -4149,6 +4229,16 @@ function App() {
|
|||
}}
|
||||
backgroundTasks={backgroundTasks}
|
||||
selectedBackgroundTask={selectedBackgroundTask}
|
||||
onNewChat={handleNewChatTab}
|
||||
onOpenSearch={() => setIsSearchOpen(true)}
|
||||
meetingState={meetingTranscription.state}
|
||||
meetingSummarizing={meetingSummarizing}
|
||||
meetingAvailable={voiceAvailable}
|
||||
onToggleMeeting={() => { void handleToggleMeeting() }}
|
||||
isBrowserOpen={isBrowserOpen}
|
||||
onToggleBrowser={handleToggleBrowser}
|
||||
isSuggestedTopicsOpen={isSuggestedTopicsOpen}
|
||||
onOpenSuggestedTopics={() => void navigateToView({ type: 'suggested-topics' })}
|
||||
/>
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
|
|
@ -4168,7 +4258,7 @@ function App() {
|
|||
canNavigateForward={canNavigateForward}
|
||||
collapsedLeftPaddingPx={collapsedLeftPaddingPx}
|
||||
>
|
||||
{(selectedPath || isGraphOpen) && fileTabs.length >= 1 ? (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && fileTabs.length >= 1 ? (
|
||||
<TabBar
|
||||
tabs={fileTabs}
|
||||
activeTabId={activeFileTabId ?? ''}
|
||||
|
|
@ -4176,7 +4266,7 @@ function App() {
|
|||
getTabId={(t) => t.id}
|
||||
onSwitchTab={switchFileTab}
|
||||
onCloseTab={closeFileTab}
|
||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
allowSingleTabClose={fileTabs.length === 1 && (isGraphOpen || isSuggestedTopicsOpen || (selectedPath != null && isBaseFilePath(selectedPath)))}
|
||||
/>
|
||||
) : (
|
||||
<TabBar
|
||||
|
|
@ -4229,7 +4319,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Version history</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !selectedTask && !isBrowserOpen && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !selectedTask && !isBrowserOpen && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4244,7 +4334,7 @@ function App() {
|
|||
<TooltipContent side="bottom">New chat tab</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!selectedPath && !isGraphOpen && !isBrowserOpen && expandedFrom && (
|
||||
{!selectedPath && !isGraphOpen && !isSuggestedTopicsOpen && !isBrowserOpen && expandedFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4259,7 +4349,7 @@ function App() {
|
|||
<TooltipContent side="bottom">Restore two-pane view</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(selectedPath || isGraphOpen) && (
|
||||
{(selectedPath || isGraphOpen || isSuggestedTopicsOpen) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
@ -4280,6 +4370,15 @@ function App() {
|
|||
|
||||
{isBrowserOpen ? (
|
||||
<BrowserPane onClose={handleCloseBrowser} />
|
||||
) : isSuggestedTopicsOpen ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<SuggestedTopicsView
|
||||
onExploreTopic={(topic) => {
|
||||
const prompt = buildSuggestedTopicExplorePrompt(topic)
|
||||
submitFromPalette(prompt, null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : selectedPath && isBaseFilePath(selectedPath) ? (
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<BasesView
|
||||
|
|
@ -4568,6 +4667,13 @@ function App() {
|
|||
runId={tabState.runId}
|
||||
initialDraft={chatDraftsRef.current.get(tab.id)}
|
||||
onDraftChange={(text) => setChatDraftForTab(tab.id, text)}
|
||||
onSelectedModelChange={(m) => {
|
||||
if (m) {
|
||||
selectedModelByTabRef.current.set(tab.id, m)
|
||||
} else {
|
||||
selectedModelByTabRef.current.delete(tab.id)
|
||||
}
|
||||
}}
|
||||
isRecording={isActive && isRecording}
|
||||
recordingText={isActive ? voice.interimText : undefined}
|
||||
recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined}
|
||||
|
|
@ -4621,6 +4727,13 @@ function App() {
|
|||
onPresetMessageConsumed={() => setPresetMessage(undefined)}
|
||||
getInitialDraft={(tabId) => chatDraftsRef.current.get(tabId)}
|
||||
onDraftChangeForTab={setChatDraftForTab}
|
||||
onSelectedModelChangeForTab={(tabId, m) => {
|
||||
if (m) {
|
||||
selectedModelByTabRef.current.set(tabId, m)
|
||||
} else {
|
||||
selectedModelByTabRef.current.delete(tabId)
|
||||
}
|
||||
}}
|
||||
pendingAskHumanRequests={pendingAskHumanRequests}
|
||||
allPermissionRequests={allPermissionRequests}
|
||||
permissionResponses={permissionResponses}
|
||||
|
|
@ -4647,18 +4760,6 @@ function App() {
|
|||
)}
|
||||
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
|
||||
<FixedSidebarToggle
|
||||
onNavigateBack={() => { void navigateBack() }}
|
||||
onNavigateForward={() => { void navigateForward() }}
|
||||
canNavigateBack={canNavigateBack}
|
||||
canNavigateForward={canNavigateForward}
|
||||
onNewChat={handleNewChatTab}
|
||||
onOpenSearch={() => setIsSearchOpen(true)}
|
||||
meetingState={meetingTranscription.state}
|
||||
meetingSummarizing={meetingSummarizing}
|
||||
meetingAvailable={voiceAvailable}
|
||||
onToggleMeeting={() => { void handleToggleMeeting() }}
|
||||
isBrowserOpen={isBrowserOpen}
|
||||
onToggleBrowser={handleToggleBrowser}
|
||||
leftInsetPx={isMac ? MACOS_TRAFFIC_LIGHTS_RESERVED_PX : 0}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
|
|
|
|||
|
|
@ -69,13 +69,20 @@ const providerDisplayNames: Record<string, string> = {
|
|||
rowboat: 'Rowboat',
|
||||
}
|
||||
|
||||
type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
|
||||
|
||||
interface ConfiguredModel {
|
||||
flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat"
|
||||
provider: ProviderName
|
||||
model: string
|
||||
apiKey?: string
|
||||
baseURL?: string
|
||||
headers?: Record<string, string>
|
||||
knowledgeGraphModel?: string
|
||||
}
|
||||
|
||||
export interface SelectedModel {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
function getSelectedModelDisplayName(model: string) {
|
||||
return model.split('/').pop() || model
|
||||
}
|
||||
|
||||
function getAttachmentIcon(kind: AttachmentIconKind) {
|
||||
|
|
@ -120,6 +127,8 @@ interface ChatInputInnerProps {
|
|||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
}
|
||||
|
||||
function ChatInputInner({
|
||||
|
|
@ -145,6 +154,7 @@ function ChatInputInner({
|
|||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onSelectedModelChange,
|
||||
}: ChatInputInnerProps) {
|
||||
const controller = usePromptInputController()
|
||||
const message = controller.textInput.value
|
||||
|
|
@ -155,10 +165,27 @@ function ChatInputInner({
|
|||
|
||||
const [configuredModels, setConfiguredModels] = useState<ConfiguredModel[]>([])
|
||||
const [activeModelKey, setActiveModelKey] = useState('')
|
||||
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(null)
|
||||
const [searchEnabled, setSearchEnabled] = useState(false)
|
||||
const [searchAvailable, setSearchAvailable] = useState(false)
|
||||
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
|
||||
|
||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setLockedModel(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
window.ipc.invoke('runs:fetch', { runId }).then((run) => {
|
||||
if (cancelled) return
|
||||
if (run.provider && run.model) {
|
||||
setLockedModel({ provider: run.provider, model: run.model })
|
||||
}
|
||||
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
|
||||
return () => { cancelled = true }
|
||||
}, [runId])
|
||||
|
||||
// Check Rowboat sign-in state
|
||||
useEffect(() => {
|
||||
window.ipc.invoke('oauth:getState', null).then((result) => {
|
||||
|
|
@ -176,42 +203,20 @@ function ChatInputInner({
|
|||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Load model config (gateway when signed in, local config when BYOK)
|
||||
// Load the list of models the user can choose from.
|
||||
// Signed-in: gateway model list. Signed-out: providers configured in models.json.
|
||||
const loadModelConfig = useCallback(async () => {
|
||||
try {
|
||||
if (isRowboatConnected) {
|
||||
// Fetch gateway models
|
||||
const listResult = await window.ipc.invoke('models:list', null)
|
||||
const rowboatProvider = listResult.providers?.find(
|
||||
(p: { id: string }) => p.id === 'rowboat'
|
||||
)
|
||||
const models: ConfiguredModel[] = (rowboatProvider?.models || []).map(
|
||||
(m: { id: string }) => ({ flavor: 'rowboat', model: m.id })
|
||||
(m: { id: string }) => ({ provider: 'rowboat', model: m.id })
|
||||
)
|
||||
|
||||
// Read current default from config
|
||||
let defaultModel = ''
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
defaultModel = parsed?.model || ''
|
||||
} catch { /* no config yet */ }
|
||||
|
||||
if (defaultModel) {
|
||||
models.sort((a, b) => {
|
||||
if (a.model === defaultModel) return -1
|
||||
if (b.model === defaultModel) return 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
setConfiguredModels(models)
|
||||
const activeKey = defaultModel
|
||||
? `rowboat/${defaultModel}`
|
||||
: models[0] ? `rowboat/${models[0].model}` : ''
|
||||
if (activeKey) setActiveModelKey(activeKey)
|
||||
} else {
|
||||
// BYOK: read from local models.json
|
||||
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
|
||||
const parsed = JSON.parse(result.data)
|
||||
const models: ConfiguredModel[] = []
|
||||
|
|
@ -223,32 +228,12 @@ function ChatInputInner({
|
|||
const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : []
|
||||
for (const model of allModels) {
|
||||
if (model) {
|
||||
models.push({
|
||||
flavor: flavor as ConfiguredModel['flavor'],
|
||||
model,
|
||||
apiKey: (e.apiKey as string) || undefined,
|
||||
baseURL: (e.baseURL as string) || undefined,
|
||||
headers: (e.headers as Record<string, string>) || undefined,
|
||||
knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined,
|
||||
})
|
||||
models.push({ provider: flavor as ProviderName, model })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const defaultKey = parsed?.provider?.flavor && parsed?.model
|
||||
? `${parsed.provider.flavor}/${parsed.model}`
|
||||
: ''
|
||||
models.sort((a, b) => {
|
||||
const aKey = `${a.flavor}/${a.model}`
|
||||
const bKey = `${b.flavor}/${b.model}`
|
||||
if (aKey === defaultKey) return -1
|
||||
if (bKey === defaultKey) return 1
|
||||
return 0
|
||||
})
|
||||
setConfiguredModels(models)
|
||||
if (defaultKey) {
|
||||
setActiveModelKey(defaultKey)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No config yet
|
||||
|
|
@ -284,40 +269,15 @@ function ChatInputInner({
|
|||
checkSearch()
|
||||
}, [isActive, isRowboatConnected])
|
||||
|
||||
const handleModelChange = useCallback(async (key: string) => {
|
||||
const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key)
|
||||
// Selecting a model affects only the *next* run created from this tab.
|
||||
// Once a run exists, model is frozen on the run and the dropdown is read-only.
|
||||
const handleModelChange = useCallback((key: string) => {
|
||||
if (lockedModel) return
|
||||
const entry = configuredModels.find((m) => `${m.provider}/${m.model}` === key)
|
||||
if (!entry) return
|
||||
setActiveModelKey(key)
|
||||
|
||||
try {
|
||||
if (entry.flavor === 'rowboat') {
|
||||
// Gateway model — save with valid Zod flavor, no credentials
|
||||
await window.ipc.invoke('models:saveConfig', {
|
||||
provider: { flavor: 'openrouter' as const },
|
||||
model: entry.model,
|
||||
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||
})
|
||||
} else {
|
||||
// BYOK — preserve full provider config
|
||||
const providerModels = configuredModels
|
||||
.filter((m) => m.flavor === entry.flavor)
|
||||
.map((m) => m.model)
|
||||
await window.ipc.invoke('models:saveConfig', {
|
||||
provider: {
|
||||
flavor: entry.flavor,
|
||||
apiKey: entry.apiKey,
|
||||
baseURL: entry.baseURL,
|
||||
headers: entry.headers,
|
||||
},
|
||||
model: entry.model,
|
||||
models: providerModels,
|
||||
knowledgeGraphModel: entry.knowledgeGraphModel,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to switch model')
|
||||
}
|
||||
}, [configuredModels])
|
||||
onSelectedModelChange?.({ provider: entry.provider, model: entry.model })
|
||||
}, [configuredModels, lockedModel, onSelectedModelChange])
|
||||
|
||||
// Restore the tab draft when this input mounts.
|
||||
useEffect(() => {
|
||||
|
|
@ -555,7 +515,14 @@ function ChatInputInner({
|
|||
)
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{configuredModels.length > 0 && (
|
||||
{lockedModel ? (
|
||||
<span
|
||||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground"
|
||||
title={`${providerDisplayNames[lockedModel.provider] || lockedModel.provider} — fixed for this chat`}
|
||||
>
|
||||
<span className="max-w-[150px] truncate">{getSelectedModelDisplayName(lockedModel.model)}</span>
|
||||
</span>
|
||||
) : configuredModels.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
|
@ -563,7 +530,7 @@ function ChatInputInner({
|
|||
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<span className="max-w-[150px] truncate">
|
||||
{configuredModels.find((m) => `${m.flavor}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model'}
|
||||
{getSelectedModelDisplayName(configuredModels.find((m) => `${m.provider}/${m.model}` === activeModelKey)?.model || configuredModels[0]?.model || 'Model')}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
|
|
@ -571,18 +538,18 @@ function ChatInputInner({
|
|||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={activeModelKey} onValueChange={handleModelChange}>
|
||||
{configuredModels.map((m) => {
|
||||
const key = `${m.flavor}/${m.model}`
|
||||
const key = `${m.provider}/${m.model}`
|
||||
return (
|
||||
<DropdownMenuRadioItem key={key} value={key}>
|
||||
<span className="truncate">{m.model}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.flavor] || m.flavor}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{providerDisplayNames[m.provider] || m.provider}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
) : null}
|
||||
{onToggleTts && ttsAvailable && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Tooltip>
|
||||
|
|
@ -729,6 +696,7 @@ export interface ChatInputWithMentionsProps {
|
|||
ttsMode?: 'summary' | 'full'
|
||||
onToggleTts?: () => void
|
||||
onTtsModeChange?: (mode: 'summary' | 'full') => void
|
||||
onSelectedModelChange?: (model: SelectedModel | null) => void
|
||||
}
|
||||
|
||||
export function ChatInputWithMentions({
|
||||
|
|
@ -757,6 +725,7 @@ export function ChatInputWithMentions({
|
|||
ttsMode,
|
||||
onToggleTts,
|
||||
onTtsModeChange,
|
||||
onSelectedModelChange,
|
||||
}: ChatInputWithMentionsProps) {
|
||||
return (
|
||||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||
|
|
@ -783,6 +752,7 @@ export function ChatInputWithMentions({
|
|||
ttsMode={ttsMode}
|
||||
onToggleTts={onToggleTts}
|
||||
onTtsModeChange={onTtsModeChange}
|
||||
onSelectedModelChange={onSelectedModelChange}
|
||||
/>
|
||||
</PromptInputProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@ import { Suggestions } from '@/components/ai-elements/suggestions'
|
|||
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override'
|
||||
import { defaultRemarkPlugins } from 'streamdown'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { TabBar, type ChatTab } from '@/components/tab-bar'
|
||||
import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions'
|
||||
import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions'
|
||||
import { ChatMessageAttachments } from '@/components/chat-message-attachments'
|
||||
import { wikiLabel } from '@/lib/wiki-links'
|
||||
import {
|
||||
|
|
@ -49,6 +51,11 @@ import {
|
|||
|
||||
const streamdownComponents = { pre: MarkdownPreOverride }
|
||||
|
||||
// Render user messages with markdown so bullets, bold, links, etc. survive the
|
||||
// round-trip from the input textarea. `remarkBreaks` turns single newlines
|
||||
// into <br> so typed line breaks are preserved without requiring blank lines.
|
||||
const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks]
|
||||
|
||||
/* ─── Billing error helpers ─── */
|
||||
|
||||
const BILLING_ERROR_PATTERNS = [
|
||||
|
|
@ -158,6 +165,7 @@ interface ChatSidebarProps {
|
|||
onPresetMessageConsumed?: () => void
|
||||
getInitialDraft?: (tabId: string) => string | undefined
|
||||
onDraftChangeForTab?: (tabId: string, text: string) => void
|
||||
onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void
|
||||
pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests']
|
||||
allPermissionRequests?: ChatTabViewState['allPermissionRequests']
|
||||
permissionResponses?: ChatTabViewState['permissionResponses']
|
||||
|
|
@ -211,6 +219,7 @@ export function ChatSidebar({
|
|||
onPresetMessageConsumed,
|
||||
getInitialDraft,
|
||||
onDraftChangeForTab,
|
||||
onSelectedModelChangeForTab,
|
||||
pendingAskHumanRequests = new Map(),
|
||||
allPermissionRequests = new Map(),
|
||||
permissionResponses = new Map(),
|
||||
|
|
@ -351,7 +360,14 @@ export function ChatSidebar({
|
|||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<MessageContent>{item.content}</MessageContent>
|
||||
<MessageContent>
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{item.content}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
|
|
@ -372,7 +388,12 @@ export function ChatSidebar({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{message}
|
||||
<MessageResponse
|
||||
components={streamdownComponents}
|
||||
remarkPlugins={userMessageRemarkPlugins}
|
||||
>
|
||||
{message}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
|
|
@ -662,6 +683,7 @@ export function ChatSidebar({
|
|||
runId={tabState.runId}
|
||||
initialDraft={getInitialDraft?.(tab.id)}
|
||||
onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined}
|
||||
onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined}
|
||||
isRecording={isActive && isRecording}
|
||||
recordingText={isActive ? recordingText : undefined}
|
||||
recordingState={isActive ? recordingState : undefined}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,16 @@ import Image from '@tiptap/extension-image'
|
|||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table'
|
||||
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react'
|
||||
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
|
||||
import { TaskBlockExtension } from '@/extensions/task-block'
|
||||
import { TrackBlockExtension } from '@/extensions/track-block'
|
||||
import { PromptBlockExtension } from '@/extensions/prompt-block'
|
||||
import { TrackTargetOpenExtension, TrackTargetCloseExtension } from '@/extensions/track-target'
|
||||
import { ImageBlockExtension } from '@/extensions/image-block'
|
||||
import { EmbedBlockExtension } from '@/extensions/embed-block'
|
||||
import { IframeBlockExtension } from '@/extensions/iframe-block'
|
||||
import { ChartBlockExtension } from '@/extensions/chart-block'
|
||||
import { TableBlockExtension } from '@/extensions/table-block'
|
||||
import { CalendarBlockExtension } from '@/extensions/calendar-block'
|
||||
|
|
@ -54,17 +58,22 @@ function preprocessMarkdown(markdown: string): string {
|
|||
// line until a blank line terminates it, and markdown inline rules (bold,
|
||||
// italics, links) don't apply inside the block. Without surrounding blank
|
||||
// lines, the line right after our placeholder div gets absorbed as HTML and
|
||||
// its markdown is not parsed. We consume any adjacent newlines in the match
|
||||
// and emit exactly `\n\n<div></div>\n\n` so the HTML block starts and ends on
|
||||
// its own line.
|
||||
// its markdown is not parsed.
|
||||
//
|
||||
// Consume ALL adjacent newlines (\n*, not \n?) so the emitted `\n\n…\n\n`
|
||||
// is load/save stable. serializeBlocksToMarkdown emits `\n\n` between blocks
|
||||
// on save; a `\n?` regex on reload would only consume one of those two
|
||||
// newlines, so every cycle would add a net newline on each side of every
|
||||
// marker — causing tracks running on an open note to steadily inflate the
|
||||
// file with blank lines around target regions.
|
||||
function preprocessTrackTargets(md: string): string {
|
||||
return md
|
||||
.replace(
|
||||
/\n?<!--track-target:([^\s>]+)-->\n?/g,
|
||||
/\n*<!--track-target:([^\s>]+)-->\n*/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-open" data-track-id="${id}"></div>\n\n`,
|
||||
)
|
||||
.replace(
|
||||
/\n?<!--\/track-target:([^\s>]+)-->\n?/g,
|
||||
/\n*<!--\/track-target:([^\s>]+)-->\n*/g,
|
||||
(_m, id: string) => `\n\n<div data-type="track-target-close" data-track-id="${id}"></div>\n\n`,
|
||||
)
|
||||
}
|
||||
|
|
@ -148,6 +157,17 @@ function serializeList(listNode: JsonNode, indent: number): string[] {
|
|||
return lines
|
||||
}
|
||||
|
||||
// Adapter for tiptap's first-party renderTableToMarkdown. Only renderChildren is
|
||||
// actually invoked — the other helpers are stubs to satisfy the type.
|
||||
const tableRenderHelpers: MarkdownRendererHelpers = {
|
||||
renderChildren: (nodes) => {
|
||||
const arr = Array.isArray(nodes) ? nodes : [nodes]
|
||||
return arr.map(n => n.type === 'paragraph' ? nodeToText(n as JsonNode) : '').join('')
|
||||
},
|
||||
wrapInBlock: (prefix, content) => prefix + content,
|
||||
indent: (content) => content,
|
||||
}
|
||||
|
||||
// Serialize a single top-level block to its markdown string. Empty paragraphs (or blank-marker
|
||||
// paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown.
|
||||
function blockToMarkdown(node: JsonNode): string {
|
||||
|
|
@ -167,6 +187,8 @@ function blockToMarkdown(node: JsonNode): string {
|
|||
return serializeList(node, 0).join('\n')
|
||||
case 'taskBlock':
|
||||
return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'promptBlock':
|
||||
return '```prompt\n' + (node.attrs?.data as string || '') + '\n```'
|
||||
case 'trackBlock':
|
||||
return '```track\n' + (node.attrs?.data as string || '') + '\n```'
|
||||
case 'trackTargetOpen':
|
||||
|
|
@ -177,6 +199,8 @@ function blockToMarkdown(node: JsonNode): string {
|
|||
return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'embedBlock':
|
||||
return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'iframeBlock':
|
||||
return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'chartBlock':
|
||||
return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'tableBlock':
|
||||
|
|
@ -189,6 +213,8 @@ function blockToMarkdown(node: JsonNode): string {
|
|||
return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```'
|
||||
case 'mermaidBlock':
|
||||
return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```'
|
||||
case 'table':
|
||||
return renderTableToMarkdown(node as JSONContent, tableRenderHelpers).trim()
|
||||
case 'codeBlock': {
|
||||
const lang = (node.attrs?.language as string) || ''
|
||||
return '```' + lang + '\n' + nodeToText(node) + '\n```'
|
||||
|
|
@ -672,10 +698,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
ImageUploadPlaceholderExtension,
|
||||
TaskBlockExtension,
|
||||
TrackBlockExtension.configure({ notePath }),
|
||||
PromptBlockExtension.configure({ notePath }),
|
||||
TrackTargetOpenExtension,
|
||||
TrackTargetCloseExtension,
|
||||
ImageBlockExtension,
|
||||
EmbedBlockExtension,
|
||||
IframeBlockExtension,
|
||||
ChartBlockExtension,
|
||||
TableBlockExtension,
|
||||
CalendarBlockExtension,
|
||||
|
|
@ -693,6 +721,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
|
|||
TaskItem.configure({
|
||||
nested: true,
|
||||
}),
|
||||
TableKit.configure({
|
||||
table: { resizable: false },
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -59,14 +59,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; 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 [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
|
|
@ -109,7 +109,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates },
|
||||
|
|
@ -458,6 +458,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const model = activeConfig.model.trim()
|
||||
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
|
||||
const trackBlockModel = activeConfig.trackBlockModel.trim() || undefined
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
|
|
@ -466,6 +468,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
},
|
||||
model,
|
||||
knowledgeGraphModel,
|
||||
meetingNotesModel,
|
||||
trackBlockModel,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -1157,6 +1161,72 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes 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.meetingNotesModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: 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 className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block 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.trackBlockModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.trackBlockModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { trackBlockModel: 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 && (
|
||||
|
|
|
|||
|
|
@ -221,6 +221,76 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Meeting Notes Model
|
||||
</label>
|
||||
{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.meetingNotesModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<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 className="space-y-2 min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Track Block Model
|
||||
</label>
|
||||
{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.trackBlockModel}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { trackBlockModel: e.target.value })}
|
||||
placeholder={activeConfig.model || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.trackBlockModel || "__same__"}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { trackBlockModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="w-full truncate">
|
||||
<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 && (
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
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; 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 [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "", knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
|
|
@ -81,7 +81,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
const [googleCalendarConnecting, setGoogleCalendarConnecting] = useState(false)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => {
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates },
|
||||
|
|
@ -435,6 +435,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const model = activeConfig.model.trim()
|
||||
const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined
|
||||
const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined
|
||||
const trackBlockModel = activeConfig.trackBlockModel.trim() || undefined
|
||||
const providerConfig = {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
|
|
@ -443,6 +445,8 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
},
|
||||
model,
|
||||
knowledgeGraphModel,
|
||||
meetingNotesModel,
|
||||
trackBlockModel,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -459,7 +463,7 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
}
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, canTest, llmProvider, handleNext])
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.trackBlockModel, canTest, llmProvider, handleNext])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
|
|
|
|||
|
|
@ -196,14 +196,14 @@ const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
|
|||
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "" },
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
})
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
|
|
@ -229,7 +229,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => {
|
||||
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string; meetingNotesModel: string; trackBlockModel: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { ...prev[prov], ...updates },
|
||||
|
|
@ -302,6 +302,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
|
||||
models: savedModels,
|
||||
knowledgeGraphModel: e.knowledgeGraphModel || "",
|
||||
meetingNotesModel: e.meetingNotesModel || "",
|
||||
trackBlockModel: e.trackBlockModel || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -318,6 +320,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
|
||||
models: activeModels.length > 0 ? activeModels : [""],
|
||||
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
|
||||
meetingNotesModel: parsed.meetingNotesModel || "",
|
||||
trackBlockModel: parsed.trackBlockModel || "",
|
||||
};
|
||||
}
|
||||
return next;
|
||||
|
|
@ -391,6 +395,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
model: allModels[0] || "",
|
||||
models: allModels,
|
||||
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
|
||||
trackBlockModel: activeConfig.trackBlockModel.trim() || undefined,
|
||||
}
|
||||
const result = await window.ipc.invoke("models:test", providerConfig)
|
||||
if (result.success) {
|
||||
|
|
@ -423,6 +429,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
model: allModels[0],
|
||||
models: allModels,
|
||||
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
|
||||
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
|
||||
trackBlockModel: config.trackBlockModel.trim() || undefined,
|
||||
})
|
||||
setDefaultProvider(prov)
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
|
|
@ -452,6 +460,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
parsed.model = defModels[0] || ""
|
||||
parsed.models = defModels
|
||||
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
|
||||
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
|
||||
parsed.trackBlockModel = defConfig.trackBlockModel.trim() || undefined
|
||||
}
|
||||
await window.ipc.invoke("workspace:writeFile", {
|
||||
path: "config/models.json",
|
||||
|
|
@ -459,7 +469,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
})
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" },
|
||||
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "", meetingNotesModel: "", trackBlockModel: "" },
|
||||
}))
|
||||
setTestState({ status: "idle" })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
|
|
@ -649,6 +659,74 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meeting notes model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Meeting notes 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.meetingNotesModel}
|
||||
onChange={(e) => updateConfig(provider, { meetingNotesModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.meetingNotesModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { meetingNotesModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track block model */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Track block 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.trackBlockModel}
|
||||
onChange={(e) => updateConfig(provider, { trackBlockModel: e.target.value })}
|
||||
placeholder={primaryModel || "Enter model"}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.trackBlockModel || "__same__"}
|
||||
onValueChange={(value) => updateConfig(provider, { trackBlockModel: value === "__same__" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__same__">Same as assistant</SelectItem>
|
||||
{modelsForProvider.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name || m.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,18 @@ import {
|
|||
FilePlus,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Globe,
|
||||
AlertTriangle,
|
||||
HelpCircle,
|
||||
Mic,
|
||||
Network,
|
||||
Pencil,
|
||||
Radio,
|
||||
SearchIcon,
|
||||
SquarePen,
|
||||
Table2,
|
||||
Plug,
|
||||
Lightbulb,
|
||||
LoaderIcon,
|
||||
Settings,
|
||||
Square,
|
||||
|
|
@ -58,6 +63,7 @@ import {
|
|||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
|
|
@ -90,6 +96,7 @@ import { SettingsDialog } from "@/components/settings-dialog"
|
|||
import { toast } from "@/lib/toast"
|
||||
import { useBilling } from "@/hooks/useBilling"
|
||||
import { ServiceEvent } from "@x/shared/src/service-events.js"
|
||||
import type { MeetingTranscriptionState } from "@/hooks/useMeetingTranscription"
|
||||
import z from "zod"
|
||||
|
||||
interface TreeNode {
|
||||
|
|
@ -164,6 +171,7 @@ type SidebarContentPanelProps = {
|
|||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||
onToggleFolder?: (path: string) => void
|
||||
knowledgeActions: KnowledgeActions
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
runs?: RunListItem[]
|
||||
|
|
@ -172,6 +180,16 @@ type SidebarContentPanelProps = {
|
|||
tasksActions?: TasksActions
|
||||
backgroundTasks?: BackgroundTaskItem[]
|
||||
selectedBackgroundTask?: string | null
|
||||
onNewChat?: () => void
|
||||
onOpenSearch?: () => void
|
||||
meetingState?: MeetingTranscriptionState
|
||||
meetingSummarizing?: boolean
|
||||
meetingAvailable?: boolean
|
||||
onToggleMeeting?: () => void
|
||||
isBrowserOpen?: boolean
|
||||
onToggleBrowser?: () => void
|
||||
isSuggestedTopicsOpen?: boolean
|
||||
onOpenSuggestedTopics?: () => void
|
||||
} & React.ComponentProps<typeof Sidebar>
|
||||
|
||||
const sectionTabs: { id: ActiveSection; label: string }[] = [
|
||||
|
|
@ -387,6 +405,7 @@ export function SidebarContentPanel({
|
|||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
onToggleFolder,
|
||||
knowledgeActions,
|
||||
onVoiceNoteCreated,
|
||||
runs = [],
|
||||
|
|
@ -395,6 +414,16 @@ export function SidebarContentPanel({
|
|||
tasksActions,
|
||||
backgroundTasks = [],
|
||||
selectedBackgroundTask,
|
||||
onNewChat,
|
||||
onOpenSearch,
|
||||
meetingState = 'idle',
|
||||
meetingSummarizing = false,
|
||||
meetingAvailable = false,
|
||||
onToggleMeeting,
|
||||
isBrowserOpen = false,
|
||||
onToggleBrowser,
|
||||
isSuggestedTopicsOpen = false,
|
||||
onOpenSuggestedTopics,
|
||||
...props
|
||||
}: SidebarContentPanelProps) {
|
||||
const { activeSection, setActiveSection } = useSidebarSection()
|
||||
|
|
@ -488,6 +517,89 @@ export function SidebarContentPanel({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick action buttons */}
|
||||
<div className="titlebar-no-drag flex flex-col gap-0.5 px-2 pb-1">
|
||||
{onNewChat && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="size-4" />
|
||||
<span>New chat</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSearch}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
)}
|
||||
{meetingAvailable && onToggleMeeting && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMeeting}
|
||||
disabled={meetingState === 'connecting' || meetingState === 'stopping' || meetingSummarizing}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors disabled:pointer-events-none",
|
||||
meetingState === 'recording'
|
||||
? "text-red-500 hover:bg-sidebar-accent"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{meetingSummarizing || meetingState === 'connecting' ? (
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
) : meetingState === 'recording' ? (
|
||||
<Square className="size-4 animate-pulse" />
|
||||
) : (
|
||||
<Radio className="size-4" />
|
||||
)}
|
||||
<span>
|
||||
{meetingSummarizing
|
||||
? 'Generating notes…'
|
||||
: meetingState === 'connecting'
|
||||
? 'Starting…'
|
||||
: meetingState === 'recording'
|
||||
? 'Stop recording'
|
||||
: 'Take meeting notes'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{onToggleBrowser && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleBrowser}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isBrowserOpen
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Globe className="size-4" />
|
||||
<span>Run browser task</span>
|
||||
</button>
|
||||
)}
|
||||
{onOpenSuggestedTopics && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSuggestedTopics}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
isSuggestedTopicsOpen
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Lightbulb className="size-4" />
|
||||
<span>Suggested Topics</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{activeSection === "knowledge" && (
|
||||
|
|
@ -496,6 +608,7 @@ export function SidebarContentPanel({
|
|||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelectFile={onSelectFile}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={knowledgeActions}
|
||||
onVoiceNoteCreated={onVoiceNoteCreated}
|
||||
/>
|
||||
|
|
@ -884,6 +997,7 @@ function KnowledgeSection({
|
|||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelectFile,
|
||||
onToggleFolder,
|
||||
actions,
|
||||
onVoiceNoteCreated,
|
||||
}: {
|
||||
|
|
@ -891,6 +1005,7 @@ function KnowledgeSection({
|
|||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||
onToggleFolder?: (path: string) => void
|
||||
actions: KnowledgeActions
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
}) {
|
||||
|
|
@ -980,6 +1095,7 @@ function KnowledgeSection({
|
|||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelectFile}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -1008,9 +1124,7 @@ function countFiles(node: TreeNode): number {
|
|||
}
|
||||
|
||||
/** Display name overrides for top-level knowledge folders */
|
||||
const FOLDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
Notes: 'My Notes',
|
||||
}
|
||||
const FOLDER_DISPLAY_NAMES: Record<string, string> = {}
|
||||
|
||||
// Tree component for file browser
|
||||
function Tree({
|
||||
|
|
@ -1018,12 +1132,14 @@ function Tree({
|
|||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelect,
|
||||
onToggleFolder,
|
||||
actions,
|
||||
}: {
|
||||
item: TreeNode
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelect: (path: string, kind: "file" | "dir") => void
|
||||
onToggleFolder?: (path: string) => void
|
||||
actions: KnowledgeActions
|
||||
}) {
|
||||
const isDir = item.kind === 'dir'
|
||||
|
|
@ -1160,15 +1276,15 @@ function Tree({
|
|||
)
|
||||
}
|
||||
|
||||
// Top-level knowledge folders (except Notes) open bases view — render as flat items
|
||||
// Top-level knowledge folders open bases view — render as flat items
|
||||
const parts = item.path.split('/')
|
||||
const isBasesFolder = isDir && parts.length === 2 && parts[0] === 'knowledge' && parts[1] !== 'Notes'
|
||||
const isBasesFolder = isDir && parts.length === 2 && parts[0] === 'knowledge'
|
||||
|
||||
if (isBasesFolder) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuItem className="group/file-item">
|
||||
<SidebarMenuButton onClick={() => onSelect(item.path, item.kind)}>
|
||||
<Folder className="size-4 shrink-0" />
|
||||
<div className="flex w-full items-center gap-1 min-w-0">
|
||||
|
|
@ -1176,6 +1292,38 @@ function Tree({
|
|||
<span className="text-xs text-sidebar-foreground/50 tabular-nums shrink-0">{countFiles(item)}</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
{onToggleFolder && (item.children?.length ?? 0) > 0 && (
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
aria-label={isExpanded ? "Collapse folder" : "Expand folder"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleFolder(item.path)
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"transition-transform",
|
||||
isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</SidebarMenuAction>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<SidebarMenuSub>
|
||||
{(item.children ?? []).map((subItem, index) => (
|
||||
<Tree
|
||||
key={index}
|
||||
item={subItem}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
{contextMenuContent}
|
||||
|
|
@ -1240,6 +1388,7 @@ function Tree({
|
|||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
onToggleFolder={onToggleFolder}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
246
apps/x/apps/renderer/src/components/suggested-topics-view.tsx
Normal file
246
apps/x/apps/renderer/src/components/suggested-topics-view.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ArrowRight, Lightbulb, Loader2 } from 'lucide-react'
|
||||
import { SuggestedTopicBlockSchema, type SuggestedTopicBlock } from '@x/shared/dist/blocks.js'
|
||||
|
||||
const SUGGESTED_TOPICS_PATH = 'suggested-topics.md'
|
||||
const LEGACY_SUGGESTED_TOPICS_PATHS = [
|
||||
'config/suggested-topics.md',
|
||||
'knowledge/Notes/Suggested Topics.md',
|
||||
]
|
||||
|
||||
/** Parse suggestedtopic code-fence blocks from the markdown file content. */
|
||||
function parseTopics(content: string): SuggestedTopicBlock[] {
|
||||
const topics: SuggestedTopicBlock[] = []
|
||||
const regex = /```suggestedtopic\s*\n([\s\S]*?)```/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
try {
|
||||
const parsed = JSON.parse(match[1].trim())
|
||||
const topic = SuggestedTopicBlockSchema.parse(parsed)
|
||||
topics.push(topic)
|
||||
} catch {
|
||||
// Skip malformed blocks
|
||||
}
|
||||
}
|
||||
|
||||
if (topics.length > 0) return topics
|
||||
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'))
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
const topic = SuggestedTopicBlockSchema.parse(parsed)
|
||||
topics.push(topic)
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return topics
|
||||
}
|
||||
|
||||
function serializeTopics(topics: SuggestedTopicBlock[]): string {
|
||||
const blocks = topics.map((topic) => [
|
||||
'```suggestedtopic',
|
||||
JSON.stringify(topic),
|
||||
'```',
|
||||
].join('\n'))
|
||||
|
||||
return ['# Suggested Topics', ...blocks].join('\n\n') + '\n'
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
Meetings: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
Projects: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||
People: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
Organizations: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400',
|
||||
Topics: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
||||
}
|
||||
|
||||
function getCategoryColor(category?: string): string {
|
||||
if (!category) return 'bg-muted text-muted-foreground'
|
||||
return CATEGORY_COLORS[category] ?? 'bg-muted text-muted-foreground'
|
||||
}
|
||||
|
||||
interface TopicCardProps {
|
||||
topic: SuggestedTopicBlock
|
||||
onTrack: () => void
|
||||
isRemoving: boolean
|
||||
}
|
||||
|
||||
function TopicCard({ topic, onTrack, isRemoving }: TopicCardProps) {
|
||||
return (
|
||||
<div className="group flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-5 transition-all hover:border-border hover:shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold leading-snug text-foreground">
|
||||
{topic.title}
|
||||
</h3>
|
||||
{topic.category && (
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${getCategoryColor(topic.category)}`}
|
||||
>
|
||||
{topic.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{topic.description}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTrack}
|
||||
disabled={isRemoving}
|
||||
className="mt-auto flex w-fit items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isRemoving ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Tracking…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Track
|
||||
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SuggestedTopicsViewProps {
|
||||
onExploreTopic: (topic: SuggestedTopicBlock) => void
|
||||
}
|
||||
|
||||
export function SuggestedTopicsView({ onExploreTopic }: SuggestedTopicsViewProps) {
|
||||
const [topics, setTopics] = useState<SuggestedTopicBlock[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [removingIndex, setRemovingIndex] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
let result
|
||||
try {
|
||||
result = await window.ipc.invoke('workspace:readFile', {
|
||||
path: SUGGESTED_TOPICS_PATH,
|
||||
})
|
||||
} catch {
|
||||
let legacyResult: { data?: string } | null = null
|
||||
let legacyPath: string | null = null
|
||||
for (const path of LEGACY_SUGGESTED_TOPICS_PATHS) {
|
||||
try {
|
||||
legacyResult = await window.ipc.invoke('workspace:readFile', { path })
|
||||
legacyPath = path
|
||||
break
|
||||
} catch {
|
||||
// Try next legacy location.
|
||||
}
|
||||
}
|
||||
if (!legacyResult || !legacyPath || legacyResult.data === undefined) {
|
||||
throw new Error('Suggested topics file not found')
|
||||
}
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: SUGGESTED_TOPICS_PATH,
|
||||
data: legacyResult.data,
|
||||
opts: { encoding: 'utf8' },
|
||||
})
|
||||
await window.ipc.invoke('workspace:remove', {
|
||||
path: legacyPath,
|
||||
opts: { trash: true },
|
||||
})
|
||||
result = legacyResult
|
||||
}
|
||||
if (cancelled) return
|
||||
if (result.data) {
|
||||
setTopics(parseTopics(result.data))
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError('No suggested topics yet. Check back once your knowledge graph has more data.')
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const handleTrack = useCallback(
|
||||
async (topic: SuggestedTopicBlock, topicIndex: number) => {
|
||||
if (removingIndex !== null) return
|
||||
const nextTopics = topics.filter((_, idx) => idx !== topicIndex)
|
||||
setRemovingIndex(topicIndex)
|
||||
setError(null)
|
||||
try {
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: SUGGESTED_TOPICS_PATH,
|
||||
data: serializeTopics(nextTopics),
|
||||
opts: { encoding: 'utf8' },
|
||||
})
|
||||
setTopics(nextTopics)
|
||||
} catch (err) {
|
||||
console.error('Failed to remove suggested topic:', err)
|
||||
setError('Failed to update suggested topics. Please try again.')
|
||||
return
|
||||
} finally {
|
||||
setRemovingIndex(null)
|
||||
}
|
||||
|
||||
onExploreTopic(topic)
|
||||
},
|
||||
[onExploreTopic, removingIndex, topics],
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || topics.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||
<div className="rounded-full bg-muted p-3">
|
||||
<Lightbulb className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error ?? 'No suggested topics yet. Check back once your knowledge graph has more data.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 border-b border-border px-6 py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="size-5 text-primary" />
|
||||
<h2 className="text-base font-semibold text-foreground">Suggested Topics</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Suggested notes surfaced from your knowledge graph. Track one to start a tracking note.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{topics.map((topic, i) => (
|
||||
<TopicCard
|
||||
key={`${topic.title}-${i}`}
|
||||
topic={topic}
|
||||
onTrack={() => { void handleTrack(topic, i) }}
|
||||
isRemoving={removingIndex === i}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -156,6 +156,8 @@ export function TrackModal() {
|
|||
const lastRunAt = track?.lastRunAt ?? ''
|
||||
const lastRunId = track?.lastRunId ?? ''
|
||||
const lastRunSummary = track?.lastRunSummary ?? ''
|
||||
const model = track?.model ?? ''
|
||||
const provider = track?.provider ?? ''
|
||||
const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule])
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
|
@ -393,6 +395,12 @@ export function TrackModal() {
|
|||
<dt>Track ID</dt><dd><code>{trackId}</code></dd>
|
||||
<dt>File</dt><dd><code>{detail.filePath}</code></dd>
|
||||
<dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd>
|
||||
{model && (<>
|
||||
<dt>Model</dt><dd><code>{model}</code></dd>
|
||||
</>)}
|
||||
{provider && (<>
|
||||
<dt>Provider</dt><dd><code>{provider}</code></dd>
|
||||
</>)}
|
||||
{lastRunAt && (<>
|
||||
<dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd>
|
||||
</>)}
|
||||
|
|
|
|||
256
apps/x/apps/renderer/src/extensions/iframe-block.tsx
Normal file
256
apps/x/apps/renderer/src/extensions/iframe-block.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Globe, X } from 'lucide-react'
|
||||
import { blocks } from '@x/shared'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height'
|
||||
const IFRAME_HEIGHT_CACHE_PREFIX = 'rowboat:iframe-height:'
|
||||
const DEFAULT_IFRAME_HEIGHT = 560
|
||||
const MIN_IFRAME_HEIGHT = 240
|
||||
const HEIGHT_UPDATE_THRESHOLD = 4
|
||||
const AUTO_RESIZE_SETTLE_MS = 160
|
||||
const LOAD_FALLBACK_READY_MS = 180
|
||||
const DEFAULT_IFRAME_ALLOW = [
|
||||
'accelerometer',
|
||||
'autoplay',
|
||||
'camera',
|
||||
'clipboard-read',
|
||||
'clipboard-write',
|
||||
'display-capture',
|
||||
'encrypted-media',
|
||||
'fullscreen',
|
||||
'geolocation',
|
||||
'microphone',
|
||||
].join('; ')
|
||||
|
||||
function getIframeHeightCacheKey(url: string): string {
|
||||
return `${IFRAME_HEIGHT_CACHE_PREFIX}${url}`
|
||||
}
|
||||
|
||||
function readCachedIframeHeight(url: string, fallbackHeight: number): number {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(getIframeHeightCacheKey(url))
|
||||
if (!raw) return fallbackHeight
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(parsed)) return fallbackHeight
|
||||
return Math.max(MIN_IFRAME_HEIGHT, parsed)
|
||||
} catch {
|
||||
return fallbackHeight
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedIframeHeight(url: string, height: number): void {
|
||||
try {
|
||||
window.localStorage.setItem(getIframeHeightCacheKey(url), String(height))
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
function parseIframeHeightMessage(event: MessageEvent): { height: number } | null {
|
||||
const data = event.data
|
||||
if (!data || typeof data !== 'object') return null
|
||||
|
||||
const candidate = data as { type?: unknown; height?: unknown }
|
||||
if (candidate.type !== IFRAME_HEIGHT_MESSAGE) return null
|
||||
if (typeof candidate.height !== 'number' || !Number.isFinite(candidate.height)) return null
|
||||
|
||||
return {
|
||||
height: Math.max(MIN_IFRAME_HEIGHT, Math.ceil(candidate.height)),
|
||||
}
|
||||
}
|
||||
|
||||
function IframeBlockView({ node, deleteNode }: { node: { attrs: Record<string, unknown> }; deleteNode: () => void }) {
|
||||
const raw = node.attrs.data as string
|
||||
let config: blocks.IframeBlock | null = null
|
||||
|
||||
try {
|
||||
config = blocks.IframeBlockSchema.parse(JSON.parse(raw))
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
|
||||
<div className="iframe-block-card iframe-block-error">
|
||||
<Globe size={16} />
|
||||
<span>Invalid iframe block</span>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const visibleTitle = config.title?.trim() || ''
|
||||
const title = visibleTitle || 'Embedded page'
|
||||
const allow = config.allow || DEFAULT_IFRAME_ALLOW
|
||||
const initialHeight = config.height ?? DEFAULT_IFRAME_HEIGHT
|
||||
const [frameHeight, setFrameHeight] = useState(() => readCachedIframeHeight(config.url, initialHeight))
|
||||
const [frameReady, setFrameReady] = useState(false)
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
||||
const loadFallbackTimerRef = useRef<number | null>(null)
|
||||
const autoResizeReadyTimerRef = useRef<number | null>(null)
|
||||
const frameReadyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
setFrameHeight(readCachedIframeHeight(config.url, initialHeight))
|
||||
setFrameReady(false)
|
||||
frameReadyRef.current = false
|
||||
if (loadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(loadFallbackTimerRef.current)
|
||||
loadFallbackTimerRef.current = null
|
||||
}
|
||||
if (autoResizeReadyTimerRef.current !== null) {
|
||||
window.clearTimeout(autoResizeReadyTimerRef.current)
|
||||
autoResizeReadyTimerRef.current = null
|
||||
}
|
||||
}, [config.url, initialHeight, raw])
|
||||
|
||||
useEffect(() => {
|
||||
frameReadyRef.current = frameReady
|
||||
}, [frameReady])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const iframeWindow = iframeRef.current?.contentWindow
|
||||
if (!iframeWindow || event.source !== iframeWindow) return
|
||||
|
||||
const message = parseIframeHeightMessage(event)
|
||||
if (!message) return
|
||||
|
||||
if (loadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(loadFallbackTimerRef.current)
|
||||
loadFallbackTimerRef.current = null
|
||||
}
|
||||
if (autoResizeReadyTimerRef.current !== null) {
|
||||
window.clearTimeout(autoResizeReadyTimerRef.current)
|
||||
}
|
||||
writeCachedIframeHeight(config.url, message.height)
|
||||
setFrameHeight((currentHeight) => (
|
||||
Math.abs(currentHeight - message.height) < HEIGHT_UPDATE_THRESHOLD ? currentHeight : message.height
|
||||
))
|
||||
|
||||
if (!frameReadyRef.current) {
|
||||
autoResizeReadyTimerRef.current = window.setTimeout(() => {
|
||||
setFrameReady(true)
|
||||
frameReadyRef.current = true
|
||||
autoResizeReadyTimerRef.current = null
|
||||
}, AUTO_RESIZE_SETTLE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
return () => window.removeEventListener('message', handleMessage)
|
||||
}, [config.url])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (loadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(loadFallbackTimerRef.current)
|
||||
}
|
||||
if (autoResizeReadyTimerRef.current !== null) {
|
||||
window.clearTimeout(autoResizeReadyTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="iframe-block-wrapper" data-type="iframe-block">
|
||||
<div className="iframe-block-card">
|
||||
<button
|
||||
className="iframe-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete iframe block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{visibleTitle && <div className="iframe-block-title">{visibleTitle}</div>}
|
||||
<div
|
||||
className={`iframe-block-frame-shell${frameReady ? ' iframe-block-frame-shell-ready' : ' iframe-block-frame-shell-loading'}`}
|
||||
style={{ height: frameHeight }}
|
||||
>
|
||||
{!frameReady && (
|
||||
<div className="iframe-block-loading-overlay" aria-hidden="true">
|
||||
<div className="iframe-block-loading-bar" />
|
||||
<div className="iframe-block-loading-copy">Loading embed…</div>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={config.url}
|
||||
title={title}
|
||||
className="iframe-block-frame"
|
||||
loading="lazy"
|
||||
onLoad={() => {
|
||||
if (loadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(loadFallbackTimerRef.current)
|
||||
}
|
||||
loadFallbackTimerRef.current = window.setTimeout(() => {
|
||||
setFrameReady(true)
|
||||
loadFallbackTimerRef.current = null
|
||||
}, LOAD_FALLBACK_READY_MS)
|
||||
}}
|
||||
allow={allow}
|
||||
allowFullScreen
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-modals allow-downloads"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const IframeBlockExtension = Node.create({
|
||||
name: 'iframeBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '{}',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-iframe')) {
|
||||
return { data: code.textContent || '{}' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'iframe-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(IframeBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```iframe\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
145
apps/x/apps/renderer/src/extensions/prompt-block.tsx
Normal file
145
apps/x/apps/renderer/src/extensions/prompt-block.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { z } from 'zod'
|
||||
import { useMemo } from 'react'
|
||||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { PromptBlockSchema } from '@x/shared/dist/prompt-block.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim()
|
||||
if (clean.length <= maxLen) return clean
|
||||
return clean.slice(0, maxLen).trimEnd() + '…'
|
||||
}
|
||||
|
||||
function PromptBlockView({ node, extension }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
extension: { options: { notePath?: string } }
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
|
||||
const prompt = useMemo<z.infer<typeof PromptBlockSchema> | null>(() => {
|
||||
try {
|
||||
return PromptBlockSchema.parse(parseYaml(raw))
|
||||
} catch { return null }
|
||||
}, [raw])
|
||||
|
||||
const notePath = extension.options.notePath
|
||||
|
||||
const handleRun = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!prompt) return
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-prompt', {
|
||||
detail: {
|
||||
instruction: prompt.instruction,
|
||||
label: prompt.label,
|
||||
filePath: notePath,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleRun(e as unknown as React.MouseEvent)
|
||||
}
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
return (
|
||||
<NodeViewWrapper data-type="prompt-block">
|
||||
<div className="my-2 rounded-xl border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
Invalid prompt block — expected YAML with <code>label</code> and <code>instruction</code>.
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-type="prompt-block">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleRun}
|
||||
onKeyDown={handleKey}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title={prompt.instruction}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-card p-3 pr-4 text-left transition-colors hover:bg-accent/50 cursor-pointer w-full my-2"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium">{prompt.label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{truncate(prompt.instruction, 80)}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="shrink-0 text-xs h-8 rounded-lg pointer-events-none">
|
||||
Run
|
||||
</Button>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const PromptBlockExtension = Node.create({
|
||||
name: 'promptBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
notePath: undefined as string | undefined,
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
data: {
|
||||
default: '',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'pre',
|
||||
priority: 60,
|
||||
getAttrs(element) {
|
||||
const code = element.querySelector('code')
|
||||
if (!code) return false
|
||||
const cls = code.className || ''
|
||||
if (cls.includes('language-prompt')) {
|
||||
return { data: code.textContent || '' }
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, unknown> }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'prompt-block' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(PromptBlockView)
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize(state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, node: { attrs: { data: string } }) {
|
||||
state.write('```prompt\n' + node.attrs.data + '\n```')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
parse: {
|
||||
// handled by parseHTML
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -36,11 +36,12 @@ function TrackBlockView({ node, deleteNode, extension }: {
|
|||
extension: { options: { notePath?: string } }
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
const cleaned = raw.replace(/[\u200B-\u200D\uFEFF]/g, "");
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
try {
|
||||
return TrackBlockSchema.parse(parseYaml(raw))
|
||||
} catch { return null }
|
||||
return TrackBlockSchema.parse(parseYaml(cleaned))
|
||||
} catch(error) { console.error('error', error); return null }
|
||||
}, [raw]) as z.infer<typeof TrackBlockSchema> | null;
|
||||
|
||||
const trackId = track?.trackId ?? ''
|
||||
|
|
|
|||
|
|
@ -146,6 +146,48 @@
|
|||
color: #eb5757;
|
||||
}
|
||||
|
||||
/* Native GFM tables (distinct from the custom tableBlock above) */
|
||||
.tiptap-editor .ProseMirror .tableWrapper {
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 13px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror table th,
|
||||
.tiptap-editor .ProseMirror table td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror table th {
|
||||
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror table p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror table .selectedCell::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.tiptap-editor .ProseMirror hr {
|
||||
border: none;
|
||||
|
|
@ -764,6 +806,7 @@
|
|||
/* Shared block styles (image, embed, chart, table) */
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .iframe-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper,
|
||||
.tiptap-editor .ProseMirror .calendar-block-wrapper,
|
||||
|
|
@ -775,6 +818,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .iframe-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-card,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card,
|
||||
|
|
@ -793,6 +837,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .iframe-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card:hover,
|
||||
|
|
@ -805,6 +850,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-wrapper.ProseMirror-selectednode .image-block-card,
|
||||
.tiptap-editor .ProseMirror .embed-block-wrapper.ProseMirror-selectednode .embed-block-card,
|
||||
.tiptap-editor .ProseMirror .iframe-block-wrapper.ProseMirror-selectednode .iframe-block-card,
|
||||
.tiptap-editor .ProseMirror .chart-block-wrapper.ProseMirror-selectednode .chart-block-card,
|
||||
.tiptap-editor .ProseMirror .table-block-wrapper.ProseMirror-selectednode .table-block-card,
|
||||
.tiptap-editor .ProseMirror .calendar-block-wrapper.ProseMirror-selectednode .calendar-block-card,
|
||||
|
|
@ -817,6 +863,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .iframe-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-delete,
|
||||
.tiptap-editor .ProseMirror .calendar-block-delete,
|
||||
|
|
@ -843,6 +890,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-card:hover .image-block-delete,
|
||||
.tiptap-editor .ProseMirror .embed-block-card:hover .embed-block-delete,
|
||||
.tiptap-editor .ProseMirror .iframe-block-card:hover .iframe-block-delete,
|
||||
.tiptap-editor .ProseMirror .chart-block-card:hover .chart-block-delete,
|
||||
.tiptap-editor .ProseMirror .table-block-card:hover .table-block-delete,
|
||||
.tiptap-editor .ProseMirror .calendar-block-card:hover .calendar-block-delete,
|
||||
|
|
@ -854,6 +902,7 @@
|
|||
|
||||
.tiptap-editor .ProseMirror .image-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .embed-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .iframe-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .chart-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .table-block-delete:hover,
|
||||
.tiptap-editor .ProseMirror .calendar-block-delete:hover,
|
||||
|
|
@ -943,6 +992,103 @@
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Iframe block */
|
||||
.tiptap-editor .ProseMirror .iframe-block-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-frame-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: height 0.18s ease;
|
||||
background:
|
||||
radial-gradient(circle at top left, color-mix(in srgb, var(--primary) 14%, transparent), transparent 45%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--muted) 65%, transparent), color-mix(in srgb, var(--background) 95%, transparent));
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background:
|
||||
radial-gradient(circle at top left, color-mix(in srgb, var(--primary) 10%, transparent), transparent 42%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--muted) 88%, transparent), color-mix(in srgb, var(--background) 98%, transparent));
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-loading-bar {
|
||||
width: min(220px, 46%);
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--primary) 60%, transparent) 50%, transparent 100%),
|
||||
color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
background-size: 180px 100%, auto;
|
||||
background-repeat: no-repeat;
|
||||
animation: iframe-block-loading-sweep 1.05s linear infinite;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-loading-copy {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-frame-shell-ready .iframe-block-loading-overlay {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
opacity: 1;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-frame-shell-loading .iframe-block-frame {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .iframe-block-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@keyframes iframe-block-loading-sweep {
|
||||
from {
|
||||
background-position: -180px 0, 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: calc(100% + 180px) 0, 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chart block */
|
||||
.tiptap-editor .ProseMirror .chart-block-title {
|
||||
font-size: 14px;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.j
|
|||
import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js";
|
||||
import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js";
|
||||
import { MessageEvent } from "@x/shared/dist/runs.js";
|
||||
import { createRun } from "../runs/runs.js";
|
||||
import z from "zod";
|
||||
|
||||
const DEFAULT_STARTING_MESSAGE = "go";
|
||||
|
|
@ -162,8 +163,8 @@ async function runAgent(
|
|||
});
|
||||
|
||||
try {
|
||||
// Create a new run
|
||||
const run = await runsRepo.create({ agentId: agentName });
|
||||
// Create a new run via core (resolves agent + default model+provider).
|
||||
const run = await createRun({ agentId: agentName });
|
||||
console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`);
|
||||
|
||||
// Add the starting message as a user message
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu
|
|||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { createProvider } from "../models/models.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import { getGatewayProvider } from "../models/gateway.js";
|
||||
import { resolveProviderConfig } from "../models/defaults.js";
|
||||
import { IAgentsRepo } from "./repo.js";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
|
|
@ -194,6 +193,19 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
await this.runsRepo.appendEvents(runId, [stoppedEvent]);
|
||||
await this.bus.publish(stoppedEvent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Run ${runId} failed:`, error);
|
||||
const message = error instanceof Error
|
||||
? (error.stack || error.message || error.name)
|
||||
: typeof error === "string" ? error : JSON.stringify(error);
|
||||
const errorEvent: z.infer<typeof RunEvent> = {
|
||||
runId,
|
||||
type: "error",
|
||||
error: message,
|
||||
subflow: [],
|
||||
};
|
||||
await this.runsRepo.appendEvents(runId, [errorEvent]);
|
||||
await this.bus.publish(errorEvent);
|
||||
} finally {
|
||||
this.abortRegistry.cleanup(runId);
|
||||
await this.runsLock.release(runId);
|
||||
|
|
@ -636,6 +648,8 @@ export class AgentState {
|
|||
runId: string | null = null;
|
||||
agent: z.infer<typeof Agent> | null = null;
|
||||
agentName: string | null = null;
|
||||
runModel: string | null = null;
|
||||
runProvider: string | null = null;
|
||||
messages: z.infer<typeof MessageList> = [];
|
||||
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
||||
subflowStates: Record<string, AgentState> = {};
|
||||
|
|
@ -749,13 +763,18 @@ export class AgentState {
|
|||
case "start":
|
||||
this.runId = event.runId;
|
||||
this.agentName = event.agentName;
|
||||
this.runModel = event.model;
|
||||
this.runProvider = event.provider;
|
||||
break;
|
||||
case "spawn-subflow":
|
||||
// Seed the subflow state with its agent so downstream loadAgent works.
|
||||
// Subflows inherit the parent run's model+provider — there's one pair per run.
|
||||
if (!this.subflowStates[event.toolCallId]) {
|
||||
this.subflowStates[event.toolCallId] = new AgentState();
|
||||
}
|
||||
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
||||
this.subflowStates[event.toolCallId].runModel = this.runModel;
|
||||
this.subflowStates[event.toolCallId].runProvider = this.runProvider;
|
||||
break;
|
||||
case "message":
|
||||
this.messages.push(event.message);
|
||||
|
|
@ -844,40 +863,32 @@ export async function* streamAgent({
|
|||
yield event;
|
||||
}
|
||||
|
||||
const modelConfig = await modelConfigRepo.getConfig();
|
||||
if (!modelConfig) {
|
||||
throw new Error("Model config not found");
|
||||
}
|
||||
|
||||
// set up agent
|
||||
const agent = await loadAgent(state.agentName!);
|
||||
|
||||
// set up tools
|
||||
const tools = await buildTools(agent);
|
||||
|
||||
// set up provider + model
|
||||
const signedIn = await isSignedIn();
|
||||
const provider = signedIn
|
||||
? await getGatewayProvider()
|
||||
: createProvider(modelConfig.provider);
|
||||
const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"];
|
||||
const isKgAgent = knowledgeGraphAgents.includes(state.agentName!);
|
||||
const isInlineTaskAgent = state.agentName === "inline_task_agent";
|
||||
const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model;
|
||||
const defaultKgModel = signedIn ? "anthropic/claude-haiku-4.5" : defaultModel;
|
||||
const defaultInlineTaskModel = signedIn ? "anthropic/claude-sonnet-4.6" : defaultModel;
|
||||
const modelId = isInlineTaskAgent
|
||||
? defaultInlineTaskModel
|
||||
: (isKgAgent && modelConfig.knowledgeGraphModel)
|
||||
? modelConfig.knowledgeGraphModel
|
||||
: isKgAgent ? defaultKgModel : defaultModel;
|
||||
// model+provider were resolved and frozen on the run at runs:create time.
|
||||
// Look up the named provider's current credentials from models.json and
|
||||
// instantiate the LLM client. No selection happens here.
|
||||
if (!state.runModel || !state.runProvider) {
|
||||
throw new Error(`Run ${runId} is missing model/provider on its start event`);
|
||||
}
|
||||
const modelId = state.runModel;
|
||||
const providerConfig = await resolveProviderConfig(state.runProvider);
|
||||
const provider = createProvider(providerConfig);
|
||||
const model = provider.languageModel(modelId);
|
||||
logger.log(`using model: ${modelId}`);
|
||||
logger.log(`using model: ${modelId} (provider: ${state.runProvider})`);
|
||||
|
||||
let loopCounter = 0;
|
||||
let voiceInput = false;
|
||||
let voiceOutput: 'summary' | 'full' | null = null;
|
||||
let searchEnabled = false;
|
||||
let middlePaneContext:
|
||||
| { kind: 'note'; path: string; content: string }
|
||||
| { kind: 'browser'; url: string; title: string }
|
||||
| null = null;
|
||||
while (true) {
|
||||
// Check abort at the top of each iteration
|
||||
signal.throwIfAborted();
|
||||
|
|
@ -938,27 +949,40 @@ export async function* streamAgent({
|
|||
subflow: [],
|
||||
});
|
||||
let result: unknown = null;
|
||||
if (agent.tools![toolCall.toolName].type === "agent") {
|
||||
const subflowState = state.subflowStates[toolCallId];
|
||||
for await (const event of streamAgent({
|
||||
state: subflowState,
|
||||
idGenerator,
|
||||
runId,
|
||||
messageQueue,
|
||||
modelConfigRepo,
|
||||
signal,
|
||||
abortRegistry,
|
||||
})) {
|
||||
yield* processEvent({
|
||||
...event,
|
||||
subflow: [toolCallId, ...event.subflow],
|
||||
});
|
||||
try {
|
||||
if (agent.tools![toolCall.toolName].type === "agent") {
|
||||
const subflowState = state.subflowStates[toolCallId];
|
||||
for await (const event of streamAgent({
|
||||
state: subflowState,
|
||||
idGenerator,
|
||||
runId,
|
||||
messageQueue,
|
||||
modelConfigRepo,
|
||||
signal,
|
||||
abortRegistry,
|
||||
})) {
|
||||
yield* processEvent({
|
||||
...event,
|
||||
subflow: [toolCallId, ...event.subflow],
|
||||
});
|
||||
}
|
||||
if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) {
|
||||
result = subflowState.finalResponse();
|
||||
}
|
||||
} else {
|
||||
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry });
|
||||
}
|
||||
if (!subflowState.getPendingAskHumans().length && !subflowState.getPendingPermissions().length) {
|
||||
result = subflowState.finalResponse();
|
||||
} catch (error) {
|
||||
if ((error instanceof Error && error.name === "AbortError") || signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry });
|
||||
const message = error instanceof Error ? (error.message || error.name) : String(error);
|
||||
_logger.log('tool failed', message);
|
||||
result = {
|
||||
success: false,
|
||||
error: message,
|
||||
toolName: toolCall.toolName,
|
||||
};
|
||||
}
|
||||
const resultPayload = result === undefined ? null : result;
|
||||
const resultMsg: z.infer<typeof ToolMessage> = {
|
||||
|
|
@ -1005,6 +1029,9 @@ export async function* streamAgent({
|
|||
if (msg.voiceOutput) {
|
||||
voiceOutput = msg.voiceOutput;
|
||||
}
|
||||
// Middle pane is NOT sticky — it should reflect the state at the moment of the
|
||||
// latest user message. If the user closed the pane between messages, clear it.
|
||||
middlePaneContext = msg.middlePaneContext ?? null;
|
||||
loopLogger.log('dequeued user message', msg.messageId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
|
|
@ -1051,6 +1078,19 @@ export async function* streamAgent({
|
|||
if (agentNotesContext) {
|
||||
instructionsWithDateTime += `\n\n${agentNotesContext}`;
|
||||
}
|
||||
// Always inject a Middle Pane section so the LLM has a clear, up-to-date signal
|
||||
// that supersedes any earlier middle-pane mention in the conversation history.
|
||||
const middlePaneHeader = `\n\n# Middle Pane (Current State)\nThis section reflects what the user has open in the middle pane RIGHT NOW, at the time of their latest message. **This is authoritative and overrides any earlier mention of a note or web page in this conversation** — if the conversation history references a different note or browser page, the user has since closed or navigated away from it. Do not treat earlier context as current.\n\n`;
|
||||
if (!middlePaneContext) {
|
||||
loopLogger.log('injecting middle pane context (empty)');
|
||||
instructionsWithDateTime += `${middlePaneHeader}**Nothing relevant is open in the middle pane right now.** The user is not looking at any note or web page. If earlier in this conversation you referenced a note or browser page as "what the user is viewing", that is no longer accurate — do not refer to it as currently open. Answer the user's latest message on its own merits.`;
|
||||
} else if (middlePaneContext.kind === 'note') {
|
||||
loopLogger.log('injecting middle pane context (note)', middlePaneContext.path);
|
||||
instructionsWithDateTime += `${middlePaneHeader}The user has a note open. Its path and full content are provided below so you can reference it when relevant.\n\n**How to use this context:**\n- The user may or may not be talking about this note. Do NOT assume every message is about it.\n- Only reference or act on this note when the user's message clearly relates to it (e.g. "this note", "what I'm looking at", "here", "above", "below", or questions whose subject is plainly this note's content).\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see this note unless it is relevant to the answer.\n\n## Open note path\n${middlePaneContext.path}\n\n## Open note content\n\`\`\`\n${middlePaneContext.content}\n\`\`\``;
|
||||
} else if (middlePaneContext.kind === 'browser') {
|
||||
loopLogger.log('injecting middle pane context (browser)', middlePaneContext.url);
|
||||
instructionsWithDateTime += `${middlePaneHeader}The user has the embedded browser open and is viewing a web page. Only the URL and page title are shown below — the page content itself is NOT included here. If you need the page content to answer, use the browser tools available to you to read the page.\n\n**How to use this context:**\n- The user may or may not be talking about this page. Do NOT assume every message is about it.\n- Only reference or act on this page when the user's message clearly relates to it (e.g. "this page", "this article", "what I'm looking at", "this site", "summarize this").\n- For unrelated questions (general chat, questions about other notes, tasks, emails, calendar, etc.), ignore this context entirely and answer normally.\n- Do not mention that you can see the browser unless it is relevant to the answer.\n\n## Current page\nURL: ${middlePaneContext.url}\nTitle: ${middlePaneContext.title}`;
|
||||
}
|
||||
}
|
||||
if (voiceInput) {
|
||||
loopLogger.log('voice input enabled, injecting voice input prompt');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { skillCatalog } from "./skills/index.js"; // eslint-disable-line @typescript-eslint/no-unused-vars -- used in template literal
|
||||
import { skillCatalog, buildSkillCatalog } from "./skills/index.js";
|
||||
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||
|
|
@ -12,15 +12,7 @@ const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
|||
*/
|
||||
async function getComposioToolsPrompt(): Promise<string> {
|
||||
if (!(await isComposioConfigured())) {
|
||||
return `
|
||||
## Composio Integrations
|
||||
|
||||
**Composio is not configured.** Composio enables integrations with third-party services like Google Sheets, GitHub, Slack, Jira, Notion, LinkedIn, and 20+ others.
|
||||
|
||||
When the user asks to interact with any third-party service (e.g., "connect to Google Sheets", "create a GitHub issue"), do NOT attempt to write code, use shell commands, or load the composio-integration skill. Instead, let the user know that these integrations are available through Composio, and they can enable them by adding their Composio API key in **Settings > Tools Library**. They can get their key from https://app.composio.dev/settings.
|
||||
|
||||
**Exception — Email and Calendar:** For email-related requests (reading emails, sending emails, drafting replies) or calendar-related requests (checking schedule, listing events), do NOT direct the user to Composio. Instead, tell them to connect their email and calendar in **Settings > Connected Accounts**.
|
||||
`;
|
||||
return '';
|
||||
}
|
||||
|
||||
const connectedToolkits = composioAccountsRepo.getConnectedToolkits();
|
||||
|
|
@ -37,7 +29,29 @@ Load the \`composio-integration\` skill when the user asks to interact with any
|
|||
`;
|
||||
}
|
||||
|
||||
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.
|
||||
function buildStaticInstructions(composioEnabled: boolean, catalog: string): string {
|
||||
// Conditionally include Composio-related instruction sections
|
||||
const emailDraftSuffix = composioEnabled
|
||||
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
|
||||
: ` Do NOT load this skill for reading, fetching, or checking emails.`;
|
||||
|
||||
const thirdPartyBlock = composioEnabled
|
||||
? `\n**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n`
|
||||
: '';
|
||||
|
||||
const toolPriority = composioEnabled
|
||||
? `For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.`
|
||||
: `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.`;
|
||||
|
||||
const slackToolsLine = composioEnabled
|
||||
? `- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.\n`
|
||||
: '';
|
||||
|
||||
const composioToolsLine = composioEnabled
|
||||
? `- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance.\n`
|
||||
: '';
|
||||
|
||||
return `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.
|
||||
|
||||
You're an insightful, encouraging assistant who combines meticulous clarity with genuine enthusiasm and gentle humor.
|
||||
|
||||
|
|
@ -58,11 +72,9 @@ You're an insightful, encouraging assistant who combines meticulous clarity with
|
|||
## What Rowboat Is
|
||||
Rowboat is an agentic assistant for everyday work - emails, meetings, projects, and people. Users give you tasks like "draft a follow-up email," "prep me for this meeting," or "summarize where we are with this project." You figure out what context you need, pull from emails and meetings, and get it done.
|
||||
|
||||
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first. Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.
|
||||
**Email Drafting:** When users ask you to **draft** or **compose** emails (e.g., "draft a follow-up to Monica", "write an email to John about the project"), load the \`draft-emails\` skill first.${emailDraftSuffix}
|
||||
|
||||
**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.
|
||||
|
||||
**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
|
||||
${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
|
||||
|
||||
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
|
||||
|
||||
|
|
@ -104,7 +116,8 @@ Unlike other AI assistants that start cold every session, you have access to a l
|
|||
When a user asks you to prep them for a call with someone, you already know every prior decision, concerns they've raised, and commitments on both sides - because memory has been accumulating across every email and call, not reconstructed on demand.
|
||||
|
||||
## The Knowledge Graph
|
||||
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into four categories:
|
||||
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into these categories:
|
||||
- **Notes/** - Default location for user-authored notes. Create new notes here unless the user specifies a different folder.
|
||||
- **People/** - Notes on individuals, tracking relationships, decisions, and commitments
|
||||
- **Organizations/** - Notes on companies and teams
|
||||
- **Projects/** - Notes on ongoing initiatives and workstreams
|
||||
|
|
@ -178,7 +191,7 @@ Use the catalog below to decide which skills to load for each user request. Befo
|
|||
- Call the \`loadSkill\` tool with the skill's name or path so you can read its guidance string.
|
||||
- Apply the instructions from every loaded skill while working on the request.
|
||||
|
||||
\${skillCatalog}
|
||||
${catalog}
|
||||
|
||||
Always consult this catalog first so you load the right skills before taking action.
|
||||
|
||||
|
|
@ -205,7 +218,7 @@ Always consult this catalog first so you load the right skills before taking act
|
|||
|
||||
## Tool Priority
|
||||
|
||||
For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.
|
||||
${toolPriority}
|
||||
|
||||
## Execution Reminders
|
||||
- Explore existing files and structure before creating new assets.
|
||||
|
|
@ -241,12 +254,11 @@ ${runtimeContextPrompt}
|
|||
- \`analyzeAgent\` - Agent analysis
|
||||
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
||||
- \`loadSkill\` - Skill loading
|
||||
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
|
||||
- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
|
||||
${slackToolsLine}- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
|
||||
- \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
|
||||
- \`browser-control\` - Control the embedded browser pane: open sites, inspect the live page, switch tabs, and interact with indexed page elements. **Load the \`browser-control\` skill before using this tool.**
|
||||
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
|
||||
- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance.
|
||||
${composioToolsLine}
|
||||
|
||||
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside the workspace root, always use these instead of \`executeCommand\`.
|
||||
|
||||
|
|
@ -293,6 +305,10 @@ For browser pages, mention the URL in plain text or use the browser-control tool
|
|||
**IMPORTANT:** Only use filepath blocks for files that already exist. The card is clickable and opens the file, so it must point to a real file. If you are proposing a path for a file that hasn't been created yet (e.g., "Shall I save it at ~/Documents/report.pdf?"), use inline code (\`~/Documents/report.pdf\`) instead of a filepath block. Use the filepath block only after the file has been written/created successfully.
|
||||
|
||||
Never output raw file paths in plain text when they could be wrapped in a filepath block — unless the file does not exist yet.`;
|
||||
}
|
||||
|
||||
/** Keep backward-compatible export for any external consumers */
|
||||
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
|
||||
|
||||
/**
|
||||
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
|
||||
|
|
@ -313,9 +329,14 @@ export function invalidateCopilotInstructionsCache(): void {
|
|||
*/
|
||||
export async function buildCopilotInstructions(): Promise<string> {
|
||||
if (cachedInstructions !== null) return cachedInstructions;
|
||||
const composioEnabled = await isComposioConfigured();
|
||||
const catalog = composioEnabled
|
||||
? skillCatalog
|
||||
: buildSkillCatalog({ excludeIds: ['composio-integration'] });
|
||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog);
|
||||
const composioPrompt = await getComposioToolsPrompt();
|
||||
cachedInstructions = composioPrompt
|
||||
? CopilotInstructions + '\n' + composioPrompt
|
||||
: CopilotInstructions;
|
||||
? baseInstructions + '\n' + composioPrompt
|
||||
: baseInstructions;
|
||||
return cachedInstructions;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,24 +71,24 @@ workspace-grep({ pattern: "[name]", path: "knowledge/" })
|
|||
- Ask: "Which document would you like to work on?"
|
||||
|
||||
**Creating new documents:**
|
||||
1. Ask simply: "Shall I create [filename]?" (don't ask about location - default to \`knowledge/\` root)
|
||||
1. Ask simply: "Shall I create [filename]?" (don't ask about location - default to \`knowledge/Notes/\` unless the user specifies a different folder)
|
||||
2. Create it with just a title - don't pre-populate with structure or outlines
|
||||
3. Ask: "What would you like in this?"
|
||||
|
||||
\`\`\`
|
||||
workspace-createFile({
|
||||
path: "knowledge/[Document Name].md",
|
||||
path: "knowledge/Notes/[Document Name].md",
|
||||
content: "# [Document Title]\n\n"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**WRONG approach:**
|
||||
- "Should this be in Projects/ or Topics/?" - don't ask, just use root
|
||||
- "Should this be in Projects/ or Topics/?" - don't ask, just use \`knowledge/Notes/\`
|
||||
- "Here's a proposed outline..." - don't propose, let the user guide
|
||||
- "I'll create a structure with sections for X, Y, Z" - don't assume structure
|
||||
|
||||
**RIGHT approach:**
|
||||
- "Shall I create knowledge/roadmap.md?"
|
||||
- "Shall I create knowledge/Notes/roadmap.md?"
|
||||
- *creates file with just the title*
|
||||
- "Created. What would you like in this?"
|
||||
|
||||
|
|
@ -167,11 +167,11 @@ workspace-readFile("knowledge/Projects/[Project].md")
|
|||
## Document Locations
|
||||
|
||||
Documents are stored in \`knowledge/\` within the workspace root, with subfolders:
|
||||
- \`Notes/\` - **Default location for user notes. Create new notes here unless the user specifies a different folder.**
|
||||
- \`People/\` - Notes about individuals
|
||||
- \`Organizations/\` - Notes about companies, teams
|
||||
- \`Projects/\` - Project documentation
|
||||
- \`Topics/\` - Subject matter notes
|
||||
- Root level for general documents
|
||||
|
||||
## Rich Blocks
|
||||
|
||||
|
|
@ -196,6 +196,17 @@ Embeds external content (YouTube videos, Figma designs, or generic links).
|
|||
- \`caption\` (optional): Caption displayed below the embed
|
||||
- YouTube and Figma render as iframes; generic shows a link card
|
||||
|
||||
### Iframe Block
|
||||
Embeds an arbitrary web page or a locally-served dashboard in the note.
|
||||
\`\`\`iframe
|
||||
{"url": "http://localhost:3210/sites/example-dashboard/", "title": "Trend Dashboard", "height": 640}
|
||||
\`\`\`
|
||||
- \`url\` (required): Full URL to render. Use \`https://\` for remote sites, or \`http://localhost:3210/sites/<slug>/\` for local dashboards
|
||||
- \`title\` (optional): Title shown above the iframe
|
||||
- \`height\` (optional): Height in pixels. Good dashboard defaults are 480-800
|
||||
- \`allow\` (optional): Custom iframe \`allow\` attribute when the page needs extra browser capabilities
|
||||
- Remote sites may refuse to render in iframes because of their own CSP / X-Frame-Options headers. When you need a reliable embed, create a local site in \`sites/<slug>/\` and use the localhost URL above
|
||||
|
||||
### Chart Block
|
||||
Renders a chart from inline data.
|
||||
\`\`\`chart
|
||||
|
|
@ -220,8 +231,9 @@ Renders a styled table from structured data.
|
|||
### Block Guidelines
|
||||
- The JSON must be valid and on a single line (no pretty-printing)
|
||||
- Insert blocks using \`workspace-editFile\` just like any other content
|
||||
- When the user asks for a chart, table, or embed — use blocks rather than plain Markdown tables or image links
|
||||
- When the user asks for a chart, table, embed, or live dashboard — use blocks rather than plain Markdown tables or image links
|
||||
- When editing a note that already contains blocks, preserve them unless the user asks to change them
|
||||
- For local dashboards and mini apps, put the site files in \`sites/<slug>/\` and point an \`iframe\` block at \`http://localhost:3210/sites/<slug>/\`
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,27 @@ export const skillCatalog = [
|
|||
catalogSections.join("\n\n"),
|
||||
].join("\n");
|
||||
|
||||
/**
|
||||
* Build a skill catalog string, optionally excluding specific skills by ID.
|
||||
*/
|
||||
export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
|
||||
const entries = options?.excludeIds
|
||||
? skillEntries.filter(e => !options.excludeIds!.includes(e.id))
|
||||
: skillEntries;
|
||||
const sections = entries.map((entry) => [
|
||||
`## ${entry.title}`,
|
||||
`- **Skill file:** \`${entry.catalogPath}\``,
|
||||
`- **Use it for:** ${entry.summary}`,
|
||||
].join("\n"));
|
||||
return [
|
||||
"# Rowboat Skill Catalog",
|
||||
"",
|
||||
"Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.",
|
||||
"",
|
||||
sections.join("\n\n"),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const normalizeIdentifier = (value: string) =>
|
||||
value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,40 @@ import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
|||
|
||||
const schemaYaml = stringifyYaml(z.toJSONSchema(TrackBlockSchema)).trimEnd();
|
||||
|
||||
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
|
||||
|
||||
The track agent can emit *rich blocks* — special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, instruct the agent explicitly so it doesn't fall back to plain markdown:
|
||||
|
||||
- \`table\` — multi-row data, scoreboards, leaderboards. *"Render as a \`table\` block with columns Rank, Title, Points, Comments."*
|
||||
- \`chart\` — time series, breakdowns, share-of-total. *"Render as a \`chart\` block (line, bar, or pie) with x=date, y=rate."*
|
||||
- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render as a \`mermaid\` diagram."*
|
||||
- \`calendar\` — upcoming events / agenda. *"Render as a \`calendar\` block."*
|
||||
- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."*
|
||||
- \`image\` — single image with caption. *"Render as an \`image\` block."*
|
||||
- \`embed\` — YouTube or Figma. *"Render as an \`embed\` block."*
|
||||
- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Render as an \`iframe\` block pointing to <url>."*
|
||||
- \`transcript\` — long meeting transcripts (collapsible). *"Render as a \`transcript\` block."*
|
||||
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
|
||||
|
||||
You **do not** need to write the block body yourself — describe the desired output in the instruction and the track agent will format it (it knows each block's exact schema). Avoid \`track\` and \`task\` block types — those are user-authored input, not agent output.
|
||||
|
||||
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
|
||||
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
|
||||
- Bad: "Show today's calendar." (vague — agent may produce a markdown bullet list when the user wants the rich block)`;
|
||||
|
||||
export const skill = String.raw`
|
||||
# Tracks Skill
|
||||
|
||||
You are helping the user create and manage **track blocks** — YAML-fenced, auto-updating content blocks embedded in notes. Load this skill whenever the user wants to track, monitor, watch, or keep an eye on something in a note, asks for recurring/auto-refreshing content ("every morning...", "show current...", "pin live X here"), or presses Cmd+K and requests auto-updating content at the cursor.
|
||||
|
||||
## First: Just Do It — Do Not Ask About Edit Mode
|
||||
|
||||
Track creation and editing are **action-first**. When the user asks to track, monitor, watch, or pin auto-updating content, you proceed directly — read the file, construct the block, ` + "`" + `workspace-edit` + "`" + ` it in. Do not ask "Should I make edits directly, or show you changes first for approval?" — that prompt belongs to generic document editing, not to tracks.
|
||||
|
||||
- If another skill or an earlier turn already asked about edit mode and is waiting, treat the user's track request as implicit "direct mode" and proceed.
|
||||
- You may still ask **one** short clarifying question when genuinely ambiguous (e.g. which note to add it to). Not about permission to edit.
|
||||
- The Suggested Topics flow below is the one first-turn-confirmation exception — leave it intact.
|
||||
|
||||
## What Is a Track Block
|
||||
|
||||
A track block is a scheduled, agent-run block embedded directly inside a markdown note. Each block has:
|
||||
|
|
@ -19,7 +48,8 @@ A track block is a scheduled, agent-run block embedded directly inside a markdow
|
|||
|
||||
` + "```" + `track
|
||||
trackId: chicago-time
|
||||
instruction: Show the current time in Chicago, IL in 12-hour format.
|
||||
instruction: |
|
||||
Show the current time in Chicago, IL in 12-hour format.
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
|
|
@ -57,6 +87,23 @@ ${schemaYaml}
|
|||
|
||||
**Runtime-managed fields — never write these yourself:** ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `.
|
||||
|
||||
## Do Not Set ` + "`" + `model` + "`" + ` or ` + "`" + `provider` + "`" + ` (almost always)
|
||||
|
||||
The schema includes optional ` + "`" + `model` + "`" + ` and ` + "`" + `provider` + "`" + ` fields. **Omit them.** A user-configurable global default already picks the right model and provider for tracks; setting per-track values bypasses that and is almost always wrong.
|
||||
|
||||
The only time these belong on a track:
|
||||
|
||||
- The user **explicitly** named a model or provider for *this specific track* in their request ("use Claude Opus for this one", "force this track onto OpenAI"). Quote the user's wording back when confirming.
|
||||
|
||||
Things that are **not** reasons to set these:
|
||||
|
||||
- "Tracks should be fast" / "I want a small model" — that's a global preference, not a per-track one. Leave it; the global default exists.
|
||||
- "This track is complex" — write a clearer instruction; don't reach for a different model.
|
||||
- "Just to be safe" / "in case it matters" — this is the antipattern. Leave them out.
|
||||
- The user changed their main chat model — that has nothing to do with tracks. Leave them out.
|
||||
|
||||
When in doubt: omit both fields. Never volunteer them. Never include them in a starter template you suggest. If you find yourself adding them as a sensible default, stop — you're wrong.
|
||||
|
||||
## Choosing a trackId
|
||||
|
||||
- Kebab-case, short, descriptive: ` + "`" + `chicago-time` + "`" + `, ` + "`" + `sfo-weather` + "`" + `, ` + "`" + `hn-top5` + "`" + `, ` + "`" + `btc-usd` + "`" + `.
|
||||
|
|
@ -68,16 +115,118 @@ ${schemaYaml}
|
|||
|
||||
## Writing a Good Instruction
|
||||
|
||||
### The Frame: This Is a Personal Knowledge Tracker
|
||||
|
||||
Track output lives in a personal knowledge base the user scans frequently. Aim for data-forward, scannable output — the answer to "what's current / what changed?" in the fewest words that carry real information. Not prose. Not decoration.
|
||||
|
||||
### Core Rules
|
||||
|
||||
- **Specific and actionable.** State exactly what to fetch or compute.
|
||||
- **Single-focus.** One block = one purpose. Split "weather + news + stocks" into three blocks, don't bundle.
|
||||
- **Imperative voice, 1-3 sentences.**
|
||||
- **Mention output style** if it matters ("markdown bullet list", "one sentence", "table with 5 rows").
|
||||
- **Specify output shape.** Describe it concretely: "one line: ` + "`" + `<temp>°F, <conditions>` + "`" + `", "3-column markdown table", "bulleted digest of 5 items".
|
||||
|
||||
Good:
|
||||
> Fetch the current temperature, feels-like, and conditions for Chicago, IL in Fahrenheit. Return as a single line: "72°F (feels like 70°F), partly cloudy".
|
||||
### Self-Sufficiency (critical)
|
||||
|
||||
Bad:
|
||||
> Tell me about Chicago.
|
||||
The instruction runs later, in a background scheduler, with **no chat context and no memory of this conversation**. It must stand alone.
|
||||
|
||||
**Never use phrases that depend on prior conversation or prior runs:**
|
||||
- "as before", "same style as before", "like last time"
|
||||
- "keep the format we discussed", "matching the previous output"
|
||||
- "continue from where you left off" (without stating the state)
|
||||
|
||||
If you want consistent style across runs, **describe the style inline** (e.g. "a 3-column markdown table with headers ` + "`" + `Location` + "`" + `, ` + "`" + `Local Time` + "`" + `, ` + "`" + `Offset` + "`" + `"; "a one-line status: HH:MM, conditions, temp"). The track agent only sees your instruction — not this chat, not what you produced last time.
|
||||
|
||||
### Output Patterns — Match the Data
|
||||
|
||||
Pick a shape that fits what the user is tracking. Five common patterns — the first four are plain markdown; the fifth is a rich rendered block:
|
||||
|
||||
**1. Single metric / status line.**
|
||||
- Good: "Fetch USD/INR. Return one line: ` + "`" + `USD/INR: <rate> (as of <HH:MM IST>)` + "`" + `."
|
||||
- Bad: "Give me a nice update about the dollar rate."
|
||||
|
||||
**2. Compact table.**
|
||||
- Good: "Show current local time for India, Chicago, Indianapolis as a 3-column markdown table: ` + "`" + `Location | Local Time | Offset vs India` + "`" + `. One row per location, no prose."
|
||||
- Bad: "Show a polished, table-first world clock with a pleasant layout."
|
||||
|
||||
**3. Rolling digest.**
|
||||
- Good: "Summarize the top 5 HN front-page stories as bullets: ` + "`" + `- <title> (<points> pts, <comments> comments)` + "`" + `. No commentary."
|
||||
- Bad: "Give me the top HN stories with thoughtful takeaways."
|
||||
|
||||
**4. Status / threshold watch.**
|
||||
- Good: "Check https://status.example.com. Return one line: ` + "`" + `✓ All systems operational` + "`" + ` or ` + "`" + `⚠ <component>: <status>` + "`" + `. If degraded, add one bullet per affected component."
|
||||
- Bad: "Keep an eye on the status page and tell me how it looks."
|
||||
|
||||
${richBlockMenu}
|
||||
|
||||
### Anti-Patterns
|
||||
|
||||
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete.
|
||||
- **References to past state** without a mechanism to access it ("as before", "same as last time").
|
||||
- **Bundling multiple purposes** into one instruction — split into separate track blocks.
|
||||
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
|
||||
- **Output-shape words without a concrete shape** ("dashboard-like", "report-style").
|
||||
|
||||
## YAML String Style (critical — read before writing any ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + `)
|
||||
|
||||
The two free-form fields — ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` — are where YAML parsing usually breaks. The runner re-emits the full YAML block every time it writes ` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunSummary` + "`" + `, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the block: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the instruction gets truncated.
|
||||
|
||||
Real failure seen in the wild — an instruction containing the phrase ` + "`" + `"polished UI style as before: clean, compact..."` + "`" + ` was written as a plain scalar, got re-emitted across multiple lines on the next run, and the ` + "`" + `as before:` + "`" + ` became a phantom key. The block parsed as garbage after that.
|
||||
|
||||
### The rule: always use a safe scalar style
|
||||
|
||||
**Default to the literal block scalar (` + "`" + `|` + "`" + `) for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, every time.** It is the only style that is robust across the full range of punctuation these fields typically contain, and it is safe even if the content later grows to multiple lines.
|
||||
|
||||
### Preferred: literal block scalar (` + "`" + `|` + "`" + `)
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: |
|
||||
Show current local time for India, Chicago, and Indianapolis as a
|
||||
3-column markdown table: Location | Local Time | Offset vs India.
|
||||
One row per location, 24-hour time (HH:MM), no extra prose.
|
||||
Note: when a location is in DST, reflect that in the offset column.
|
||||
eventMatchCriteria: |
|
||||
Emails from the finance team about Q3 budget or OKRs.
|
||||
` + "```" + `
|
||||
|
||||
- ` + "`" + `|` + "`" + ` preserves line breaks verbatim. Colons, ` + "`" + `#` + "`" + `, quotes, leading ` + "`" + `-` + "`" + `, percent signs — all literal. No escaping needed.
|
||||
- **Indent every content line by 2 spaces** relative to the key (` + "`" + `instruction:` + "`" + `). Use spaces, never tabs.
|
||||
- Leave a real newline after ` + "`" + `|` + "`" + ` — content starts on the next line, not the same line.
|
||||
- Default chomping (no modifier) is fine. Do **not** add ` + "`" + `-` + "`" + ` or ` + "`" + `+` + "`" + ` unless you know you need them.
|
||||
- A ` + "`" + `|` + "`" + ` block is terminated by a line indented less than the content — typically the next sibling key (` + "`" + `active:` + "`" + `, ` + "`" + `schedule:` + "`" + `).
|
||||
|
||||
### Acceptable alternative: double-quoted on a single line
|
||||
|
||||
Fine for short single-sentence fields with no newline needs:
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: "Show the current time in Chicago, IL in 12-hour format."
|
||||
eventMatchCriteria: "Emails about Q3 planning, OKRs, or roadmap decisions."
|
||||
` + "```" + `
|
||||
|
||||
- Escape ` + "`" + `"` + "`" + ` as ` + "`" + `\"` + "`" + ` and backslash as ` + "`" + `\\` + "`" + `.
|
||||
- Prefer ` + "`" + `|` + "`" + ` the moment the string needs two sentences or a newline.
|
||||
|
||||
### Single-quoted on a single line (only if double-quoted would require heavy escaping)
|
||||
|
||||
` + "```" + `yaml
|
||||
instruction: 'He said "hi" at 9:00.'
|
||||
` + "```" + `
|
||||
|
||||
- A literal single quote is escaped by doubling it: ` + "`" + `'it''s fine'` + "`" + `.
|
||||
- No other escape sequences work.
|
||||
|
||||
### Do NOT use plain (unquoted) scalars for these two fields
|
||||
|
||||
Even if the current value looks safe, a future edit (by you or the user) may introduce a ` + "`" + `:` + "`" + ` or ` + "`" + `#` + "`" + `, and a future re-emit may fold the line. The ` + "`" + `|` + "`" + ` style is safe under **all** future edits — plain scalars are not.
|
||||
|
||||
### Editing an existing track
|
||||
|
||||
If you ` + "`" + `workspace-edit` + "`" + ` an existing track's ` + "`" + `instruction` + "`" + ` or ` + "`" + `eventMatchCriteria` + "`" + ` and find it is still a plain scalar, **upgrade it to ` + "`" + `|` + "`" + `** in the same edit. Don't leave a plain scalar behind that the next run will corrupt.
|
||||
|
||||
### Never-hand-write fields
|
||||
|
||||
` + "`" + `lastRunAt` + "`" + `, ` + "`" + `lastRunId` + "`" + `, ` + "`" + `lastRunSummary` + "`" + ` are owned by the runner. Don't touch them — don't even try to style them. If your ` + "`" + `workspace-edit` + "`" + `'s ` + "`" + `oldString` + "`" + ` happens to include these lines, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
|
||||
|
||||
## Schedules
|
||||
|
||||
|
|
@ -132,9 +281,12 @@ In addition to manual and scheduled, a track can be triggered by **events** —
|
|||
|
||||
` + "```" + `track
|
||||
trackId: q3-planning-emails
|
||||
instruction: Maintain a running summary of decisions and open questions about Q3 planning, drawn from emails on the topic.
|
||||
instruction: |
|
||||
Maintain a running summary of decisions and open questions about Q3
|
||||
planning, drawn from emails on the topic.
|
||||
active: true
|
||||
eventMatchCriteria: Emails about Q3 planning, roadmap decisions, or quarterly OKRs
|
||||
eventMatchCriteria: |
|
||||
Emails about Q3 planning, roadmap decisions, or quarterly OKRs.
|
||||
` + "```" + `
|
||||
|
||||
How it works:
|
||||
|
|
@ -155,6 +307,8 @@ Tracks **without** ` + "`" + `eventMatchCriteria` + "`" + ` opt out of events en
|
|||
|
||||
## Insertion Workflow
|
||||
|
||||
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
|
||||
|
||||
### Cmd+K with cursor context
|
||||
|
||||
When the user invokes Cmd+K, the context includes an attachment mention like:
|
||||
|
|
@ -180,13 +334,29 @@ Workflow:
|
|||
|
||||
Ask one question: "Which note should this track live in?" Don't create a new note unless the user asks.
|
||||
|
||||
### Suggested Topics exploration flow
|
||||
|
||||
Sometimes the user arrives from the Suggested Topics panel and gives you a prompt like:
|
||||
- "I am exploring a suggested topic card from the Suggested Topics panel."
|
||||
- a title, category, description, and target folder such as ` + "`" + `knowledge/Topics/` + "`" + ` or ` + "`" + `knowledge/People/` + "`" + `
|
||||
|
||||
In that flow:
|
||||
1. On the first turn, **do not create or modify anything yet**. Briefly explain the tracking note you can set up and ask for confirmation.
|
||||
2. If the user clearly confirms ("yes", "set it up", "do it"), treat that as explicit permission to proceed.
|
||||
3. Before creating a new note, search the target folder for an existing matching note and update it if one already exists.
|
||||
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask "which note should this live in?".
|
||||
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
|
||||
6. Keep the surrounding note scaffolding minimal but useful. The track block should be the core of the note.
|
||||
7. If the target folder is one of the structured knowledge folders (` + "`" + `knowledge/People/` + "`" + `, ` + "`" + `knowledge/Organizations/` + "`" + `, ` + "`" + `knowledge/Projects/` + "`" + `, ` + "`" + `knowledge/Topics/` + "`" + `), mirror the local note style by quickly checking a nearby note or config before writing if needed.
|
||||
|
||||
## The Exact Text to Insert
|
||||
|
||||
Write it verbatim like this (including the blank line between fence and target):
|
||||
|
||||
` + "```" + `track
|
||||
trackId: <id>
|
||||
instruction: <instruction>
|
||||
instruction: |
|
||||
<instruction, indented 2 spaces, may span multiple lines>
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
|
|
@ -199,6 +369,7 @@ schedule:
|
|||
**Rules:**
|
||||
- One blank line between the closing ` + "`" + "```" + `" + " fence and the ` + "`" + `<!--track-target:ID-->` + "`" + `.
|
||||
- Target pair is **empty on creation**. The runner fills it on the first run.
|
||||
- **Always use the literal block scalar (` + "`" + `|` + "`" + `)** for ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + `, indented 2 spaces. Never a plain (unquoted) scalar — see the YAML String Style section above for why.
|
||||
- **Always quote cron expressions** in YAML — they contain spaces and ` + "`" + `*` + "`" + `.
|
||||
- Use 2-space YAML indent. No tabs.
|
||||
- Top-level markdown only — never inside a code fence, blockquote, or table.
|
||||
|
|
@ -302,7 +473,8 @@ Minimal template:
|
|||
|
||||
` + "```" + `track
|
||||
trackId: <kebab-id>
|
||||
instruction: <what to produce>
|
||||
instruction: |
|
||||
<what to produce — always use ` + "`" + `|` + "`" + `, indented 2 spaces>
|
||||
active: true
|
||||
schedule:
|
||||
type: cron
|
||||
|
|
@ -313,6 +485,8 @@ schedule:
|
|||
<!--/track-target:<kebab-id>-->
|
||||
|
||||
Top cron expressions: ` + "`" + `"0 * * * *"` + "`" + ` (hourly), ` + "`" + `"0 8 * * *"` + "`" + ` (daily 8am), ` + "`" + `"0 9 * * 1-5"` + "`" + ` (weekdays 9am), ` + "`" + `"*/15 * * * *"` + "`" + ` (every 15m).
|
||||
|
||||
YAML style reminder: ` + "`" + `instruction` + "`" + ` and ` + "`" + `eventMatchCriteria` + "`" + ` are **always** ` + "`" + `|` + "`" + ` block scalars. Never plain. Never leave a plain scalar in place when editing.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
|
|||
|
|
@ -21,9 +21,8 @@ import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/d
|
|||
import type { ToolContext } from "./exec-tool.js";
|
||||
import { generateText } from "ai";
|
||||
import { createProvider } from "../../models/models.js";
|
||||
import { IModelConfigRepo } from "../../models/repo.js";
|
||||
import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.js";
|
||||
import { isSignedIn } from "../../account/account.js";
|
||||
import { getGatewayProvider } from "../../models/gateway.js";
|
||||
import { getAccessToken } from "../../auth/tokens.js";
|
||||
import { API_URL } from "../../config/env.js";
|
||||
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
|
||||
|
|
@ -746,13 +745,9 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
|
||||
const base64 = buffer.toString('base64');
|
||||
|
||||
// Resolve model config from DI container
|
||||
const modelConfigRepo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const modelConfig = await modelConfigRepo.getConfig();
|
||||
const provider = await isSignedIn()
|
||||
? await getGatewayProvider()
|
||||
: createProvider(modelConfig.provider);
|
||||
const model = provider.languageModel(modelConfig.model);
|
||||
const { model: modelId, provider: providerName } = await getDefaultModelAndProvider();
|
||||
const providerConfig = await resolveProviderConfig(providerName);
|
||||
const model = createProvider(providerConfig).languageModel(modelId);
|
||||
|
||||
const userPrompt = prompt || 'Convert this file to well-structured markdown.';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import z from "zod";
|
|||
|
||||
export type UserMessageContentType = z.infer<typeof UserMessageContent>;
|
||||
export type VoiceOutputMode = 'summary' | 'full';
|
||||
export type MiddlePaneContext =
|
||||
| { kind: 'note'; path: string; content: string }
|
||||
| { kind: 'browser'; url: string; title: string };
|
||||
|
||||
type EnqueuedMessage = {
|
||||
messageId: string;
|
||||
|
|
@ -11,10 +14,11 @@ type EnqueuedMessage = {
|
|||
voiceInput?: boolean;
|
||||
voiceOutput?: VoiceOutputMode;
|
||||
searchEnabled?: boolean;
|
||||
middlePaneContext?: MiddlePaneContext;
|
||||
};
|
||||
|
||||
export interface IMessageQueue {
|
||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string>;
|
||||
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string>;
|
||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||
}
|
||||
|
||||
|
|
@ -30,7 +34,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string> {
|
||||
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
||||
if (!this.store[runId]) {
|
||||
this.store[runId] = [];
|
||||
}
|
||||
|
|
@ -41,6 +45,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
voiceInput,
|
||||
voiceOutput,
|
||||
searchEnabled,
|
||||
middlePaneContext,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,12 +216,15 @@ export async function refreshTokens(
|
|||
return tokens;
|
||||
}
|
||||
|
||||
const EXPIRY_MARGIN_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Check if tokens are expired
|
||||
* Check if tokens are expired. Treats tokens as expired EXPIRY_MARGIN_SECONDS
|
||||
* before the real expiry to absorb clock skew and in-flight request latency.
|
||||
*/
|
||||
export function isTokenExpired(tokens: OAuthTokens): boolean {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return tokens.expires_at <= now;
|
||||
return tokens.expires_at <= now + EXPIRY_MARGIN_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,18 +3,12 @@ import { IOAuthRepo } from './repo.js';
|
|||
import { IClientRegistrationRepo } from './client-repo.js';
|
||||
import { getProviderConfig } from './providers.js';
|
||||
import * as oauthClient from './oauth-client.js';
|
||||
import { OAuthTokens } from './types.js';
|
||||
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const { tokens } = await oauthRepo.read('rowboat');
|
||||
if (!tokens) {
|
||||
throw new Error('Not signed into Rowboat');
|
||||
}
|
||||
|
||||
if (!oauthClient.isTokenExpired(tokens)) {
|
||||
return tokens.access_token;
|
||||
}
|
||||
let refreshInFlight: Promise<OAuthTokens> | null = null;
|
||||
|
||||
async function performRefresh(tokens: OAuthTokens): Promise<OAuthTokens> {
|
||||
console.log("Refreshing rowboat access token");
|
||||
if (!tokens.refresh_token) {
|
||||
throw new Error('Rowboat token expired and no refresh token available. Please sign in again.');
|
||||
}
|
||||
|
|
@ -40,7 +34,29 @@ export async function getAccessToken(): Promise<string> {
|
|||
tokens.refresh_token,
|
||||
tokens.scopes,
|
||||
);
|
||||
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
await oauthRepo.upsert('rowboat', { tokens: refreshed });
|
||||
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
|
||||
const { tokens } = await oauthRepo.read('rowboat');
|
||||
if (!tokens) {
|
||||
throw new Error('Not signed into Rowboat');
|
||||
}
|
||||
|
||||
if (!oauthClient.isTokenExpired(tokens)) {
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = performRefresh(tokens).finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
const refreshed = await refreshInFlight;
|
||||
return refreshed.access_token;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import path from 'path';
|
|||
import { google } from 'googleapis';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
|
||||
|
|
@ -305,7 +306,7 @@ async function processAgentNotes(): Promise<void> {
|
|||
const timestamp = new Date().toISOString();
|
||||
const message = `Current timestamp: ${timestamp}\n\nProcess the following source material and update the Agent Notes folder accordingly.\n\n${messageParts.join('\n\n')}`;
|
||||
|
||||
const agentRun = await createRun({ agentId: AGENT_ID });
|
||||
const agentRun = await createRun({ agentId: AGENT_ID, model: await getKgModel() });
|
||||
await createMessage(agentRun.id, message);
|
||||
await waitForRunCompletion(agentRun.id);
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ import { getTagDefinitions } from './tag_system.js';
|
|||
|
||||
const NOTES_OUTPUT_DIR = path.join(WorkDir, 'knowledge');
|
||||
const NOTE_CREATION_AGENT = 'note_creation';
|
||||
const SUGGESTED_TOPICS_REL_PATH = 'suggested-topics.md';
|
||||
const SUGGESTED_TOPICS_PATH = path.join(WorkDir, 'suggested-topics.md');
|
||||
const LEGACY_SUGGESTED_TOPICS_REL_PATH = 'config/suggested-topics.md';
|
||||
const LEGACY_SUGGESTED_TOPICS_PATH = path.join(WorkDir, 'config', 'suggested-topics.md');
|
||||
const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_REL_PATH = 'knowledge/Notes/Suggested Topics.md';
|
||||
const LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH = path.join(WorkDir, 'knowledge', 'Notes', 'Suggested Topics.md');
|
||||
|
||||
// Configuration for the graph builder service
|
||||
const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds
|
||||
|
|
@ -32,6 +38,7 @@ const SOURCE_FOLDERS = [
|
|||
'gmail_sync',
|
||||
path.join('knowledge', 'Meetings', 'fireflies'),
|
||||
path.join('knowledge', 'Meetings', 'granola'),
|
||||
path.join('knowledge', 'Meetings', 'rowboat'),
|
||||
];
|
||||
|
||||
// Voice memos are now created directly in knowledge/Voice Memos/<date>/
|
||||
|
|
@ -88,6 +95,49 @@ function extractPathFromToolInput(input: string): string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function ensureSuggestedTopicsFileLocation(): string {
|
||||
if (fs.existsSync(SUGGESTED_TOPICS_PATH)) {
|
||||
return SUGGESTED_TOPICS_PATH;
|
||||
}
|
||||
|
||||
const legacyCandidates: Array<{ absPath: string; relPath: string }> = [
|
||||
{ absPath: LEGACY_SUGGESTED_TOPICS_PATH, relPath: LEGACY_SUGGESTED_TOPICS_REL_PATH },
|
||||
{ absPath: LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_PATH, relPath: LEGACY_SUGGESTED_TOPICS_KNOWLEDGE_REL_PATH },
|
||||
];
|
||||
|
||||
for (const legacy of legacyCandidates) {
|
||||
if (!fs.existsSync(legacy.absPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.renameSync(legacy.absPath, SUGGESTED_TOPICS_PATH);
|
||||
console.log(`[buildGraph] Moved suggested topics file from ${legacy.relPath} to ${SUGGESTED_TOPICS_REL_PATH}`);
|
||||
return SUGGESTED_TOPICS_PATH;
|
||||
} catch (error) {
|
||||
console.error(`[buildGraph] Failed to move suggested topics file from ${legacy.relPath} to ${SUGGESTED_TOPICS_REL_PATH}:`, error);
|
||||
return legacy.absPath;
|
||||
}
|
||||
}
|
||||
|
||||
return SUGGESTED_TOPICS_PATH;
|
||||
}
|
||||
|
||||
function readSuggestedTopicsFile(): string {
|
||||
try {
|
||||
const suggestedTopicsPath = ensureSuggestedTopicsFileLocation();
|
||||
if (!fs.existsSync(suggestedTopicsPath)) {
|
||||
return '_No existing suggested topics file._';
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(suggestedTopicsPath, 'utf-8').trim();
|
||||
return content.length > 0 ? content : '_Existing suggested topics file is empty._';
|
||||
} catch (error) {
|
||||
console.error(`[buildGraph] Error reading suggested topics file:`, error);
|
||||
return '_Failed to read existing suggested topics file._';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unprocessed voice memo files from knowledge/Voice Memos/
|
||||
* Voice memos are created directly in this directory by the UI.
|
||||
|
|
@ -203,6 +253,7 @@ async function createNotesFromBatch(
|
|||
const run = await createRun({
|
||||
agentId: NOTE_CREATION_AGENT,
|
||||
});
|
||||
const suggestedTopicsContent = readSuggestedTopicsFile();
|
||||
|
||||
// Build message with index and all files in the batch
|
||||
let message = `Process the following ${files.length} source files and create/update obsidian notes.\n\n`;
|
||||
|
|
@ -210,8 +261,9 @@ async function createNotesFromBatch(
|
|||
message += `- Use the KNOWLEDGE BASE INDEX below to resolve entities - DO NOT grep/search for existing notes\n`;
|
||||
message += `- Extract entities (people, organizations, projects, topics) from ALL files below\n`;
|
||||
message += `- Create or update notes in "knowledge" directory (workspace-relative paths like "knowledge/People/Name.md")\n`;
|
||||
message += `- You may also create or update "${SUGGESTED_TOPICS_REL_PATH}" to maintain curated suggested-topic cards\n`;
|
||||
message += `- If the same entity appears in multiple files, merge the information into a single note\n`;
|
||||
message += `- Use workspace tools to read existing notes (when you need full content) and write updates\n`;
|
||||
message += `- Use workspace tools to read existing notes or "${SUGGESTED_TOPICS_REL_PATH}" (when you need full content) and write updates\n`;
|
||||
message += `- Follow the note templates and guidelines in your instructions\n\n`;
|
||||
|
||||
// Add the knowledge base index
|
||||
|
|
@ -219,6 +271,11 @@ async function createNotesFromBatch(
|
|||
message += knowledgeIndex;
|
||||
message += `\n---\n\n`;
|
||||
|
||||
message += `# Current Suggested Topics File\n\n`;
|
||||
message += `Path: ${SUGGESTED_TOPICS_REL_PATH}\n\n`;
|
||||
message += suggestedTopicsContent;
|
||||
message += `\n\n---\n\n`;
|
||||
|
||||
// Add each file's content
|
||||
message += `# Source Files to Process\n\n`;
|
||||
files.forEach((file, idx) => {
|
||||
|
|
|
|||
|
|
@ -1,44 +1,157 @@
|
|||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import z from 'zod';
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md');
|
||||
const TARGET_ID = 'dailybrief';
|
||||
|
||||
interface Section {
|
||||
heading: string;
|
||||
track: z.infer<typeof TrackBlockSchema>;
|
||||
}
|
||||
|
||||
const SECTIONS: Section[] = [
|
||||
{
|
||||
heading: '## ⏱ Up Next',
|
||||
track: {
|
||||
trackId: 'up-next',
|
||||
instruction:
|
||||
`Write 1-3 sentences of plain markdown giving the user a shoulder-tap about what's next on their calendar today.
|
||||
|
||||
This section refreshes on calendar changes, not on a clock tick — do NOT promise live minute countdowns. Frame urgency in buckets based on the event's start time relative to now:
|
||||
- Start time is in the past or within roughly half an hour → imminent: name the meeting and say it's starting soon (e.g. "Standup is starting — join link in the Calendar section below.").
|
||||
- Start time is later this morning or this afternoon → upcoming: name the meeting and roughly when (e.g. "Design review later this morning." / "1:1 with Sam this afternoon.").
|
||||
- Start time is several hours out or nothing before then → focus block: frame the gap (e.g. "Next up is the all-hands at 3pm — good long focus block until then.").
|
||||
|
||||
Use the event's start time of day ("at 3pm", "this afternoon") rather than a countdown ("in 40 minutes"). Countdowns go stale between syncs.
|
||||
|
||||
Data: read today's events from calendar_sync/ (workspace-readdir, then workspace-readFile each .json file). Filter to events whose start datetime is today and hasn't ended yet — for finding the next event, pick the earliest upcoming one; if all have passed, treat as clear.
|
||||
|
||||
If you find quick context in knowledge/ that's genuinely useful, add one short clause ("Ramnique pushed the OAuth PR yesterday — might come up"). Use workspace-grep / workspace-readFile conservatively; don't stall on deep research.
|
||||
|
||||
If nothing remains today, output exactly: Clear for the rest of the day.
|
||||
|
||||
Plain markdown prose only — no calendar block, no email block, no headings.`,
|
||||
eventMatchCriteria:
|
||||
`Calendar event changes affecting today — new meetings, reschedules, cancellations, meetings starting soon. Skip changes to events on other days.`,
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: '## 📅 Calendar',
|
||||
track: {
|
||||
trackId: 'calendar',
|
||||
instruction:
|
||||
`Emit today's meetings as a calendar block titled "Today's Meetings".
|
||||
|
||||
Data: read calendar_sync/ via workspace-readdir, then workspace-readFile each .json event file. Filter to events occurring today. After 10am local time, drop meetings that have already ended — only include meetings that haven't ended yet.
|
||||
|
||||
This section refreshes on calendar changes, not on a clock tick — the "drop ended meetings" rule applies on each refresh, so an ended meeting disappears the next time any calendar event changes (not exactly on the clock hour). That's fine.
|
||||
|
||||
Always emit the calendar block, even when there are no remaining events (in that case use events: [] and showJoinButton: false). Set showJoinButton: true whenever any event has a conferenceLink.
|
||||
|
||||
After the block, you MAY add one short markdown line per event giving useful prep context pulled from knowledge/ ("Design review: last week we agreed to revisit the type-picker UX."). Keep it tight — one line each, only when meaningful. Skip routine/recurring meetings.`,
|
||||
eventMatchCriteria:
|
||||
`Calendar event changes affecting today — additions, updates, cancellations, reschedules.`,
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: '## 📧 Emails',
|
||||
track: {
|
||||
trackId: 'emails',
|
||||
instruction:
|
||||
`Maintain a digest of email threads worth the user's attention today, rendered as zero or more email blocks (one per thread).
|
||||
|
||||
Event-driven path (primary): the agent message will include a "Gmail sync update" digest payload describing one or more freshly-synced threads from a single sync run. The digest lists each thread with its subject, sender, date, threadId, and body. Iterate over every thread in the payload and decide per thread whether it warrants surfacing. Skip marketing, auto-notifications, closed-out threads, and other low-signal mail. For threads that are attention-worthy, integrate them into the existing digest: add a new email block for a new threadId, or update the existing block if the threadId is already shown. If NONE of the threads in the payload are attention-worthy, skip the update — do NOT call update-track-content. Emit at most one update-track-content call that covers the full set of changes from this event.
|
||||
|
||||
Manual path (fallback): with no event payload, scan gmail_sync/ via workspace-readdir (skip sync_state.json and attachments/). Read threads with workspace-readFile. Prioritize threads whose frontmatter action field is "reply" or "respond", plus other high-signal recent threads.
|
||||
|
||||
Each email block should include threadId, subject, from, date, summary, and latest_email. For threads that need a reply, add a draft_response written in the user's voice — direct, informal, no fluff. For FYI threads, omit draft_response.
|
||||
|
||||
If there is genuinely nothing to surface, output the single line: No new emails.
|
||||
|
||||
Do NOT re-list threads the user has already seen unless their state changed (new reply, status flip).`,
|
||||
eventMatchCriteria:
|
||||
`New or updated email threads that may need the user's attention today — drafts to send, replies to write, urgent requests, time-sensitive info. Skip marketing, newsletters, auto-notifications, and chatter on closed threads.`,
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: '## 📰 What You Missed',
|
||||
track: {
|
||||
trackId: 'what-you-missed',
|
||||
instruction:
|
||||
`Short markdown summary of what happened yesterday that matters this morning.
|
||||
|
||||
Data sources:
|
||||
- knowledge/Meetings/<source>/<YYYY-MM-DD>/meeting-<timestamp>.md — use workspace-readdir with recursive: true on knowledge/Meetings, filter for folders matching yesterday's date (compute yesterday from the current local date), read each matching file. Pull out: decisions made, action items assigned, blockers raised, commitments.
|
||||
- gmail_sync/ — skim for threads from yesterday that went unresolved or still need a reply.
|
||||
|
||||
Skip recurring/routine events (standups, weekly syncs) unless something unusual happened in them.
|
||||
|
||||
Write concise markdown — a few bullets or a short paragraph, whichever reads better. Lead with anything that shifts the user's priorities today.
|
||||
|
||||
If nothing notable happened, output exactly: Quiet day yesterday — nothing to flag.
|
||||
|
||||
Do NOT manufacture content to fill the section.`,
|
||||
active: true,
|
||||
schedule: {
|
||||
type: 'cron',
|
||||
expression: '0 7 * * *',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: '## ✅ Today\'s Priorities',
|
||||
track: {
|
||||
trackId: 'priorities',
|
||||
instruction:
|
||||
`Ranked markdown list of the real, actionable items the user should focus on today.
|
||||
|
||||
Data sources:
|
||||
- Yesterday's meeting notes under knowledge/Meetings/<source>/<YYYY-MM-DD>/ — action items assigned to the user are often the most important source.
|
||||
- knowledge/ — use workspace-grep for "- [ ]" checkboxes, explicit action items, deadlines, follow-ups.
|
||||
- Optional: workspace-readFile on knowledge/Today.md for the current "What You Missed" section — useful for alignment.
|
||||
|
||||
Rules:
|
||||
- Do NOT list calendar events as tasks — they're already in the Calendar section.
|
||||
- Do NOT list trivial admin (filing small invoices, archiving spam).
|
||||
- Rank by importance. Lead with the most critical item. Note time-sensitivity when it exists ("needs to go out before the 3pm review").
|
||||
- Add a brief reason for each item when it's not self-evident.
|
||||
|
||||
If nothing genuinely needs attention, output exactly: No pressing tasks today — good day to make progress on bigger items.
|
||||
|
||||
Do NOT invent busywork.`,
|
||||
active: true,
|
||||
schedule: {
|
||||
type: 'cron',
|
||||
expression: '30 7 * * *',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function buildDailyNoteContent(): string {
|
||||
const now = new Date();
|
||||
const startDate = now.toISOString();
|
||||
const endDate = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const instruction = 'Create a daily brief for me';
|
||||
|
||||
const taskBlock = JSON.stringify({
|
||||
instruction,
|
||||
schedule: {
|
||||
type: 'cron',
|
||||
expression: '*/15 * * * *',
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
'schedule-label': 'runs every 15 minutes',
|
||||
targetId: TARGET_ID,
|
||||
});
|
||||
|
||||
return [
|
||||
'---',
|
||||
'live_note: true',
|
||||
'---',
|
||||
'# Today',
|
||||
'',
|
||||
'```task',
|
||||
taskBlock,
|
||||
'```',
|
||||
'',
|
||||
`<!--task-target:${TARGET_ID}-->`,
|
||||
`<!--/task-target:${TARGET_ID}-->`,
|
||||
'',
|
||||
].join('\n');
|
||||
const parts: string[] = ['# Today', ''];
|
||||
for (const { heading, track } of SECTIONS) {
|
||||
const yaml = stringifyYaml(track, { lineWidth: 0, blockQuote: 'literal' }).trimEnd();
|
||||
parts.push(
|
||||
heading,
|
||||
'',
|
||||
'```track',
|
||||
yaml,
|
||||
'```',
|
||||
'',
|
||||
`<!--track-target:${track.trackId}-->`,
|
||||
`<!--/track-target:${track.trackId}-->`,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export function ensureDailyNote(): void {
|
||||
|
|
|
|||
18
apps/x/packages/core/src/knowledge/file-lock.ts
Normal file
18
apps/x/packages/core/src/knowledge/file-lock.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
const locks = new Map<string, Promise<void>>();
|
||||
|
||||
export async function withFileLock<T>(absPath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = locks.get(absPath) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => { release = r; });
|
||||
const myTail = prev.then(() => gate);
|
||||
locks.set(absPath, myTail);
|
||||
try {
|
||||
await prev;
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
if (locks.get(absPath) === myTail) {
|
||||
locks.delete(absPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ export function getRaw(): string {
|
|||
const defaultEndISO = defaultEnd.toISOString();
|
||||
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
${toolEntries}
|
||||
---
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { CronExpressionParser } from 'cron-parser';
|
|||
import { generateText } from 'ai';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage, fetchRun } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import container from '../di/container.js';
|
||||
import type { IModelConfigRepo } from '../models/repo.js';
|
||||
import { createProvider } from '../models/models.js';
|
||||
|
|
@ -467,7 +468,7 @@ async function processInlineTasks(): Promise<void> {
|
|||
console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`);
|
||||
|
||||
try {
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT });
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT, model: await getKgModel() });
|
||||
|
||||
const message = [
|
||||
`Execute the following instruction from the note "${relativePath}":`,
|
||||
|
|
@ -547,7 +548,7 @@ export async function processRowboatInstruction(
|
|||
scheduleLabel: string | null;
|
||||
response: string | null;
|
||||
}> {
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT });
|
||||
const run = await createRun({ agentId: INLINE_TASK_AGENT, model: await getKgModel() });
|
||||
|
||||
const message = [
|
||||
`Process the following @rowboat instruction from the note "${notePath}":`,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { bus } from '../runs/bus.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
|
|
@ -71,6 +72,7 @@ async function labelEmailBatch(
|
|||
): Promise<{ runId: string; filesEdited: Set<string> }> {
|
||||
const run = await createRun({
|
||||
agentId: LABELING_AGENT,
|
||||
model: await getKgModel(),
|
||||
});
|
||||
|
||||
let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { renderTagSystemForEmails } from './tag_system.js';
|
|||
|
||||
export function getRaw(): string {
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { renderNoteEffectRules } from './tag_system.js';
|
|||
|
||||
export function getRaw(): string {
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
workspace-writeFile:
|
||||
type: builtin
|
||||
|
|
@ -485,9 +484,9 @@ RESOLVED (use canonical name with absolute path):
|
|||
- "Acme", "Acme Corp", "@acme.com" → [[Organizations/Acme Corp]]
|
||||
- "the pilot", "the integration" → [[Projects/Acme Integration]]
|
||||
|
||||
NEW ENTITIES (create notes if source passes filters):
|
||||
NEW ENTITIES (create notes or suggestion cards if source passes filters):
|
||||
- "Jennifer" (CTO, Acme Corp) → Create [[People/Jennifer]] or [[People/Jennifer (Acme Corp)]]
|
||||
- "SOC 2" → Create [[Topics/Security Compliance]]
|
||||
- "SOC 2" → Add or update a suggestion card in \`suggested-topics.md\` with category \`Topics\`
|
||||
|
||||
AMBIGUOUS (flag or skip):
|
||||
- "Mike" (no context) → Mention in activity only, don't create note
|
||||
|
|
@ -508,8 +507,8 @@ For entities not resolved to existing notes, determine if they warrant new notes
|
|||
|
||||
**CREATE a note for people who are:**
|
||||
- External (not @user.domain)
|
||||
- Attendees in meetings
|
||||
- Email correspondents (emails that reach this step already passed label-based filtering)
|
||||
- People you directly interacted with in meetings
|
||||
- Email correspondents directly participating in the thread (emails that reach this step already passed label-based filtering)
|
||||
- Decision makers or contacts at customers, prospects, or partners
|
||||
- Investors or potential investors
|
||||
- Candidates you are interviewing
|
||||
|
|
@ -521,6 +520,7 @@ For entities not resolved to existing notes, determine if they warrant new notes
|
|||
- Large group meeting attendees you didn't interact with
|
||||
- Internal colleagues (@user.domain)
|
||||
- Assistants handling only logistics
|
||||
- People mentioned only as third parties ("we work with X", "I can introduce you to Y") when there has been no direct interaction yet
|
||||
|
||||
### Role Inference
|
||||
|
||||
|
|
@ -579,31 +579,155 @@ For people who don't warrant their own note, add to Organization note's Contacts
|
|||
- Sarah Lee — Support, handled wire transfer issue
|
||||
\`\`\`
|
||||
|
||||
### Direct Interaction Test (People and Organizations)
|
||||
|
||||
For **new canonical People and Organizations notes**, require **direct interaction**, not just mention.
|
||||
|
||||
**Direct interaction = YES**
|
||||
- The person sent the email, replied in the thread, or was directly addressed as part of the active exchange
|
||||
- The person participated in the meeting, and there is evidence the user actually interacted with them or the meeting centered on them
|
||||
- The organization is directly represented in the exchange by participants/senders and is part of an active first-degree relationship with the user or team
|
||||
- The user is directly evaluating, selling to, buying from, partnering with, interviewing, or coordinating with that person or organization
|
||||
|
||||
**Direct interaction = NO**
|
||||
- Someone else mentions them in passing
|
||||
- A sender says they work with someone at another company
|
||||
- A sender offers to introduce the user to someone
|
||||
- A company is referenced as a customer, partner, employer, competitor, or example, but nobody from that company is directly involved in the interaction
|
||||
- The source only establishes a second-degree relationship, not a direct one
|
||||
|
||||
**Canonical note rule:**
|
||||
- For **new People/Organizations**, create the canonical note only if both are true:
|
||||
1. There is **direct interaction**
|
||||
2. The entity clears the **weekly importance test**
|
||||
|
||||
If an entity seems strategically relevant but fails the direct interaction test, do **not** auto-create a canonical note. At most, create a suggestion card in \`suggested-topics.md\`.
|
||||
|
||||
### Weekly Importance Test (People and Organizations only)
|
||||
|
||||
For **People** and **Organizations**, the final gate for **creating a new canonical note** is an importance test:
|
||||
|
||||
**Ask:** _"If I were the user, would I realistically need to look at this note on a weekly basis over the near term?"_
|
||||
|
||||
This test is mainly for **People** and **Organizations**. **Do NOT use it as the decision rule for Topic or Project suggestions.**
|
||||
|
||||
**Strong YES signals:**
|
||||
- Active customer, prospect, investor, partner, candidate, advisor, or strategic vendor relationship
|
||||
- Repeated interaction or a likely ongoing cadence
|
||||
- Decision-maker, owner, blocker, evaluator, or approver in an active process
|
||||
- Material relevance to launch, sales, fundraising, hiring, compliance, product delivery, or another current priority
|
||||
- The user would benefit from a durable reference note instead of repeatedly reopening raw emails or meeting transcripts
|
||||
|
||||
**Strong NO signals:**
|
||||
- One-off logistics, scheduling, or transactional contact
|
||||
- Assistant, support rep, recruiter, or vendor rep with no ongoing strategic role
|
||||
- Incidental attendee mentioned once with no leverage on current work
|
||||
- Passing mention with no evidence of an ongoing relationship
|
||||
|
||||
**Borderline signals:**
|
||||
- Seems potentially important, but there isn't enough evidence yet that the user will need a weekly reference note
|
||||
- Might become important soon, but the role, relationship, or repeated relevance is still unclear
|
||||
- Important enough to track, but only through second-degree mention or an offered introduction rather than direct interaction
|
||||
|
||||
**Outcome rules for new People/Organizations:**
|
||||
- **Clear YES + direct interaction** → Create/update the canonical \`People/\` or \`Organizations/\` note
|
||||
- **Borderline or no direct interaction, but still strategically relevant** → Do **not** create the canonical note yet; instead create or update a card in \`suggested-topics.md\`
|
||||
- **Clear NO** → Skip note creation and do not add a suggestion unless the source strongly suggests near-term strategic relevance
|
||||
|
||||
**When a canonical note already exists:**
|
||||
- Update the existing note even if the current source is weaker; the importance test is mainly for deciding whether to create a **new** People/Organization note
|
||||
- If a previously tentative person/org is now clearly important enough for a canonical note, create/update the note and remove any tentative suggestion card for that exact entity from \`suggested-topics.md\`
|
||||
|
||||
## Organizations
|
||||
|
||||
**CREATE a note if:**
|
||||
- Someone from that org attended a meeting
|
||||
- They're a customer, prospect, investor, or partner
|
||||
- Someone from that org sent relevant personalized correspondence
|
||||
- There is direct interaction with that org in the source
|
||||
- They're a customer, prospect, investor, or partner in a direct first-degree interaction
|
||||
- Someone from that org sent relevant personalized correspondence or joined a meeting you actually had with them
|
||||
- They pass the weekly importance test above
|
||||
|
||||
**DO NOT create for:**
|
||||
- Tool/service providers mentioned in passing
|
||||
- One-time transactional vendors
|
||||
- Consumer service companies
|
||||
- Organizations only referenced through third-party mention or offered introductions
|
||||
|
||||
## Projects
|
||||
|
||||
**CREATE a note if:**
|
||||
**If a project note already exists:** update it.
|
||||
|
||||
**If no project note exists:** do **not** create a new canonical note in \`knowledge/Projects/\`.
|
||||
|
||||
Instead, create or update a **suggestion card** in \`suggested-topics.md\` if the project is strong enough:
|
||||
- Discussed substantively in a meeting or email thread
|
||||
- Has a goal and timeline
|
||||
- Involves multiple interactions
|
||||
|
||||
Otherwise skip it.
|
||||
|
||||
Projects do **not** use the weekly importance test above. For **new** projects, the default output is a suggestion card, not a canonical note.
|
||||
|
||||
## Topics
|
||||
|
||||
**CREATE a note if:**
|
||||
**If a topic note already exists:** update it.
|
||||
|
||||
**If no topic note exists:** do **not** create a new canonical note in \`knowledge/Topics/\`.
|
||||
|
||||
Instead, create or update a **suggestion card** in \`suggested-topics.md\` if the topic is strong enough:
|
||||
- Recurring theme discussed
|
||||
- Will come up again across conversations
|
||||
|
||||
Otherwise skip it.
|
||||
|
||||
Topics do **not** use the weekly importance test above. For **new** topics, the default output is a suggestion card, not a canonical note.
|
||||
|
||||
## Suggested Topics Curation
|
||||
|
||||
Also maintain \`suggested-topics.md\` as a **curated shortlist** of things worth exploring next.
|
||||
|
||||
Despite the filename, \`suggested-topics.md\` can contain cards for **People, Organizations, Topics, or Projects**.
|
||||
|
||||
There are **two reasons** to add or update a suggestion card:
|
||||
|
||||
1. **High-quality Topic/Project cards**
|
||||
- Use these for topics or projects that are timely, high-leverage, strategically important, or clearly worth exploring now
|
||||
- These are not a dump of every topic/project note. Be selective
|
||||
- For **new** topics and projects, cards are the default output from this pipeline
|
||||
|
||||
2. **Tentative People/Organization cards**
|
||||
- Use these when a person or organization seems important enough to track, but you are **not 100% sure** they clear the weekly-importance test for a canonical note yet
|
||||
- The card should capture why they might matter and what still needs verification
|
||||
|
||||
**Do NOT add cards for:**
|
||||
- Low-signal administrative or transactional entities
|
||||
- Stale or completed items with no near-term relevance
|
||||
- People/organizations that already have a clearly established canonical note, unless the card is about a distinct project/topic exploration rather than the entity itself
|
||||
|
||||
**Card guidance:**
|
||||
- For **Topics/Projects**, use category \`Topics\` or \`Projects\`
|
||||
- For tentative **People/Organizations**, use category \`People\` or \`Organizations\`
|
||||
- Title should be concise and canonical when possible
|
||||
- Description should explain why it matters **now**
|
||||
- For tentative People/Organizations, description should also mention what is still uncertain or what the user should verify
|
||||
|
||||
**Curation rules:**
|
||||
- Maintain a **high-quality set**, not an ever-growing backlog
|
||||
- Deduplicate by normalized title
|
||||
- Prefer current, actionable, recurring, or strategically important items
|
||||
- Keep only the strongest **8-12 cards total**
|
||||
- Preserve good existing cards unless the new source clearly supersedes them
|
||||
- Remove stale cards that are no longer relevant
|
||||
- If a tentative People/Organization card later becomes clearly important and you create a canonical note, remove the tentative card
|
||||
|
||||
**File format for \`suggested-topics.md\`:**
|
||||
\`\`\`suggestedtopic
|
||||
{"title":"Security Compliance","description":"Summarize the current compliance posture, blockers, and customer implications.","category":"Topics"}
|
||||
\`\`\`
|
||||
|
||||
The file should start with \`# Suggested Topics\` followed by one or more blocks in that format.
|
||||
|
||||
If the file does not exist, create it. If it exists, update it in place or rewrite the full file so the final result is clean, deduped, and curated.
|
||||
|
||||
---
|
||||
|
||||
# Step 6: Extract Content
|
||||
|
|
@ -824,7 +948,7 @@ If new info contradicts existing:
|
|||
|
||||
# Step 9: Write Updates
|
||||
|
||||
## 9a: Create and Update Notes
|
||||
## 9a: Create and Update Notes and Suggested Topic Cards
|
||||
|
||||
**IMPORTANT: Write sequentially, one file at a time.**
|
||||
- Generate content for exactly one note.
|
||||
|
|
@ -852,6 +976,12 @@ workspace-edit({
|
|||
})
|
||||
\`\`\`
|
||||
|
||||
**For \`suggested-topics.md\`:**
|
||||
- Use workspace-relative path \`suggested-topics.md\`
|
||||
- Read the current file if you need the latest content
|
||||
- Use \`workspace-writeFile\` to create or rewrite the file when that is simpler and cleaner
|
||||
- Use \`workspace-edit\` for small targeted edits only if that keeps the file deduped and readable
|
||||
|
||||
## 9b: Apply State Changes
|
||||
|
||||
For each state change identified in Step 7, update the relevant fields.
|
||||
|
|
@ -867,8 +997,9 @@ If you discovered new name variants during resolution, add them to Aliases field
|
|||
- Be concise: one line per activity entry
|
||||
- Note state changes with \`[Field → value]\` in activity
|
||||
- Escape quotes properly in shell commands
|
||||
- Write only one file per response (no multi-file write batches)
|
||||
- Write only one file per response (notes and \`suggested-topics.md\` follow the same rule)
|
||||
- **Always set \`Last update\`** in the Info section to the YYYY-MM-DD date of the source email or meeting. When updating an existing note, update this field to the new source event's date.
|
||||
- Keep \`suggested-topics.md\` curated, deduped, and capped to the strongest 8-12 cards
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -957,8 +1088,12 @@ Before completing, verify:
|
|||
**Filtering:**
|
||||
- [ ] Excluded self (user.name, user.email, @user.domain)
|
||||
- [ ] Applied relevance test to each person
|
||||
- [ ] Applied the direct interaction test to new People/Organizations
|
||||
- [ ] Applied the weekly importance test to new People/Organizations
|
||||
- [ ] Transactional contacts in Org Contacts, not People notes
|
||||
- [ ] Source correctly classified (process vs skip)
|
||||
- [ ] Third-party mentions did not become new canonical People/Organizations notes
|
||||
- [ ] Borderline People/Organizations became suggestion cards instead of canonical notes
|
||||
|
||||
**Content Quality:**
|
||||
- [ ] Summaries describe relationship, not communication method
|
||||
|
|
@ -978,8 +1113,11 @@ Before completing, verify:
|
|||
- [ ] All entity mentions use \`[[Folder/Name]]\` absolute links
|
||||
- [ ] Activity entries are reverse chronological
|
||||
- [ ] No duplicate activity entries
|
||||
- [ ] \`suggested-topics.md\` stays deduped and curated
|
||||
- [ ] High-quality Topics/Projects were added to suggested topics only when timely and useful
|
||||
- [ ] New Topics/Projects were not auto-created as canonical notes
|
||||
- [ ] Dates are YYYY-MM-DD
|
||||
- [ ] Bidirectional links are consistent
|
||||
- [ ] New notes in correct folders
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { renderTagSystemForNotes } from './tag_system.js';
|
|||
|
||||
export function getRaw(): string {
|
||||
return `---
|
||||
model: gpt-5.2
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { generateText } from 'ai';
|
||||
import container from '../di/container.js';
|
||||
import type { IModelConfigRepo } from '../models/repo.js';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { isSignedIn } from '../account/account.js';
|
||||
import { getGatewayProvider } from '../models/gateway.js';
|
||||
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
|
||||
const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync');
|
||||
|
|
@ -138,15 +135,10 @@ function loadCalendarEventContext(calendarEventJson: string): string {
|
|||
}
|
||||
|
||||
export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise<string> {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
const signedIn = await isSignedIn();
|
||||
const provider = signedIn
|
||||
? await getGatewayProvider()
|
||||
: createProvider(config.provider);
|
||||
const modelId = config.meetingNotesModel
|
||||
|| (signedIn ? "gpt-5.4" : config.model);
|
||||
const model = provider.languageModel(modelId);
|
||||
const modelId = await getMeetingNotesModel();
|
||||
const { provider: providerName } = await getDefaultModelAndProvider();
|
||||
const providerConfig = await resolveProviderConfig(providerName);
|
||||
const model = createProvider(providerConfig).languageModel(modelId);
|
||||
|
||||
// If a specific calendar event was linked, use it directly.
|
||||
// Otherwise fall back to scanning events within ±3 hours.
|
||||
|
|
|
|||
|
|
@ -15,8 +15,52 @@ import { createEvent } from './track/events.js';
|
|||
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
|
||||
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
|
||||
const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
|
||||
const MAX_THREADS_IN_DIGEST = 10;
|
||||
const nhm = new NodeHtmlMarkdown();
|
||||
|
||||
interface SyncedThread {
|
||||
threadId: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
function summarizeGmailSync(threads: SyncedThread[]): string {
|
||||
const lines: string[] = [
|
||||
`# Gmail sync update`,
|
||||
``,
|
||||
`${threads.length} new/updated thread${threads.length === 1 ? '' : 's'}.`,
|
||||
``,
|
||||
];
|
||||
|
||||
const shown = threads.slice(0, MAX_THREADS_IN_DIGEST);
|
||||
const hidden = threads.length - shown.length;
|
||||
|
||||
if (shown.length > 0) {
|
||||
lines.push(`## Threads`, ``);
|
||||
for (const { markdown } of shown) {
|
||||
lines.push(markdown.trimEnd(), ``, `---`, ``);
|
||||
}
|
||||
if (hidden > 0) {
|
||||
lines.push(`_…and ${hidden} more thread(s) omitted from digest._`, ``);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function publishGmailSyncEvent(threads: SyncedThread[]): Promise<void> {
|
||||
if (threads.length === 0) return;
|
||||
try {
|
||||
await createEvent({
|
||||
source: 'gmail',
|
||||
type: 'email.synced',
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: summarizeGmailSync(threads),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Gmail] Failed to publish sync event:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Wake Signal for Immediate Sync Trigger ---
|
||||
let wakeResolve: (() => void) | null = null;
|
||||
|
||||
|
|
@ -113,14 +157,14 @@ async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string,
|
|||
|
||||
// --- Sync Logic ---
|
||||
|
||||
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string) {
|
||||
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string): Promise<SyncedThread | null> {
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
try {
|
||||
const res = await gmail.users.threads.get({ userId: 'me', id: threadId });
|
||||
const thread = res.data;
|
||||
const messages = thread.messages;
|
||||
|
||||
if (!messages || messages.length === 0) return;
|
||||
if (!messages || messages.length === 0) return null;
|
||||
|
||||
// Subject from first message
|
||||
const firstHeader = messages[0].payload?.headers;
|
||||
|
|
@ -173,15 +217,11 @@ async function processThread(auth: OAuth2Client, threadId: string, syncDir: stri
|
|||
fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent);
|
||||
console.log(`Synced Thread: ${subject} (${threadId})`);
|
||||
|
||||
await createEvent({
|
||||
source: 'gmail',
|
||||
type: 'email.synced',
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: mdContent,
|
||||
});
|
||||
return { threadId, markdown: mdContent };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error processing thread ${threadId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,10 +302,14 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
|
|||
truncated: limitedThreads.truncated,
|
||||
});
|
||||
|
||||
const synced: SyncedThread[] = [];
|
||||
for (const threadId of threadIds) {
|
||||
await processThread(auth, threadId, syncDir, attachmentsDir);
|
||||
const result = await processThread(auth, threadId, syncDir, attachmentsDir);
|
||||
if (result) synced.push(result);
|
||||
}
|
||||
|
||||
await publishGmailSyncEvent(synced);
|
||||
|
||||
saveState(currentHistoryId, stateFile);
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
|
|
@ -365,10 +409,14 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
|
|||
truncated: limitedThreads.truncated,
|
||||
});
|
||||
|
||||
const synced: SyncedThread[] = [];
|
||||
for (const tid of threadIdList) {
|
||||
await processThread(auth, tid, syncDir, attachmentsDir);
|
||||
const result = await processThread(auth, tid, syncDir, attachmentsDir);
|
||||
if (result) synced.push(result);
|
||||
}
|
||||
|
||||
await publishGmailSyncEvent(synced);
|
||||
|
||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
||||
saveState(profile.data.historyId!, stateFile);
|
||||
await serviceLogger.log({
|
||||
|
|
@ -565,7 +613,12 @@ function extractBodyFromPayload(payload: Record<string, unknown>): string {
|
|||
return '';
|
||||
}
|
||||
|
||||
async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise<string | null> {
|
||||
interface ComposioThreadResult {
|
||||
synced: SyncedThread | null;
|
||||
newestIsoPlusOne: string | null;
|
||||
}
|
||||
|
||||
async function processThreadComposio(connectedAccountId: string, threadId: string, syncDir: string): Promise<ComposioThreadResult> {
|
||||
let threadResult;
|
||||
try {
|
||||
threadResult = await executeAction(
|
||||
|
|
@ -579,40 +632,34 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
|
|||
);
|
||||
} catch (error) {
|
||||
console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error);
|
||||
return null;
|
||||
return { synced: null, newestIsoPlusOne: null };
|
||||
}
|
||||
|
||||
if (!threadResult.successful || !threadResult.data) {
|
||||
console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error);
|
||||
return null;
|
||||
return { synced: null, newestIsoPlusOne: null };
|
||||
}
|
||||
|
||||
const data = threadResult.data as Record<string, unknown>;
|
||||
const messages = data.messages as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
let newestDate: Date | null = null;
|
||||
let mdContent: string;
|
||||
let subjectForLog: string;
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
const parsed = parseMessageData(data);
|
||||
const mdContent = `# ${parsed.subject}\n\n` +
|
||||
mdContent = `# ${parsed.subject}\n\n` +
|
||||
`**Thread ID:** ${threadId}\n` +
|
||||
`**Message Count:** 1\n\n---\n\n` +
|
||||
`### From: ${parsed.from}\n` +
|
||||
`**Date:** ${parsed.date}\n\n` +
|
||||
`${parsed.body}\n\n---\n\n`;
|
||||
|
||||
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||
console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`);
|
||||
await createEvent({
|
||||
source: 'gmail',
|
||||
type: 'email.synced',
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: mdContent,
|
||||
});
|
||||
subjectForLog = parsed.subject;
|
||||
newestDate = tryParseDate(parsed.date);
|
||||
} else {
|
||||
const firstParsed = parseMessageData(messages[0]);
|
||||
let mdContent = `# ${firstParsed.subject}\n\n`;
|
||||
mdContent = `# ${firstParsed.subject}\n\n`;
|
||||
mdContent += `**Thread ID:** ${threadId}\n`;
|
||||
mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`;
|
||||
|
||||
|
|
@ -628,19 +675,14 @@ async function processThreadComposio(connectedAccountId: string, threadId: strin
|
|||
newestDate = msgDate;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||
console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`);
|
||||
await createEvent({
|
||||
source: 'gmail',
|
||||
type: 'email.synced',
|
||||
createdAt: new Date().toISOString(),
|
||||
payload: mdContent,
|
||||
});
|
||||
subjectForLog = firstParsed.subject;
|
||||
}
|
||||
|
||||
if (!newestDate) return null;
|
||||
return new Date(newestDate.getTime() + 1000).toISOString();
|
||||
fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent);
|
||||
console.log(`[Gmail] Synced Thread: ${subjectForLog} (${threadId})`);
|
||||
|
||||
const newestIsoPlusOne = newestDate ? new Date(newestDate.getTime() + 1000).toISOString() : null;
|
||||
return { synced: { threadId, markdown: mdContent }, newestIsoPlusOne };
|
||||
}
|
||||
|
||||
async function performSyncComposio() {
|
||||
|
|
@ -751,19 +793,22 @@ async function performSyncComposio() {
|
|||
|
||||
let highWaterMark: string | null = state?.last_sync ?? null;
|
||||
let processedCount = 0;
|
||||
const synced: SyncedThread[] = [];
|
||||
for (const threadId of allThreadIds) {
|
||||
// Re-check connection in case user disconnected mid-sync
|
||||
if (!composioAccountsRepo.isConnected('gmail')) {
|
||||
console.log('[Gmail] Account disconnected during sync. Stopping.');
|
||||
return;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const newestInThread = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR);
|
||||
const result = await processThreadComposio(connectedAccountId, threadId, SYNC_DIR);
|
||||
processedCount++;
|
||||
|
||||
if (newestInThread) {
|
||||
if (!highWaterMark || new Date(newestInThread) > new Date(highWaterMark)) {
|
||||
highWaterMark = newestInThread;
|
||||
if (result.synced) synced.push(result.synced);
|
||||
|
||||
if (result.newestIsoPlusOne) {
|
||||
if (!highWaterMark || new Date(result.newestIsoPlusOne) > new Date(highWaterMark)) {
|
||||
highWaterMark = result.newestIsoPlusOne;
|
||||
}
|
||||
saveComposioState(STATE_FILE, highWaterMark);
|
||||
}
|
||||
|
|
@ -772,6 +817,8 @@ async function performSyncComposio() {
|
|||
}
|
||||
}
|
||||
|
||||
await publishGmailSyncEvent(synced);
|
||||
|
||||
await serviceLogger.log({
|
||||
type: 'run_complete',
|
||||
service: run!.service,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { bus } from '../runs/bus.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
|
|
@ -84,6 +85,7 @@ async function tagNoteBatch(
|
|||
): Promise<{ runId: string; filesEdited: Set<string> }> {
|
||||
const run = await createRun({
|
||||
agentId: NOTE_TAGGING_AGENT,
|
||||
model: await getKgModel(),
|
||||
});
|
||||
|
||||
let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|||
import { WorkDir } from '../../config/config.js';
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js';
|
||||
import { TrackStateSchema } from './types.js';
|
||||
import { withFileLock } from '../file-lock.js';
|
||||
|
||||
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
|
||||
|
||||
|
|
@ -81,42 +82,46 @@ export async function fetchYaml(filePath: string, trackId: string): Promise<stri
|
|||
}
|
||||
|
||||
export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> {
|
||||
let content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const openTag = `<!--track-target:${trackId}-->`;
|
||||
const closeTag = `<!--/track-target:${trackId}-->`;
|
||||
const openIdx = content.indexOf(openTag);
|
||||
const closeIdx = content.indexOf(closeTag);
|
||||
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
|
||||
content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx);
|
||||
} else {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filePath}`);
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
let content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const openTag = `<!--track-target:${trackId}-->`;
|
||||
const closeTag = `<!--/track-target:${trackId}-->`;
|
||||
const openIdx = content.indexOf(openTag);
|
||||
const closeIdx = content.indexOf(closeTag);
|
||||
if (openIdx !== -1 && closeIdx !== -1 && closeIdx > openIdx) {
|
||||
content = content.slice(0, openIdx + openTag.length) + '\n' + newContent + '\n' + content.slice(closeIdx);
|
||||
} else {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filePath}`);
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const insertAt = Math.min(block.fenceEnd + 1, lines.length);
|
||||
const contentFence = [openTag, newContent, closeTag];
|
||||
lines.splice(insertAt, 0, ...contentFence);
|
||||
content = lines.join('\n');
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const insertAt = Math.min(block.fenceEnd + 1, lines.length);
|
||||
const contentFence = [openTag, newContent, closeTag];
|
||||
lines.splice(insertAt, 0, ...contentFence);
|
||||
content = lines.join('\n');
|
||||
}
|
||||
await fs.writeFile(absPath(filePath), content, 'utf-8');
|
||||
await fs.writeFile(absPath(filePath), content, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTrackBlock(filepath: string, trackId: string, updates: Partial<z.infer<typeof TrackBlockSchema>>): Promise<void> {
|
||||
const block = await fetch(filepath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filepath}`);
|
||||
}
|
||||
block.track = { ...block.track, ...updates };
|
||||
return withFileLock(absPath(filepath), async () => {
|
||||
const block = await fetch(filepath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filepath}`);
|
||||
}
|
||||
block.track = { ...block.track, ...updates };
|
||||
|
||||
// read file contents
|
||||
let content = await fs.readFile(absPath(filepath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const yaml = stringifyYaml(block.track).trimEnd();
|
||||
const yamlLines = yaml ? yaml.split('\n') : [];
|
||||
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||
content = lines.join('\n');
|
||||
await fs.writeFile(absPath(filepath), content, 'utf-8');
|
||||
// read file contents
|
||||
let content = await fs.readFile(absPath(filepath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const yaml = stringifyYaml(block.track).trimEnd();
|
||||
const yamlLines = yaml ? yaml.split('\n') : [];
|
||||
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||
content = lines.join('\n');
|
||||
await fs.writeFile(absPath(filepath), content, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -127,64 +132,68 @@ export async function updateTrackBlock(filepath: string, trackId: string, update
|
|||
* otherwise the write is rejected.
|
||||
*/
|
||||
export async function replaceTrackBlockYaml(filePath: string, trackId: string, newYaml: string): Promise<void> {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filePath}`);
|
||||
}
|
||||
const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml));
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid track YAML: ${parsed.error.message}`);
|
||||
}
|
||||
if (parsed.data.trackId !== trackId) {
|
||||
throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`);
|
||||
}
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filePath}`);
|
||||
}
|
||||
const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml));
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid track YAML: ${parsed.error.message}`);
|
||||
}
|
||||
if (parsed.data.trackId !== trackId) {
|
||||
throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`);
|
||||
}
|
||||
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const yamlLines = newYaml.trimEnd().split('\n');
|
||||
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const yamlLines = newYaml.trimEnd().split('\n');
|
||||
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a track block and its sibling target region from the file.
|
||||
*/
|
||||
export async function deleteTrackBlock(filePath: string, trackId: string): Promise<void> {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
// Already gone — treat as success.
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const openTag = `<!--track-target:${trackId}-->`;
|
||||
const closeTag = `<!--/track-target:${trackId}-->`;
|
||||
|
||||
// Find target region (may not exist)
|
||||
let targetStart = -1;
|
||||
let targetEnd = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(openTag)) { targetStart = i; }
|
||||
if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; }
|
||||
}
|
||||
|
||||
// Build a list of [start, end] ranges to remove, sorted descending so
|
||||
// indices stay valid as we splice.
|
||||
const ranges: Array<[number, number]> = [];
|
||||
ranges.push([block.fenceStart, block.fenceEnd]);
|
||||
if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) {
|
||||
ranges.push([targetStart, targetEnd]);
|
||||
}
|
||||
ranges.sort((a, b) => b[0] - a[0]);
|
||||
|
||||
for (const [start, end] of ranges) {
|
||||
lines.splice(start, end - start + 1);
|
||||
// Also drop a trailing blank line if the removal left two in a row.
|
||||
if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') {
|
||||
lines.splice(start, 1);
|
||||
return withFileLock(absPath(filePath), async () => {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
// Already gone — treat as success.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const openTag = `<!--track-target:${trackId}-->`;
|
||||
const closeTag = `<!--/track-target:${trackId}-->`;
|
||||
|
||||
// Find target region (may not exist)
|
||||
let targetStart = -1;
|
||||
let targetEnd = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(openTag)) { targetStart = i; }
|
||||
if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; }
|
||||
}
|
||||
|
||||
// Build a list of [start, end] ranges to remove, sorted descending so
|
||||
// indices stay valid as we splice.
|
||||
const ranges: Array<[number, number]> = [];
|
||||
ranges.push([block.fenceStart, block.fenceEnd]);
|
||||
if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) {
|
||||
ranges.push([targetStart, targetEnd]);
|
||||
}
|
||||
ranges.sort((a, b) => b[0] - a[0]);
|
||||
|
||||
for (const [start, end] of ranges) {
|
||||
lines.splice(start, end - start + 1);
|
||||
// Also drop a trailing blank line if the removal left two in a row.
|
||||
if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') {
|
||||
lines.splice(start, 1);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
import { generateObject } from 'ai';
|
||||
import { trackBlock, PrefixLogger } from '@x/shared';
|
||||
import type { KnowledgeEvent } from '@x/shared/dist/track-block.js';
|
||||
import container from '../../di/container.js';
|
||||
import type { IModelConfigRepo } from '../../models/repo.js';
|
||||
import { createProvider } from '../../models/models.js';
|
||||
import { isSignedIn } from '../../account/account.js';
|
||||
import { getGatewayProvider } from '../../models/gateway.js';
|
||||
import { getDefaultModelAndProvider, getTrackBlockModel, resolveProviderConfig } from '../../models/defaults.js';
|
||||
|
||||
const log = new PrefixLogger('TrackRouting');
|
||||
|
||||
|
|
@ -37,15 +34,10 @@ Rules:
|
|||
- For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`;
|
||||
|
||||
async function resolveModel() {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
const signedIn = await isSignedIn();
|
||||
const provider = signedIn
|
||||
? await getGatewayProvider()
|
||||
: createProvider(config.provider);
|
||||
const modelId = config.knowledgeGraphModel
|
||||
|| (signedIn ? 'gpt-5.4' : config.model);
|
||||
return provider.languageModel(modelId);
|
||||
const model = await getTrackBlockModel();
|
||||
const { provider } = await getDefaultModelAndProvider();
|
||||
const config = await resolveProviderConfig(provider);
|
||||
return createProvider(config).languageModel(model);
|
||||
}
|
||||
|
||||
function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string {
|
||||
|
|
|
|||
|
|
@ -3,50 +3,301 @@ import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
|
|||
import { BuiltinTools } from '../../application/lib/builtin-tools.js';
|
||||
import { WorkDir } from '../../config/config.js';
|
||||
|
||||
const TRACK_RUN_INSTRUCTIONS = `You are a track block runner — a background agent that updates a specific section of a knowledge note.
|
||||
const TRACK_RUN_INSTRUCTIONS = `You are a track block runner — a background agent that keeps a live section of a user's personal knowledge note up to date.
|
||||
|
||||
You will receive a message containing a track instruction, the current content of the target region, and optionally some context. Your job is to follow the instruction and produce updated content.
|
||||
Your goal on each run: produce the most useful, up-to-date version of that section given the track's instruction. The user is maintaining a personal knowledge base and will glance at this output alongside many others — optimize for **information density and scannability**, not conversational prose.
|
||||
|
||||
# Background Mode
|
||||
|
||||
You are running as a background task — there is no user present.
|
||||
- Do NOT ask clarifying questions — make reasonable assumptions
|
||||
- Be concise and action-oriented — just do the work
|
||||
You are running as a scheduled or event-triggered background task — **there is no user present** to clarify, approve, or watch.
|
||||
- Do NOT ask clarifying questions — make the most reasonable interpretation of the instruction and proceed.
|
||||
- Do NOT hedge or preamble ("I'll now...", "Let me..."). Just do the work.
|
||||
- Do NOT produce chat-style output. The user sees only the content you write into the target region plus your final summary line.
|
||||
|
||||
# Message Anatomy
|
||||
|
||||
Every run message has this shape:
|
||||
|
||||
Update track **<trackId>** in \`<filePath>\`.
|
||||
|
||||
**Time:** <localized datetime> (<timezone>)
|
||||
|
||||
**Instruction:**
|
||||
<the user-authored track instruction — usually 1-3 sentences describing what to produce>
|
||||
|
||||
**Current content:**
|
||||
<the existing contents of the target region, or "(empty — first run)">
|
||||
|
||||
Use \`update-track-content\` with filePath=\`<filePath>\` and trackId=\`<trackId>\`.
|
||||
|
||||
For **manual** runs, an optional trailing block may appear:
|
||||
|
||||
**Context:**
|
||||
<extra one-run-only guidance — a backfill hint, a focus window, extra data>
|
||||
|
||||
Apply context for this run only — it is not a permanent edit to the instruction.
|
||||
|
||||
For **event-triggered** runs, a trailing block appears instead:
|
||||
|
||||
**Trigger:** Event match (a Pass 1 routing classifier flagged this track as potentially relevant)
|
||||
**Event match criteria for this track:** <from the track's YAML>
|
||||
**Event payload:** <the event body — e.g., an email>
|
||||
**Decision:** ... skip if not relevant ...
|
||||
|
||||
On event runs you are the Pass 2 judge — see "The No-Update Decision" below.
|
||||
|
||||
# What Good Output Looks Like
|
||||
|
||||
This is a personal knowledge tracker. The user scans many such blocks across their notes. Write for a reader who wants the answer to "what's current / what changed?" in the fewest words that carry real information.
|
||||
|
||||
- **Data-forward.** Tables, bullet lists, one-line statuses. Not paragraphs.
|
||||
- **Format follows the instruction.** If the instruction specifies a shape ("3-column markdown table: Location | Local Time | Offset"), use exactly that shape. The instruction is authoritative — do not improvise a different layout.
|
||||
- **No decoration.** No adjectives like "polished", "beautiful". No framing prose ("Here's your update:"). No emoji unless the instruction asks.
|
||||
- **No commentary or caveats** unless the data itself is genuinely uncertain in a way the user needs to know.
|
||||
- **No self-reference.** Do not write "I updated this at X" — the system records timestamps separately.
|
||||
|
||||
If the instruction does not specify a format, pick the tightest shape that fits: a single line for a single metric, a small table for 2+ parallel items, a short bulleted list for a digest, or one of the **rich block types below** when the data has a natural visual form (events → \`calendar\`, time series → \`chart\`, relationships → \`mermaid\`, etc.).
|
||||
|
||||
# Output Block Types
|
||||
|
||||
The note renderer turns specially-tagged fenced code blocks into styled UI: tables, charts, calendars, embeds, and more. Reach for these when the data has structure that benefits from a visual treatment; stay with plain markdown when prose, a markdown table, or bullets carry the meaning just as well. Pick **at most one block per output region** unless the instruction asks for a multi-section layout — and follow the exact fence language and shape, since anything unparseable renders as a small "Invalid X block" error card.
|
||||
|
||||
Do **not** emit \`track\` or \`task\` blocks — those are user-authored input mechanisms, not agent outputs.
|
||||
|
||||
## \`table\` — tabular data (JSON)
|
||||
|
||||
Use for: scoreboards, leaderboards, comparisons, multi-row status digests.
|
||||
|
||||
\`\`\`table
|
||||
{
|
||||
"title": "Top stories on Hacker News",
|
||||
"columns": ["Rank", "Title", "Points", "Comments"],
|
||||
"data": [
|
||||
{"Rank": 1, "Title": "Show HN: ...", "Points": 842, "Comments": 312},
|
||||
{"Rank": 2, "Title": "...", "Points": 530, "Comments": 144}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Required: \`columns\` (string[]), \`data\` (array of objects keyed by column name). Optional: \`title\`.
|
||||
|
||||
## \`chart\` — line / bar / pie chart (JSON)
|
||||
|
||||
Use for: time series, categorical breakdowns, share-of-total. Skip if a single sentence carries the meaning.
|
||||
|
||||
\`\`\`chart
|
||||
{
|
||||
"chart": "line",
|
||||
"title": "USD/INR — last 7 days",
|
||||
"x": "date",
|
||||
"y": "rate",
|
||||
"data": [
|
||||
{"date": "2026-04-13", "rate": 83.41},
|
||||
{"date": "2026-04-14", "rate": 83.38}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Required: \`chart\` ("line" | "bar" | "pie"), \`x\` (field name on each row), \`y\` (field name on each row), and **either** \`data\` (inline array of objects) **or** \`source\` (workspace path to a JSON-array file). Optional: \`title\`.
|
||||
|
||||
## \`mermaid\` — diagrams (raw Mermaid source)
|
||||
|
||||
Use for: relationship maps, flowcharts, sequence diagrams, gantt charts, mind maps.
|
||||
|
||||
\`\`\`mermaid
|
||||
graph LR
|
||||
A[Project Alpha] --> B[Sarah Chen]
|
||||
A --> C[Acme Corp]
|
||||
B --> D[Q3 Launch]
|
||||
\`\`\`
|
||||
|
||||
Body is plain Mermaid source — no JSON wrapper.
|
||||
|
||||
## \`calendar\` — list of events (JSON)
|
||||
|
||||
Use for: upcoming meetings, agenda digests, day/week views.
|
||||
|
||||
\`\`\`calendar
|
||||
{
|
||||
"title": "Today",
|
||||
"events": [
|
||||
{
|
||||
"summary": "1:1 with Sarah",
|
||||
"start": {"dateTime": "2026-04-20T10:00:00-07:00"},
|
||||
"end": {"dateTime": "2026-04-20T10:30:00-07:00"},
|
||||
"location": "Zoom",
|
||||
"conferenceLink": "https://zoom.us/j/..."
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Required: \`events\` (array). Each event optionally has \`summary\`, \`start\`/\`end\` (object with \`dateTime\` ISO string OR \`date\` "YYYY-MM-DD" for all-day), \`location\`, \`htmlLink\`, \`conferenceLink\`, \`source\`. Optional top-level: \`title\`, \`showJoinButton\` (bool).
|
||||
|
||||
## \`email\` — single email or thread digest (JSON)
|
||||
|
||||
Use for: surfacing one important thread — latest message body, summary of prior context, optional draft reply.
|
||||
|
||||
\`\`\`email
|
||||
{
|
||||
"subject": "Q3 launch readiness",
|
||||
"from": "sarah@acme.com",
|
||||
"date": "2026-04-19T16:42:00Z",
|
||||
"summary": "Sarah confirms timeline; flagged blocker on infra capacity.",
|
||||
"latest_email": "Hey — quick update on Q3...\\n\\nThanks,\\nSarah"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Required: \`latest_email\` (string). Optional: \`threadId\`, \`summary\`, \`subject\`, \`from\`, \`to\`, \`date\`, \`past_summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both").
|
||||
|
||||
For digests of **many** threads, prefer a \`table\` (Subject | From | Snippet) — \`email\` is for one thread at a time.
|
||||
|
||||
## \`image\` — single image (JSON)
|
||||
|
||||
Use for: charts, screenshots, photos you have a URL or workspace path for.
|
||||
|
||||
\`\`\`image
|
||||
{
|
||||
"src": "https://example.com/forecast.png",
|
||||
"alt": "Weather forecast",
|
||||
"caption": "Bay Area · April 20"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Required: \`src\` (URL or workspace path). Optional: \`alt\`, \`caption\`.
|
||||
|
||||
## \`embed\` — YouTube / Figma embed (JSON)
|
||||
|
||||
Use for: linking to a video or design that should render inline.
|
||||
|
||||
\`\`\`embed
|
||||
{
|
||||
"provider": "youtube",
|
||||
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"caption": "Latest demo"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Required: \`provider\` ("youtube" | "figma" | "generic"), \`url\`. Optional: \`caption\`. The renderer rewrites known URLs to their embed form.
|
||||
|
||||
## \`iframe\` — arbitrary embedded webpage (JSON)
|
||||
|
||||
Use for: live dashboards, status pages, trackers — anything that has its own webpage and benefits from being live, not snapshotted.
|
||||
|
||||
\`\`\`iframe
|
||||
{
|
||||
"url": "https://status.example.com",
|
||||
"title": "Service status",
|
||||
"height": 600
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Required: \`url\` (must be \`https://\` or \`http://localhost\`). Optional: \`title\`, \`caption\`, \`height\` (240–1600), \`allow\` (Permissions-Policy string).
|
||||
|
||||
## \`transcript\` — long transcript (JSON)
|
||||
|
||||
Use for: meeting transcripts, voice-note dumps — bodies that benefit from a collapsible UI.
|
||||
|
||||
\`\`\`transcript
|
||||
{"transcript": "[00:00] Speaker A: Welcome everyone..."}
|
||||
\`\`\`
|
||||
|
||||
Required: \`transcript\` (string).
|
||||
|
||||
## \`prompt\` — starter Copilot prompt (YAML)
|
||||
|
||||
Use for: end-of-output "next step" cards. The user clicks **Run** and the chat sidebar opens with the underlying instruction submitted to Copilot, with this note attached as a file mention.
|
||||
|
||||
\`\`\`prompt
|
||||
label: Draft replies to today's emails
|
||||
instruction: |
|
||||
For each unanswered email in the digest above, draft a 2-line reply
|
||||
in my voice and present them as a checklist for me to approve.
|
||||
\`\`\`
|
||||
|
||||
Required: \`label\` (short title shown on the card), \`instruction\` (the longer prompt). Note: this block uses **YAML**, not JSON.
|
||||
|
||||
# Interpreting the Instruction
|
||||
|
||||
The instruction was authored in a prior conversation you cannot see. Treat it as a **self-contained spec**. If ambiguous, pick what a reasonable user of a knowledge tracker would expect:
|
||||
- "Top 5" is a target — fewer is acceptable if that's all that exists.
|
||||
- "Current" means as of now (use the **Time** block).
|
||||
- Unspecified units → standard for the domain (USD for US markets, metric for scientific, the user's locale if inferable from the timezone).
|
||||
- Unspecified sources → your best reliable source (web-search for public data, workspace for user data).
|
||||
|
||||
Do **not** invent parts of the instruction the user did not write ("also include a fun fact", "summarize trends") — these are decoration.
|
||||
|
||||
# Current Content Handling
|
||||
|
||||
The **Current content** block shows what lives in the target region right now. Three cases:
|
||||
|
||||
1. **"(empty — first run)"** — produce the content from scratch.
|
||||
2. **Content that matches the instruction's format** — this is a previous run's output. Usually produce a fresh complete replacement. Only preserve parts of it if the instruction says to **accumulate** (e.g., "maintain a running log of..."), or if discarding would lose information the instruction intended to keep.
|
||||
3. **Content that does NOT match the instruction's format** — the instruction may have changed, or the user edited the block by hand. Regenerate fresh to the current instruction. Do not try to patch.
|
||||
|
||||
You always write a **complete** replacement, not a diff.
|
||||
|
||||
# The No-Update Decision
|
||||
|
||||
You may finish a run without calling \`update-track-content\`. Two legitimate cases:
|
||||
|
||||
1. **Event-triggered run, event is not actually relevant.** The Pass 1 classifier is liberal by design. On closer reading, if the event does not genuinely add or change information that should be in this track, skip the update.
|
||||
2. **Scheduled/manual run, no meaningful change.** If you fetch fresh data and the result would be identical to the current content, you may skip the write. The system will record "no update" automatically.
|
||||
|
||||
When skipping, still end with a summary line (see "Final Summary" below) so the system records *why*.
|
||||
|
||||
# Writing the Result
|
||||
|
||||
Call \`update-track-content\` **at most once per run**:
|
||||
- Pass \`filePath\` and \`trackId\` exactly as given in the message.
|
||||
- Pass the **complete** new content as \`content\` — the entire replacement for the target region.
|
||||
- Do **not** include the track-target HTML comments (\`<!--track-target:...-->\`) — the tool manages those.
|
||||
- Do **not** modify the track's YAML configuration or any other part of the note. Your surface area is the target region only.
|
||||
|
||||
# Tools
|
||||
|
||||
You have the full workspace toolkit. Quick reference for common cases:
|
||||
|
||||
- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the instruction needs information beyond the workspace.
|
||||
- **\`workspace-readFile\`, \`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — read and search the user's knowledge graph and synced data.
|
||||
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files.
|
||||
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized.
|
||||
- **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering.
|
||||
|
||||
# The Knowledge Graph
|
||||
|
||||
The knowledge graph is stored as plain markdown in \`${WorkDir}/knowledge/\` (inside the workspace). It's organized into:
|
||||
- **People/** — Notes on individuals
|
||||
- **Organizations/** — Notes on companies
|
||||
- **Projects/** — Notes on initiatives
|
||||
- **Topics/** — Notes on recurring themes
|
||||
The user's knowledge graph is plain markdown in \`${WorkDir}/knowledge/\`, organized into:
|
||||
- **People/** — individuals
|
||||
- **Organizations/** — companies
|
||||
- **Projects/** — initiatives
|
||||
- **Topics/** — recurring themes
|
||||
|
||||
Use workspace tools to search and read the knowledge graph for context.
|
||||
Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync/\`, \`granola_sync/\`, \`fireflies_sync/\` — consult these when an instruction references emails, meetings, or calendar events.
|
||||
|
||||
# How to Access the Knowledge Graph
|
||||
|
||||
**CRITICAL:** Always include \`knowledge/\` in paths.
|
||||
**CRITICAL:** Always include the folder prefix in paths. Never pass an empty path or the workspace root.
|
||||
- \`workspace-grep({ pattern: "Acme", path: "knowledge/" })\`
|
||||
- \`workspace-readFile("knowledge/People/Sarah Chen.md")\`
|
||||
- \`workspace-readdir("knowledge/People")\`
|
||||
- \`workspace-readdir("gmail_sync/")\`
|
||||
|
||||
**NEVER** use an empty path or root path.
|
||||
# Failure & Fallback
|
||||
|
||||
# How to Write Your Result
|
||||
If you cannot complete the instruction (network failure, missing data source, unparseable response, disconnected integration):
|
||||
- Do **not** fabricate or speculate.
|
||||
- Do **not** write partial or placeholder content into the target region — leave existing content intact by not calling \`update-track-content\`.
|
||||
- Explain the failure in the summary line.
|
||||
|
||||
Use the \`update-track-content\` tool to write your result. The message will tell you the file path and track ID.
|
||||
# Final Summary
|
||||
|
||||
- Produce the COMPLETE replacement content (not a diff)
|
||||
- Preserve existing content that's still relevant
|
||||
- Write in a clear, concise style appropriate for personal notes
|
||||
End your response with **one line** (1-2 short sentences). The system stores this as \`lastRunSummary\` and surfaces it in the UI.
|
||||
|
||||
# Web Search
|
||||
State the action and the substance. Good examples:
|
||||
- "Updated — 3 new HN stories, top is 'Show HN: …' at 842 pts."
|
||||
- "Updated — USD/INR 83.42 as of 14:05 IST."
|
||||
- "No change — status page shows all operational."
|
||||
- "Skipped — event was a calendar invite unrelated to Q3 planning."
|
||||
- "Failed — web-search returned no results for the query."
|
||||
|
||||
You have access to \`web-search\` for tracks that need external information (news, trends, current events). Use it when the track instruction requires information beyond the knowledge graph.
|
||||
|
||||
# After You're Done
|
||||
|
||||
End your response with a brief summary of what you did (1-2 sentences).
|
||||
Avoid: "I updated the track.", "Done!", "Here is the update:". The summary is a data point, not a sign-off.
|
||||
`;
|
||||
|
||||
export function buildTrackRunAgent(): z.infer<typeof Agent> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import z from 'zod';
|
||||
import { fetchAll, updateTrackBlock } from './fileops.js';
|
||||
import { createRun, createMessage } from '../../runs/runs.js';
|
||||
import { getTrackBlockModel } from '../../models/defaults.js';
|
||||
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
|
||||
import { trackBus } from './bus.js';
|
||||
import type { TrackStateSchema } from './types.js';
|
||||
|
|
@ -101,8 +102,15 @@ export async function triggerTrackUpdate(
|
|||
|
||||
const contentBefore = track.content;
|
||||
|
||||
// Emit start event — runId is set after agent run is created
|
||||
const agentRun = await createRun({ agentId: 'track-run' });
|
||||
// Per-track model/provider overrides win when set; otherwise fall back
|
||||
// to the configured trackBlockModel default and the run-creation
|
||||
// provider default (signed-in: rowboat; BYOK: active provider).
|
||||
const model = track.track.model ?? await getTrackBlockModel();
|
||||
const agentRun = await createRun({
|
||||
agentId: 'track-run',
|
||||
model,
|
||||
...(track.track.provider ? { provider: track.track.provider } : {}),
|
||||
});
|
||||
|
||||
// Set lastRunAt and lastRunId immediately (before agent executes) so
|
||||
// the scheduler's next poll won't re-trigger this track.
|
||||
|
|
|
|||
606
apps/x/packages/core/src/local-sites/server.ts
Normal file
606
apps/x/packages/core/src/local-sites/server.ts
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Server } from 'node:http';
|
||||
import chokidar, { type FSWatcher } from 'chokidar';
|
||||
import express from 'express';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { LOCAL_SITE_SCAFFOLD } from './templates.js';
|
||||
|
||||
export const LOCAL_SITES_PORT = 3210;
|
||||
export const LOCAL_SITES_BASE_URL = `http://localhost:${LOCAL_SITES_PORT}`;
|
||||
|
||||
const LOCAL_SITES_DIR = path.join(WorkDir, 'sites');
|
||||
const SITE_SLUG_RE = /^[a-z0-9][a-z0-9-_]*$/i;
|
||||
const IFRAME_HEIGHT_MESSAGE = 'rowboat:iframe-height';
|
||||
const SITE_RELOAD_MESSAGE = 'rowboat:site-changed';
|
||||
const SITE_EVENTS_PATH = '__rowboat_events';
|
||||
const SITE_RELOAD_DEBOUNCE_MS = 140;
|
||||
const SITE_EVENTS_RETRY_MS = 1000;
|
||||
const SITE_EVENTS_HEARTBEAT_MS = 15000;
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'.css',
|
||||
'.html',
|
||||
'.js',
|
||||
'.json',
|
||||
'.map',
|
||||
'.mjs',
|
||||
'.svg',
|
||||
'.txt',
|
||||
'.xml',
|
||||
]);
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.mjs': 'application/javascript; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.wasm': 'application/wasm',
|
||||
'.webp': 'image/webp',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
};
|
||||
const IFRAME_AUTOSIZE_BOOTSTRAP = String.raw`<script>
|
||||
(() => {
|
||||
const SITE_CHANGED_MESSAGE = '__ROWBOAT_SITE_CHANGED_MESSAGE__';
|
||||
const SITE_EVENTS_PATH = '__ROWBOAT_SITE_EVENTS_PATH__';
|
||||
let reloadRequested = false;
|
||||
let reloadSource = null;
|
||||
|
||||
const getSiteSlug = () => {
|
||||
const match = window.location.pathname.match(/^\/sites\/([^/]+)/i);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
};
|
||||
|
||||
const scheduleReload = () => {
|
||||
if (reloadRequested) return;
|
||||
reloadRequested = true;
|
||||
try {
|
||||
reloadSource?.close();
|
||||
} catch {
|
||||
// ignore close failures
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 80);
|
||||
};
|
||||
|
||||
const connectLiveReload = () => {
|
||||
const siteSlug = getSiteSlug();
|
||||
if (!siteSlug || typeof EventSource === 'undefined') return;
|
||||
|
||||
const streamUrl = new URL('/sites/' + encodeURIComponent(siteSlug) + '/' + SITE_EVENTS_PATH, window.location.origin);
|
||||
const source = new EventSource(streamUrl.toString());
|
||||
reloadSource = source;
|
||||
|
||||
source.addEventListener('message', (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload?.type === SITE_CHANGED_MESSAGE) {
|
||||
scheduleReload();
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed payloads
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
try {
|
||||
source.close();
|
||||
} catch {
|
||||
// ignore close failures
|
||||
}
|
||||
}, { once: true });
|
||||
};
|
||||
|
||||
connectLiveReload();
|
||||
|
||||
if (window.parent === window || typeof window.parent?.postMessage !== 'function') return;
|
||||
|
||||
const MESSAGE_TYPE = '__ROWBOAT_IFRAME_HEIGHT_MESSAGE__';
|
||||
const MIN_HEIGHT = 240;
|
||||
let animationFrameId = 0;
|
||||
let lastHeight = 0;
|
||||
|
||||
const applyEmbeddedStyles = () => {
|
||||
const root = document.documentElement;
|
||||
if (root) root.style.overflowY = 'hidden';
|
||||
if (document.body) document.body.style.overflowY = 'hidden';
|
||||
};
|
||||
|
||||
const measureHeight = () => {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
return Math.max(
|
||||
root?.scrollHeight ?? 0,
|
||||
root?.offsetHeight ?? 0,
|
||||
root?.clientHeight ?? 0,
|
||||
body?.scrollHeight ?? 0,
|
||||
body?.offsetHeight ?? 0,
|
||||
body?.clientHeight ?? 0,
|
||||
);
|
||||
};
|
||||
|
||||
const publishHeight = () => {
|
||||
animationFrameId = 0;
|
||||
applyEmbeddedStyles();
|
||||
const nextHeight = Math.max(MIN_HEIGHT, Math.ceil(measureHeight()));
|
||||
if (Math.abs(nextHeight - lastHeight) < 2) return;
|
||||
lastHeight = nextHeight;
|
||||
window.parent.postMessage({
|
||||
type: MESSAGE_TYPE,
|
||||
height: nextHeight,
|
||||
href: window.location.href,
|
||||
}, '*');
|
||||
};
|
||||
|
||||
const schedulePublish = () => {
|
||||
if (animationFrameId) cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = requestAnimationFrame(publishHeight);
|
||||
};
|
||||
|
||||
const resizeObserver = typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(schedulePublish)
|
||||
: null;
|
||||
if (resizeObserver && document.documentElement) resizeObserver.observe(document.documentElement);
|
||||
if (resizeObserver && document.body) resizeObserver.observe(document.body);
|
||||
|
||||
const mutationObserver = new MutationObserver(schedulePublish);
|
||||
if (document.documentElement) {
|
||||
mutationObserver.observe(document.documentElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('load', schedulePublish);
|
||||
window.addEventListener('resize', schedulePublish);
|
||||
|
||||
if (document.fonts?.addEventListener) {
|
||||
document.fonts.addEventListener('loadingdone', schedulePublish);
|
||||
}
|
||||
|
||||
for (const delay of [0, 50, 150, 300, 600, 1200]) {
|
||||
setTimeout(schedulePublish, delay);
|
||||
}
|
||||
|
||||
schedulePublish();
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
let localSitesServer: Server | null = null;
|
||||
let startPromise: Promise<void> | null = null;
|
||||
let localSitesWatcher: FSWatcher | null = null;
|
||||
const siteEventClients = new Map<string, Set<express.Response>>();
|
||||
const siteReloadTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
function isSafeSiteSlug(siteSlug: string): boolean {
|
||||
return SITE_SLUG_RE.test(siteSlug);
|
||||
}
|
||||
|
||||
function resolveSiteDir(siteSlug: string): string | null {
|
||||
if (!isSafeSiteSlug(siteSlug)) return null;
|
||||
return path.join(LOCAL_SITES_DIR, siteSlug);
|
||||
}
|
||||
|
||||
function resolveRequestedPath(siteDir: string, requestPath: string): string | null {
|
||||
const candidate = requestPath === '/' ? '/index.html' : requestPath;
|
||||
const normalized = path.posix.normalize(candidate);
|
||||
const relativePath = normalized.replace(/^\/+/, '');
|
||||
|
||||
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || relativePath.includes('\0')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(siteDir, relativePath);
|
||||
if (!absolutePath.startsWith(siteDir + path.sep) && absolutePath !== siteDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
function getRequestPath(req: express.Request): string {
|
||||
const rawPath = req.url.split('?')[0] || '/';
|
||||
return rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
||||
}
|
||||
|
||||
function listLocalSites(): Array<{ slug: string; url: string }> {
|
||||
if (!fs.existsSync(LOCAL_SITES_DIR)) return [];
|
||||
|
||||
return fs.readdirSync(LOCAL_SITES_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && isSafeSiteSlug(entry.name))
|
||||
.map((entry) => ({
|
||||
slug: entry.name,
|
||||
url: `${LOCAL_SITES_BASE_URL}/sites/${entry.name}/`,
|
||||
}))
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
}
|
||||
|
||||
function isPathInsideRoot(rootPath: string, candidatePath: string): boolean {
|
||||
return candidatePath === rootPath || candidatePath.startsWith(rootPath + path.sep);
|
||||
}
|
||||
|
||||
async function writeIfMissing(filePath: string, content: string): Promise<void> {
|
||||
try {
|
||||
await fsp.access(filePath);
|
||||
} catch {
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fsp.writeFile(filePath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLocalSiteScaffold(): Promise<void> {
|
||||
await fsp.mkdir(LOCAL_SITES_DIR, { recursive: true });
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(LOCAL_SITE_SCAFFOLD).map(([relativePath, content]) =>
|
||||
writeIfMissing(path.join(LOCAL_SITES_DIR, relativePath), content),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function injectIframeAutosizeBootstrap(html: string): string {
|
||||
const bootstrap = IFRAME_AUTOSIZE_BOOTSTRAP
|
||||
.replace('__ROWBOAT_IFRAME_HEIGHT_MESSAGE__', IFRAME_HEIGHT_MESSAGE)
|
||||
.replace('__ROWBOAT_SITE_CHANGED_MESSAGE__', SITE_RELOAD_MESSAGE)
|
||||
.replace('__ROWBOAT_SITE_EVENTS_PATH__', SITE_EVENTS_PATH)
|
||||
if (/<\/body>/i.test(html)) {
|
||||
return html.replace(/<\/body>/i, `${bootstrap}\n</body>`)
|
||||
}
|
||||
return `${html}\n${bootstrap}`
|
||||
}
|
||||
|
||||
function getSiteSlugFromAbsolutePath(absolutePath: string): string | null {
|
||||
const relativePath = path.relative(LOCAL_SITES_DIR, absolutePath);
|
||||
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [siteSlug] = relativePath.split(path.sep);
|
||||
return siteSlug && isSafeSiteSlug(siteSlug) ? siteSlug : null;
|
||||
}
|
||||
|
||||
function removeSiteEventClient(siteSlug: string, res: express.Response): void {
|
||||
const clients = siteEventClients.get(siteSlug);
|
||||
if (!clients) return;
|
||||
clients.delete(res);
|
||||
if (clients.size === 0) {
|
||||
siteEventClients.delete(siteSlug);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastSiteReload(siteSlug: string, changedPath: string): void {
|
||||
const clients = siteEventClients.get(siteSlug);
|
||||
if (!clients || clients.size === 0) return;
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: SITE_RELOAD_MESSAGE,
|
||||
siteSlug,
|
||||
changedPath,
|
||||
at: Date.now(),
|
||||
});
|
||||
|
||||
for (const res of Array.from(clients)) {
|
||||
try {
|
||||
res.write(`data: ${payload}\n\n`);
|
||||
} catch {
|
||||
removeSiteEventClient(siteSlug, res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSiteReload(siteSlug: string, changedPath: string): void {
|
||||
const existingTimer = siteReloadTimers.get(siteSlug);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
siteReloadTimers.delete(siteSlug);
|
||||
broadcastSiteReload(siteSlug, changedPath);
|
||||
}, SITE_RELOAD_DEBOUNCE_MS);
|
||||
|
||||
siteReloadTimers.set(siteSlug, timer);
|
||||
}
|
||||
|
||||
async function startSiteWatcher(): Promise<void> {
|
||||
if (localSitesWatcher) return;
|
||||
|
||||
const watcher = chokidar.watch(LOCAL_SITES_DIR, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 180,
|
||||
pollInterval: 50,
|
||||
},
|
||||
});
|
||||
|
||||
watcher
|
||||
.on('all', (eventName, absolutePath) => {
|
||||
if (!['add', 'addDir', 'change', 'unlink', 'unlinkDir'].includes(eventName)) return;
|
||||
|
||||
const siteSlug = getSiteSlugFromAbsolutePath(absolutePath);
|
||||
if (!siteSlug) return;
|
||||
|
||||
const siteRoot = path.join(LOCAL_SITES_DIR, siteSlug);
|
||||
const relativePath = path.relative(siteRoot, absolutePath);
|
||||
const normalizedPath = !relativePath || relativePath === '.'
|
||||
? '.'
|
||||
: relativePath.split(path.sep).join('/');
|
||||
|
||||
scheduleSiteReload(siteSlug, normalizedPath);
|
||||
})
|
||||
.on('error', (error: unknown) => {
|
||||
console.error('[LocalSites] Watcher error:', error);
|
||||
});
|
||||
|
||||
localSitesWatcher = watcher;
|
||||
}
|
||||
|
||||
function handleSiteEventsRequest(req: express.Request, res: express.Response): void {
|
||||
const siteSlugParam = req.params.siteSlug;
|
||||
const siteSlug = Array.isArray(siteSlugParam) ? siteSlugParam[0] : siteSlugParam;
|
||||
if (!siteSlug || !isSafeSiteSlug(siteSlug)) {
|
||||
res.status(400).json({ error: 'Invalid site slug' });
|
||||
return;
|
||||
}
|
||||
|
||||
const clients = siteEventClients.get(siteSlug) ?? new Set<express.Response>();
|
||||
siteEventClients.set(siteSlug, clients);
|
||||
clients.add(res);
|
||||
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders?.();
|
||||
res.write(`retry: ${SITE_EVENTS_RETRY_MS}\n`);
|
||||
res.write(`event: ready\ndata: {"ok":true}\n\n`);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(`: keepalive ${Date.now()}\n\n`);
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
removeSiteEventClient(siteSlug, res);
|
||||
}
|
||||
}, SITE_EVENTS_HEARTBEAT_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
removeSiteEventClient(siteSlug, res);
|
||||
};
|
||||
|
||||
req.on('close', cleanup);
|
||||
res.on('close', cleanup);
|
||||
}
|
||||
|
||||
async function respondWithFile(res: express.Response, filePath: string, method: string): Promise<void> {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const mimeType = MIME_TYPES[extension] || 'application/octet-stream';
|
||||
const stats = await fsp.stat(filePath);
|
||||
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Length', String(stats.size));
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
|
||||
if (method === 'HEAD') {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (TEXT_EXTENSIONS.has(extension)) {
|
||||
let text = await fsp.readFile(filePath, 'utf8');
|
||||
if (extension === '.html') {
|
||||
text = injectIframeAutosizeBootstrap(text);
|
||||
}
|
||||
res.setHeader('Content-Length', String(Buffer.byteLength(text)));
|
||||
res.end(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await fsp.readFile(filePath);
|
||||
res.end(data);
|
||||
}
|
||||
|
||||
async function sendSiteResponse(req: express.Request, res: express.Response): Promise<void> {
|
||||
const siteSlugParam = req.params.siteSlug;
|
||||
const siteSlug = Array.isArray(siteSlugParam) ? siteSlugParam[0] : siteSlugParam;
|
||||
const siteDir = siteSlug ? resolveSiteDir(siteSlug) : null;
|
||||
if (!siteDir) {
|
||||
res.status(400).json({ error: 'Invalid site slug' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(siteDir) || !fs.statSync(siteDir).isDirectory()) {
|
||||
res.status(404).json({ error: 'Site not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const realSitesDir = fs.realpathSync(LOCAL_SITES_DIR);
|
||||
const realSiteDir = fs.realpathSync(siteDir);
|
||||
if (!isPathInsideRoot(realSitesDir, realSiteDir)) {
|
||||
res.status(403).json({ error: 'Site path escapes sites directory' });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedPath = resolveRequestedPath(siteDir, getRequestPath(req));
|
||||
if (!requestedPath) {
|
||||
res.status(400).json({ error: 'Invalid site path' });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedExt = path.extname(requestedPath);
|
||||
if (fs.existsSync(requestedPath)) {
|
||||
const stat = fs.statSync(requestedPath);
|
||||
if (stat.isDirectory()) {
|
||||
const indexPath = path.join(requestedPath, 'index.html');
|
||||
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
|
||||
const realIndexPath = fs.realpathSync(indexPath);
|
||||
if (!isPathInsideRoot(realSiteDir, realIndexPath)) {
|
||||
res.status(403).json({ error: 'Site path escapes root' });
|
||||
return;
|
||||
}
|
||||
await respondWithFile(res, indexPath, req.method);
|
||||
return;
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
const realRequestedPath = fs.realpathSync(requestedPath);
|
||||
if (!isPathInsideRoot(realSiteDir, realRequestedPath)) {
|
||||
res.status(403).json({ error: 'Site path escapes root' });
|
||||
return;
|
||||
}
|
||||
await respondWithFile(res, requestedPath, req.method);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedExt) {
|
||||
res.status(404).json({ error: 'Asset not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const spaFallback = path.join(siteDir, 'index.html');
|
||||
if (!fs.existsSync(spaFallback) || !fs.statSync(spaFallback).isFile()) {
|
||||
res.status(404).json({ error: 'Site entrypoint not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const realFallback = fs.realpathSync(spaFallback);
|
||||
if (!isPathInsideRoot(realSiteDir, realFallback)) {
|
||||
res.status(403).json({ error: 'Site path escapes root' });
|
||||
return;
|
||||
}
|
||||
|
||||
await respondWithFile(res, spaFallback, req.method);
|
||||
}
|
||||
|
||||
function createLocalSitesApp(): express.Express {
|
||||
const app = express();
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
baseUrl: LOCAL_SITES_BASE_URL,
|
||||
sitesDir: LOCAL_SITES_DIR,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/sites', (_req, res) => {
|
||||
res.json({
|
||||
sites: listLocalSites(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get(`/sites/:siteSlug/${SITE_EVENTS_PATH}`, (req, res) => {
|
||||
handleSiteEventsRequest(req, res);
|
||||
});
|
||||
|
||||
app.use('/sites/:siteSlug', (req, res) => {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.status(405).json({ error: 'Method not allowed' });
|
||||
return;
|
||||
}
|
||||
|
||||
void sendSiteResponse(req, res).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: message });
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startServer(): Promise<void> {
|
||||
if (localSitesServer) return;
|
||||
|
||||
const app = createLocalSitesApp();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const server = app.listen(LOCAL_SITES_PORT, 'localhost', () => {
|
||||
localSitesServer = server;
|
||||
console.log('[LocalSites] Server starting.');
|
||||
console.log(` Sites directory: ${LOCAL_SITES_DIR}`);
|
||||
console.log(` Base URL: ${LOCAL_SITES_BASE_URL}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
reject(new Error(`Port ${LOCAL_SITES_PORT} is already in use.`));
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
if (localSitesServer) return;
|
||||
if (startPromise) return startPromise;
|
||||
|
||||
startPromise = (async () => {
|
||||
try {
|
||||
await ensureLocalSiteScaffold();
|
||||
await startSiteWatcher();
|
||||
await startServer();
|
||||
} catch (error) {
|
||||
await shutdown();
|
||||
throw error;
|
||||
}
|
||||
})().finally(() => {
|
||||
startPromise = null;
|
||||
});
|
||||
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
export async function shutdown(): Promise<void> {
|
||||
const watcher = localSitesWatcher;
|
||||
localSitesWatcher = null;
|
||||
if (watcher) {
|
||||
await watcher.close();
|
||||
}
|
||||
|
||||
for (const timer of siteReloadTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
siteReloadTimers.clear();
|
||||
|
||||
for (const clients of siteEventClients.values()) {
|
||||
for (const res of clients) {
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
// ignore close failures
|
||||
}
|
||||
}
|
||||
}
|
||||
siteEventClients.clear();
|
||||
|
||||
const server = localSitesServer;
|
||||
localSitesServer = null;
|
||||
if (!server) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
625
apps/x/packages/core/src/local-sites/templates.ts
Normal file
625
apps/x/packages/core/src/local-sites/templates.ts
Normal file
|
|
@ -0,0 +1,625 @@
|
|||
export const LOCAL_SITE_SCAFFOLD: Record<string, string> = {
|
||||
'README.md': `# Local Sites
|
||||
|
||||
Anything inside this folder is available at:
|
||||
|
||||
\`http://localhost:3210/sites/<slug>/\`
|
||||
|
||||
Examples:
|
||||
|
||||
- \`sites/example-dashboard/\` -> \`http://localhost:3210/sites/example-dashboard/\`
|
||||
- \`sites/team-ops/\` -> \`http://localhost:3210/sites/team-ops/\`
|
||||
|
||||
You can embed a local site in a note with:
|
||||
|
||||
\`\`\`iframe
|
||||
{"url":"http://localhost:3210/sites/example-dashboard/","title":"Signal Deck","height":640,"caption":"Local dashboard served from sites/example-dashboard"}
|
||||
\`\`\`
|
||||
|
||||
Notes:
|
||||
|
||||
- The app serves each site with SPA-friendly routing, so client-side routers work
|
||||
- Local HTML pages auto-expand inside Rowboat iframe blocks to fit their content height
|
||||
- Put an \`index.html\` file at the site root
|
||||
- Remote APIs still need to allow browser requests from a local page
|
||||
`,
|
||||
'example-dashboard/index.html': `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Signal Deck</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient ambient-one"></div>
|
||||
<div class="ambient ambient-two"></div>
|
||||
<main class="shell">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Local iframe sample · external APIs</p>
|
||||
<h1>Signal Deck</h1>
|
||||
<p class="lede">
|
||||
A locally-served dashboard designed to live inside a Rowboat note. It fetches
|
||||
live signals from public APIs and stays readable at note width.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-status" id="hero-status">Booting dashboard...</div>
|
||||
</header>
|
||||
|
||||
<section class="metric-grid" id="metric-grid"></section>
|
||||
|
||||
<section class="board">
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="panel-kicker">Hacker News</p>
|
||||
<h2>Live headlines</h2>
|
||||
</div>
|
||||
<span class="panel-chip">public API</span>
|
||||
</div>
|
||||
<div class="story-list" id="story-list"></div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="panel-kicker">GitHub</p>
|
||||
<h2>Repo pulse</h2>
|
||||
</div>
|
||||
<span class="panel-chip">public API</span>
|
||||
</div>
|
||||
<div class="repo-list" id="repo-list"></div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
'example-dashboard/styles.css': `:root {
|
||||
color-scheme: dark;
|
||||
--bg: #090816;
|
||||
--panel: rgba(18, 16, 39, 0.88);
|
||||
--panel-strong: rgba(26, 23, 54, 0.96);
|
||||
--line: rgba(255, 255, 255, 0.08);
|
||||
--text: #f5f7ff;
|
||||
--muted: rgba(230, 235, 255, 0.68);
|
||||
--cyan: #66e2ff;
|
||||
--lime: #b7ff6a;
|
||||
--amber: #ffcb6b;
|
||||
--pink: #ff7ed1;
|
||||
--shadow: 0 24px 80px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(74, 51, 175, 0.28), transparent 34%),
|
||||
linear-gradient(180deg, #0c0b1d 0%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
.ambient {
|
||||
position: fixed;
|
||||
inset: auto;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
border-radius: 999px;
|
||||
filter: blur(70px);
|
||||
pointer-events: none;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.ambient-one {
|
||||
top: -80px;
|
||||
right: -40px;
|
||||
background: rgba(102, 226, 255, 0.22);
|
||||
}
|
||||
|
||||
.ambient-two {
|
||||
bottom: -120px;
|
||||
left: -60px;
|
||||
background: rgba(255, 126, 209, 0.18);
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 40px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.panel-kicker {
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 11px;
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 620px;
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.hero-status {
|
||||
flex-shrink: 0;
|
||||
min-width: 180px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(102, 226, 255, 0.18);
|
||||
border-radius: 16px;
|
||||
background: rgba(14, 17, 32, 0.62);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
|
||||
var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 18px;
|
||||
min-height: 152px;
|
||||
}
|
||||
|
||||
.metric-card::after,
|
||||
.panel::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.07), transparent 40%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin-top: 16px;
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
font-size: clamp(2rem, 4vw, 2.7rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
.metric-detail {
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric-spark {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
gap: 6px;
|
||||
align-items: end;
|
||||
height: 40px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.metric-spark span {
|
||||
display: block;
|
||||
border-radius: 999px 999px 3px 3px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.panel-chip {
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.story-list,
|
||||
.repo-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.story-item,
|
||||
.repo-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 18px;
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.story-rank {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
font-family: "Space Grotesk", "IBM Plex Sans", sans-serif;
|
||||
font-size: 1.2rem;
|
||||
color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.story-item a,
|
||||
.repo-item a {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.story-item a:hover,
|
||||
.repo-item a:hover {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.story-title,
|
||||
.repo-name {
|
||||
padding-right: 34px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.story-meta,
|
||||
.repo-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.story-pill,
|
||||
.repo-pill {
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.repo-description {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell {
|
||||
padding: 22px 14px 28px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-status {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.metric-card {
|
||||
border-radius: 18px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
'example-dashboard/app.js': `const formatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
const reposConfig = [
|
||||
{
|
||||
slug: 'rowboatlabs/rowboat',
|
||||
label: 'Rowboat',
|
||||
description: 'AI coworker with memory',
|
||||
},
|
||||
{
|
||||
slug: 'openai/openai-cookbook',
|
||||
label: 'OpenAI Cookbook',
|
||||
description: 'Examples and guides for building with OpenAI APIs',
|
||||
},
|
||||
];
|
||||
|
||||
const fallbackStories = [
|
||||
{ id: 1, title: 'AI product launches keep getting more opinionated', score: 182, descendants: 49, by: 'analyst', url: '#' },
|
||||
{ id: 2, title: 'Designing dashboards that can survive a narrow iframe', score: 141, descendants: 26, by: 'maker', url: '#' },
|
||||
{ id: 3, title: 'Why local mini-apps inside notes are underrated', score: 119, descendants: 18, by: 'builder', url: '#' },
|
||||
{ id: 4, title: 'Teams want live data in docs, not screenshots', score: 97, descendants: 14, by: 'operator', url: '#' },
|
||||
];
|
||||
|
||||
const fallbackRepos = [
|
||||
{ ...reposConfig[0], stars: 1280, forks: 144, issues: 28, url: 'https://github.com/rowboatlabs/rowboat' },
|
||||
{ ...reposConfig[1], stars: 71600, forks: 11300, issues: 52, url: 'https://github.com/openai/openai-cookbook' },
|
||||
];
|
||||
|
||||
const metricGrid = document.getElementById('metric-grid');
|
||||
const storyList = document.getElementById('story-list');
|
||||
const repoList = document.getElementById('repo-list');
|
||||
const heroStatus = document.getElementById('hero-status');
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed with status ' + response.status);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadRepos() {
|
||||
try {
|
||||
const repos = await Promise.all(
|
||||
reposConfig.map(async (repo) => {
|
||||
const data = await fetchJson('https://api.github.com/repos/' + repo.slug);
|
||||
return {
|
||||
...repo,
|
||||
stars: data.stargazers_count,
|
||||
forks: data.forks_count,
|
||||
issues: data.open_issues_count,
|
||||
url: data.html_url,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return repos;
|
||||
} catch {
|
||||
return fallbackRepos;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStories() {
|
||||
try {
|
||||
const ids = await fetchJson('https://hacker-news.firebaseio.com/v0/topstories.json');
|
||||
const stories = await Promise.all(
|
||||
ids.slice(0, 4).map((id) =>
|
||||
fetchJson('https://hacker-news.firebaseio.com/v0/item/' + id + '.json'),
|
||||
),
|
||||
);
|
||||
|
||||
return stories
|
||||
.filter(Boolean)
|
||||
.map((story) => ({
|
||||
id: story.id,
|
||||
title: story.title,
|
||||
score: story.score || 0,
|
||||
descendants: story.descendants || 0,
|
||||
by: story.by || 'unknown',
|
||||
url: story.url || ('https://news.ycombinator.com/item?id=' + story.id),
|
||||
}));
|
||||
} catch {
|
||||
return fallbackStories;
|
||||
}
|
||||
}
|
||||
|
||||
function metricSpark(values) {
|
||||
const max = Math.max(...values, 1);
|
||||
const bars = values.map((value) => {
|
||||
const height = Math.max(18, Math.round((value / max) * 40));
|
||||
return '<span style="height:' + height + 'px"></span>';
|
||||
});
|
||||
return '<div class="metric-spark">' + bars.join('') + '</div>';
|
||||
}
|
||||
|
||||
function renderMetrics(repos, stories) {
|
||||
const leadRepo = repos[0];
|
||||
const companionRepo = repos[1];
|
||||
const topStory = stories[0];
|
||||
const averageScore = Math.round(
|
||||
stories.reduce((sum, story) => sum + story.score, 0) / Math.max(stories.length, 1),
|
||||
);
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: 'Rowboat stars',
|
||||
value: formatter.format(leadRepo.stars),
|
||||
detail: formatter.format(leadRepo.forks) + ' forks · ' + leadRepo.issues + ' open issues',
|
||||
spark: [leadRepo.stars * 0.58, leadRepo.stars * 0.71, leadRepo.stars * 0.88, leadRepo.stars],
|
||||
accent: 'var(--cyan)',
|
||||
},
|
||||
{
|
||||
label: 'Cookbook stars',
|
||||
value: formatter.format(companionRepo.stars),
|
||||
detail: formatter.format(companionRepo.forks) + ' forks · ' + companionRepo.issues + ' open issues',
|
||||
spark: [companionRepo.stars * 0.76, companionRepo.stars * 0.81, companionRepo.stars * 0.93, companionRepo.stars],
|
||||
accent: 'var(--lime)',
|
||||
},
|
||||
{
|
||||
label: 'Top story score',
|
||||
value: formatter.format(topStory.score),
|
||||
detail: topStory.descendants + ' comments · by ' + topStory.by,
|
||||
spark: stories.map((story) => story.score),
|
||||
accent: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'Average HN score',
|
||||
value: formatter.format(averageScore),
|
||||
detail: stories.length + ' live stories in this panel',
|
||||
spark: stories.map((story) => story.descendants + 10),
|
||||
accent: 'var(--pink)',
|
||||
},
|
||||
];
|
||||
|
||||
metricGrid.innerHTML = metrics
|
||||
.map((metric) => (
|
||||
'<article class="metric-card" style="box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 24px 80px rgba(0,0,0,0.34), 0 0 0 1px color-mix(in srgb, ' + metric.accent + ' 16%, transparent);">' +
|
||||
'<div class="metric-label">' + metric.label + '</div>' +
|
||||
'<div class="metric-value">' + metric.value + '</div>' +
|
||||
'<div class="metric-detail">' + metric.detail + '</div>' +
|
||||
metricSpark(metric.spark) +
|
||||
'</article>'
|
||||
))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderStories(stories) {
|
||||
storyList.innerHTML = stories
|
||||
.map((story, index) => (
|
||||
'<article class="story-item">' +
|
||||
'<div class="story-rank">0' + (index + 1) + '</div>' +
|
||||
'<a class="story-title" href="' + story.url + '" target="_blank" rel="noreferrer">' + story.title + '</a>' +
|
||||
'<div class="story-meta">' +
|
||||
'<span class="story-pill">' + formatter.format(story.score) + ' pts</span>' +
|
||||
'<span class="story-pill">' + story.descendants + ' comments</span>' +
|
||||
'<span class="story-pill">by ' + story.by + '</span>' +
|
||||
'</div>' +
|
||||
'</article>'
|
||||
))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderRepos(repos) {
|
||||
repoList.innerHTML = repos
|
||||
.map((repo) => (
|
||||
'<article class="repo-item">' +
|
||||
'<a class="repo-name" href="' + repo.url + '" target="_blank" rel="noreferrer">' + repo.label + '</a>' +
|
||||
'<p class="repo-description">' + repo.description + '</p>' +
|
||||
'<div class="repo-meta">' +
|
||||
'<span class="repo-pill">' + formatter.format(repo.stars) + ' stars</span>' +
|
||||
'<span class="repo-pill">' + formatter.format(repo.forks) + ' forks</span>' +
|
||||
'<span class="repo-pill">' + repo.issues + ' open issues</span>' +
|
||||
'</div>' +
|
||||
'</article>'
|
||||
))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderErrorState(message) {
|
||||
metricGrid.innerHTML = '<div class="empty-state">' + message + '</div>';
|
||||
storyList.innerHTML = '<div class="empty-state">No stories available.</div>';
|
||||
repoList.innerHTML = '<div class="empty-state">No repositories available.</div>';
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
heroStatus.textContent = 'Refreshing live signals...';
|
||||
|
||||
try {
|
||||
const [repos, stories] = await Promise.all([loadRepos(), loadStories()]);
|
||||
|
||||
if (!repos.length || !stories.length) {
|
||||
renderErrorState('The sample site loaded, but the data sources returned no content.');
|
||||
heroStatus.textContent = 'Loaded with empty data.';
|
||||
return;
|
||||
}
|
||||
|
||||
renderMetrics(repos, stories);
|
||||
renderStories(stories);
|
||||
renderRepos(repos);
|
||||
|
||||
heroStatus.textContent = 'Updated ' + new Date().toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}) + ' · embedded from sites/example-dashboard';
|
||||
} catch (error) {
|
||||
renderErrorState('This site is running, but the live fetch failed. The local scaffold is still valid.');
|
||||
heroStatus.textContent = error instanceof Error ? error.message : 'Refresh failed';
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 120000);
|
||||
`,
|
||||
}
|
||||
88
apps/x/packages/core/src/models/defaults.ts
Normal file
88
apps/x/packages/core/src/models/defaults.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import z from "zod";
|
||||
import { LlmProvider } from "@x/shared/dist/models.js";
|
||||
import { IModelConfigRepo } from "./repo.js";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import container from "../di/container.js";
|
||||
|
||||
const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
|
||||
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
|
||||
const SIGNED_IN_KG_MODEL = "anthropic/claude-haiku-4.5";
|
||||
const SIGNED_IN_TRACK_BLOCK_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
/**
|
||||
* The single source of truth for "what model+provider should we use when
|
||||
* the caller didn't specify and the agent didn't declare". Returns names only.
|
||||
* This is the only place that branches on signed-in state.
|
||||
*/
|
||||
export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> {
|
||||
if (await isSignedIn()) {
|
||||
return { model: SIGNED_IN_DEFAULT_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER };
|
||||
}
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
const cfg = await repo.getConfig();
|
||||
return { model: cfg.model, provider: cfg.provider.flavor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider name (as stored on a run, an agent, or returned by
|
||||
* getDefaultModelAndProvider) into the full LlmProvider config that
|
||||
* createProvider expects (apiKey/baseURL/headers).
|
||||
*
|
||||
* - "rowboat" → gateway provider (auth via OAuth bearer; no creds field).
|
||||
* - other names → look up models.json's `providers[name]` map.
|
||||
* - fallback: if the name matches the active default's flavor (legacy
|
||||
* single-provider configs that didn't write to the providers map yet).
|
||||
*/
|
||||
export async function resolveProviderConfig(name: string): Promise<z.infer<typeof LlmProvider>> {
|
||||
if (name === "rowboat") {
|
||||
return { flavor: "rowboat" };
|
||||
}
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
const cfg = await repo.getConfig();
|
||||
const entry = cfg.providers?.[name];
|
||||
if (entry) {
|
||||
return LlmProvider.parse({
|
||||
flavor: name,
|
||||
apiKey: entry.apiKey,
|
||||
baseURL: entry.baseURL,
|
||||
headers: entry.headers,
|
||||
});
|
||||
}
|
||||
if (cfg.provider.flavor === name) {
|
||||
return cfg.provider;
|
||||
}
|
||||
throw new Error(`Provider '${name}' is referenced but not configured`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by knowledge-graph agents (note_creation, labeling_agent, etc.)
|
||||
* when they're the top-level of a run. Signed-in: curated default.
|
||||
* BYOK: user override (`knowledgeGraphModel`) or assistant model.
|
||||
*/
|
||||
export async function getKgModel(): Promise<string> {
|
||||
if (await isSignedIn()) return SIGNED_IN_KG_MODEL;
|
||||
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
|
||||
return cfg.knowledgeGraphModel ?? cfg.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by track-block runner + routing classifier.
|
||||
* Signed-in: curated default. BYOK: user override (`trackBlockModel`) or
|
||||
* assistant model.
|
||||
*/
|
||||
export async function getTrackBlockModel(): Promise<string> {
|
||||
if (await isSignedIn()) return SIGNED_IN_TRACK_BLOCK_MODEL;
|
||||
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
|
||||
return cfg.trackBlockModel ?? cfg.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model used by the meeting-notes summarizer. No special signed-in default —
|
||||
* historically meetings used the assistant model. BYOK: user override
|
||||
* (`meetingNotesModel`) or assistant model.
|
||||
*/
|
||||
export async function getMeetingNotesModel(): Promise<string> {
|
||||
if (await isSignedIn()) return SIGNED_IN_DEFAULT_MODEL;
|
||||
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
|
||||
return cfg.meetingNotesModel ?? cfg.model;
|
||||
}
|
||||
|
|
@ -3,11 +3,18 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|||
import { getAccessToken } from '../auth/tokens.js';
|
||||
import { API_URL } from '../config/env.js';
|
||||
|
||||
export async function getGatewayProvider(): Promise<ProviderV2> {
|
||||
const accessToken = await getAccessToken();
|
||||
const authedFetch: typeof fetch = async (input, init) => {
|
||||
const token = await getAccessToken();
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
|
||||
export function getGatewayProvider(): ProviderV2 {
|
||||
return createOpenRouter({
|
||||
baseURL: `${API_URL}/v1/llm`,
|
||||
apiKey: accessToken,
|
||||
apiKey: 'managed-by-rowboat',
|
||||
fetch: authedFetch,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
|
||||
import z from "zod";
|
||||
import { isSignedIn } from "../account/account.js";
|
||||
import { getGatewayProvider } from "./gateway.js";
|
||||
|
||||
export const Provider = LlmProvider;
|
||||
|
|
@ -65,6 +64,8 @@ export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
|
|||
baseURL,
|
||||
headers,
|
||||
}) as unknown as ProviderV2;
|
||||
case "rowboat":
|
||||
return getGatewayProvider();
|
||||
default:
|
||||
throw new Error(`Unsupported provider flavor: ${config.flavor}`);
|
||||
}
|
||||
|
|
@ -80,9 +81,7 @@ export async function testModelConnection(
|
|||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), effectiveTimeout);
|
||||
try {
|
||||
const provider = await isSignedIn()
|
||||
? await getGatewayProvider()
|
||||
: createProvider(providerConfig);
|
||||
const provider = createProvider(providerConfig);
|
||||
const languageModel = provider.languageModel(model);
|
||||
await generateText({
|
||||
model: languageModel,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
|||
models: config.models,
|
||||
knowledgeGraphModel: config.knowledgeGraphModel,
|
||||
meetingNotesModel: config.meetingNotesModel,
|
||||
trackBlockModel: config.trackBlockModel,
|
||||
};
|
||||
|
||||
const toWrite = { ...config, providers: existingProviders };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
model: gpt-4.1
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
---
|
||||
model: gpt-4.1
|
||||
tools:
|
||||
workspace-readFile:
|
||||
type: builtin
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createRun, createMessage } from '../runs/runs.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { waitForRunCompletion } from '../agents/utils.js';
|
||||
import {
|
||||
loadConfig,
|
||||
|
|
@ -41,6 +42,7 @@ async function runAgent(agentName: string): Promise<void> {
|
|||
// The agent file is expected to be in the agents directory with the same name
|
||||
const run = await createRun({
|
||||
agentId: agentName,
|
||||
model: await getKgModel(),
|
||||
});
|
||||
|
||||
// Build trigger message with user context
|
||||
|
|
|
|||
|
|
@ -6,9 +6,28 @@ import fsp from "fs/promises";
|
|||
import fs from "fs";
|
||||
import readline from "readline";
|
||||
import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse, MessageEvent } from "@x/shared/dist/runs.js";
|
||||
import { getDefaultModelAndProvider } from "../models/defaults.js";
|
||||
|
||||
/**
|
||||
* Reading-only schemas: extend the canonical `StartEvent` / `RunEvent` to
|
||||
* accept legacy run files written before `model`/`provider` were required.
|
||||
*
|
||||
* `RunEvent.or(LegacyStartEvent)` works because zod unions try left-to-right:
|
||||
* for any non-start event RunEvent matches first; for a strict start event
|
||||
* RunEvent still matches; only a legacy start event falls through and parses
|
||||
* as LegacyStartEvent. New event types stay maintained in one place
|
||||
* (`@x/shared/dist/runs.js`) — the lenient form just adds one fallback variant.
|
||||
*/
|
||||
const LegacyStartEvent = StartEvent.extend({
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
});
|
||||
const ReadRunEvent = RunEvent.or(LegacyStartEvent);
|
||||
|
||||
export type CreateRunRepoOptions = Required<z.infer<typeof CreateRunOptions>>;
|
||||
|
||||
export interface IRunsRepo {
|
||||
create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>>;
|
||||
create(options: CreateRunRepoOptions): Promise<z.infer<typeof Run>>;
|
||||
fetch(id: string): Promise<z.infer<typeof Run>>;
|
||||
list(cursor?: string): Promise<z.infer<typeof ListRunsResponse>>;
|
||||
appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void>;
|
||||
|
|
@ -69,16 +88,19 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
/**
|
||||
* Read file line-by-line using streams, stopping early once we have
|
||||
* the start event and title (or determine there's no title).
|
||||
*
|
||||
* Parses the start event with `LegacyStartEvent` so runs written before
|
||||
* `model`/`provider` were required still surface in the list view.
|
||||
*/
|
||||
private async readRunMetadata(filePath: string): Promise<{
|
||||
start: z.infer<typeof StartEvent>;
|
||||
start: z.infer<typeof LegacyStartEvent>;
|
||||
title: string | undefined;
|
||||
} | null> {
|
||||
return new Promise((resolve) => {
|
||||
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
let start: z.infer<typeof StartEvent> | null = null;
|
||||
let start: z.infer<typeof LegacyStartEvent> | null = null;
|
||||
let title: string | undefined;
|
||||
let lineIndex = 0;
|
||||
|
||||
|
|
@ -88,11 +110,10 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
|
||||
try {
|
||||
if (lineIndex === 0) {
|
||||
// First line should be the start event
|
||||
start = StartEvent.parse(JSON.parse(trimmed));
|
||||
start = LegacyStartEvent.parse(JSON.parse(trimmed));
|
||||
} else {
|
||||
// Subsequent lines - look for first user message or assistant response
|
||||
const event = RunEvent.parse(JSON.parse(trimmed));
|
||||
const event = ReadRunEvent.parse(JSON.parse(trimmed));
|
||||
if (event.type === 'message') {
|
||||
const msg = event.message;
|
||||
if (msg.role === 'user') {
|
||||
|
|
@ -157,13 +178,15 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
);
|
||||
}
|
||||
|
||||
async create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
|
||||
async create(options: CreateRunRepoOptions): Promise<z.infer<typeof Run>> {
|
||||
const runId = await this.idGenerator.next();
|
||||
const ts = new Date().toISOString();
|
||||
const start: z.infer<typeof StartEvent> = {
|
||||
type: "start",
|
||||
runId,
|
||||
agentName: options.agentId,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
subflow: [],
|
||||
ts,
|
||||
};
|
||||
|
|
@ -172,24 +195,41 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
id: runId,
|
||||
createdAt: ts,
|
||||
agentId: options.agentId,
|
||||
model: options.model,
|
||||
provider: options.provider,
|
||||
log: [start],
|
||||
};
|
||||
}
|
||||
|
||||
async fetch(id: string): Promise<z.infer<typeof Run>> {
|
||||
const contents = await fsp.readFile(path.join(WorkDir, 'runs', `${id}.jsonl`), 'utf8');
|
||||
const events = contents.split('\n')
|
||||
// Parse with the lenient schema so legacy start events (no model/provider) load.
|
||||
const rawEvents = contents.split('\n')
|
||||
.filter(line => line.trim() !== '')
|
||||
.map(line => RunEvent.parse(JSON.parse(line)));
|
||||
if (events.length === 0 || events[0].type !== 'start') {
|
||||
.map(line => ReadRunEvent.parse(JSON.parse(line)));
|
||||
if (rawEvents.length === 0 || rawEvents[0].type !== 'start') {
|
||||
throw new Error('Corrupt run data');
|
||||
}
|
||||
// Backfill model/provider on the start event from current defaults if missing,
|
||||
// then promote to the canonical strict types for callers.
|
||||
const rawStart = rawEvents[0];
|
||||
const defaults = (!rawStart.model || !rawStart.provider)
|
||||
? await getDefaultModelAndProvider()
|
||||
: null;
|
||||
const start: z.infer<typeof StartEvent> = {
|
||||
...rawStart,
|
||||
model: rawStart.model ?? defaults!.model,
|
||||
provider: rawStart.provider ?? defaults!.provider,
|
||||
};
|
||||
const events: z.infer<typeof RunEvent>[] = [start, ...rawEvents.slice(1) as z.infer<typeof RunEvent>[]];
|
||||
const title = this.extractTitle(events);
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
createdAt: events[0].ts!,
|
||||
agentId: events[0].agentName,
|
||||
createdAt: start.ts!,
|
||||
agentId: start.agentName,
|
||||
model: start.model,
|
||||
provider: start.provider,
|
||||
log: events,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import z from "zod";
|
||||
import container from "../di/container.js";
|
||||
import { IMessageQueue, UserMessageContentType, VoiceOutputMode } from "../application/lib/message-queue.js";
|
||||
import { IMessageQueue, UserMessageContentType, VoiceOutputMode, MiddlePaneContext } 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";
|
||||
|
|
@ -10,18 +10,28 @@ import { IRunsLock } from "./lock.js";
|
|||
import { forceCloseAllMcpClients } from "../mcp/mcp.js";
|
||||
import { extractCommandNames } from "../application/lib/command-executor.js";
|
||||
import { addToSecurityConfig } from "../config/security.js";
|
||||
import { loadAgent } from "../agents/runtime.js";
|
||||
import { getDefaultModelAndProvider } from "../models/defaults.js";
|
||||
|
||||
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const bus = container.resolve<IBus>('bus');
|
||||
const run = await repo.create(opts);
|
||||
|
||||
// Resolve model+provider once at creation: opts > agent declaration > defaults.
|
||||
// Both fields are plain strings (provider is a name, looked up at runtime).
|
||||
const agent = await loadAgent(opts.agentId);
|
||||
const defaults = await getDefaultModelAndProvider();
|
||||
const model = opts.model ?? agent.model ?? defaults.model;
|
||||
const provider = opts.provider ?? agent.provider ?? defaults.provider;
|
||||
|
||||
const run = await repo.create({ agentId: opts.agentId, model, provider });
|
||||
await bus.publish(run.log[0]);
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean): Promise<string> {
|
||||
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
|
||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled);
|
||||
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
runtime.trigger(runId);
|
||||
return id;
|
||||
|
|
@ -110,4 +120,4 @@ export async function fetchRun(runId: string): Promise<z.infer<typeof Run>> {
|
|||
export async function listRuns(cursor?: string): Promise<z.infer<typeof ListRunsResponse>> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
return repo.list(cursor);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/share
|
|||
import { WorkDir } from '../config/config.js';
|
||||
import { rewriteWikiLinksForRenamedKnowledgeFile } from './wiki-link-rewrite.js';
|
||||
import { commitAll } from '../knowledge/version_history.js';
|
||||
import { withFileLock } from '../knowledge/file-lock.js';
|
||||
|
||||
// ============================================================================
|
||||
// Path Utilities
|
||||
|
|
@ -249,38 +250,42 @@ export async function writeFile(
|
|||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
// Check expectedEtag if provided (conflict detection)
|
||||
if (opts?.expectedEtag) {
|
||||
const existingStats = await fs.lstat(filePath);
|
||||
const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs);
|
||||
if (existingEtag !== opts.expectedEtag) {
|
||||
throw new Error('File was modified (ETag mismatch)');
|
||||
const result = await withFileLock(filePath, async () => {
|
||||
// Check expectedEtag if provided (conflict detection)
|
||||
if (opts?.expectedEtag) {
|
||||
const existingStats = await fs.lstat(filePath);
|
||||
const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs);
|
||||
if (existingEtag !== opts.expectedEtag) {
|
||||
throw new Error('File was modified (ETag mismatch)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert data to buffer based on encoding
|
||||
let buffer: Buffer;
|
||||
if (encoding === 'utf8') {
|
||||
buffer = Buffer.from(data, 'utf8');
|
||||
} else if (encoding === 'base64') {
|
||||
buffer = Buffer.from(data, 'base64');
|
||||
} else {
|
||||
// binary: assume data is base64-encoded
|
||||
buffer = Buffer.from(data, 'base64');
|
||||
}
|
||||
// Convert data to buffer based on encoding
|
||||
let buffer: Buffer;
|
||||
if (encoding === 'utf8') {
|
||||
buffer = Buffer.from(data, 'utf8');
|
||||
} else if (encoding === 'base64') {
|
||||
buffer = Buffer.from(data, 'base64');
|
||||
} else {
|
||||
// binary: assume data is base64-encoded
|
||||
buffer = Buffer.from(data, 'base64');
|
||||
}
|
||||
|
||||
if (atomic) {
|
||||
// Atomic write: write to temp file, then rename
|
||||
const tempPath = filePath + '.tmp.' + Date.now() + Math.random().toString(36).slice(2);
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
await fs.rename(tempPath, filePath);
|
||||
} else {
|
||||
await fs.writeFile(filePath, buffer);
|
||||
}
|
||||
if (atomic) {
|
||||
// Atomic write: write to temp file, then rename
|
||||
const tempPath = filePath + '.tmp.' + Date.now() + Math.random().toString(36).slice(2);
|
||||
await fs.writeFile(tempPath, buffer);
|
||||
await fs.rename(tempPath, filePath);
|
||||
} else {
|
||||
await fs.writeFile(filePath, buffer);
|
||||
}
|
||||
|
||||
const stats = await fs.lstat(filePath);
|
||||
const stat = statToSchema(stats, 'file');
|
||||
const etag = computeEtag(stats.size, stats.mtimeMs);
|
||||
const stats = await fs.lstat(filePath);
|
||||
const stat = statToSchema(stats, 'file');
|
||||
const etag = computeEtag(stats.size, stats.mtimeMs);
|
||||
|
||||
return { stat, etag };
|
||||
});
|
||||
|
||||
// Schedule a debounced version history commit for knowledge files
|
||||
if (relPath.startsWith('knowledge/') && relPath.endsWith('.md')) {
|
||||
|
|
@ -289,8 +294,8 @@ export async function writeFile(
|
|||
|
||||
return {
|
||||
path: relPath,
|
||||
stat,
|
||||
etag,
|
||||
stat: result.stat,
|
||||
etag: result.etag,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const IFRAME_LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
|
||||
export function isAllowedIframeUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === 'https:') return true;
|
||||
if (parsed.protocol !== 'http:') return false;
|
||||
return IFRAME_LOCAL_HOSTS.has(parsed.hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const ImageBlockSchema = z.object({
|
||||
src: z.string(),
|
||||
alt: z.string().optional(),
|
||||
|
|
@ -16,6 +29,18 @@ export const EmbedBlockSchema = z.object({
|
|||
|
||||
export type EmbedBlock = z.infer<typeof EmbedBlockSchema>;
|
||||
|
||||
export const IframeBlockSchema = z.object({
|
||||
url: z.string().url().refine(isAllowedIframeUrl, {
|
||||
message: 'Iframe URLs must use https:// or local http://localhost / 127.0.0.1.',
|
||||
}),
|
||||
title: z.string().optional(),
|
||||
caption: z.string().optional(),
|
||||
height: z.number().int().min(240).max(1600).optional(),
|
||||
allow: z.string().optional(),
|
||||
});
|
||||
|
||||
export type IframeBlock = z.infer<typeof IframeBlockSchema>;
|
||||
|
||||
export const ChartBlockSchema = z.object({
|
||||
chart: z.enum(['line', 'bar', 'pie']),
|
||||
title: z.string().optional(),
|
||||
|
|
@ -81,3 +106,11 @@ export const TranscriptBlockSchema = z.object({
|
|||
});
|
||||
|
||||
export type TranscriptBlock = z.infer<typeof TranscriptBlockSchema>;
|
||||
|
||||
export const SuggestedTopicBlockSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
category: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SuggestedTopicBlock = z.infer<typeof SuggestedTopicBlockSchema>;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export * as serviceEvents from './service-events.js'
|
|||
export * as inlineTask from './inline-task.js';
|
||||
export * as blocks from './blocks.js';
|
||||
export * as trackBlock from './track-block.js';
|
||||
export * as promptBlock from './prompt-block.js';
|
||||
export * as frontmatter from './frontmatter.js';
|
||||
export * as bases from './bases.js';
|
||||
export * as browserControl from './browser-control.js';
|
||||
|
|
|
|||
|
|
@ -137,6 +137,18 @@ const ipcSchemas = {
|
|||
voiceInput: z.boolean().optional(),
|
||||
voiceOutput: z.enum(['summary', 'full']).optional(),
|
||||
searchEnabled: z.boolean().optional(),
|
||||
middlePaneContext: z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('note'),
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal('browser'),
|
||||
url: z.string(),
|
||||
title: z.string(),
|
||||
}),
|
||||
]).optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
messageId: z.string(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const LlmProvider = z.object({
|
||||
flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]),
|
||||
flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat"]),
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
|
|
@ -11,6 +11,16 @@ export const LlmModelConfig = z.object({
|
|||
provider: LlmProvider,
|
||||
model: z.string(),
|
||||
models: z.array(z.string()).optional(),
|
||||
providers: z.record(z.string(), z.object({
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
model: z.string().optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
})).optional(),
|
||||
// Per-category model overrides (BYOK only — signed-in users always get
|
||||
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
|
||||
knowledgeGraphModel: z.string().optional(),
|
||||
meetingNotesModel: z.string().optional(),
|
||||
trackBlockModel: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
8
apps/x/packages/shared/src/prompt-block.ts
Normal file
8
apps/x/packages/shared/src/prompt-block.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import z from 'zod';
|
||||
|
||||
export const PromptBlockSchema = z.object({
|
||||
label: z.string().min(1).describe('Short title shown on the card'),
|
||||
instruction: z.string().min(1).describe('Full prompt sent to Copilot when Run is clicked'),
|
||||
});
|
||||
|
||||
export type PromptBlock = z.infer<typeof PromptBlockSchema>;
|
||||
|
|
@ -19,6 +19,8 @@ export const RunProcessingEndEvent = BaseRunEvent.extend({
|
|||
export const StartEvent = BaseRunEvent.extend({
|
||||
type: z.literal("start"),
|
||||
agentName: z.string(),
|
||||
model: z.string(),
|
||||
provider: z.string(),
|
||||
});
|
||||
|
||||
export const SpawnSubFlowEvent = BaseRunEvent.extend({
|
||||
|
|
@ -121,6 +123,8 @@ export const Run = z.object({
|
|||
title: z.string().optional(),
|
||||
createdAt: z.iso.datetime(),
|
||||
agentId: z.string(),
|
||||
model: z.string(),
|
||||
provider: z.string(),
|
||||
log: z.array(RunEvent),
|
||||
});
|
||||
|
||||
|
|
@ -134,6 +138,8 @@ export const ListRunsResponse = z.object({
|
|||
nextCursor: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateRunOptions = Run.pick({
|
||||
agentId: true,
|
||||
});
|
||||
export const CreateRunOptions = z.object({
|
||||
agentId: z.string(),
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export const TrackBlockSchema = z.object({
|
|||
eventMatchCriteria: z.string().optional().describe('When set, this track participates in event-based triggering. Describe what kinds of events should consider this track for an update (e.g. "Emails about Q3 planning"). Omit to disable event triggers — the track will only run on schedule or manually.'),
|
||||
active: z.boolean().default(true).describe('Set false to pause without deleting'),
|
||||
schedule: TrackScheduleSchema.optional(),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-track LLM model override (e.g. "anthropic/claude-sonnet-4.6"). Only set when the user explicitly asked for a specific model for THIS track. The global default already picks a tuned model for tracks; overriding usually makes things worse, not better.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-track provider name override (e.g. "openai", "anthropic"). Only set when the user explicitly asked for a specific provider for THIS track. Almost always omitted; the global default flows through correctly.'),
|
||||
lastRunAt: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
lastRunSummary: z.string().optional().describe('Runtime-managed — never write this yourself'),
|
||||
|
|
|
|||
34
apps/x/pnpm-lock.yaml
generated
34
apps/x/pnpm-lock.yaml
generated
|
|
@ -184,6 +184,9 @@ importers:
|
|||
'@tiptap/extension-placeholder':
|
||||
specifier: ^3.15.3
|
||||
version: 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
|
||||
'@tiptap/extension-table':
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
|
||||
'@tiptap/extension-task-item':
|
||||
specifier: ^3.15.3
|
||||
version: 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))
|
||||
|
|
@ -244,6 +247,9 @@ importers:
|
|||
recharts:
|
||||
specifier: ^3.8.0
|
||||
version: 3.8.1(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1)
|
||||
remark-breaks:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
|
|
@ -3166,6 +3172,12 @@ packages:
|
|||
peerDependencies:
|
||||
'@tiptap/core': ^3.15.3
|
||||
|
||||
'@tiptap/extension-table@3.22.4':
|
||||
resolution: {integrity: sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 3.22.4
|
||||
'@tiptap/pm': 3.22.4
|
||||
|
||||
'@tiptap/extension-task-item@3.15.3':
|
||||
resolution: {integrity: sha512-bkrmouc1rE5n9ONw2G7+zCGfBRoF2HJWq8REThPMzg/6+L5GJJ5YTN4UmncaP48U9jHX8xeihjgg9Ypenjl4lw==}
|
||||
peerDependencies:
|
||||
|
|
@ -5799,6 +5811,9 @@ packages:
|
|||
mdast-util-mdxjs-esm@2.0.1:
|
||||
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
|
||||
|
||||
mdast-util-newline-to-break@2.0.0:
|
||||
resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==}
|
||||
|
||||
mdast-util-phrasing@4.1.0:
|
||||
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
||||
|
||||
|
|
@ -6759,6 +6774,9 @@ packages:
|
|||
rehype-raw@7.0.0:
|
||||
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
|
||||
|
||||
remark-breaks@4.0.0:
|
||||
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
|
||||
|
||||
remark-cjk-friendly-gfm-strikethrough@1.2.3:
|
||||
resolution: {integrity: sha512-bXfMZtsaomK6ysNN/UGRIcasQAYkC10NtPmP0oOHOV8YOhA2TXmwRXCku4qOzjIFxAPfish5+XS0eIug2PzNZA==}
|
||||
engines: {node: '>=16'}
|
||||
|
|
@ -11151,6 +11169,11 @@ snapshots:
|
|||
dependencies:
|
||||
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
|
||||
|
||||
'@tiptap/extension-table@3.22.4(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.15.3(@tiptap/pm@3.15.3)
|
||||
'@tiptap/pm': 3.15.3
|
||||
|
||||
'@tiptap/extension-task-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3))':
|
||||
dependencies:
|
||||
'@tiptap/extension-list': 3.15.3(@tiptap/core@3.15.3(@tiptap/pm@3.15.3))(@tiptap/pm@3.15.3)
|
||||
|
|
@ -14400,6 +14423,11 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-newline-to-break@2.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
mdast-util-find-and-replace: 3.0.2
|
||||
|
||||
mdast-util-phrasing@4.1.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
|
|
@ -15594,6 +15622,12 @@ snapshots:
|
|||
hast-util-raw: 9.1.0
|
||||
vfile: 6.0.3
|
||||
|
||||
remark-breaks@4.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
mdast-util-newline-to-break: 2.0.0
|
||||
unified: 11.0.5
|
||||
|
||||
remark-cjk-friendly-gfm-strikethrough@1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5):
|
||||
dependencies:
|
||||
micromark-extension-cjk-friendly-gfm-strikethrough: 1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue