Build a Docker-packaged ASP.NET Core 10 web app with a React (TypeScript/Vite) UI served as static files from wwwroot (NOT a separate Docker service) that uses Semantic Kernel to orchestrate four AI agents (Planner, Writer, Editor, Continuity Checker) for collaborative book writing. Agents stream progress via SignalR and can pause to ask the user plot-clarifying questions. PostgreSQL stores relational data; Qdrant vector database (separate Docker service) stores chapter embeddings for RAG-based context retrieval. LLM provider is pluggable (Ollama by default, swappable to OpenAI/Azure/Anthropic via configuration). Multi-user with cookie-based authentication.
[React SPA (static files in ASP.NET wwwroot — single container)]
↕ REST API + SignalR
[ASP.NET Core 10 API]
↕ Semantic Kernel (pluggable LLM connector)
↕ EF Core ↕ Qdrant .NET client
[PostgreSQL (Docker)] [Qdrant (Docker)] [Ollama (host machine)]
Docker Compose runs: ASP.NET app (with React static files baked in) + PostgreSQL + Qdrant. Ollama is external on the host. React is NOT a separate service — it's built in a Dockerfile stage and copied into wwwroot/.
- Book: Id, Title, Premise, Genre, TargetChapterCount, Status (Draft/InProgress/Complete), Language, PlannerSystemPrompt, WriterSystemPrompt, EditorSystemPrompt, ContinuityCheckerSystemPrompt, UserId (FK → AppUser), CreatedAt, UpdatedAt
- Chapter: Id, BookId, Number, Title, Outline, Content (markdown), Status (Outlined/Writing/Review/Editing/Done), CreatedAt, UpdatedAt
- AgentMessage: Id, BookId, ChapterId (nullable), AgentRole, MessageType (Content/Question/Answer/SystemNote/Feedback), Content, IsResolved, CreatedAt
- LlmConfiguration: Id, BookId (nullable, FK → Book), UserId (nullable, FK → AppUser), Provider (Ollama/OpenAI/Azure/Anthropic), ModelName, Endpoint, ApiKey (nullable), EmbeddingModelName (nullable)
- Lookup chain: book-specific (BookId) → user-default (UserId, no BookId) → global (neither)
- AppUser: Id, Username, PasswordHash, IsAdmin
- ChapterEmbedding — collection per book, stores chunked chapter text with embeddings
- Each chapter is split into ~500-token overlapping chunks
- Metadata: BookId, ChapterId, ChapterNumber, ChunkIndex
- Used by agents for RAG: query relevant passages across all chapters without exceeding context window
- Embedding model: configurable (default: Ollama embedding model or OpenAI
text-embedding-3-small)
- Input: Book premise + user guidance
- Output: Chapter outlines (title + synopsis per chapter)
- Can ask: "Should the story have a subplot about X?" etc.
- Input: Chapter outline + previous chapters' summaries (for context)
- Output: Full chapter content in markdown
- Can pause mid-generation via
[ASK: question]marker to ask the user about pivotal plot/character decisions; answer is fed back and generation continues - Can ask: "How should character Y react to event Z?"
- Input: Written chapter
- Output: Edited chapter + list of suggested changes
- Can ask: "This passage contradicts chapter N. Which version is canonical?"
- Input: All chapters written so far
- Output: List of inconsistencies + suggested fixes
- Can ask: "Character's eye color is brown in Ch1 but blue in Ch5. Which is correct?"
- Agent encounters ambiguity → calls
AskUserAndWaitAsyncinAgentBase - Persists
AgentMessage(type=Question) + notifies UI via SignalR - Registers a
TaskCompletionSource<string>inAgentRunStateService.SetPending - Sets run state to
WaitingForInput;await tcs.Taskblocks the agent coroutine - SignalR pushes
AgentQuestionevent to React UI; answer form appears in chat panel - User types answer →
POST /api/books/{id}/messages/answer→AgentOrchestrator.ResumeWithAnswerAsync TryResolvePendingcallstcs.SetResult(answer)→ agent resumes with answer injected into context- Cancellation (Stop button) calls
CancelRunwhich cancels the CTS and callstcs.TrySetCanceled()
- Create solution structure:
ABook.slnx(VS Solution XML format, requires .NET 9+ SDK)src/ABook.Api/— ASP.NET Core Web API project (.NET 10)src/ABook.Core/— Class library (domain models, interfaces)src/ABook.Infrastructure/— Class library (EF Core, LLM connectors)src/ABook.Agents/— Class library (Semantic Kernel agent definitions)src/abook-ui/— React + TypeScript + Vite app
- Set up Docker infrastructure:
Dockerfile(multi-stage: build .NET + build React → final runtime image with static files in wwwroot)docker-compose.yml(app + PostgreSQL + Qdrant, withextra_hostsfor host Ollama access).dockerignore
- Configure PostgreSQL with EF Core (Npgsql):
AppDbContextwith entities from data model- Initial migration
- Connection string from environment variables /
appsettings.json
- Configure Qdrant client:
IVectorStoreServiceinterface inABook.CoreQdrantVectorStoreServiceinABook.InfrastructureusingQdrant.ClientNuGet- Embedding generation via SK
ITextEmbeddingGenerationService(pluggable, same factory pattern as chat completion)
- Define domain models in
ABook.Core:Book,Chapter,AgentMessage,LlmConfigurationentitiesBookStatus,ChapterStatus,AgentRole,MessageTypeenumsIBookRepository,IAgentOrchestrator,ILlmProviderFactory,IVectorStoreServiceinterfaces
- Implement EF Core repositories in
ABook.Infrastructure - Build REST API controllers in
ABook.Api:BooksController— CRUD for booksChaptersController— CRUD for chapters within a bookMessagesController— post answers, get conversation historyConfigurationController— manage LLM provider settingsAgentController— start/stop agent runs; returns 202 immediately (fire-and-forget)AuthController— login, register, logout, current user (/api/auth/me)UsersController— admin-only user CRUD (create, change password, toggle role)OllamaController—GET /api/ollama/models(proxy to Ollama/api/tags),POST /api/ollama/pull(SSE stream)
- Set up SignalR hub (
BookHub):- Methods:
JoinBook(bookId),LeaveBook(bookId) - Events:
AgentStreaming(bookId, chapterId, token),AgentQuestion(bookId, message),AgentStatusChanged(bookId, agentRole, status),ChapterUpdated(bookId, chapterId)
- Methods:
- Configure Semantic Kernel with pluggable LLM connector:
ILlmProviderFactorythat creates SKIChatCompletionServicebased onLlmConfiguration- Support Ollama via
OllamaApiClient/ OpenAI-compatible endpoint - Support OpenAI, Azure OpenAI, Anthropic connectors behind same interface
- Implement vector store integration for RAG:
- On chapter save/update → chunk text → generate embeddings → upsert to Qdrant
RetrieveContext(bookId, query, topK)method for agents to pull relevant passages- Agents use retrieved context instead of full chapter text to stay within context window
- Implement agent orchestrator (
AgentOrchestrator):- Manages agent run lifecycle (start, pause, resume, complete)
- Fire-and-forget execution via
RunInBackgroundhelper usingIServiceScopeFactory AgentRunStateServicesingleton tracks active runs cross-request; duplicate run returns 409- Returns HTTP 202 immediately; progress streamed via SignalR
- Implement each agent as a Semantic Kernel function/plugin:
PlannerAgent— system prompt for outlining, outputs structured chapter listWriterAgent— system prompt for creative writing, streams tokens via SignalR, uses RAG for prior chapter contextEditorAgent— system prompt for editing/improving proseContinuityCheckerAgent— system prompt for cross-chapter analysis, heavy RAG user
- Implement question-pause mechanism:
- Agent detects need for clarification (via tool call or special token in prompt)
- Creates
AgentMessage(Question), sets run toWaitingForInput - On user answer, run resumes with answer injected into chat history
- Scaffold React app with Vite + TypeScript:
- Configure proxy to ASP.NET dev server
- Install:
react-router,@microsoft/signalr,zustand(state),react-markdown
- Build pages/components:
- Dashboard: List of book projects, create new book
- Book Detail: Overview, chapter list with statuses, agent run buttons (disabled while running), spinner banner
- Chapter View: Rendered markdown content, edit capability
- Agent Chat Panel: Sidebar/panel showing agent messages, questions, answer input
- Settings: LLM provider + Ollama model management (dropdown of installed/common models, pull with SSE progress), per-book language, per-agent system prompt overrides (collapsible)
- Login: Cookie-based auth login form
- Admin → Users: Admin-only page for user CRUD
- Real-time indicators: Streaming text as agents write, status badges per agent, planning output progressively parsed into chapter cards (
parsePlanningStream)
- SignalR integration:
useBookHubhook connecting toBookHubuseAuthhook /AuthProvidercontext for current user state- Auth guard in
App.tsxredirects unauthenticated users to/login - Live token streaming rendered in chapter view
- Toast/notification when agent asks a question
- Build React for production → output to
src/ABook.Api/wwwroot/
- Finalize multi-stage Dockerfile:
- Stage 1: Build React (
node:20-alpine,npm run build) - Stage 2: Build .NET (
mcr.microsoft.com/dotnet/sdk:10.0,dotnet publish) - Stage 3: Runtime (
mcr.microsoft.com/dotnet/aspnet:10.0, copy published + wwwroot)
- Stage 1: Build React (
- Finalize
docker-compose.yml:abook-apiservice with environment variables (DB connection, Ollama URL, Qdrant URL)postgresservice with volume for data persistenceqdrantservice (qdrant/qdrant:latest) with volume for vector data persistenceextra_hosts: ["host.docker.internal:host-gateway"]for Ollama access
- ASP.NET SPA fallback middleware to serve React static files for all non-API routes
ABook.slnx— Solution root (XML format)src/ABook.Core/Models/—Book.cs,Chapter.cs,AgentMessage.cs,LlmConfiguration.cs,AppUser.cs,TokenUsageRecord.cs,Enums.cssrc/ABook.Core/Interfaces/—IBookRepository.cs,IAgentOrchestrator.cs,ILlmProviderFactory.cs,IVectorStoreService.cs,IBookNotifier.cs,IUserRepository.cssrc/ABook.Infrastructure/Data/AppDbContext.cs— EF Core contextsrc/ABook.Infrastructure/Repositories/— Data access repositoriessrc/ABook.Infrastructure/Llm/LlmProviderFactory.cs— Pluggable LLM factorysrc/ABook.Infrastructure/VectorStore/QdrantVectorStoreService.cs— Qdrant integration, chunking, embeddingsrc/ABook.Infrastructure/Migrations/— EF Core migrations (InitialCreate,AddLanguageAndUsers,AddUserLlmConfig,AddTokenUsageRecord)src/ABook.Agents/AgentBase.cs— Base class for all agentssrc/ABook.Agents/PlannerAgent.cs,WriterAgent.cs,EditorAgent.cs,ContinuityCheckerAgent.cssrc/ABook.Agents/AgentOrchestrator.cs— Run lifecycle managementsrc/ABook.Agents/AgentRunStateService.cs— Singleton run state trackersrc/ABook.Api/Controllers/—BooksController,ChaptersController,MessagesController,ConfigurationController,AgentController,AuthController,UsersController,OllamaControllersrc/ABook.Api/Hubs/BookHub.cs— SignalR hubsrc/ABook.Api/Services/SignalRBookNotifier.cs—IBookNotifierimplementation (decouples agents from SignalR)src/ABook.Api/Program.cs— App configuration (cookie auth, EF Core, SignalR, Qdrant, SK)src/abook-ui/src/pages/—Dashboard.tsx,BookDetail.tsx,Settings.tsx,Login.tsx,AdminUsers.tsxsrc/abook-ui/src/hooks/—useBookHub.ts,useAuth.tsxsrc/abook-ui/src/utils/bookHtmlExport.ts— Client-side HTML export: markdown→HTML converter, 6 color presets, font-size controls;downloadBookAsHtml(book)triggers browser downloadsrc/abook-ui/src/api.ts— Typed API clientsrc/abook-ui/src/App.tsx— Router + auth guardDockerfile— Multi-stage build (Node 20 + .NET 10 SDK + .NET 10 runtime)docker-compose.yml— App + PostgreSQL + Qdrant.dockerignore
docker-compose up --buildstarts app + PostgreSQL + Qdrant, React UI loads athttp://localhost:5000- Create a book via UI → verify stored in PostgreSQL
- Start planning → Planner agent streams chapter outlines via SignalR, visible in UI in real-time
- Agent asks a question → notification appears in chat panel → answer submitted → agent resumes
- Write a chapter → Writer streams content → Editor reviews → Continuity Checker analyzes
- Switch LLM provider in settings → next agent run uses new provider
docker-compose down && docker-compose up→ data persists (PostgreSQL + Qdrant volumes)
- .NET 10 (latest; Docker images
mcr.microsoft.com/dotnet/sdk:10.0+aspnet:10.0) .slnxsolution format — requires .NET 9+ SDK (XML-based, lighter than classic.sln)- Ollama accessed via host.docker.internal — not containerized, user manages it externally
- LLM provider is pluggable — abstracted behind
ILlmProviderFactory, configured per-book or globally; supported: Ollama, LMStudio, OpenAI, AzureOpenAI, Anthropic - SK Ollama connector is alpha (
Microsoft.SemanticKernel.Connectors.Ollama1.x-alpha); suppressSKEXP0070pragma - Agents use Semantic Kernel function calling for the "ask question" tool — agent invokes a
AskUserfunction which triggers the pause mechanism - Agent runs are fire-and-forget —
AgentControllerreturns 202 immediately;AgentRunStateServicesingleton tracks state; duplicate run returns 409 - Cookie authentication — multi-user support with
IPasswordHasher<AppUser>; admin role for user management - Per-book customization —
Languagefield and per-agent system prompt overrides stored onBookentity - Ollama model management —
OllamaControllerproxies Ollama's/api/tagsand streams pull progress via SSE - Markdown only for book output — no DOCX/PDF export (can be added later)
- PostgreSQL + Qdrant in compose with persistent volumes
- React UI is static files — built in Dockerfile, served from
wwwroot/by ASP.NET Core, NOT a separate Docker service - Qdrant for vector storage — separate Docker service, used for RAG-based context retrieval so agents can handle large books without exceeding context windows
IBookNotifierinterface (SignalRBookNotifierimplementation) decouples agent/orchestrator code from direct SignalR hub dependency- Per-user LLM settings:
LlmConfigurationhas nullableUserId; lookup chain is book-specific → user-default → global.ConfigurationControllerautomatically setsUserIdfrom cookie claims when saving a global (non-book) config. Agents resolve config viaGetKernelAsyncwhich passesbook.UserId. - Default system prompts API:
GET /api/books/{id}/default-promptsreturns pre-interpolated default prompts (using book's title/genre/language). Settings page fetches these and shows a "Load Defaults" button that pre-fills empty textarea fields. Placeholders also show the default text. - Migration:
AddUserLlmConfigadds nullableUserIdFK toLlmConfigurationstable. - Qdrant cleanup:
IVectorStoreService.DeleteCollectionAsyncdrops the whole book collection; called byBooksController.Delete(non-fatal).ChaptersController.UpdatecallsDeleteChapterChunksAsyncwhen content is cleared to empty (e.g. the "Clear" button in the UI). - LlmProvider.LMStudio: uses SK's OpenAI connector with a custom endpoint (
/v1). API key defaults to"lm-studio"if omitted. Embedding support requiresEmbeddingModelNameto be set. Default endpoint:http://host.docker.internal:1234. GET /api/ollama/modelsnow accepts?provider=query param; whenprovider=LMStudioit queries{endpoint}/v1/models(OpenAI-compatible format:{"data":[{"id":"..."}]}). For Ollama it still queries/api/tags.- Model list in Settings: fetched dynamically from the configured endpoint; only Ollama and LMStudio fetch model lists. Switching provider resets endpoint to the provider's default (only if the current endpoint matches the previous provider's default).
- ContinuityCheckerAgent uses RAG: runs three targeted queries (character descriptions, timeline, locations) against Qdrant before checking continuity; appends retrieved passages to the LLM prompt alongside the chapter synopsis.
StripLeadingChapterHeadingmoved toAgentBase(protected): bothWriterAgentandEditorAgentstrip LLM-added chapter headings from prose before saving. Now handles consecutive heading lines, bold-formatted headings, and ordinal word variants ("Chapter One", "Chapter Two" etc.).InterpolateSystemPromptinAgentBase(protected static): replaces{TITLE},{GENRE},{PREMISE},{LANGUAGE},{CHAPTER_COUNT}tokens in user-supplied system prompts with book data. All four agents call this when using a custom prompt.GetPreviousChapterEndingAsyncinAgentBase(protected): returns the last 3 paragraphs of the immediately preceding chapter.WriterAgentincludes this in the system prompt so prose is narratively continuous even without RAG context.EditorAgentnotes split: uses a regex to find any## Editorial Notes/## Editor's Notes/## Feedbacketc. heading (case-insensitive) instead of a hard string compare; more resilient to LLM phrasing variation.- Settings UI placeholder hint: the "Custom Agent System Prompts" section now shows a reference block listing all supported template tokens and reminds users that the Editor prompt must end with
## Editorial Notes. ContinuityCheckerAgent.CheckAsyncfocused mode: accepts optionalint? chapterId. When provided (per-chapter workflow), separates chapters into "preceding facts" vs "chapter under review" and instructs the LLM to report only issues introduced by that chapter, ignoring pre-existing issues between earlier chapters. No-id calls (final check, standalone button) retain full cross-manuscript review behaviour.AgentOrchestratorper-chapter call sites now passchapter.Id; final checks pass null.- Token statistics:
AgentBase.StreamResponseAsyncnow acceptsAgentRole role(required param beforeCancellationToken). After each LLM streaming call, emits approximate token counts (chars/4) viaIBookNotifier.NotifyTokenStatsAsync→ SignalRTokenStatsevent. Also persists aTokenUsageRecordrow (BookId, ChapterId, AgentRole, PromptTokens, CompletionTokens) viaIBookRepository.AddTokenUsageAsync.GET /api/books/{id}/token-usagereturns all persisted records. UI loads historical stats on page load and refreshes them on eachTokenStatsSignalR event; shown as a collapsible<details>panel at the bottom of the chat sidebar. Total accumulated tokens shown. - Chapter inline edit: "✎ Edit" button appears on chapter header when not running. Opens an inline form to edit title and outline; saves via
PUT /api/books/{id}/chapters/{chapterId}. - Book inline edit: "✎ Edit" button on book overview. Opens an inline form to edit title, genre, target chapters, premise/plot; saves via
PUT /api/books/{id}. - Add chapter manually: "+ Chapter" button at the bottom of the sidebar chapter list. Inline form collects title and outline; auto-assigns the next chapter number; saves via
POST /api/books/{id}/chaptersand immediately selects the new chapter. - Default LLM config from env vars: on startup,
Program.csreadsLlmDefaultsconfig section and upserts the globalLlmConfigurationrecord (BookId=null, UserId=null). Env var names follow .NET double-underscore convention:LlmDefaults__Provider,LlmDefaults__ModelName,LlmDefaults__Endpoint,LlmDefaults__ApiKey,LlmDefaults__EmbeddingModelName. Defaults are pre-populated inappsettings.json(Provider=Ollama, ModelName=llama3, Endpoint=http://host.docker.internal:11434). Commented examples indocker-compose.yml.
- Concurrent agent runs — Should multiple agents run in parallel on different chapters (e.g., Writer on Ch3 while Editor reviews Ch2)? Recommend yes, with a configurable concurrency limit.
- Chapter editing — Should users be able to manually edit chapter content in the UI (rich markdown editor), or only through agents? Recommend allowing manual edits alongside agent work.
- EF Core / Npgsql: Use
10.0.*versions; both stable as of April 2026 ABook.AgentspinsMicrosoft.EntityFrameworkCore.Relational 10.0.*to avoid version conflict with Semantic Kernel- Qdrant.Client 1.x API:
CreateCollectionAsync(name, VectorParams, ...)— notVectorsConfig - SK embedding API:
ITextEmbeddingGenerationService.GenerateEmbeddingsAsync(list)— notGenerateEmbeddingAsync OllamaPromptExecutionSettings.Temperatureisfloat?notdoubleMicrosoft.AspNetCore.SignalR 1.*NuGet package removed — SignalR is built into the framework in .NET 3+- Enum JSON serialization: Register
JsonStringEnumConverterinAddJsonOptionsso enum values serialize as strings for the React client parsePlanningStream: React helper that progressively parses the Planner agent's streaming JSON into chapter cards as tokens arriveAskUserAndWaitAsyncinAgentBase: creates aTaskCompletionSource<string>, registers it viaAgentRunStateService.SetPending, sets status toWaitingForInput, thenawait tcs.Task. Unblocked byResumeWithAnswerAsync(answer) orCancelRun(cancellation).AgentBasenow takesAgentRunStateServiceas a constructor param.- Full autonomous workflow:
POST /api/books/{id}/agent/workflow/startruns Plan → Write+Edit each chapter → Continuity check in sequence. Uses aCancellationTokenSourcefromAgentRunStateService.CreateRunCts. Stop viaPOST .../workflow/stop. PlannerAgentgenerates outline directly from book metadata with no up-front question.WriterAgentmid-generation questions: the LLM can emit[ASK: question]at any point while writing to pause and request the author's input on a pivotal plot/character decision.WriteWithQuestionsAsyncloops up to 6 rounds: it streams until the marker is detected, extracts the partial prose (before the marker), callsAskUserAndWaitAsync, then feeds the partial prose + answer back as a new turn and resumes streaming. Works with any model — no function-calling required.WorkflowProgressSignalR event: emitted byAgentOrchestrator.StartWorkflowAsyncat each step with(bookId, step, isComplete). UI accumulates steps inworkflowLogstate array shown in sidebar.