Add outline import feature; drop Dto naming, map entities at the API boundary

Services now return entities; endpoints (and the agent toolsets) map to
*Response records instead of services building wire DTOs themselves.
Also brings in the outline-import agent, MCP tool, ledger and web dialog
that were already in progress on disk.
This commit is contained in:
James Wampler
2026-08-06 18:36:40 -07:00
parent 40f93e40a8
commit 189ebf3237
66 changed files with 3310 additions and 364 deletions
+36
View File
@@ -10,6 +10,9 @@ import type {
Conversation,
ConversationSummary,
Beat,
ImportInspection,
ImportJob,
ImportJobStatus,
OpenQuestion,
Project,
ProjectSummary,
@@ -30,6 +33,7 @@ export const keys = {
chapter: (id: string) => ['chapters', id] as const,
conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const,
conversation: (id: string) => ['conversations', id] as const,
importJob: (id: string) => ['imports', id] as const,
}
// --- Projects ---------------------------------------------------------------
@@ -412,3 +416,35 @@ export function useSendAgentMessage(projectId: string) {
},
})
}
// --- Outline import ----------------------------------------------------------
export function useInspectImport() {
return useMutation({
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
})
}
export function useStartImport() {
return useMutation({
mutationFn: (body: { sourceRoot: string; forceRestart?: boolean }) =>
api.post<ImportJob>('/api/imports', body),
})
}
const terminalImportStatuses: ImportJobStatus[] = ['Completed', 'Failed', 'Paused']
/**
* Polls a running import job. This is the app's first polling hook — there's no
* SSE/websocket infrastructure to reuse — so it stops on its own once the job reaches a
* terminal status rather than depending on the caller to unmount it in time.
*/
export function useImportJob(jobId: string | undefined) {
return useQuery({
queryKey: keys.importJob(jobId ?? ''),
queryFn: () => api.get<ImportJob>(`/api/imports/${jobId}`),
enabled: Boolean(jobId),
refetchInterval: (query) =>
query.state.data && terminalImportStatuses.includes(query.state.data.status) ? false : 1500,
})
}
+27
View File
@@ -251,3 +251,30 @@ export interface AgentTurn {
conversationId: string
message: AgentMessage
}
// --- Outline import -----------------------------------------------------------
export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | 'Paused'
export interface ImportJob {
id: string
sourceRoot: string
projectId: string | null
status: ImportJobStatus
statusMessage: string | null
chaptersCompleted: number
chaptersTotal: number
createdAt: string
updatedAt: string
}
/** Whether a source folder is ready for a fresh import, has one to resume, or is already done. */
export type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete'
export interface ImportInspection {
readiness: ImportReadiness
projectId: string | null
chaptersCompleted: number
chaptersTotal: number
completedPasses: string[]
}
@@ -0,0 +1,278 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useImportJob, useInspectImport, useStartImport } from '../api/hooks'
import type { ImportInspection, ImportJob } from '../api/types'
import { ErrorNote, Modal, Spinner } from './ui'
/**
* Kicks off (or resumes) an outline import against an absolute folder path. The app runs
* locally with the API and browser on the same machine, so a pasted path is meaningful —
* there's no browser folder picker that can hand back one instead.
*
* State machine: type a path → Check (inspects the folder without starting anything) →
* Start/Resume/Delete-and-reimport → poll until the background job finishes.
*/
export function ImportDialog({
onClose,
onImported,
}: {
onClose: () => void
onImported?: (projectId: string) => void
}) {
const [sourceRoot, setSourceRoot] = useState('')
const [inspection, setInspection] = useState<ImportInspection | null>(null)
const [jobId, setJobId] = useState<string>()
const [confirmingRestart, setConfirmingRestart] = useState(false)
const inspect = useInspectImport()
const start = useStartImport()
const job = useImportJob(jobId)
const qc = useQueryClient()
useEffect(() => {
if (job.data?.status !== 'Completed') return
// The import writes project data through the same services the UI uses to edit it —
// everything on screen may be stale once it finishes.
qc.invalidateQueries()
if (job.data.projectId) onImported?.(job.data.projectId)
}, [job.data?.status, job.data?.projectId, qc, onImported])
const check = (e: FormEvent) => {
e.preventDefault()
if (!sourceRoot.trim()) return
inspect.mutate(sourceRoot.trim(), { onSuccess: setInspection })
}
const beginImport = (forceRestart = false) => {
start.mutate({ sourceRoot: sourceRoot.trim(), forceRestart }, { onSuccess: (created) => setJobId(created.id) })
}
const changeFolder = () => {
setInspection(null)
setJobId(undefined)
setConfirmingRestart(false)
}
if (jobId && job.data) {
return (
<Modal title="Import outline" onClose={onClose}>
<ImportProgress
job={job.data}
onRetry={() => beginImport(false)}
onDone={onClose}
/>
</Modal>
)
}
return (
<Modal title="Import outline" onClose={onClose}>
<form onSubmit={check} className="grid gap-3">
<label className="block">
<span className="label">Source folder</span>
<input
className="input"
autoFocus
value={sourceRoot}
onChange={(e) => {
setSourceRoot(e.target.value)
setInspection(null)
}}
placeholder="/home/you/Documents/Novels/my-outline"
disabled={inspect.isPending || start.isPending}
/>
</label>
<p className="text-sm muted">
Absolute path to the folder holding outline.md, its chapter files and character
dossiers.
</p>
{inspect.error && <ErrorNote error={inspect.error} />}
{start.error && <ErrorNote error={start.error} />}
{inspection && (
<ImportReadinessSummary
inspection={inspection}
confirmingRestart={confirmingRestart}
onStart={() => beginImport(false)}
onConfirmRestart={() => setConfirmingRestart(true)}
onCancelRestart={() => setConfirmingRestart(false)}
onRestart={() => beginImport(true)}
starting={start.isPending}
/>
)}
<div className="mt-1 flex justify-end gap-2">
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
{!inspection && (
<button
type="submit"
className="btn btn-primary"
disabled={!sourceRoot.trim() || inspect.isPending}
>
{inspect.isPending ? 'Checking…' : 'Check'}
</button>
)}
{inspection && (
<button type="button" className="btn" onClick={changeFolder}>
Change folder
</button>
)}
</div>
</form>
</Modal>
)
}
function ImportReadinessSummary({
inspection,
confirmingRestart,
onStart,
onConfirmRestart,
onCancelRestart,
onRestart,
starting,
}: {
inspection: ImportInspection
confirmingRestart: boolean
onStart: () => void
onConfirmRestart: () => void
onCancelRestart: () => void
onRestart: () => void
starting: boolean
}) {
if (inspection.readiness === 'Fresh') {
return (
<div className="rounded-md px-3 py-2 text-sm" style={{ background: 'var(--surface-sunken)' }}>
<p>No previous import found here this will start fresh.</p>
<button type="button" className="btn btn-primary mt-2" onClick={onStart} disabled={starting}>
{starting ? 'Starting…' : 'Start import'}
</button>
</div>
)
}
if (inspection.readiness === 'Resumable') {
return (
<div className="rounded-md px-3 py-2 text-sm" style={{ background: 'var(--surface-sunken)' }}>
<p>
A previous import is partway through: {inspection.chaptersCompleted} of{' '}
{inspection.chaptersTotal || '?'} chapters done.
</p>
<button type="button" className="btn btn-primary mt-2" onClick={onStart} disabled={starting}>
{starting ? 'Resuming…' : 'Resume import'}
</button>
</div>
)
}
return (
<div className="rounded-md px-3 py-2 text-sm" style={{ background: 'var(--surface-sunken)' }}>
<p>This folder has already been fully imported.</p>
{!confirmingRestart ? (
<button
type="button"
className="btn mt-2"
style={{ color: 'var(--accent)' }}
onClick={onConfirmRestart}
>
Delete &amp; reimport
</button>
) : (
<div className="mt-2">
<p className="mb-2" style={{ color: 'var(--accent)' }}>
This permanently deletes the project this import created its chapters,
characters, everything then starts over. This cannot be undone.
</p>
<div className="flex gap-2">
<button type="button" className="btn" onClick={onCancelRestart}>
Cancel
</button>
<button
type="button"
className="btn"
style={{ color: 'var(--accent)' }}
onClick={onRestart}
disabled={starting}
>
{starting ? 'Deleting…' : 'Delete and start over'}
</button>
</div>
</div>
)}
</div>
)
}
function ImportProgress({
job,
onRetry,
onDone,
}: {
job: ImportJob
onRetry: () => void
onDone: () => void
}) {
if (job.status === 'Pending' || job.status === 'Running') {
return (
<div>
<Spinner label={job.status === 'Pending' ? 'Queued' : 'Importing'} />
{job.chaptersTotal > 0 && (
<p className="text-sm muted">
{job.chaptersCompleted} of {job.chaptersTotal} chapters
</p>
)}
</div>
)
}
if (job.status === 'Completed') {
return (
<div className="grid gap-3">
<p>
Import complete {job.chaptersCompleted} chapter{job.chaptersCompleted === 1 ? '' : 's'} imported.
</p>
<div className="flex justify-end">
<button type="button" className="btn btn-primary" onClick={onDone}>
Done
</button>
</div>
</div>
)
}
if (job.status === 'Paused') {
return (
<div className="grid gap-3">
<p className="text-sm muted">{job.statusMessage ?? 'Paused — more work remains.'}</p>
<p className="text-sm muted">
{job.chaptersCompleted} of {job.chaptersTotal || '?'} chapters so far.
</p>
<div className="flex justify-end gap-2">
<button type="button" className="btn" onClick={onDone}>
Close
</button>
<button type="button" className="btn btn-primary" onClick={onRetry}>
Continue import
</button>
</div>
</div>
)
}
return (
<div className="grid gap-3">
<ErrorNote error={job.statusMessage ?? 'The import failed.'} />
<div className="flex justify-end gap-2">
<button type="button" className="btn" onClick={onDone}>
Close
</button>
<button type="button" className="btn btn-primary" onClick={onRetry}>
Try again
</button>
</div>
</div>
)
}
@@ -1,5 +1,7 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useDeleteProject, useProject, useUpdateProject } from '../api/hooks'
import { ImportDialog } from '../components/ImportDialog'
import { AutoField, ErrorNote, Spinner } from '../components/ui'
export default function OverviewPage() {
@@ -10,6 +12,7 @@ export default function OverviewPage() {
const { data: chapters } = useChapters(projectId)
const update = useUpdateProject(projectId)
const remove = useDeleteProject()
const [importing, setImporting] = useState(false)
if (isPending || !project) return <Spinner label="Loading brief" />
@@ -114,6 +117,18 @@ export default function OverviewPage() {
</dl>
</div>
<div className="card p-5">
<h2 className="mb-3 text-sm font-semibold tracking-wide uppercase muted">Import outline</h2>
<p className="mb-3 text-sm muted">
Start a new novel from an author's existing outline folder — chapters, beats and
character dossiers. This doesn't touch the novel you're viewing; it creates
another one.
</p>
<button className="btn w-full" onClick={() => setImporting(true)}>
Import a new novel from outline
</button>
</div>
<div className="card p-5">
<h2 className="mb-2 text-sm font-semibold tracking-wide uppercase muted">Danger zone</h2>
<p className="mb-3 text-sm muted">
@@ -132,6 +147,13 @@ export default function OverviewPage() {
</button>
</div>
</aside>
{importing && (
<ImportDialog
onClose={() => setImporting(false)}
onImported={(newProjectId) => navigate(`/projects/${newProjectId}`)}
/>
)}
</div>
)
}
+18 -4
View File
@@ -1,11 +1,14 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { useCreateProject, useProjects } from '../api/hooks'
import { ImportDialog } from '../components/ImportDialog'
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
export default function ProjectsPage() {
const { data: projects, isPending, error } = useProjects()
const [creating, setCreating] = useState(false)
const [importing, setImporting] = useState(false)
const navigate = useNavigate()
return (
<div className="mx-auto max-w-4xl px-6 py-12">
@@ -16,9 +19,14 @@ export default function ProjectsPage() {
Outlines, character dossiers, and a writing partner that knows the book.
</p>
</div>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
<div className="flex gap-2">
<button className="btn" onClick={() => setImporting(true)}>
Import from outline
</button>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
</div>
</header>
{error && <ErrorNote error={error} />}
@@ -61,6 +69,12 @@ export default function ProjectsPage() {
</div>
{creating && <CreateProjectModal onClose={() => setCreating(false)} />}
{importing && (
<ImportDialog
onClose={() => setImporting(false)}
onImported={(projectId) => navigate(`/projects/${projectId}`)}
/>
)}
</div>
)
}