Commit 396b06b9 authored by santiago duque's avatar santiago duque

novelcrafter V1

parent b466c6be
# Novelcrafter-like System in Kirby CMS
## Overview
Replicate core Novelcrafter functionality in Kirby CMS: writing novels with chapters (using blocks), managing codex (characters, locations), and AI-assisted writing via OpenRouter API.
## Goals
- Chapters as subpages with block-based editing
- Codex as page with subpages for characters/locations
- AI integration: Generate text directly in Kirby using context from chapters and codex
- Seamless workflow for novel writing
## Current State Analysis
- Chapter template/blueprint exists with blocks (prose, pull, note, texture, pinned-text, pinned-image)
- Default blueprint has basic blocks (heading, text, image, quote)
- Kirby 5 with languages, debug enabled
- No existing AI plugins
- Home template serves as novel overview
## Proposed Structure
### Page Hierarchy
```
/ (novel overview page)
├── chapter-1/
├── chapter-2/
│ └── ... (flat chapters, not in folder)
└── codex/
├── characters/
│ ├── alice/
│ └── bob/
└── locations/
├── forest/
└── castle/
```
### Templates
- `home.php`: Existing, functions as novel overview (title, chapter list, codex link)
- `codex.php`: Display codex overview, lists of characters/locations
- `character.php`: Character details (name, description, traits)
- `location.php`: Location details (name, description, history)
- `chapter.php`: Existing, enhance with AI features
### Blueprints
- `home.yml`: Title, description, author (if needed, or use default)
- `codex.yml`: Title, description
- `character.yml`: Name, description, traits, relationships
- `location.yml`: Name, description, history, significance
- `chapter.yml`: Existing, add new AI tab with:
- Context selection: Toggle for whole novel (if false, multi-select chapter pages), multi-select codex entries (characters/locations)
- Prompt textarea
- Chat structure field (entries with prompt, response, timestamp)
### Plugin: novelcrafter-ai
- Handle OpenRouter API integration
- Add config for OpenRouter API key and default model
- Context building logic (aggregate selected chapter/codex content)
- Add button in AI tab to send prompt and store response in chat structure
## Implementation Steps
### 0. Setup
- Create new git branch for this feature (e.g., `feature/novelcrafter-ai`)
### 1. Define Page Structure and Blueprints
- Create blueprints for novel, codex, character, location
- Update chapter blueprint to include AI-related fields (context sources, system prompt)
### 2. Create Templates
- Implement novel template with chapter navigation
- Implement codex template with subpage lists
- Implement character/location templates
- Enhance chapter template if needed
### 3. Develop AI Plugin
- Create plugin structure
- Add config options (OpenRouter API key, default model)
- Implement API client class for OpenRouter requests
- Build context aggregation function
- Add panel integration (button to generate text)
- Handle response insertion into blocks
### 4. AI Features
- Prompt construction: Aggregate selected context (chapters/codex pages) + user prompt
- Plain text responses stored in chat structure
- Manual copy-paste from chat to chapter blocks
- Error handling and rate limiting
### 5. UI Enhancements
- AI tab in chapter panel with context selectors and chat history
- Generate button to call OpenRouter and add chat entry
- Simple progress indicator during API call
## Technical Considerations
- OpenRouter API: Use chat/completions endpoint, similar to OpenAI
- Security: Store API key securely, validate inputs
- Performance: Aggregate context on demand, handle large content
- Context aggregation: Collect text from selected pages (chapters use blocks, codex use fields)
## Potential Challenges
- Aggregating context from mixed sources (block content vs field content)
- Implementing multi-select page fields in blueprint
- Handling API errors and rate limits in panel
- Ensuring chat structure is user-friendly
## Next Steps
- Confirm blueprint fields (page multi-select, structure for chat)
- Decide on plugin approach (custom panel button or AJAX route)
- Test OpenRouter API integration</content>
<parameter name="filePath">.kilo/plans/1778746362668-crisp-forest.md
\ No newline at end of file
......@@ -49,9 +49,13 @@ tabs:
help: "e.g. 23:47"
width: 1/2
scroll_cue:
label: Scroll Cue Text
type: text
default: Scroll to descend
label: Scroll Cue Text
type: text
default: Scroll to descend
text_content:
type: hidden
default: ''
sections:
label: Chapter Sections
......@@ -69,3 +73,64 @@ tabs:
# kirby seo
seo: tabs/seo
# ai assistance
ai:
label: AI Assistant
icon: bolt
fields:
use_whole_novel:
label: Context Source
type: toggle
text:
- Use selected chapters
- Use whole novel
help: If on, all chapter content will be included in context; if off, select specific chapters
selected_chapters:
label: Selected Chapters
type: pages
query: site.children.filterBy('template', 'chapter')
multiple: true
when:
use_whole_novel: false
help: Select chapters to include in AI context
selected_codex:
label: Selected Codex Entries
type: pages
query: site.find('codex').childrenAndDrafts
multiple: true
help: Select characters and locations to include in AI context
prompt:
label: AI Prompt
type: writer
help: Enter your prompt for the AI assistant
marks: false
nodes:
- paragraph
generate:
type: janitor
command: 'novelcrafter-generate'
label: Generate
icon: bolt
autosave: true
progress: Generating…
success: Response saved to chat history
error: "{{ item.message }}"
chat_history:
label: Chat History
type: structure
fields:
prompt:
label: Prompt
type: writer
readonly: true
response:
label: Response
type: writer
readonly: true
timestamp:
label: Timestamp
type: text
readonly: true
title: Character
fields:
title:
label: Name
type: text
required: true
description:
label: Description
type: writer
help: Detailed description of the character
traits:
label: Traits
type: tags
help: Key personality traits or characteristics
relationships:
label: Relationships
type: pages
query: site.find('codex').find('characters').children
multiple: true
help: Other characters this character is related to
text_content:
type: hidden
default: ''
\ No newline at end of file
title: Codex
sections:
codex:
type: pages
templates:
- character
- location
content:
type: fields
fields:
title:
label: Codex Title
type: text
required: true
description:
label: Description
type: textarea
help: Brief overview of the codex
text_content:
type: hidden
default: ""
title: Location
fields:
title:
label: Name
type: text
required: true
description:
label: Description
type: writer
help: Detailed description of the location
history:
label: History
type: writer
help: Historical background or events
significance:
label: Significance
type: writer
help: Importance to the story
text_content:
type: hidden
default: ''
\ No newline at end of file
......@@ -27,5 +27,8 @@ tabs:
chapters:
type: pages
templates: chapter
codex:
type: pages
templates: codex
<?php
use Bnomei\Janitor;
use Kirby\CLI\CLI;
return [
'description' => 'Generate AI text for a chapter using OpenRouter',
'args' => [] + Janitor::ARGS, // page, file, user, site, data, model
'command' => static function (CLI $cli): void {
$page = $cli->kirby()->page($cli->arg('page'));
if (!$page || $page->template()->name() !== 'chapter') {
janitor()->data($cli->arg('command'), [
'status' => 400,
'message' => 'Invalid page — must be a chapter',
]);
return;
}
$prompt = $page->prompt()->value();
if (empty(trim($prompt))) {
janitor()->data($cli->arg('command'), [
'status' => 400,
'message' => 'Please enter a prompt before generating',
]);
return;
}
// Build list of context page IDs
$contextPageIds = [];
if ($page->use_whole_novel()->isTrue()) {
// All chapter pages
foreach ($cli->kirby()->site()->children()->filterBy('intendedTemplate', 'chapter') as $chapter) {
$contextPageIds[] = $chapter->id();
}
} else {
// Selected chapters
foreach ($page->selected_chapters()->toPages() as $chapter) {
$contextPageIds[] = $chapter->id();
}
}
// Selected codex entries
foreach ($page->selected_codex()->toPages() as $codexPage) {
$contextPageIds[] = $codexPage->id();
}
try {
$ai = new NovelCrafterAI();
$context = $ai->buildContext($contextPageIds);
$response = $ai->generateText($context, $prompt);
// Append to chat history
$chatHistory = $page->chat_history()->toStructure()->toArray();
$chatHistory[] = [
'prompt' => $prompt,
'response' => $response,
'timestamp' => date('Y-m-d H:i:s'),
];
$page->update([
'chat_history' => $chatHistory,
]);
janitor()->data($cli->arg('command'), [
'status' => 200,
'message' => 'Response saved to chat history',
'reload' => true,
]);
} catch (Exception $e) {
janitor()->data($cli->arg('command'), [
'status' => 500,
'message' => $e->getMessage(),
]);
}
},
];
......@@ -27,6 +27,19 @@ return [
//'css' => 'assets/css/custom-panel.css'
],
/***
*
* NovelCrafter AI
*
*/
'novelcrafter.ai' => [
'openRouterApiKey' => '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',
'defaultModel' => 'anthropic/claude-3-haiku:beta',
'maxTokens' => 2000,
// Override the system prompt here if you want different behaviour
// 'systemPrompt' => 'You are a literary author ...',
],
/***
*
......
<?php
use Kirby\Cms\Page;
use Kirby\Data\Yaml;
require_once __DIR__ . '/helpers.php';
class ChapterPage extends Page
{
public function update(array|null $input = null, string|null $languageCode = null, bool $validate = false): static
{
if (isset($input['sections'])) {
$sections = $input['sections'];
if (is_string($sections)) {
$sections = Yaml::decode($sections);
}
if (is_array($sections)) {
$textContent = '';
foreach ($sections as $blockData) {
$type = $blockData['type'] ?? '';
switch ($type) {
case 'prose':
$textContent .= writerToPlainText($blockData['content']['text'] ?? '') . "\n\n";
break;
case 'pull':
$textContent .= writerToPlainText($blockData['content']['text'] ?? '') . "\n\n";
break;
case 'note':
$textContent .= writerToPlainText($blockData['content']['text'] ?? '') . "\n\n";
break;
case 'texture':
$textContent .= ($blockData['content']['caption'] ?? '') . "\n\n";
break;
case 'pinned-text':
$textContent .= writerToPlainText($blockData['content']['heading'] ?? '') . "\n\n";
$textContent .= writerToPlainText($blockData['content']['body'] ?? '') . "\n\n";
break;
case 'pinned-image':
$textContent .= writerToPlainText($blockData['content']['text'] ?? '') . "\n\n";
break;
}
}
}
$input['text_content'] = trim($textContent);
}
return parent::update($input, $languageCode, $validate);
}
}
<?php
use Kirby\Cms\Page;
require_once __DIR__ . '/helpers.php';
class CharacterPage extends Page
{
public function update(array|null $input = null, string|null $languageCode = null, bool $validate = false): static
{
$textContent = '';
if (!empty($input['description'])) {
$textContent .= writerToPlainText($input['description']) . "\n\n";
}
if (!empty($input['traits'])) {
$textContent .= 'Traits: ' . writerToPlainText($input['traits']) . "\n\n";
}
$input['text_content'] = trim($textContent);
return parent::update($input, $languageCode, $validate);
}
}
<?php
/**
* Strip HTML from a writer/rich-text field while preserving readable whitespace.
* Block-level elements (p, li, br, headings, hr, div) are replaced with newlines
* before the tags are removed, so words never run together.
*/
function writerToPlainText(string $html): string
{
// Replace block-level closing/self-closing tags with a newline
$text = preg_replace(
'/<\\/?(p|li|br|h[1-6]|hr|div|blockquote|tr)[^>]*>/i',
"\n",
$html
);
// Drop all remaining tags
$text = strip_tags($text);
// Decode HTML entities (e.g. &amp; → &)
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Collapse runs of blank lines to a single blank line, trim edges
$text = preg_replace("/\n{3,}/", "\n\n", $text);
return trim($text);
}
<?php
use Kirby\Cms\Page;
require_once __DIR__ . '/helpers.php';
class LocationPage extends Page
{
public function update(array|null $input = null, string|null $languageCode = null, bool $validate = false): static
{
$textContent = '';
if (!empty($input['description'])) {
$textContent .= writerToPlainText($input['description']) . "\n\n";
}
if (!empty($input['history'])) {
$textContent .= writerToPlainText($input['history']) . "\n\n";
}
if (!empty($input['significance'])) {
$textContent .= writerToPlainText($input['significance']) . "\n\n";
}
$input['text_content'] = trim($textContent);
return parent::update($input, $languageCode, $validate);
}
}
<?php
Kirby::plugin('novelcrafter/ai', [
'options' => [
'openRouterApiKey' => null,
'defaultModel' => 'anthropic/claude-3-haiku:beta',
'maxTokens' => 2000,
'systemPrompt' => 'You are a literary author. Output only the requested prose — no preamble, no commentary, no titles, no meta-sentences like "here is your story". Begin writing immediately and stop when the content is complete.',
],
]);
class NovelCrafterAI
{
private string $apiKey;
private string $model;
public function __construct()
{
$this->apiKey = option('novelcrafter.ai.openRouterApiKey') ?? '';
$this->model = option('novelcrafter.ai.defaultModel', 'anthropic/claude-3-haiku:beta');
if (empty($this->apiKey)) {
throw new Exception('OpenRouter API key not configured. Add OPENROUTER_API_KEY to your .env file.');
}
}
public function buildContext(array $pageIds): string
{
$context = '';
foreach ($pageIds as $pageId) {
$page = kirby()->page($pageId);
if (!$page) continue;
$textContent = $page->text_content()->value();
if (empty($textContent)) continue;
$context .= '## ' . $page->title()->value() . "\n\n";
$context .= $textContent . "\n\n";
}
return trim($context);
}
public function generateText(string $context, string $prompt): string
{
$systemPrompt = option('novelcrafter.ai.systemPrompt', '');
$systemContent = $systemPrompt;
if (!empty($context)) {
$systemContent .= "\n\nUse the following story context to inform your writing:\n\n" . $context;
}
$messages = [
[
'role' => 'system',
'content' => $systemContent,
],
[
'role' => 'user',
'content' => $prompt,
],
];
$payload = [
'model' => $this->model,
'messages' => $messages,
'max_tokens' => option('novelcrafter.ai.maxTokens', 2000),
'temperature' => 0.7,
];
$ch = curl_init('https://openrouter.ai/api/v1/chat/completions');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json',
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$error = json_decode($response, true);
$msg = $error['error']['message'] ?? $response;
throw new Exception('OpenRouter error (' . $httpCode . '): ' . $msg);
}
$result = json_decode($response, true);
if (!isset($result['choices'][0]['message']['content'])) {
throw new Exception('Unexpected API response format.');
}
return trim($result['choices'][0]['message']['content']);
}
}
<?php snippet('html-head', [
'pageTitle' => $page->title()->value() . ' — Codex — ' . $site->title()->value(),
]) ?>
<body data-screen-label="Character">
<?php snippet('effects') ?>
<?php snippet('header') ?>
<section class="character-section">
<div class="character-header">
<h1 class="reveal"><?= $page->title()->html() ?></h1>
</div>
<div class="character-content">
<div class="character-description">
<h2>Description</h2>
<?= $page->description()->kt() ?>
</div>
<?php if ($page->traits()->isNotEmpty()): ?>
<div class="character-traits">
<h2>Traits</h2>
<ul>
<?php foreach ($page->traits()->split() as $trait): ?>
<li><?= $trait ?></li>
<?php endforeach ?>
</ul>
</div>
<?php endif ?>
<?php if ($page->relationships()->isNotEmpty()): ?>
<div class="character-relationships">
<h2>Relationships</h2>
<ul>
<?php foreach ($page->relationships()->toPages() as $rel): ?>
<li><a href="<?= $rel->url() ?>"><?= $rel->title()->html() ?></a></li>
<?php endforeach ?>
</ul>
</div>
<?php endif ?>
</div>
</section>
<?php snippet('menu', ['currentPage' => $page]) ?>
<?php snippet('scripts') ?>
</body>
</html>
\ No newline at end of file
<?php snippet('html-head', [
'pageTitle' => $page->title()->value() . ' — ' . $site->title()->value(),
]) ?>
<body data-screen-label="Codex">
<?php snippet('effects') ?>
<?php snippet('header') ?>
<section class="codex-section">
<div class="codex-header">
<h1 class="reveal"><?= $page->title()->html() ?></h1>
<div class="reveal text"><?= $page->description() ?></div>
</div>
<div class="codex-content">
<?php if ($characters = $page->find('characters')): ?>
<div class="codex-category">
<h2>Characters</h2>
<ul class="codex-list">
<?php foreach ($characters->children() as $character): ?>
<li>
<a href="<?= $character->url() ?>"><?= $character->title()->html() ?></a>
</li>
<?php endforeach ?>
</ul>
</div>
<?php endif ?>
<?php if ($locations = $page->find('locations')): ?>
<div class="codex-category">
<h2>Locations</h2>
<ul class="codex-list">
<?php foreach ($locations->children() as $location): ?>
<li>
<a href="<?= $location->url() ?>"><?= $location->title()->html() ?></a>
</li>
<?php endforeach ?>
</ul>
</div>
<?php endif ?>
</div>
</section>
<?php snippet('menu', ['currentPage' => $page]) ?>
<?php snippet('scripts') ?>
</body>
</html>
\ No newline at end of file
<?php snippet('html-head', [
'pageTitle' => $page->title()->value() . ' — Codex — ' . $site->title()->value(),
]) ?>
<body data-screen-label="Location">
<?php snippet('effects') ?>
<?php snippet('header') ?>
<section class="location-section">
<div class="location-header">
<h1 class="reveal"><?= $page->title()->html() ?></h1>
</div>
<div class="location-content">
<div class="location-description">
<h2>Description</h2>
<?= $page->description()->kt() ?>
</div>
<?php if ($page->history()->isNotEmpty()): ?>
<div class="location-history">
<h2>History</h2>
<?= $page->history()->kt() ?>
</div>
<?php endif ?>
<?php if ($page->significance()->isNotEmpty()): ?>
<div class="location-significance">
<h2>Significance</h2>
<?= $page->significance()->kt() ?>
</div>
<?php endif ?>
</div>
</section>
<?php snippet('menu', ['currentPage' => $page]) ?>
<?php snippet('scripts') ?>
</body>
</html>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment