mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
app navigation
This commit is contained in:
parent
8f1adfb6a5
commit
e730c118dc
9 changed files with 433 additions and 1 deletions
|
|
@ -35,6 +35,7 @@ import {
|
|||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from '@/components/ai-elements/tool';
|
||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
||||
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
||||
import { Suggestions } from '@/components/ai-elements/suggestions';
|
||||
|
|
@ -62,6 +63,7 @@ import {
|
|||
type ToolCall,
|
||||
createEmptyChatTabViewState,
|
||||
getWebSearchCardData,
|
||||
getAppActionCardData,
|
||||
inferRunTitleFromMessage,
|
||||
isChatMessage,
|
||||
isErrorMessage,
|
||||
|
|
@ -499,6 +501,9 @@ function App() {
|
|||
const recentLocalMarkdownWritesRef = useRef<Map<string, number>>(new Map())
|
||||
const untitledRenameReadyPathsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// Pending app-navigation result to process once navigation functions are ready
|
||||
const pendingAppNavRef = useRef<Record<string, unknown> | null>(null)
|
||||
|
||||
// Global navigation history (back/forward) across views (chat/file/graph/task)
|
||||
const historyRef = useRef<{ back: ViewState[]; forward: ViewState[] }>({ back: [], forward: [] })
|
||||
const [viewHistory, setViewHistory] = useState(historyRef.current)
|
||||
|
|
@ -1660,6 +1665,15 @@ function App() {
|
|||
}
|
||||
return next
|
||||
})
|
||||
|
||||
// Handle app-navigation tool results — trigger UI side effects
|
||||
if (event.toolName === 'app-navigation') {
|
||||
const result = event.result as { success?: boolean; action?: string; [key: string]: unknown } | undefined
|
||||
if (result?.success) {
|
||||
pendingAppNavRef.current = result
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
|
|
@ -2563,6 +2577,106 @@ function App() {
|
|||
}
|
||||
}, [selectedPath, baseConfigByPath, loadDirectory, navigateToView])
|
||||
|
||||
// External search set by app-navigation tool (passed to BasesView)
|
||||
const [externalBaseSearch, setExternalBaseSearch] = useState<string | undefined>(undefined)
|
||||
|
||||
// Process pending app-navigation results
|
||||
useEffect(() => {
|
||||
const result = pendingAppNavRef.current
|
||||
if (!result) return
|
||||
pendingAppNavRef.current = null
|
||||
|
||||
switch (result.action) {
|
||||
case 'open-note':
|
||||
navigateToFile(result.path as string)
|
||||
break
|
||||
case 'open-view':
|
||||
if (result.view === 'graph') void navigateToView({ type: 'graph' })
|
||||
if (result.view === 'bases') {
|
||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||
}
|
||||
break
|
||||
case 'update-base-view': {
|
||||
// Navigate to bases if not already there
|
||||
const targetPath = selectedPath && isBaseFilePath(selectedPath) ? selectedPath : BASES_DEFAULT_TAB_PATH
|
||||
if (!selectedPath || !isBaseFilePath(selectedPath)) {
|
||||
void navigateToView({ type: 'file', path: BASES_DEFAULT_TAB_PATH })
|
||||
}
|
||||
|
||||
// Apply updates to the base config
|
||||
const updates = result.updates as Record<string, unknown> | undefined
|
||||
if (updates) {
|
||||
setBaseConfigByPath(prev => {
|
||||
const current = prev[targetPath] ?? { ...DEFAULT_BASE_CONFIG }
|
||||
const next = { ...current }
|
||||
|
||||
// Apply filter updates
|
||||
const filterUpdates = updates.filters as Record<string, unknown> | undefined
|
||||
if (filterUpdates) {
|
||||
if (filterUpdates.clear) {
|
||||
next.filters = []
|
||||
}
|
||||
if (filterUpdates.set) {
|
||||
next.filters = filterUpdates.set as Array<{ category: string; value: string }>
|
||||
}
|
||||
if (filterUpdates.add) {
|
||||
const toAdd = filterUpdates.add as Array<{ category: string; value: string }>
|
||||
const existing = next.filters
|
||||
for (const f of toAdd) {
|
||||
if (!existing.some(e => e.category === f.category && e.value === f.value)) {
|
||||
existing.push(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filterUpdates.remove) {
|
||||
const toRemove = filterUpdates.remove as Array<{ category: string; value: string }>
|
||||
next.filters = next.filters.filter(
|
||||
e => !toRemove.some(r => r.category === e.category && r.value === e.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply column updates
|
||||
const colUpdates = updates.columns as Record<string, unknown> | undefined
|
||||
if (colUpdates) {
|
||||
if (colUpdates.set) {
|
||||
next.visibleColumns = colUpdates.set as string[]
|
||||
}
|
||||
if (colUpdates.add) {
|
||||
const toAdd = colUpdates.add as string[]
|
||||
for (const col of toAdd) {
|
||||
if (!next.visibleColumns.includes(col)) next.visibleColumns.push(col)
|
||||
}
|
||||
}
|
||||
if (colUpdates.remove) {
|
||||
const toRemove = new Set(colUpdates.remove as string[])
|
||||
next.visibleColumns = next.visibleColumns.filter(c => !toRemove.has(c))
|
||||
}
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
if (updates.sort) {
|
||||
next.sort = updates.sort as { field: string; dir: 'asc' | 'desc' }
|
||||
}
|
||||
|
||||
return { ...prev, [targetPath]: next }
|
||||
})
|
||||
|
||||
// Apply search externally
|
||||
if (updates.search !== undefined) {
|
||||
setExternalBaseSearch(updates.search as string || undefined)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'create-base':
|
||||
if (result.path) {
|
||||
navigateToFile(result.path as string)
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
const navigateToFullScreenChat = useCallback(() => {
|
||||
// Only treat this as navigation when coming from another view
|
||||
if (currentViewState.type !== 'chat') {
|
||||
|
|
@ -3184,6 +3298,10 @@ function App() {
|
|||
}
|
||||
|
||||
if (isToolCall(item)) {
|
||||
const appActionData = getAppActionCardData(item)
|
||||
if (appActionData) {
|
||||
return <AppActionCard key={item.id} data={appActionData} status={item.status} />
|
||||
}
|
||||
const webSearchData = getWebSearchCardData(item)
|
||||
if (webSearchData) {
|
||||
return (
|
||||
|
|
@ -3492,6 +3610,8 @@ function App() {
|
|||
onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)}
|
||||
isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH}
|
||||
onSave={(name) => void handleBaseSave(name)}
|
||||
externalSearch={externalBaseSearch}
|
||||
onExternalSearchConsumed={() => setExternalBaseSearch(undefined)}
|
||||
/>
|
||||
</div>
|
||||
) : isGraphOpen ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
FileTextIcon,
|
||||
FilterIcon,
|
||||
LayoutGridIcon,
|
||||
LoaderIcon,
|
||||
NetworkIcon,
|
||||
PlusCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { AppActionCardData } from "@/lib/chat-conversation";
|
||||
|
||||
interface AppActionCardProps {
|
||||
data: AppActionCardData;
|
||||
status: "pending" | "running" | "completed" | "error";
|
||||
}
|
||||
|
||||
const actionIcons: Record<string, React.ReactNode> = {
|
||||
"open-note": <FileTextIcon className="size-4" />,
|
||||
"open-view": <NetworkIcon className="size-4" />,
|
||||
"update-base-view": <FilterIcon className="size-4" />,
|
||||
"create-base": <PlusCircleIcon className="size-4" />,
|
||||
};
|
||||
|
||||
export function AppActionCard({ data, status }: AppActionCardProps) {
|
||||
const isRunning = status === "pending" || status === "running";
|
||||
const isError = status === "error";
|
||||
|
||||
return (
|
||||
<div className="not-prose mb-4 flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<span className="text-muted-foreground">
|
||||
{actionIcons[data.action] || <LayoutGridIcon className="size-4" />}
|
||||
</span>
|
||||
<span className="text-sm flex-1">{data.label}</span>
|
||||
{isRunning ? (
|
||||
<LoaderIcon className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : isError ? (
|
||||
<span className="text-xs text-destructive">Failed</span>
|
||||
) : (
|
||||
<CheckCircleIcon className="size-3.5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -87,6 +87,10 @@ type BasesViewProps = {
|
|||
onConfigChange: (config: BaseConfig) => void
|
||||
isDefaultBase: boolean
|
||||
onSave: (name: string | null) => void
|
||||
/** Search query set externally (e.g. by app-navigation tool). */
|
||||
externalSearch?: string
|
||||
/** Called after the external search has been consumed (applied to internal state). */
|
||||
onExternalSearchConsumed?: () => void
|
||||
}
|
||||
|
||||
function collectFiles(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
||||
|
|
@ -142,7 +146,7 @@ function getSortValue(note: NoteEntry, column: string): string | number {
|
|||
const isBuiltin = (col: string): col is BuiltinColumn =>
|
||||
(BUILTIN_COLUMNS as readonly string[]).includes(col)
|
||||
|
||||
export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave }: BasesViewProps) {
|
||||
export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaultBase, onSave, externalSearch, onExternalSearchConsumed }: BasesViewProps) {
|
||||
// Build notes instantly from tree
|
||||
const notes = useMemo<NoteEntry[]>(() => {
|
||||
return collectFiles(tree).map((f) => ({
|
||||
|
|
@ -300,6 +304,15 @@ export function BasesView({ tree, onSelectNote, config, onConfigChange, isDefaul
|
|||
// Search
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Apply external search from app-navigation tool
|
||||
useEffect(() => {
|
||||
if (externalSearch !== undefined) {
|
||||
setSearchQuery(externalSearch)
|
||||
setSearchOpen(true)
|
||||
onExternalSearchConsumed?.()
|
||||
}
|
||||
}, [externalSearch, onExternalSearchConsumed])
|
||||
const debouncedSearch = useDebounce(searchQuery, 250)
|
||||
const [searchMatchPaths, setSearchMatchPaths] = useState<Set<string> | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
|
|
|||
|
|
@ -150,6 +150,89 @@ export const getWebSearchCardData = (tool: ToolCall): WebSearchCardData | null =
|
|||
return null
|
||||
}
|
||||
|
||||
// App navigation action card data
|
||||
export type AppActionCardData = {
|
||||
action: string
|
||||
label: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const summarizeFilterUpdates = (updates: Record<string, unknown>): string => {
|
||||
const filters = updates.filters as Record<string, unknown> | undefined
|
||||
const parts: string[] = []
|
||||
|
||||
if (filters) {
|
||||
if (filters.clear) parts.push('Cleared filters')
|
||||
const set = filters.set as Array<{ category: string; value: string }> | undefined
|
||||
if (set?.length) parts.push(`Set ${set.length} filter${set.length !== 1 ? 's' : ''}: ${set.map(f => `${f.category}=${f.value}`).join(', ')}`)
|
||||
const add = filters.add as Array<{ category: string; value: string }> | undefined
|
||||
if (add?.length) parts.push(`Added ${add.length} filter${add.length !== 1 ? 's' : ''}`)
|
||||
const remove = filters.remove as Array<{ category: string; value: string }> | undefined
|
||||
if (remove?.length) parts.push(`Removed ${remove.length} filter${remove.length !== 1 ? 's' : ''}`)
|
||||
}
|
||||
|
||||
if (updates.sort) {
|
||||
const sort = updates.sort as { field: string; dir: string }
|
||||
parts.push(`Sorted by ${sort.field} ${sort.dir}`)
|
||||
}
|
||||
|
||||
if (updates.search !== undefined) {
|
||||
parts.push(updates.search ? `Searching "${updates.search}"` : 'Cleared search')
|
||||
}
|
||||
|
||||
const columns = updates.columns as Record<string, unknown> | undefined
|
||||
if (columns) {
|
||||
const set = columns.set as string[] | undefined
|
||||
if (set) parts.push(`Set ${set.length} column${set.length !== 1 ? 's' : ''}`)
|
||||
const add = columns.add as string[] | undefined
|
||||
if (add?.length) parts.push(`Added ${add.length} column${add.length !== 1 ? 's' : ''}`)
|
||||
const remove = columns.remove as string[] | undefined
|
||||
if (remove?.length) parts.push(`Removed ${remove.length} column${remove.length !== 1 ? 's' : ''}`)
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(', ') : 'Updated view'
|
||||
}
|
||||
|
||||
export const getAppActionCardData = (tool: ToolCall): AppActionCardData | null => {
|
||||
if (tool.name !== 'app-navigation') return null
|
||||
const result = tool.result as Record<string, unknown> | undefined
|
||||
|
||||
// While pending/running, derive label from input
|
||||
if (!result || !result.success) {
|
||||
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
|
||||
if (!input) return null
|
||||
const action = input.action as string
|
||||
switch (action) {
|
||||
case 'open-note': return { action, label: `Opening ${(input.path as string || '').split('/').pop()?.replace(/\.md$/, '') || 'note'}...` }
|
||||
case 'open-view': return { action, label: `Opening ${input.view} view...` }
|
||||
case 'update-base-view': return { action, label: 'Updating view...' }
|
||||
case 'create-base': return { action, label: `Creating "${input.name}"...` }
|
||||
case 'get-base-state': return null // renders as normal tool block
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
switch (result.action) {
|
||||
case 'open-note': {
|
||||
const filePath = result.path as string || ''
|
||||
const name = filePath.split('/').pop()?.replace(/\.md$/, '') || 'note'
|
||||
return { action: 'open-note', label: `Opened ${name}` }
|
||||
}
|
||||
case 'open-view':
|
||||
return { action: 'open-view', label: `Opened ${result.view} view` }
|
||||
case 'update-base-view':
|
||||
return {
|
||||
action: 'update-base-view',
|
||||
label: summarizeFilterUpdates(result.updates as Record<string, unknown> || {}),
|
||||
details: result.updates as Record<string, unknown>,
|
||||
}
|
||||
case 'create-base':
|
||||
return { action: 'create-base', label: `Created base "${result.name}"` }
|
||||
default:
|
||||
return null // get-base-state renders as normal tool block
|
||||
}
|
||||
}
|
||||
|
||||
// Parse attached files from message content and return clean message + file paths.
|
||||
export const parseAttachedFiles = (content: string): { message: string; files: string[] } => {
|
||||
const attachedFilesRegex = /<attached-files>\s*([\s\S]*?)\s*<\/attached-files>/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
export const skill = String.raw`
|
||||
# App Navigation Skill
|
||||
|
||||
You have access to the **app-navigation** tool which lets you control the Rowboat UI directly — opening notes, switching views, filtering the knowledge base, and creating saved views.
|
||||
|
||||
## Actions
|
||||
|
||||
### open-note
|
||||
Open a specific knowledge file in the editor pane.
|
||||
|
||||
**When to use:** When the user asks to see, open, or view a specific note (e.g., "open John's note", "show me the Acme project page").
|
||||
|
||||
**Parameters:**
|
||||
- ` + "`path`" + `: Full workspace-relative path (e.g., ` + "`knowledge/People/John Smith.md`" + `)
|
||||
|
||||
**Tips:**
|
||||
- Use ` + "`workspace-grep`" + ` first to find the exact path if you're unsure of the filename.
|
||||
- Always pass the full ` + "`knowledge/...`" + ` path, not just the filename.
|
||||
|
||||
### open-view
|
||||
Switch the UI to the graph or bases view.
|
||||
|
||||
**When to use:** When the user asks to see the knowledge graph, view all notes, or open the bases/table view.
|
||||
|
||||
**Parameters:**
|
||||
- ` + "`view`" + `: ` + "`\"graph\"`" + ` or ` + "`\"bases\"`" + `
|
||||
|
||||
### update-base-view
|
||||
Change filters, columns, sort order, or search in the bases (table) view.
|
||||
|
||||
**When to use:** When the user asks to find, filter, sort, or search notes. Examples: "show me all active customers", "filter by topic=hiring", "sort by name", "search for pricing".
|
||||
|
||||
**Parameters:**
|
||||
- ` + "`filters`" + `: Object with ` + "`set`" + `, ` + "`add`" + `, ` + "`remove`" + `, or ` + "`clear`" + ` — each takes an array of ` + "`{ category, value }`" + ` pairs.
|
||||
- ` + "`set`" + `: Replace ALL current filters with these.
|
||||
- ` + "`add`" + `: Append filters without removing existing ones.
|
||||
- ` + "`remove`" + `: Remove specific filters.
|
||||
- ` + "`clear: true`" + `: Remove all filters.
|
||||
- ` + "`columns`" + `: Object with ` + "`set`" + `, ` + "`add`" + `, or ` + "`remove`" + ` — each takes an array of column names (frontmatter keys).
|
||||
- ` + "`sort`" + `: ` + "`{ field, dir }`" + ` where dir is ` + "`\"asc\"`" + ` or ` + "`\"desc\"`" + `.
|
||||
- ` + "`search`" + `: Free-text search string.
|
||||
|
||||
**Tips:**
|
||||
- If unsure what categories/values are available, call ` + "`get-base-state`" + ` first.
|
||||
- For "show me X", prefer ` + "`filters.set`" + ` to start fresh rather than ` + "`filters.add`" + `.
|
||||
- Categories come from frontmatter keys (e.g., relationship, status, topic, type).
|
||||
|
||||
### get-base-state
|
||||
Retrieve information about what's in the knowledge base — available filter categories, values, and note count.
|
||||
|
||||
**When to use:** When you need to know what properties exist before filtering, or when the user asks "what can I filter by?", "how many notes are there?", etc.
|
||||
|
||||
**Parameters:**
|
||||
- ` + "`base_name`" + ` (optional): Name of a saved base to inspect.
|
||||
|
||||
### create-base
|
||||
Save the current view configuration as a named base.
|
||||
|
||||
**When to use:** When the user asks to save a filtered view, create a saved search, or says "save this as [name]".
|
||||
|
||||
**Parameters:**
|
||||
- ` + "`name`" + `: Human-readable name for the base.
|
||||
|
||||
## Workflow Example
|
||||
|
||||
1. User: "Show me all people who are customers"
|
||||
2. First, check what properties are available:
|
||||
` + "`app-navigation({ action: \"get-base-state\" })`" + `
|
||||
3. Apply filters based on the available properties:
|
||||
` + "`app-navigation({ action: \"update-base-view\", filters: { set: [{ category: \"relationship\", value: \"customer\" }] } })`" + `
|
||||
4. If the user wants to save it:
|
||||
` + "`app-navigation({ action: \"create-base\", name: \"Customers\" })`" + `
|
||||
|
||||
## Important Notes
|
||||
- The ` + "`update-base-view`" + ` action will automatically navigate to the bases view if the user isn't already there.
|
||||
- ` + "`open-note`" + ` validates that the file exists before navigating.
|
||||
- Filter categories and values come from frontmatter in knowledge files.
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -11,6 +11,7 @@ import slackSkill from "./slack/skill.js";
|
|||
import backgroundAgentsSkill from "./background-agents/skill.js";
|
||||
import createPresentationsSkill from "./create-presentations/skill.js";
|
||||
import webSearchSkill from "./web-search/skill.js";
|
||||
import appNavigationSkill from "./app-navigation/skill.js";
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||
|
|
@ -95,6 +96,12 @@ const definitions: SkillDefinition[] = [
|
|||
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
|
||||
content: deletionGuardrailsSkill,
|
||||
},
|
||||
{
|
||||
id: "app-navigation",
|
||||
title: "App Navigation",
|
||||
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
|
||||
content: appNavigationSkill,
|
||||
},
|
||||
];
|
||||
|
||||
const skillEntries = definitions.map((definition) => ({
|
||||
|
|
|
|||
22
apps/x/packages/shared/src/bases.ts
Normal file
22
apps/x/packages/shared/src/bases.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Shared types for the Bases view (saved filtered views over the knowledge graph).
|
||||
*/
|
||||
|
||||
export type SortDir = 'asc' | 'desc';
|
||||
|
||||
export interface ActiveFilter {
|
||||
category: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface BaseConfig {
|
||||
filters: ActiveFilter[];
|
||||
columns: string[];
|
||||
sort?: { field: string; dir: SortDir };
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_BASE_CONFIG: BaseConfig = {
|
||||
filters: [],
|
||||
columns: [],
|
||||
};
|
||||
60
apps/x/packages/shared/src/frontmatter.ts
Normal file
60
apps/x/packages/shared/src/frontmatter.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Frontmatter parsing utilities for knowledge base markdown files.
|
||||
* Used by core (get-base-state) to scan knowledge files for available properties.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a markdown file's YAML frontmatter into key-value pairs.
|
||||
* Handles both scalar values (`key: value`) and list values (`key:\n - item`).
|
||||
* Returns `{ fields, body }` where fields maps keys to string or string[].
|
||||
*/
|
||||
export function parseFrontmatter(content: string): { fields: Record<string, string | string[]>; body: string } {
|
||||
if (!content.startsWith('---')) {
|
||||
return { fields: {}, body: content };
|
||||
}
|
||||
const endIndex = content.indexOf('\n---', 3);
|
||||
if (endIndex === -1) {
|
||||
return { fields: {}, body: content };
|
||||
}
|
||||
|
||||
const rawBlock = content.slice(4, endIndex); // skip opening '---\n'
|
||||
const body = content.slice(endIndex + 4).replace(/^\n/, '');
|
||||
const fields: Record<string, string | string[]> = {};
|
||||
let currentKey: string | null = null;
|
||||
|
||||
for (const line of rawBlock.split('\n')) {
|
||||
if (line.trim() === '' || line === '---') {
|
||||
currentKey = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Top-level key: value
|
||||
const topMatch = line.match(/^(\w[\w\s]*\w|\w+):\s*(.*)$/);
|
||||
if (topMatch) {
|
||||
const key = topMatch[1];
|
||||
const value = topMatch[2].trim();
|
||||
if (value) {
|
||||
fields[key] = value;
|
||||
currentKey = null;
|
||||
} else {
|
||||
// List will follow
|
||||
currentKey = key;
|
||||
fields[key] = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// List item under current key
|
||||
if (currentKey) {
|
||||
const itemMatch = line.match(/^\s+-\s+(.+)$/);
|
||||
if (itemMatch) {
|
||||
const arr = fields[currentKey];
|
||||
if (Array.isArray(arr)) {
|
||||
arr.push(itemMatch[1].trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { fields, body };
|
||||
}
|
||||
|
|
@ -8,4 +8,6 @@ export * as agentSchedule from './agent-schedule.js';
|
|||
export * as agentScheduleState from './agent-schedule-state.js';
|
||||
export * as serviceEvents from './service-events.js'
|
||||
export * as inlineTask from './inline-task.js';
|
||||
export * as frontmatter from './frontmatter.js';
|
||||
export * as bases from './bases.js';
|
||||
export { PrefixLogger };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue