mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-04-28 03:40:11 +00:00
fix: redesign RuntimeVariableDisplay as vertical stat rows
- Replace fragmented chip layout with full-width vertical rows - Respect icon-only setting (show icon without displayName when icon is set) - Text variables now show full text at full width (no truncation) - Number+range progress bar is full width (not tiny w-16 inside chip) - Remove border-t divider for cleaner integration with entity panels - Include uncommitted runtime variable schema and store changes
This commit is contained in:
parent
87ea7e5fbc
commit
18298c8d1e
5 changed files with 260 additions and 315 deletions
|
|
@ -160,158 +160,94 @@
|
|||
</script>
|
||||
|
||||
{#if filtered.length > 0}
|
||||
<div class="border-border/50 mt-2 border-t pt-2">
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
{#if editMode && onValueChange}
|
||||
<!-- Edit mode: vertical list of inline editors -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each filtered as def (def.id)}
|
||||
{@const rawVal = getRawValue(def)}
|
||||
<RuntimeVariableEditor
|
||||
definition={def}
|
||||
currentValue={rawVal}
|
||||
onChange={(v) => onValueChange(def.id, v)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#each filtered as def (def.id)}
|
||||
{@const rawVal = getRawValue(def)}
|
||||
<RuntimeVariableEditor
|
||||
definition={def}
|
||||
currentValue={rawVal}
|
||||
onChange={(v) => onValueChange(def.id, v)}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Display mode: flowing chip layout -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each filtered as def (def.id)}
|
||||
{@const rawVal = getRawValue(def)}
|
||||
{@const isSet = rawVal != null}
|
||||
{@const Icon = getIconComponent(def.icon)}
|
||||
<!-- Display mode: vertical stat rows -->
|
||||
{#each filtered as def (def.id)}
|
||||
{@const rawVal = getRawValue(def)}
|
||||
{@const isSet = rawVal != null}
|
||||
{@const Icon = getIconComponent(def.icon)}
|
||||
|
||||
{#if def.variableType === 'number' && hasMinMax(def)}
|
||||
<!-- Number with range: chip with stat bar -->
|
||||
<div
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1"
|
||||
style="background-color: color-mix(in srgb, {def.color} {isSet
|
||||
? '12%'
|
||||
: '6%'}, transparent)"
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon
|
||||
class="h-3.5 w-3.5 shrink-0 {isSet ? '' : 'opacity-40'}"
|
||||
style="color: {def.color}"
|
||||
/>
|
||||
{/if}
|
||||
<div
|
||||
class="rounded-md px-2 py-1"
|
||||
style="background-color: color-mix(in srgb, {def.color} {isSet
|
||||
? '10%'
|
||||
: '5%'}, transparent)"
|
||||
>
|
||||
<!-- Label row: icon/name on left, value on right -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if Icon}
|
||||
<Icon
|
||||
class="h-3.5 w-3.5 shrink-0 {isSet ? '' : 'opacity-40'}"
|
||||
style="color: {def.color}"
|
||||
title={def.displayName}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="text-[10px] font-medium whitespace-nowrap {isSet ? '' : 'opacity-40'}"
|
||||
style="color: {def.color}"
|
||||
>
|
||||
{def.displayName}
|
||||
</span>
|
||||
{#if isSet && typeof rawVal === 'number'}
|
||||
<div class="bg-muted/50 relative h-3.5 w-16 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="h-full rounded-sm"
|
||||
style="width: {getProgressPercent(
|
||||
def,
|
||||
rawVal,
|
||||
)}%; background-color: {def.color}; opacity: 0.6"
|
||||
></div>
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center text-[9px] font-bold tabular-nums"
|
||||
style="color: {def.color}"
|
||||
>
|
||||
{rawVal}/{def.maxValue}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-[10px] italic opacity-40">--</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if def.variableType === 'number'}
|
||||
<!-- Number without range: chip with value -->
|
||||
<div
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1"
|
||||
style="background-color: color-mix(in srgb, {def.color} {isSet
|
||||
? '12%'
|
||||
: '6%'}, transparent)"
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon
|
||||
class="h-3.5 w-3.5 shrink-0 {isSet ? '' : 'opacity-40'}"
|
||||
style="color: {def.color}"
|
||||
/>
|
||||
{/if}
|
||||
<span
|
||||
class="text-[10px] font-medium whitespace-nowrap {isSet ? '' : 'opacity-40'}"
|
||||
class="text-[11px] font-medium whitespace-nowrap {isSet ? '' : 'opacity-40'}"
|
||||
style="color: {def.color}"
|
||||
>
|
||||
{def.displayName}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if def.variableType === 'number'}
|
||||
{#if isSet}
|
||||
<span class="text-xs font-bold tabular-nums" style="color: {def.color}"
|
||||
>{rawVal}</span
|
||||
>
|
||||
<span class="ml-auto text-xs font-bold tabular-nums" style="color: {def.color}">
|
||||
{rawVal}{#if hasMinMax(def)}<span class="opacity-50">/{def.maxValue}</span>{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[10px] italic opacity-40">--</span>
|
||||
<span class="ml-auto text-[10px] italic opacity-40">--</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if def.variableType === 'enum'}
|
||||
<!-- Enum: colored badge chip -->
|
||||
<div
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1"
|
||||
style="background-color: color-mix(in srgb, {def.color} {isSet
|
||||
? '12%'
|
||||
: '6%'}, transparent)"
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon
|
||||
class="h-3.5 w-3.5 shrink-0 {isSet ? '' : 'opacity-40'}"
|
||||
style="color: {def.color}"
|
||||
/>
|
||||
{/if}
|
||||
<span
|
||||
class="text-[10px] font-medium whitespace-nowrap {isSet ? '' : 'opacity-40'}"
|
||||
style="color: {def.color}"
|
||||
>
|
||||
{def.displayName}
|
||||
</span>
|
||||
{:else if def.variableType === 'enum'}
|
||||
{#if isSet}
|
||||
<span class="text-[10px] font-bold" style="color: {def.color}">
|
||||
<span class="ml-auto text-[11px] font-semibold" style="color: {def.color}">
|
||||
{getEnumLabel(def, rawVal)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[10px] italic opacity-40">--</span>
|
||||
<span class="ml-auto text-[10px] italic opacity-40">--</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Text: colored text chip -->
|
||||
<div
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-2 py-1"
|
||||
style="background-color: color-mix(in srgb, {def.color} {isSet
|
||||
? '12%'
|
||||
: '6%'}, transparent)"
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon
|
||||
class="h-3.5 w-3.5 shrink-0 {isSet ? '' : 'opacity-40'}"
|
||||
style="color: {def.color}"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Text: "not set" indicator on right when empty -->
|
||||
{#if !isSet}
|
||||
<span class="ml-auto text-[10px] italic opacity-40">--</span>
|
||||
{/if}
|
||||
<span
|
||||
class="text-[10px] font-medium whitespace-nowrap {isSet ? '' : 'opacity-40'}"
|
||||
style="color: {def.color}"
|
||||
>
|
||||
{def.displayName}
|
||||
</span>
|
||||
{#if isSet}
|
||||
<span
|
||||
class="max-w-[100px] truncate text-[10px] font-medium"
|
||||
style="color: {def.color}"
|
||||
title={String(rawVal)}
|
||||
>
|
||||
{rawVal}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-[10px] italic opacity-40">--</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Full-width progress bar for number with range -->
|
||||
{#if def.variableType === 'number' && hasMinMax(def)}
|
||||
<div class="bg-muted/50 mt-1 h-1.5 w-full overflow-hidden rounded-full">
|
||||
{#if isSet && typeof rawVal === 'number'}
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
style="width: {getProgressPercent(
|
||||
def,
|
||||
rawVal,
|
||||
)}%; background-color: {def.color}; opacity: 0.7"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Full text value below label -->
|
||||
{#if def.variableType === 'text' && isSet}
|
||||
<p class="mt-0.5 text-[11px] leading-snug" style="color: {def.color}; opacity: 0.8">
|
||||
{rawVal}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -103,12 +103,12 @@
|
|||
Gauge,
|
||||
}
|
||||
|
||||
let icon = $derived(definition.icon ? (ICON_MAP[definition.icon] ?? null) : null)
|
||||
let Icon = $derived(definition.icon ? (ICON_MAP[definition.icon] ?? null) : null)
|
||||
|
||||
// Local state for text/number inputs
|
||||
let textValue = $state(currentValue != null ? String(currentValue) : '')
|
||||
let numValue = $state(currentValue != null ? Number(currentValue) : 0)
|
||||
let enumValue = $state(currentValue != null ? String(currentValue) : '')
|
||||
// Local state for text/number inputs (synced from props via $effect below)
|
||||
let textValue = $state('')
|
||||
let numValue = $state(0)
|
||||
let enumValue = $state('')
|
||||
|
||||
// Sync external changes
|
||||
$effect(() => {
|
||||
|
|
@ -151,20 +151,15 @@
|
|||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- Icon or label -->
|
||||
<div class="flex w-5 shrink-0 items-center justify-center" title={definition.displayName}>
|
||||
{#if icon}
|
||||
<svelte:component this={icon} class="h-3.5 w-3.5" style="color: {definition.color}" />
|
||||
{:else}
|
||||
<span class="text-[10px] font-medium" style="color: {definition.color}">
|
||||
{definition.displayName.slice(0, 2)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Label -->
|
||||
<span class="text-muted-foreground max-w-[80px] min-w-0 shrink-0 truncate text-xs">
|
||||
{definition.displayName}
|
||||
</span>
|
||||
{#if Icon}
|
||||
<div class="flex w-5 shrink-0 items-center justify-center" title={definition.displayName}>
|
||||
<Icon class="h-3.5 w-3.5" style="color: {definition.color}" />
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-muted-foreground max-w-[80px] min-w-0 shrink-0 truncate text-xs">
|
||||
{definition.displayName}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Editor -->
|
||||
<div class="min-w-0 flex-1">
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export class ClassifierService {
|
|||
/**
|
||||
* Classify a narrative response to extract world state changes.
|
||||
* When the story's pack defines runtime variables, the schema is dynamically
|
||||
* extended to include customVars extraction in the same LLM pass.
|
||||
* extended to include inline runtime variable extraction in the same LLM pass.
|
||||
*/
|
||||
async classify(
|
||||
context: ClassificationContext,
|
||||
|
|
@ -87,7 +87,7 @@ export class ClassifierService {
|
|||
runtimeVarsByType = this.groupByEntityType(runtimeVars)
|
||||
}
|
||||
|
||||
// Build the schema: extended with customVars if runtime variables exist, else base
|
||||
// Build the schema: extended with inline vars if runtime variables exist, else base
|
||||
const schema =
|
||||
runtimeVars.length > 0
|
||||
? buildExtendedClassificationSchema(runtimeVarsByType)
|
||||
|
|
@ -268,7 +268,7 @@ export class ClassifierService {
|
|||
})
|
||||
|
||||
sections.push(
|
||||
`For ${labels.updates}/${labels.new}, also extract these custom variables in a \`customVars\` object:\n${varLines.join('\n')}`,
|
||||
`For ${labels.updates}/${labels.new}, include these as direct fields alongside standard fields:\n${varLines.join('\n')}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -279,7 +279,7 @@ export class ClassifierService {
|
|||
|
||||
/**
|
||||
* Post-process: clamp number-type runtime variable values to min/max constraints.
|
||||
* Walks through all entity updates/new entities and clamps customVars number values.
|
||||
* Walks through all entity updates/new entities and clamps inline number values.
|
||||
*/
|
||||
private clampRuntimeVarNumbers(
|
||||
result: ExtendedClassificationResult,
|
||||
|
|
@ -296,41 +296,40 @@ export class ClassifierService {
|
|||
|
||||
if (numberDefs.size === 0) return
|
||||
|
||||
// Clamp values in update changes.customVars
|
||||
const clampCustomVars = (customVars: Record<string, unknown> | undefined) => {
|
||||
if (!customVars) return
|
||||
for (const [key, value] of Object.entries(customVars)) {
|
||||
// Clamp inline number values on an object
|
||||
const clampInlineVars = (obj: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const def = numberDefs.get(key)
|
||||
if (def && typeof value === 'number') {
|
||||
customVars[key] = clampNumber(value, def.minValue, def.maxValue)
|
||||
obj[key] = clampNumber(value, def.minValue, def.maxValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walk all entity types
|
||||
// Walk all entity types — vars are inline on changes/entity objects
|
||||
for (const update of result.entryUpdates.characterUpdates) {
|
||||
clampCustomVars(update.changes?.customVars)
|
||||
clampInlineVars(update.changes as unknown as Record<string, unknown>)
|
||||
}
|
||||
for (const entity of result.entryUpdates.newCharacters) {
|
||||
clampCustomVars(entity.customVars)
|
||||
clampInlineVars(entity as unknown as Record<string, unknown>)
|
||||
}
|
||||
for (const update of result.entryUpdates.locationUpdates) {
|
||||
clampCustomVars(update.changes?.customVars)
|
||||
clampInlineVars(update.changes as unknown as Record<string, unknown>)
|
||||
}
|
||||
for (const entity of result.entryUpdates.newLocations) {
|
||||
clampCustomVars(entity.customVars)
|
||||
clampInlineVars(entity as unknown as Record<string, unknown>)
|
||||
}
|
||||
for (const update of result.entryUpdates.itemUpdates) {
|
||||
clampCustomVars(update.changes?.customVars)
|
||||
clampInlineVars(update.changes as unknown as Record<string, unknown>)
|
||||
}
|
||||
for (const entity of result.entryUpdates.newItems) {
|
||||
clampCustomVars(entity.customVars)
|
||||
clampInlineVars(entity as unknown as Record<string, unknown>)
|
||||
}
|
||||
for (const update of result.entryUpdates.storyBeatUpdates) {
|
||||
clampCustomVars(update.changes?.customVars)
|
||||
clampInlineVars(update.changes as unknown as Record<string, unknown>)
|
||||
}
|
||||
for (const entity of result.entryUpdates.newStoryBeats) {
|
||||
clampCustomVars(entity.customVars)
|
||||
clampInlineVars(entity as unknown as Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
* custom variable extraction when a story's pack defines runtime variables.
|
||||
*
|
||||
* Key design decisions:
|
||||
* - Runtime vars are added as INLINE fields (not nested under `customVars`)
|
||||
* - Single-element enums use z.literal() directly (z.union crashes with <2 items)
|
||||
* - Number min/max constraints are NOT enforced in the schema; we clamp after extraction
|
||||
* - Number min/max are included in .describe() for LLM guidance; clamped post-extraction
|
||||
* - Variables with defaultValue are marked .optional() in the schema
|
||||
* - The LLM sees variableName as the key, but stored values are keyed by defId
|
||||
*/
|
||||
|
|
@ -28,84 +29,103 @@ import {
|
|||
type ClassificationResult,
|
||||
} from './classifier'
|
||||
|
||||
// ============================================================================
|
||||
// Variable Description Builder
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build a description string for a runtime variable, including min/max range for numbers.
|
||||
* This becomes the `// comment` in the schema the LLM sees.
|
||||
*/
|
||||
function buildVariableDescription(def: RuntimeVariable): string {
|
||||
const desc = def.description || def.displayName
|
||||
|
||||
if (def.variableType === 'number') {
|
||||
if (def.minValue !== undefined && def.maxValue !== undefined) {
|
||||
return `${desc} (range: ${def.minValue}-${def.maxValue})`
|
||||
} else if (def.minValue !== undefined) {
|
||||
return `${desc} (min: ${def.minValue})`
|
||||
} else if (def.maxValue !== undefined) {
|
||||
return `${desc} (max: ${def.maxValue})`
|
||||
}
|
||||
}
|
||||
|
||||
return desc
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Single Variable Schema Builder
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build a Zod schema for a single runtime variable definition.
|
||||
*
|
||||
* - text: z.string()
|
||||
* - number: z.number() (no min/max -- clamped post-extraction)
|
||||
* - enum: z.literal() for 1 option, z.union() for 2+
|
||||
*
|
||||
* Variables with a defaultValue are marked .optional().
|
||||
* Build the base (non-optional) Zod schema for a runtime variable.
|
||||
* Used internally; call buildVariableSchema() for the version with optionality.
|
||||
*/
|
||||
export function buildVariableSchema(def: RuntimeVariable): z.ZodTypeAny {
|
||||
const desc = def.description || def.displayName
|
||||
const hasDefault = def.defaultValue !== undefined && def.defaultValue !== null
|
||||
function buildVariableBaseSchema(def: RuntimeVariable): z.ZodTypeAny {
|
||||
const desc = buildVariableDescription(def)
|
||||
|
||||
switch (def.variableType) {
|
||||
case 'text': {
|
||||
const base = z.string().describe(desc)
|
||||
return hasDefault ? base.optional() : base
|
||||
}
|
||||
case 'text':
|
||||
return z.string().describe(desc)
|
||||
|
||||
case 'number': {
|
||||
const base = z.number().describe(desc)
|
||||
return hasDefault ? base.optional() : base
|
||||
}
|
||||
case 'number':
|
||||
return z.number().describe(desc)
|
||||
|
||||
case 'enum': {
|
||||
const options = def.enumOptions ?? []
|
||||
if (options.length === 0) {
|
||||
// No options defined -- fall back to string
|
||||
const base = z.string().describe(desc)
|
||||
return hasDefault ? base.optional() : base
|
||||
}
|
||||
if (options.length === 0) return z.string().describe(desc)
|
||||
if (options.length === 1) return z.literal(options[0].value).describe(desc)
|
||||
|
||||
if (options.length === 1) {
|
||||
// Single-element: use z.literal directly (z.union requires >= 2)
|
||||
const base = z.literal(options[0].value).describe(desc)
|
||||
return hasDefault ? base.optional() : base
|
||||
}
|
||||
|
||||
// 2+ options: z.union of literals
|
||||
const literals = options.map((opt) => z.literal(opt.value)) as [
|
||||
z.ZodLiteral<string>,
|
||||
z.ZodLiteral<string>,
|
||||
...z.ZodLiteral<string>[],
|
||||
]
|
||||
const base = z.union(literals).describe(desc)
|
||||
return hasDefault ? base.optional() : base
|
||||
return z.union(literals).describe(desc)
|
||||
}
|
||||
|
||||
default:
|
||||
// Fallback for unknown type
|
||||
return z.string().describe(desc).optional()
|
||||
return z.string().describe(desc)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Zod schema for a single runtime variable definition.
|
||||
* Variables with a defaultValue are marked .optional().
|
||||
*/
|
||||
export function buildVariableSchema(def: RuntimeVariable): z.ZodTypeAny {
|
||||
const base = buildVariableBaseSchema(def)
|
||||
const hasDefault = def.defaultValue !== undefined && def.defaultValue !== null
|
||||
return hasDefault ? base.optional() : base
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Entity Custom Vars Schema Builder
|
||||
// Entity Variable Shape Builder
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build a z.object schema for a set of runtime variables (already filtered to one entity type).
|
||||
* Each field key is the variable's variableName, value is the built schema.
|
||||
* Returns null if the variables array is empty.
|
||||
* Build a Zod shape (Record of field schemas) for runtime variables of one entity type.
|
||||
* Returns null if no variables.
|
||||
*
|
||||
* @param allOptional - true for update schemas (only include changed fields),
|
||||
* false for new entity schemas (required fields stay required)
|
||||
*/
|
||||
export function buildEntityCustomVarsSchema(
|
||||
export function buildEntityVarsShape(
|
||||
variables: RuntimeVariable[],
|
||||
): z.ZodObject<z.ZodRawShape> | null {
|
||||
allOptional: boolean,
|
||||
): z.ZodRawShape | null {
|
||||
if (variables.length === 0) return null
|
||||
|
||||
const shape: z.ZodRawShape = {}
|
||||
for (const def of variables) {
|
||||
shape[def.variableName] = buildVariableSchema(def)
|
||||
// For updates: all vars optional (only send what changed)
|
||||
// For new entities: respect defaultValue-based optionality
|
||||
shape[def.variableName] = allOptional
|
||||
? buildVariableBaseSchema(def).optional()
|
||||
: buildVariableSchema(def)
|
||||
}
|
||||
|
||||
return z.object(shape).describe('Custom runtime variables for this entity')
|
||||
return shape
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -123,7 +143,7 @@ const ENTITY_TYPE_TO_SCHEMA_FIELDS: Record<RuntimeEntityType, { updates: string;
|
|||
}
|
||||
|
||||
/**
|
||||
* Base update/new schemas per entity type -- used to extend with customVars.
|
||||
* Base update/new schemas per entity type -- used to extend with inline vars.
|
||||
*/
|
||||
const BASE_UPDATE_SCHEMAS: Record<RuntimeEntityType, z.ZodObject<z.ZodRawShape>> = {
|
||||
character: characterUpdateSchema as unknown as z.ZodObject<z.ZodRawShape>,
|
||||
|
|
@ -140,12 +160,12 @@ const BASE_NEW_SCHEMAS: Record<RuntimeEntityType, z.ZodObject<z.ZodRawShape>> =
|
|||
}
|
||||
|
||||
/**
|
||||
* Build an extended classification schema that includes customVars fields
|
||||
* for entity types that have runtime variable definitions.
|
||||
* Build an extended classification schema that includes runtime variable fields
|
||||
* INLINE on entity update/new schemas (not nested under a `customVars` object).
|
||||
*
|
||||
* For each entity type with runtime variables:
|
||||
* - Update schemas get customVars added inside their `changes` object
|
||||
* - New entity schemas get customVars added at the top level
|
||||
* - Update schemas get var fields added directly inside their `changes` object (all optional)
|
||||
* - New entity schemas get var fields added at the top level (with default-based optionality)
|
||||
*
|
||||
* If no runtime variables exist for any entity type, returns the base schema unchanged.
|
||||
*/
|
||||
|
|
@ -168,30 +188,25 @@ export function buildExtendedClassificationSchema(
|
|||
for (const entityType of ['character', 'location', 'item', 'story_beat'] as RuntimeEntityType[]) {
|
||||
const fields = ENTITY_TYPE_TO_SCHEMA_FIELDS[entityType]
|
||||
const vars = runtimeVarsByEntityType[entityType]
|
||||
const customVarsSchema = vars ? buildEntityCustomVarsSchema(vars) : null
|
||||
const updateVarsShape = vars ? buildEntityVarsShape(vars, true) : null
|
||||
const newVarsShape = vars ? buildEntityVarsShape(vars, false) : null
|
||||
|
||||
if (customVarsSchema) {
|
||||
// Extend the update schema: add customVars inside the `changes` object
|
||||
if (updateVarsShape) {
|
||||
// Extend the update schema: add var fields directly inside `changes`
|
||||
const baseUpdate = BASE_UPDATE_SCHEMAS[entityType]
|
||||
const changesKey = entityType === 'story_beat' ? 'changes' : 'changes'
|
||||
const originalChanges = (baseUpdate.shape as Record<string, z.ZodTypeAny>)[changesKey]
|
||||
const originalChanges = (baseUpdate.shape as Record<string, z.ZodTypeAny>).changes
|
||||
|
||||
if (originalChanges && originalChanges instanceof z.ZodObject) {
|
||||
const extendedChanges = originalChanges.extend({
|
||||
customVars: customVarsSchema.optional(),
|
||||
})
|
||||
const extendedUpdate = baseUpdate.extend({ [changesKey]: extendedChanges })
|
||||
const extendedChanges = originalChanges.extend(updateVarsShape)
|
||||
const extendedUpdate = baseUpdate.extend({ changes: extendedChanges })
|
||||
entryUpdatesShape[fields.updates] = z.array(extendedUpdate).default([])
|
||||
} else {
|
||||
// Fallback: use base schema as-is
|
||||
entryUpdatesShape[fields.updates] = z.array(baseUpdate).default([])
|
||||
}
|
||||
|
||||
// Extend the new entity schema: add customVars at top level
|
||||
// Extend the new entity schema: add var fields at top level
|
||||
const baseNew = BASE_NEW_SCHEMAS[entityType]
|
||||
const extendedNew = baseNew.extend({
|
||||
customVars: customVarsSchema.optional(),
|
||||
})
|
||||
const extendedNew = baseNew.extend(newVarsShape!)
|
||||
entryUpdatesShape[fields.new] = z.array(extendedNew).default([])
|
||||
} else {
|
||||
// No variables for this entity type: use base schemas
|
||||
|
|
@ -210,50 +225,31 @@ export function buildExtendedClassificationSchema(
|
|||
// Extended Classification Result Type
|
||||
// ============================================================================
|
||||
|
||||
/** Classification result that may include customVars from runtime variable extraction. */
|
||||
/**
|
||||
* Classification result that may include inline runtime variable fields.
|
||||
* Runtime variables appear as direct fields on changes/entity objects
|
||||
* (e.g., `health: 80` alongside `status: "active"`), not nested under `customVars`.
|
||||
*
|
||||
* Use extractInlineCustomVars() to pick out the variable fields from these objects.
|
||||
*/
|
||||
export type ExtendedClassificationResult = ClassificationResult & {
|
||||
entryUpdates: ClassificationResult['entryUpdates'] & {
|
||||
characterUpdates: Array<
|
||||
ClassificationResult['entryUpdates']['characterUpdates'][number] & {
|
||||
changes: { customVars?: Record<string, unknown> }
|
||||
}
|
||||
>
|
||||
newCharacters: Array<
|
||||
ClassificationResult['entryUpdates']['newCharacters'][number] & {
|
||||
customVars?: Record<string, unknown>
|
||||
}
|
||||
>
|
||||
locationUpdates: Array<
|
||||
ClassificationResult['entryUpdates']['locationUpdates'][number] & {
|
||||
changes: { customVars?: Record<string, unknown> }
|
||||
}
|
||||
>
|
||||
newLocations: Array<
|
||||
ClassificationResult['entryUpdates']['newLocations'][number] & {
|
||||
customVars?: Record<string, unknown>
|
||||
}
|
||||
>
|
||||
itemUpdates: Array<
|
||||
ClassificationResult['entryUpdates']['itemUpdates'][number] & {
|
||||
changes: { customVars?: Record<string, unknown> }
|
||||
}
|
||||
>
|
||||
newItems: Array<
|
||||
ClassificationResult['entryUpdates']['newItems'][number] & {
|
||||
customVars?: Record<string, unknown>
|
||||
}
|
||||
>
|
||||
storyBeatUpdates: Array<
|
||||
ClassificationResult['entryUpdates']['storyBeatUpdates'][number] & {
|
||||
changes: { customVars?: Record<string, unknown> }
|
||||
}
|
||||
>
|
||||
newStoryBeats: Array<
|
||||
ClassificationResult['entryUpdates']['newStoryBeats'][number] & {
|
||||
customVars?: Record<string, unknown>
|
||||
}
|
||||
>
|
||||
}
|
||||
/** Internal metadata: runtime variable definitions for use by applyClassificationResult. Not LLM output. */
|
||||
_runtimeVarDefs?: RuntimeVariable[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract inline runtime variable values from an LLM-generated object.
|
||||
* Filters the object's entries against known variable names from defsByName.
|
||||
*/
|
||||
export function extractInlineCustomVars(
|
||||
obj: Record<string, unknown>,
|
||||
defsByName: Map<string, RuntimeVariable>,
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (defsByName.has(key) && value !== undefined) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ import { database } from '$lib/services/database'
|
|||
import { rollbackService } from '$lib/services/rollbackService'
|
||||
import { ui } from './ui.svelte'
|
||||
import { settings } from './settings.svelte'
|
||||
import type { ExtendedClassificationResult } from '$lib/services/ai/sdk/schemas/runtime-variables'
|
||||
import {
|
||||
extractInlineCustomVars,
|
||||
type ExtendedClassificationResult,
|
||||
} from '$lib/services/ai/sdk/schemas/runtime-variables'
|
||||
import type { RuntimeVariable } from '$lib/services/packs/types'
|
||||
import { DEFAULT_MEMORY_CONFIG } from '$lib/services/ai/generation/MemoryService'
|
||||
import { convertToEntries, type ImportedEntry } from '$lib/services/lorebookImporter'
|
||||
|
|
@ -52,28 +55,28 @@ function log(...args: any[]) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Merge LLM-extracted customVars into entity metadata.runtimeVars.
|
||||
* Merge LLM-extracted inline runtime vars into entity metadata.runtimeVars.
|
||||
* Values are keyed by defId (RuntimeVariable.id), NOT variableName,
|
||||
* so renames only change the definition -- stored values follow automatically.
|
||||
*
|
||||
* @param existingMetadata - Current entity metadata (may be null)
|
||||
* @param customVars - LLM-extracted vars keyed by variableName
|
||||
* @param inlineVars - LLM-extracted vars keyed by variableName (from extractInlineCustomVars)
|
||||
* @param defsByName - Lookup from variableName to RuntimeVariable definition
|
||||
* @returns Updated metadata with runtimeVars merged
|
||||
*/
|
||||
function mergeRuntimeVars(
|
||||
existingMetadata: Record<string, unknown> | null,
|
||||
customVars: Record<string, unknown> | undefined,
|
||||
inlineVars: Record<string, unknown> | undefined,
|
||||
defsByName: Map<string, RuntimeVariable>,
|
||||
): Record<string, unknown> {
|
||||
if (!customVars || Object.keys(customVars).length === 0) {
|
||||
if (!inlineVars || Object.keys(inlineVars).length === 0) {
|
||||
return existingMetadata ?? {}
|
||||
}
|
||||
|
||||
const base = existingMetadata ?? {}
|
||||
const runtimeVars = { ...((base.runtimeVars as Record<string, unknown>) ?? {}) }
|
||||
|
||||
for (const [key, value] of Object.entries(customVars)) {
|
||||
for (const [key, value] of Object.entries(inlineVars)) {
|
||||
const def = defsByName.get(key)
|
||||
if (def) {
|
||||
runtimeVars[def.id] = { variableName: def.variableName, v: value }
|
||||
|
|
@ -1963,13 +1966,13 @@ class StoryStore {
|
|||
) {
|
||||
changes.visualDescriptors = update.changes.visualDescriptors
|
||||
}
|
||||
// Merge runtime variable values into metadata if present
|
||||
if (update.changes.customVars && Object.keys(update.changes.customVars).length > 0) {
|
||||
changes.metadata = mergeRuntimeVars(
|
||||
existing.metadata,
|
||||
update.changes.customVars,
|
||||
defsByName,
|
||||
)
|
||||
// Merge inline runtime variable values into metadata if present
|
||||
const charInlineVars = extractInlineCustomVars(
|
||||
update.changes as unknown as Record<string, unknown>,
|
||||
defsByName,
|
||||
)
|
||||
if (Object.keys(charInlineVars).length > 0) {
|
||||
changes.metadata = mergeRuntimeVars(existing.metadata, charInlineVars, defsByName)
|
||||
}
|
||||
// COW: ensure entity is owned by current branch before updating
|
||||
const { entity: ownedChar, wasCowed: charWasCowed } = await this.cowCharacter(existing)
|
||||
|
|
@ -2005,13 +2008,13 @@ class StoryStore {
|
|||
: addition
|
||||
}
|
||||
}
|
||||
// Merge runtime variable values into metadata if present
|
||||
if (update.changes.customVars && Object.keys(update.changes.customVars).length > 0) {
|
||||
changes.metadata = mergeRuntimeVars(
|
||||
existing.metadata,
|
||||
update.changes.customVars,
|
||||
defsByName,
|
||||
)
|
||||
// Merge inline runtime variable values into metadata if present
|
||||
const locInlineVars = extractInlineCustomVars(
|
||||
update.changes as unknown as Record<string, unknown>,
|
||||
defsByName,
|
||||
)
|
||||
if (Object.keys(locInlineVars).length > 0) {
|
||||
changes.metadata = mergeRuntimeVars(existing.metadata, locInlineVars, defsByName)
|
||||
}
|
||||
|
||||
// COW: ensure entity is owned by current branch before updating
|
||||
|
|
@ -2092,13 +2095,13 @@ class StoryStore {
|
|||
if (update.changes.quantity !== undefined) changes.quantity = update.changes.quantity
|
||||
if (update.changes.equipped !== undefined) changes.equipped = update.changes.equipped
|
||||
if (update.changes.location) changes.location = update.changes.location
|
||||
// Merge runtime variable values into metadata if present
|
||||
if (update.changes.customVars && Object.keys(update.changes.customVars).length > 0) {
|
||||
changes.metadata = mergeRuntimeVars(
|
||||
existing.metadata,
|
||||
update.changes.customVars,
|
||||
defsByName,
|
||||
)
|
||||
// Merge inline runtime variable values into metadata if present
|
||||
const itemInlineVars = extractInlineCustomVars(
|
||||
update.changes as unknown as Record<string, unknown>,
|
||||
defsByName,
|
||||
)
|
||||
if (Object.keys(itemInlineVars).length > 0) {
|
||||
changes.metadata = mergeRuntimeVars(existing.metadata, itemInlineVars, defsByName)
|
||||
}
|
||||
// COW: ensure entity is owned by current branch before updating
|
||||
const { entity: ownedItem, wasCowed: itemWasCowed } = await this.cowItem(existing)
|
||||
|
|
@ -2130,13 +2133,13 @@ class StoryStore {
|
|||
}
|
||||
}
|
||||
if (update.changes.description) changes.description = update.changes.description
|
||||
// Merge runtime variable values into metadata if present
|
||||
if (update.changes.customVars && Object.keys(update.changes.customVars).length > 0) {
|
||||
changes.metadata = mergeRuntimeVars(
|
||||
existing.metadata,
|
||||
update.changes.customVars,
|
||||
defsByName,
|
||||
)
|
||||
// Merge inline runtime variable values into metadata if present
|
||||
const beatInlineVars = extractInlineCustomVars(
|
||||
update.changes as unknown as Record<string, unknown>,
|
||||
defsByName,
|
||||
)
|
||||
if (Object.keys(beatInlineVars).length > 0) {
|
||||
changes.metadata = mergeRuntimeVars(existing.metadata, beatInlineVars, defsByName)
|
||||
}
|
||||
// COW: ensure entity is owned by current branch before updating
|
||||
const { entity: ownedBeat, wasCowed: beatWasCowed } = await this.cowStoryBeat(existing)
|
||||
|
|
@ -2162,8 +2165,12 @@ class StoryStore {
|
|||
if (!exists) {
|
||||
log('Adding new character:', newChar.name)
|
||||
const charMetadata: Record<string, unknown> = { source: 'classifier' }
|
||||
if (newChar.customVars && Object.keys(newChar.customVars).length > 0) {
|
||||
Object.assign(charMetadata, mergeRuntimeVars(null, newChar.customVars, defsByName))
|
||||
const newCharInlineVars = extractInlineCustomVars(
|
||||
newChar as unknown as Record<string, unknown>,
|
||||
defsByName,
|
||||
)
|
||||
if (Object.keys(newCharInlineVars).length > 0) {
|
||||
Object.assign(charMetadata, mergeRuntimeVars(null, newCharInlineVars, defsByName))
|
||||
}
|
||||
const character: Character = {
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -2213,8 +2220,12 @@ class StoryStore {
|
|||
}
|
||||
}
|
||||
const locMetadata: Record<string, unknown> = { source: 'classifier' }
|
||||
if (newLoc.customVars && Object.keys(newLoc.customVars).length > 0) {
|
||||
Object.assign(locMetadata, mergeRuntimeVars(null, newLoc.customVars, defsByName))
|
||||
const newLocInlineVars = extractInlineCustomVars(
|
||||
newLoc as unknown as Record<string, unknown>,
|
||||
defsByName,
|
||||
)
|
||||
if (Object.keys(newLocInlineVars).length > 0) {
|
||||
Object.assign(locMetadata, mergeRuntimeVars(null, newLocInlineVars, defsByName))
|
||||
}
|
||||
const location: Location = {
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -2287,8 +2298,12 @@ class StoryStore {
|
|||
if (!exists) {
|
||||
log('Adding new item:', newItem.name)
|
||||
const itemMetadata: Record<string, unknown> = { source: 'classifier' }
|
||||
if (newItem.customVars && Object.keys(newItem.customVars).length > 0) {
|
||||
Object.assign(itemMetadata, mergeRuntimeVars(null, newItem.customVars, defsByName))
|
||||
const newItemInlineVars = extractInlineCustomVars(
|
||||
newItem as unknown as Record<string, unknown>,
|
||||
defsByName,
|
||||
)
|
||||
if (Object.keys(newItemInlineVars).length > 0) {
|
||||
Object.assign(itemMetadata, mergeRuntimeVars(null, newItemInlineVars, defsByName))
|
||||
}
|
||||
const item: Item = {
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -2317,8 +2332,12 @@ class StoryStore {
|
|||
if (!exists) {
|
||||
log('Adding new story beat:', newBeat.title)
|
||||
const beatMetadata: Record<string, unknown> = { source: 'classifier' }
|
||||
if (newBeat.customVars && Object.keys(newBeat.customVars).length > 0) {
|
||||
Object.assign(beatMetadata, mergeRuntimeVars(null, newBeat.customVars, defsByName))
|
||||
const newBeatInlineVars = extractInlineCustomVars(
|
||||
newBeat as unknown as Record<string, unknown>,
|
||||
defsByName,
|
||||
)
|
||||
if (Object.keys(newBeatInlineVars).length > 0) {
|
||||
Object.assign(beatMetadata, mergeRuntimeVars(null, newBeatInlineVars, defsByName))
|
||||
}
|
||||
const beat: StoryBeat = {
|
||||
id: crypto.randomUUID(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue