Simplify character dossier fields, add ShowPronouns setting
CI / build-and-push (push) Successful in 51s
CI / deploy (push) Successful in 9s

Collapse Want/Need into Motivation and Internal/External Conflict into
Conflict, drop Arc summary field, and move tags below open questions on
the character page. Gate Pronouns display behind new UiSettings:ShowPronouns
config (default off).
This commit is contained in:
James Wampler
2026-08-19 15:33:14 -07:00
parent bbd5e66777
commit 45afc980d3
18 changed files with 1425 additions and 147 deletions
+6 -15
View File
@@ -146,11 +146,8 @@ public class NovelAgentToolset(
JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"),
JsonInput.String(input, "need"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "motivation"),
JsonInput.String(input, "conflict"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags"),
@@ -177,11 +174,8 @@ public class NovelAgentToolset(
JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"),
JsonInput.String(input, "need"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "motivation"),
JsonInput.String(input, "conflict"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags"),
@@ -667,11 +661,8 @@ public class NovelAgentToolset(
.Str("appearance", "How they look.")
.Str("personality", "Temperament, habits, how they treat people.")
.Str("backstory", "History that shapes who they are now.")
.Str("want", "What they consciously pursue.")
.Str("need", "What they actually need, usually at odds with what they want.")
.Str("internal_conflict", "The war inside them.")
.Str("external_conflict", "What in the world opposes them.")
.Str("arc_summary", "How they change over the course of the book.")
.Str("motivation", "What they consciously pursue, weighed against what they actually need.")
.Str("conflict", "The war inside them and what in the world opposes them.")
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
.Str("notes", "Anything else worth recording.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
+2 -7
View File
@@ -26,14 +26,9 @@ public class Character
public string? Personality { get; set; }
public string? Backstory { get; set; }
public string? Want { get; set; }
public string? Motivation { get; set; }
public string? Need { get; set; }
public string? InternalConflict { get; set; }
public string? ExternalConflict { get; set; }
public string? ArcSummary { get; set; }
public string? Conflict { get; set; }
public string? Voice { get; set; }
@@ -16,11 +16,8 @@ public record CharacterResponse(
string? Appearance,
string? Personality,
string? Backstory,
string? Want,
string? Need,
string? InternalConflict,
string? ExternalConflict,
string? ArcSummary,
string? Motivation,
string? Conflict,
string? Voice,
string? Notes,
IReadOnlyList<string> Aliases,
@@ -54,11 +51,8 @@ public record CreateCharacterRequest(
string? Appearance = null,
string? Personality = null,
string? Backstory = null,
string? Want = null,
string? Need = null,
string? InternalConflict = null,
string? ExternalConflict = null,
string? ArcSummary = null,
string? Motivation = null,
string? Conflict = null,
string? Voice = null,
string? Notes = null,
IReadOnlyList<string>? Tags = null,
@@ -73,7 +67,7 @@ public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRe
result.AddRequiredTextErrors("Name", "Name", model.Name, 200);
CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
model.Motivation, model.Conflict, model.Voice,
model.Notes, model.Tags, model.Aliases, result);
return result;
@@ -90,11 +84,8 @@ public record UpdateCharacterRequest(
string? Appearance = null,
string? Personality = null,
string? Backstory = null,
string? Want = null,
string? Need = null,
string? InternalConflict = null,
string? ExternalConflict = null,
string? ArcSummary = null,
string? Motivation = null,
string? Conflict = null,
string? Voice = null,
string? Notes = null,
IReadOnlyList<string>? Tags = null,
@@ -109,7 +100,7 @@ public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRe
result.AddUnclearableTextErrors("Name", "Name", model.Name, "a character", 200);
CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
model.Motivation, model.Conflict, model.Voice,
model.Notes, model.Tags, model.Aliases, result);
return result;
@@ -120,7 +111,7 @@ file static class CharacterValidation
{
public static void OptionalFields(
string? age, string? pronouns, string? occupation, string? appearance, string? personality, string? backstory,
string? want, string? need, string? internalConflict, string? externalConflict, string? arcSummary, string? voice,
string? motivation, string? conflict, string? voice,
string? notes, IReadOnlyList<string>? tags, IReadOnlyList<string>? aliases, ValidationResult result)
{
Cap(age, "Age", 100, result);
@@ -129,11 +120,8 @@ file static class CharacterValidation
Cap(appearance, "Appearance", 20000, result);
Cap(personality, "Personality", 20000, result);
Cap(backstory, "Backstory", 20000, result);
Cap(want, "Want", 2000, result);
Cap(need, "Need", 2000, result);
Cap(internalConflict, "InternalConflict", 2000, result);
Cap(externalConflict, "ExternalConflict", 2000, result);
Cap(arcSummary, "ArcSummary", 20000, result);
Cap(motivation, "Motivation", 2000, result);
Cap(conflict, "Conflict", 2000, result);
Cap(voice, "Voice", 2000, result);
Cap(notes, "Notes", 20000, result);
@@ -300,8 +288,7 @@ public static class CharacterMapping
{
public static CharacterResponse ToResponse(this Character c) => new(
c.Id, c.NovelId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes,
[.. c.Aliases],
c.SameCharacterAsId,
c.SameCharacterAs?.Name,
+4 -10
View File
@@ -84,11 +84,8 @@ public class CharacterService(
Appearance = request.Appearance,
Personality = request.Personality,
Backstory = request.Backstory,
Want = request.Want,
Need = request.Need,
InternalConflict = request.InternalConflict,
ExternalConflict = request.ExternalConflict,
ArcSummary = request.ArcSummary,
Motivation = request.Motivation,
Conflict = request.Conflict,
Voice = request.Voice,
Notes = request.Notes
};
@@ -134,11 +131,8 @@ public class CharacterService(
character.Appearance = Patch.Apply(character.Appearance, request.Appearance);
character.Personality = Patch.Apply(character.Personality, request.Personality);
character.Backstory = Patch.Apply(character.Backstory, request.Backstory);
character.Want = Patch.Apply(character.Want, request.Want);
character.Need = Patch.Apply(character.Need, request.Need);
character.InternalConflict = Patch.Apply(character.InternalConflict, request.InternalConflict);
character.ExternalConflict = Patch.Apply(character.ExternalConflict, request.ExternalConflict);
character.ArcSummary = Patch.Apply(character.ArcSummary, request.ArcSummary);
character.Motivation = Patch.Apply(character.Motivation, request.Motivation);
character.Conflict = Patch.Apply(character.Conflict, request.Conflict);
character.Voice = Patch.Apply(character.Voice, request.Voice);
character.Notes = Patch.Apply(character.Notes, request.Notes);
character.UpdatedAt = DateTimeOffset.UtcNow;
@@ -26,6 +26,8 @@ public static class NovellyServiceRegistration
{
public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<UiSettingsOptions>(configuration.GetSection(UiSettingsOptions.SectionName));
var connectionString = configuration.GetConnectionString("Novel")
?? "Data Source=novel.db";
@@ -0,0 +1,18 @@
using Microsoft.Extensions.Options;
namespace Novelly.Api.Common;
public static class UiSettingsEndpoints
{
public static IEndpointRouteBuilder MapUiSettingsEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/ui-settings", (IOptions<UiSettingsOptions> options) =>
Results.Ok(new UiSettingsResponse(options.Value.ShowPronouns)))
.WithTags("UiSettings")
.AllowAnonymous();
return app;
}
}
public record UiSettingsResponse(bool ShowPronouns);
@@ -0,0 +1,8 @@
namespace Novelly.Api.Common;
public class UiSettingsOptions
{
public const string SectionName = "UiSettings";
public bool ShowPronouns { get; set; }
}
@@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class CollapseCharacterMotivationAndConflict : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE Characters SET Want = CASE
WHEN Want IS NULL OR Want = '' THEN Need
WHEN Need IS NULL OR Need = '' THEN Want
ELSE Want || char(10) || char(10) || Need
END;
""");
migrationBuilder.Sql(
"""
UPDATE Characters SET InternalConflict = CASE
WHEN InternalConflict IS NULL OR InternalConflict = '' THEN ExternalConflict
WHEN ExternalConflict IS NULL OR ExternalConflict = '' THEN InternalConflict
ELSE InternalConflict || char(10) || char(10) || ExternalConflict
END;
""");
migrationBuilder.DropColumn(
name: "ArcSummary",
table: "Characters");
migrationBuilder.DropColumn(
name: "ExternalConflict",
table: "Characters");
migrationBuilder.DropColumn(
name: "Need",
table: "Characters");
migrationBuilder.RenameColumn(
name: "Want",
table: "Characters",
newName: "Motivation");
migrationBuilder.RenameColumn(
name: "InternalConflict",
table: "Characters",
newName: "Conflict");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Motivation",
table: "Characters",
newName: "Want");
migrationBuilder.RenameColumn(
name: "Conflict",
table: "Characters",
newName: "InternalConflict");
migrationBuilder.AddColumn<string>(
name: "ArcSummary",
table: "Characters",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ExternalConflict",
table: "Characters",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Need",
table: "Characters",
type: "TEXT",
nullable: true);
}
}
}
@@ -333,18 +333,15 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Appearance")
.HasColumnType("TEXT");
b.Property<string>("ArcSummary")
b.Property<string>("Backstory")
.HasColumnType("TEXT");
b.Property<string>("Backstory")
b.Property<string>("Conflict")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("ExternalConflict")
.HasColumnType("TEXT");
b.Property<string>("IdentityNote")
.HasColumnType("TEXT");
@@ -353,7 +350,7 @@ namespace Novelly.Api.Data.Migrations
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("InternalConflict")
b.Property<string>("Motivation")
.HasColumnType("TEXT");
b.Property<string>("Name")
@@ -361,9 +358,6 @@ namespace Novelly.Api.Data.Migrations
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Need")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
@@ -396,9 +390,6 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Voice")
.HasColumnType("TEXT");
b.Property<string>("Want")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NovelId");
@@ -241,7 +241,7 @@ public class ImportAgentToolset(
Occupation: JsonInput.String(input, "occupation"),
Appearance: JsonInput.String(input, "appearance"),
Backstory: JsonInput.String(input, "backstory"),
Want: JsonInput.String(input, "want"),
Motivation: JsonInput.String(input, "motivation"),
Notes: JsonInput.String(input, "notes")), ct);
return created is null
@@ -264,7 +264,7 @@ public class ImportAgentToolset(
Occupation: JsonInput.String(input, "occupation"),
Appearance: JsonInput.String(input, "appearance"),
Backstory: JsonInput.String(input, "backstory"),
Want: JsonInput.String(input, "want"),
Motivation: JsonInput.String(input, "motivation"),
Notes: JsonInput.String(input, "notes")), ct);
return updated is null
@@ -379,6 +379,6 @@ public class ImportAgentToolset(
.Str("occupation", "The italic tagline under the heading.")
.Str("appearance", "The ## Appearance section.")
.Str("backstory", "The ## Background section.")
.Str("want", "The ## Motivation section.")
.Str("motivation", "The ## Motivation section.")
.Str("notes", "The ## Notes section, if present.");
}
+1
View File
@@ -105,6 +105,7 @@ if (app.Environment.IsDevelopment())
app.MapDefaultEndpoints();
app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Health").AllowAnonymous();
app.MapUiSettingsEndpoints();
app.MapUserEndpoints();
app.MapNovelMemberEndpoints();
+4 -1
View File
@@ -28,9 +28,12 @@
"Origins": [ "http://localhost:5173" ]
},
"Agent": {
"Model": "claude-opus-5",
"Model": "claude-sonnet-5",
"MaxTokens": 16000,
"Effort": "high",
"MaxIterations": 12
},
"UiSettings": {
"ShowPronouns": false
}
}
+8 -20
View File
@@ -41,11 +41,8 @@ public static class CharacterTools
[Description("How they look.")] string? appearance = null,
[Description("Temperament, habits, how they treat people.")] string? personality = null,
[Description("History that shapes who they are now.")] string? backstory = null,
[Description("What they consciously pursue.")] string? want = null,
[Description("What they actually need, usually at odds with what they want.")] string? need = null,
[Description("The war inside them.")] string? internalConflict = null,
[Description("What in the world opposes them.")] string? externalConflict = null,
[Description("How they change over the course of the book.")] string? arcSummary = null,
[Description("What they consciously pursue, weighed against what they actually need.")] string? motivation = null,
[Description("The war inside them and what in the world opposes them.")] string? conflict = null,
[Description("Speech patterns and register that make their dialogue theirs.")] string? voice = null,
[Description("Anything else worth recording.")] string? notes = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null,
@@ -61,11 +58,8 @@ public static class CharacterTools
appearance,
personality,
backstory,
want,
need,
internalConflict,
externalConflict,
arcSummary,
motivation,
conflict,
voice,
notes,
tags,
@@ -89,11 +83,8 @@ public static class CharacterTools
[Description("How they look.")] string? appearance = null,
[Description("Temperament, habits, how they treat people.")] string? personality = null,
[Description("History that shapes who they are now.")] string? backstory = null,
[Description("What they consciously pursue.")] string? want = null,
[Description("What they actually need.")] string? need = null,
[Description("The war inside them.")] string? internalConflict = null,
[Description("What in the world opposes them.")] string? externalConflict = null,
[Description("How they change over the course of the book.")] string? arcSummary = null,
[Description("What they consciously pursue, weighed against what they actually need.")] string? motivation = null,
[Description("The war inside them and what in the world opposes them.")] string? conflict = null,
[Description("Speech patterns and register.")] string? voice = null,
[Description("Anything else worth recording.")] string? notes = null,
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null,
@@ -109,11 +100,8 @@ public static class CharacterTools
appearance,
personality,
backstory,
want,
need,
internalConflict,
externalConflict,
arcSummary,
motivation,
conflict,
voice,
notes,
tags,
+5
View File
@@ -23,11 +23,13 @@ import type {
NovelSummary,
TagReferences,
TagSummary,
UiSettings,
User,
} from './types'
export const keys = {
me: ['me'] as const,
uiSettings: ['ui-settings'] as const,
members: (novelId: string) => ['novels', novelId, 'members'] as const,
novels: ['novels'] as const,
genres: ['genres'] as const,
@@ -46,6 +48,9 @@ export const keys = {
importJob: (id: string) => ['imports', id] as const,
}
export const useUiSettings = () =>
useQuery({ queryKey: keys.uiSettings, queryFn: () => api.get<UiSettings>('/api/ui-settings') })
export const useMe = () =>
useQuery({
queryKey: keys.me,
+6 -5
View File
@@ -49,6 +49,10 @@ export interface User {
globalRole: GlobalRole
}
export interface UiSettings {
showPronouns: boolean
}
export interface NovelMember {
userId: string
email: string
@@ -197,11 +201,8 @@ export interface Character {
appearance: string | null
personality: string | null
backstory: string | null
want: string | null
need: string | null
internalConflict: string | null
externalConflict: string | null
arcSummary: string | null
motivation: string | null
conflict: string | null
voice: string | null
notes: string | null
aliases: string[]
@@ -9,6 +9,7 @@ import {
useNovel,
useRemoveRelationship,
useTags,
useUiSettings,
useUnlinkCharacterIdentity,
useUpdateCharacter,
} from '../api/hooks'
@@ -79,6 +80,7 @@ function CharacterSheet({
canDelete: boolean
}) {
const navigate = useNavigate()
const { data: uiSettings } = useUiSettings()
const { data: allTags } = useTags(novelId)
const { data: allCharacters } = useCharacters(novelId)
const { data: chapters } = useChapters(novelId)
@@ -125,12 +127,14 @@ function CharacterSheet({
<div className="grid gap-4 sm:grid-cols-3">
<AutoField label="Age" value={character.age} onCommit={(age) => patch({ age })} readOnly={!canWrite} />
{uiSettings?.showPronouns && (
<AutoField
label="Pronouns"
value={character.pronouns}
onCommit={(pronouns) => patch({ pronouns })}
readOnly={!canWrite}
/>
)}
<AutoField
label="Occupation"
value={character.occupation}
@@ -139,13 +143,7 @@ function CharacterSheet({
/>
</div>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<TagEditor
label="Tags"
tags={character.tags}
suggestions={allTags?.map((t) => t.name) ?? []}
onChange={(tags) => canWrite && patch({ tags })}
/>
<div className="mt-5">
<AliasEditor
label="Also known as"
aliases={character.aliases}
@@ -156,42 +154,20 @@ function CharacterSheet({
<div className="mt-6 grid gap-4 lg:grid-cols-2">
<AutoField
label="Wants"
value={character.want}
label="Motivation"
value={character.motivation}
multiline
rows={3}
placeholder="What they are consciously chasing."
onCommit={(want) => patch({ want })}
placeholder="What they consciously want, and what they need instead."
onCommit={(motivation) => patch({ motivation })}
readOnly={!canWrite}
/>
<AutoField
label="Needs"
value={character.need}
label="Conflict"
value={character.conflict}
multiline
rows={3}
placeholder="What the story will make them face instead."
onCommit={(need) => patch({ need })}
readOnly={!canWrite}
/>
<AutoField
label="Internal conflict"
value={character.internalConflict}
multiline
onCommit={(internalConflict) => patch({ internalConflict })}
readOnly={!canWrite}
/>
<AutoField
label="External conflict"
value={character.externalConflict}
multiline
onCommit={(externalConflict) => patch({ externalConflict })}
readOnly={!canWrite}
/>
<AutoField
label="Arc"
value={character.arcSummary}
multiline
onCommit={(arcSummary) => patch({ arcSummary })}
placeholder="The war inside them, and what in the world opposes them."
onCommit={(conflict) => patch({ conflict })}
readOnly={!canWrite}
/>
<AutoField
@@ -304,6 +280,15 @@ function CharacterSheet({
canDelete={canDelete}
/>
<section id="character-tags" className="card mt-6 p-5">
<TagEditor
label="Tags"
tags={character.tags}
suggestions={allTags?.map((t) => t.name) ?? []}
onChange={(tags) => canWrite && patch({ tags })}
/>
</section>
{confirmingDelete && (
<ConfirmModal
title="Delete character"
@@ -48,23 +48,23 @@ public class CharacterServiceTests : ServiceTestFixture
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest(
"Ines", Want: "To find her sister.", Need: "To let go of the guilt."));
"Ines", Motivation: "To find her sister.", Conflict: "Torn between vengeance and letting go."));
var renamed = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Name: "Ines Vell")))!;
Assert.Multiple(() =>
{
Assert.That(renamed.Name, Is.EqualTo("Ines Vell"));
Assert.That(renamed.Want, Is.EqualTo("To find her sister."));
Assert.That(renamed.Need, Is.EqualTo("To let go of the guilt."));
Assert.That(renamed.Motivation, Is.EqualTo("To find her sister."));
Assert.That(renamed.Conflict, Is.EqualTo("Torn between vengeance and letting go."));
});
var cleared = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Need: "")))!;
var cleared = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Conflict: "")))!;
Assert.Multiple(() =>
{
Assert.That(cleared.Need, Is.Null);
Assert.That(cleared.Want, Is.EqualTo("To find her sister."));
Assert.That(cleared.Conflict, Is.Null);
Assert.That(cleared.Motivation, Is.EqualTo("To find her sister."));
});
}