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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user