mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-09-01 19:48:52 +02:00
Implement autocomplete hints feature across various components and contexts
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
- "Frontend Tooling: Migrated the frontend from npm to pnpm for supply-chain hardening. pnpm is provisioned automatically through Corepack (bundled with Node.js), so there is no extra install step. A 7-day minimum-release-age cooldown prevents installing dependency versions less than a week old — keeping the project out of the blast radius of fast-moving npm supply-chain attacks — and dependency install/build scripts are now blocked unless explicitly allowlisted. pnpm 11 requires Node.js 22, so the install, update, and Docker build scripts now provision Node 22 automatically; the Linux and Windows installers download a portable Node runtime."
|
||||
- "Scene Message List: Long scenes render more smoothly when the message history is modified. Operations that remove or rearrange messages (deleting a message, hiding from context, status updates that replace a previous status, etc.) now only re-render the affected slot instead of every message after the change point. As a side benefit, the collapsed state on context-investigation, director, and time-passage messages now survives when an earlier message in the scene is removed."
|
||||
- "OpenAI Compatible Client: Added a Parameters config tab with individual toggles for `temperature`, `top_p`, and `presence_penalty`. When a parameter is toggled off it is omitted from the request payload entirely, which is needed for OpenAI-compatible backends that hard-error if the parameter is sent for the selected model. All three default to on so existing clients are unaffected."
|
||||
- "Autocomplete Hints: Dialogue and narrative autocomplete now accept a free-form `{...}` hint block at the end of the input. Anything inside the curly braces is passed to the LLM as directional guidance for the continuation (tone, beats, sensory detail, character reactions, etc.) and the brace block itself is stripped from the scene input when the suggestion is accepted. Example: typing `\"Kaira!?\" he yelled {dark corridor, no response, ship shakes}` cues the model on what to weave into the completion without those tokens ending up in the scene text. Trailing braces only; mid-text `{...}` is left alone, and the hint is opt-in — no behavior change when no brace block is present."
|
||||
- "Autocomplete Hints: Dialogue, narrative, and contextual autocomplete now accept a free-form `{...}` hint block at the end of the input. Anything inside the curly braces is passed to the LLM as directional guidance for the continuation (tone, beats, sensory detail, character reactions, etc.) and the brace block itself is stripped from the field when the suggestion is accepted. Example: typing `\"Kaira!?\" he yelled {dark corridor, no response, ship shakes}` cues the model on what to weave into the completion without those tokens ending up in the scene text. Works in the scene input, character description / details / attributes, scene intro, and inline character / narrator / context-investigation message editing. Toggle via the new `Enable Hints` setting on the Creator agent's Autocomplete config (default on); trailing braces only, mid-text `{...}` is left alone."
|
||||
fixes:
|
||||
- "Game Loop Event: Fixed an internal scene-loop event being constructed with the wrong scene reference, surfaced by the pydantic migration."
|
||||
- "Conversation Agent: Stopped injecting `#` into the LLM stop-sequence list on every conversation turn. The conversation agent's prompt-parameter hook now only wipes character-name stop sequences when the `inject_character_names_into_stop` setting is disabled, and is a no-op otherwise."
|
||||
|
||||
@@ -57,6 +57,7 @@ class ContextualGenerateEmission(AgentTemplateEmission):
|
||||
|
||||
content_generation_context: "ContentGenerationContext | None" = None
|
||||
character: "Character | None" = None
|
||||
autocomplete_hint: str | None = None
|
||||
|
||||
@property
|
||||
def context_type(self) -> str:
|
||||
@@ -75,6 +76,7 @@ class AutocompleteEmission(AgentTemplateEmission):
|
||||
input: str = ""
|
||||
type: str = ""
|
||||
character: "Character | None" = None
|
||||
autocomplete_hint: str | None = None
|
||||
|
||||
|
||||
class ContentGenerationContext(pydantic.BaseModel):
|
||||
@@ -207,6 +209,42 @@ class AssistantMixin:
|
||||
max=256,
|
||||
step=16,
|
||||
),
|
||||
"character_attribute_suggestion_length": AgentActionConfig(
|
||||
type="number",
|
||||
label="Character Attribute Suggestion Length",
|
||||
description="Length of the generated suggestion when using autocomplete on a character attribute field.",
|
||||
value=32,
|
||||
min=32,
|
||||
max=256,
|
||||
step=16,
|
||||
),
|
||||
"character_detail_suggestion_length": AgentActionConfig(
|
||||
type="number",
|
||||
label="Character Detail Suggestion Length",
|
||||
description="Length of the generated suggestion when using autocomplete on a character detail or description field.",
|
||||
value=64,
|
||||
min=32,
|
||||
max=256,
|
||||
step=16,
|
||||
),
|
||||
"scene_intro_suggestion_length": AgentActionConfig(
|
||||
type="number",
|
||||
label="Scene Intro Suggestion Length",
|
||||
description="Length of the generated suggestion when using autocomplete on the scene intro field.",
|
||||
value=96,
|
||||
min=32,
|
||||
max=256,
|
||||
step=16,
|
||||
),
|
||||
"context_investigation_suggestion_length": AgentActionConfig(
|
||||
type="number",
|
||||
label="Context Investigation Suggestion Length",
|
||||
description="Length of the generated suggestion when using autocomplete on a context-investigation message.",
|
||||
value=64,
|
||||
min=32,
|
||||
max=256,
|
||||
step=16,
|
||||
),
|
||||
"hints_enabled": AgentActionConfig(
|
||||
type="bool",
|
||||
label="Enable Hints",
|
||||
@@ -238,6 +276,36 @@ class AssistantMixin:
|
||||
def autocomplete_narrative_suggestion_length(self):
|
||||
return self.resolve_config("autocomplete", "narrative_suggestion_length")
|
||||
|
||||
@property
|
||||
def autocomplete_character_attribute_suggestion_length(self):
|
||||
return self.resolve_config("autocomplete", "character_attribute_suggestion_length")
|
||||
|
||||
@property
|
||||
def autocomplete_character_detail_suggestion_length(self):
|
||||
return self.resolve_config("autocomplete", "character_detail_suggestion_length")
|
||||
|
||||
@property
|
||||
def autocomplete_scene_intro_suggestion_length(self):
|
||||
return self.resolve_config("autocomplete", "scene_intro_suggestion_length")
|
||||
|
||||
@property
|
||||
def autocomplete_context_investigation_suggestion_length(self):
|
||||
return self.resolve_config("autocomplete", "context_investigation_suggestion_length")
|
||||
|
||||
def autocomplete_contextual_length_for(self, context_type: str) -> int:
|
||||
"""Resolve the configured suggestion length for a contextual autocomplete
|
||||
type. Properties follow `autocomplete_<context>_suggestion_length`
|
||||
(spaces → underscores). Unmapped types fall back to character-detail."""
|
||||
attr = f"autocomplete_{context_type.replace(' ', '_')}_suggestion_length"
|
||||
length = getattr(self, attr, None)
|
||||
if length is None:
|
||||
log.debug(
|
||||
"autocomplete length fallback (unmapped context_type)",
|
||||
context_type=context_type,
|
||||
)
|
||||
return self.autocomplete_character_detail_suggestion_length
|
||||
return length
|
||||
|
||||
@property
|
||||
def autocomplete_hints_enabled(self):
|
||||
return self.resolve_config("autocomplete", "hints_enabled")
|
||||
@@ -309,9 +377,16 @@ class AssistantMixin:
|
||||
|
||||
kind = f"create_{generation_context.length}"
|
||||
|
||||
hint = None
|
||||
if self.autocomplete_hints_enabled and generation_context.partial:
|
||||
cleaned, hint = util.extract_autocomplete_hint(generation_context.partial)
|
||||
if hint is not None:
|
||||
generation_context.partial = cleaned
|
||||
|
||||
log.debug(
|
||||
f"Contextual generate: {context_typ} - {context_name}",
|
||||
generation_context=generation_context,
|
||||
hint=hint,
|
||||
)
|
||||
|
||||
character = (
|
||||
@@ -332,6 +407,7 @@ class AssistantMixin:
|
||||
"history_aware": generation_context.history_aware,
|
||||
"character": character,
|
||||
"template": generation_context.template,
|
||||
"hint": hint,
|
||||
}
|
||||
|
||||
emission = ContextualGenerateEmission(
|
||||
@@ -339,6 +415,7 @@ class AssistantMixin:
|
||||
content_generation_context=generation_context,
|
||||
character=character,
|
||||
template_vars=template_vars,
|
||||
autocomplete_hint=hint,
|
||||
)
|
||||
|
||||
await async_signals.get("agent.creator.contextual_generate.before").send(
|
||||
@@ -574,6 +651,7 @@ class AssistantMixin:
|
||||
type="dialogue",
|
||||
character=character,
|
||||
template_vars=template_vars,
|
||||
autocomplete_hint=hint,
|
||||
)
|
||||
|
||||
await async_signals.get("agent.creator.autocomplete.before").send(emission)
|
||||
@@ -674,6 +752,7 @@ class AssistantMixin:
|
||||
input=input,
|
||||
type="narrative",
|
||||
template_vars=template_vars,
|
||||
autocomplete_hint=hint,
|
||||
)
|
||||
|
||||
await async_signals.get("agent.creator.autocomplete.before").send(emission)
|
||||
|
||||
@@ -82,6 +82,8 @@ Override example:
|
||||
{% with information=generation_context.information %}{% include "task-information.jinja2" %}{% endwith %}
|
||||
{# END INFORMATION #}
|
||||
|
||||
{% include "autocomplete-hint.jinja2" %}
|
||||
|
||||
{# TASK DISPATCH #}
|
||||
<|SECTION:TASK|>
|
||||
{% if context_typ == "list" %}
|
||||
|
||||
@@ -145,8 +145,7 @@ class AssistantPlugin(Plugin):
|
||||
await creator.autocomplete_narrative(data.partial, emit_signal=True)
|
||||
return
|
||||
|
||||
# force length to 35
|
||||
data.length = 35
|
||||
data.length = creator.autocomplete_contextual_length_for(context_type)
|
||||
log.info("Running autocomplete for contextual generation", args=data)
|
||||
completion = await creator.contextual_generate(data)
|
||||
log.info(
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
import { SceneTextParser } from '@/utils/sceneMessageRenderer';
|
||||
import { insertNewlineAtCursor } from '@/utils/textAreaUtils';
|
||||
import { isPrimaryModifier } from '@/utils/keyboardModifiers';
|
||||
import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint';
|
||||
import { spliceContinuation } from '@/utils/messageContinuation';
|
||||
import MessageAssetImage from './MessageAssetImage.vue';
|
||||
import MessageAssetMixin from './MessageAssetMixin.js';
|
||||
@@ -324,8 +325,8 @@ export default {
|
||||
context: "dialogue:npc",
|
||||
character: this.character,
|
||||
},
|
||||
(completion) => {
|
||||
this.editing_text += completion;
|
||||
(completion, { hintsEnabled }) => {
|
||||
this.editing_text = applyAutocompleteCompletion(this.editing_text, completion, hintsEnabled);
|
||||
this.autocompleting = false;
|
||||
},
|
||||
this.$refs.textarea
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
import { SceneTextParser } from '@/utils/sceneMessageRenderer';
|
||||
import { insertNewlineAtCursor } from '@/utils/textAreaUtils';
|
||||
import { isPrimaryModifier } from '@/utils/keyboardModifiers';
|
||||
import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint';
|
||||
import MessageAssetImage from './MessageAssetImage.vue';
|
||||
import MessageAssetMixin from './MessageAssetMixin.js';
|
||||
import RevisionNav from './RevisionNav.vue';
|
||||
@@ -221,8 +222,8 @@ export default {
|
||||
partial: this.editing_text,
|
||||
context: "context_investigation:continue",
|
||||
},
|
||||
(completion) => {
|
||||
this.editing_text += completion;
|
||||
(completion, { hintsEnabled }) => {
|
||||
this.editing_text = applyAutocompleteCompletion(this.editing_text, completion, hintsEnabled);
|
||||
this.autocompleting = false;
|
||||
},
|
||||
this.$refs.textarea
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
import { SceneTextParser } from '@/utils/sceneMessageRenderer';
|
||||
import { insertNewlineAtCursor } from '@/utils/textAreaUtils';
|
||||
import { isPrimaryModifier } from '@/utils/keyboardModifiers';
|
||||
import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint';
|
||||
import { spliceContinuation } from '@/utils/messageContinuation';
|
||||
import MessageAssetImage from './MessageAssetImage.vue';
|
||||
import MessageAssetMixin from './MessageAssetMixin.js';
|
||||
@@ -250,8 +251,8 @@ export default {
|
||||
partial: this.editing_text,
|
||||
context: "narrative:continue",
|
||||
},
|
||||
(completion) => {
|
||||
this.editing_text += completion;
|
||||
(completion, { hintsEnabled }) => {
|
||||
this.editing_text = applyAutocompleteCompletion(this.editing_text, completion, hintsEnabled);
|
||||
this.autocompleting = false;
|
||||
},
|
||||
this.$refs.textarea
|
||||
|
||||
@@ -412,10 +412,7 @@ import PromptsMenu from './prompts/PromptsMenu.vue';
|
||||
import { debounce } from 'lodash';
|
||||
import { isVisualAgentReady, isImageEditAvailable, isImageCreateAvailable } from '@/constants/visual';
|
||||
import { createSceneAssetsRequester } from './VisualAssetsMixin.js';
|
||||
|
||||
// Mirror of AUTOCOMPLETE_HINT_RE in src/talemate/util/dialogue.py.
|
||||
// Used only for cosmetic textbox cleanup; the backend is the semantic source of truth.
|
||||
const AUTOCOMPLETE_HINT_RE = /\s*\{[^{}]+\}\s*$/;
|
||||
import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -1295,10 +1292,9 @@ export default {
|
||||
this.autocompleting = false;
|
||||
// Use the toggle value captured at send time, so toggling mid-flight
|
||||
// doesn't desync from what the backend already decided.
|
||||
if (this.autocompleteHintsEnabledAtSend) {
|
||||
this.messageInput = this.messageInput.replace(AUTOCOMPLETE_HINT_RE, '');
|
||||
}
|
||||
this.messageInput += completion;
|
||||
this.messageInput = applyAutocompleteCompletion(
|
||||
this.messageInput, completion, this.autocompleteHintsEnabledAtSend
|
||||
);
|
||||
},
|
||||
|
||||
autocompleteHintsEnabled() {
|
||||
@@ -1347,14 +1343,15 @@ export default {
|
||||
|
||||
autocompleteRequest(param, callback, focus_element, delay=500) {
|
||||
|
||||
const hintsEnabled = this.autocompleteHintsEnabled();
|
||||
this.autocompleteCallback = (completion) => {
|
||||
setTimeout(() => {
|
||||
callback(completion);
|
||||
callback(completion, { hintsEnabled });
|
||||
}, delay);
|
||||
};
|
||||
this.autocompleteFocusElement = focus_element;
|
||||
this.autocompletePartialInput = param.partial;
|
||||
this.autocompleteHintsEnabledAtSend = this.autocompleteHintsEnabled();
|
||||
this.autocompleteHintsEnabledAtSend = hintsEnabled;
|
||||
|
||||
const param_copy = JSON.parse(JSON.stringify(param));
|
||||
param_copy.type = "assistant";
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
@keyup.ctrl.enter.stop="sendAutocompleteRequest"
|
||||
|
||||
@update:modelValue="dirty = true"
|
||||
@blur="update(selected, true)"
|
||||
@blur="onBlurSave"
|
||||
|
||||
v-model="character.base_attributes[selected]">
|
||||
</v-textarea>
|
||||
@@ -115,6 +115,7 @@
|
||||
import ConfirmActionInline from './ConfirmActionInline.vue';
|
||||
import ContextualGenerate from './ContextualGenerate.vue';
|
||||
import WorldStateManagerTemplateApplicator from './WorldStateManagerTemplateApplicator.vue';
|
||||
import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint';
|
||||
import SpiceAppliedNotification from './SpiceAppliedNotification.vue';
|
||||
|
||||
export default {
|
||||
@@ -272,6 +273,12 @@ export default {
|
||||
}));
|
||||
},
|
||||
|
||||
onBlurSave() {
|
||||
// Guard: blur during autocomplete would save the un-stripped {hint}.
|
||||
if (this.busy) return;
|
||||
this.update(this.selected, true);
|
||||
},
|
||||
|
||||
setShared(name, shared) {
|
||||
const payload = {
|
||||
type: 'world_state_manager',
|
||||
@@ -315,8 +322,8 @@ export default {
|
||||
partial: this.character.base_attributes[this.selected],
|
||||
context: `character attribute:${this.selected}`,
|
||||
character: this.character.name
|
||||
}, (completion) => {
|
||||
this.character.base_attributes[this.selected] += completion;
|
||||
}, (completion, { hintsEnabled }) => {
|
||||
this.character.base_attributes[this.selected] = applyAutocompleteCompletion(this.character.base_attributes[this.selected], completion, hintsEnabled);
|
||||
this.busy = false;
|
||||
}, this.$refs.attribute);
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
|
||||
import ConfirmActionInline from './ConfirmActionInline.vue';
|
||||
import WorldStateManagerTemplateApplicator from './WorldStateManagerTemplateApplicator.vue';
|
||||
import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint';
|
||||
|
||||
export default {
|
||||
name: "WorldStateManagerCharacterCreator",
|
||||
@@ -171,8 +172,8 @@ export default {
|
||||
context: `character detail:description`,
|
||||
instructions: this.character.generation_context.instructions,
|
||||
character: this.character.name
|
||||
}, (completion) => {
|
||||
this.character.description += completion;
|
||||
}, (completion, { hintsEnabled }) => {
|
||||
this.character.description = applyAutocompleteCompletion(this.character.description, completion, hintsEnabled);
|
||||
this.descriptionBusy = false;
|
||||
}, this.$refs.description);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
@keyup.ctrl.enter.stop="sendAutocompleteRequest"
|
||||
|
||||
@update:model-value="dirty = true"
|
||||
@blur="update(true)"
|
||||
@blur="onBlurSave"
|
||||
label="Description"
|
||||
:hint="'A short description of the character. '+autocompleteInfoMessage(busy)">
|
||||
</v-textarea>
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
import ContextualGenerate from './ContextualGenerate.vue';
|
||||
import SpiceAppliedNotification from './SpiceAppliedNotification.vue';
|
||||
import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint';
|
||||
|
||||
export default {
|
||||
name: 'WorldStateManagerCharacterDescription',
|
||||
@@ -97,14 +98,20 @@ export default {
|
||||
this.update();
|
||||
},
|
||||
|
||||
onBlurSave() {
|
||||
// Guard: blur during autocomplete would save the un-stripped {hint}.
|
||||
if (this.busy) return;
|
||||
this.update(true);
|
||||
},
|
||||
|
||||
sendAutocompleteRequest() {
|
||||
this.busy = true;
|
||||
this.autocompleteRequest({
|
||||
partial: this.character.description,
|
||||
context: `character detail:description`,
|
||||
character: this.character.name
|
||||
}, (completion) => {
|
||||
this.character.description += completion;
|
||||
}, (completion, { hintsEnabled }) => {
|
||||
this.character.description = applyAutocompleteCompletion(this.character.description, completion, hintsEnabled);
|
||||
this.busy = false;
|
||||
}, this.$refs.description);
|
||||
},
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@keyup.ctrl.enter.stop="sendAutocompleteRequest"
|
||||
|
||||
@update:modelValue="dirty = true"
|
||||
@blur="update(selected, true)"
|
||||
@blur="onBlurSave"
|
||||
|
||||
v-model="character.details[selected]">
|
||||
</v-textarea>
|
||||
@@ -126,6 +126,7 @@
|
||||
import ContextualGenerate from './ContextualGenerate.vue';
|
||||
import WorldStateManagerTemplateApplicator from './WorldStateManagerTemplateApplicator.vue';
|
||||
import SpiceAppliedNotification from './SpiceAppliedNotification.vue';
|
||||
import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint';
|
||||
import ConfirmActionInline from './ConfirmActionInline.vue';
|
||||
|
||||
export default {
|
||||
@@ -298,6 +299,12 @@ export default {
|
||||
}));
|
||||
},
|
||||
|
||||
onBlurSave() {
|
||||
// Guard: blur during autocomplete would save the un-stripped {hint}.
|
||||
if (this.busy) return;
|
||||
this.update(this.selected, true);
|
||||
},
|
||||
|
||||
setShared(name, shared) {
|
||||
this.getWebsocket().send(JSON.stringify({
|
||||
type: 'world_state_manager',
|
||||
@@ -340,8 +347,8 @@ export default {
|
||||
partial: this.character.details[this.selected],
|
||||
context: `character detail:${this.selected}`,
|
||||
character: this.character.name
|
||||
}, (completion) => {
|
||||
this.character.details[this.selected] += completion;
|
||||
}, (completion, { hintsEnabled }) => {
|
||||
this.character.details[this.selected] = applyAutocompleteCompletion(this.character.details[this.selected], completion, hintsEnabled);
|
||||
this.busy = false;
|
||||
}, this.$refs.detail);
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
max-rows="32"
|
||||
|
||||
@update:model-value="setFieldDirty('intro')"
|
||||
@blur="update(true)"
|
||||
@blur="onIntroBlurSave"
|
||||
:color="dirty['intro'] ? 'dirty' : ''"
|
||||
|
||||
:disabled="busy['intro']"
|
||||
@@ -168,6 +168,7 @@
|
||||
|
||||
import ContextualGenerate from './ContextualGenerate.vue';
|
||||
import { MAX_CONTENT_WIDTH } from '@/constants/layout';
|
||||
import { applyCompletion as applyAutocompleteCompletion } from '@/utils/autocompleteHint';
|
||||
|
||||
const defaultPerspectives = () => ({ default: "", player: "", other: "", narrator: "" });
|
||||
|
||||
@@ -299,13 +300,19 @@ export default {
|
||||
}));
|
||||
},
|
||||
|
||||
onIntroBlurSave() {
|
||||
// Guard: blur during autocomplete would save the un-stripped {hint}.
|
||||
if (this.busy['intro']) return;
|
||||
this.update(true);
|
||||
},
|
||||
|
||||
sendAutocompleteRequestForIntro() {
|
||||
this.busy['intro'] = true;
|
||||
this.autocompleteRequest({
|
||||
partial: this.scene.data.intro,
|
||||
context: "scene intro:scene intro",
|
||||
}, (completion) => {
|
||||
this.scene.data.intro += completion;
|
||||
}, (completion, { hintsEnabled }) => {
|
||||
this.scene.data.intro = applyAutocompleteCompletion(this.scene.data.intro, completion, hintsEnabled);
|
||||
this.busy['intro'] = false;
|
||||
}, this.$refs.intro);
|
||||
|
||||
|
||||
11
talemate_frontend/src/utils/autocompleteHint.js
Normal file
11
talemate_frontend/src/utils/autocompleteHint.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// Mirror of AUTOCOMPLETE_HINT_RE in src/talemate/util/dialogue.py.
|
||||
// Used only for cosmetic textbox cleanup; the backend is the semantic source of truth.
|
||||
export const AUTOCOMPLETE_HINT_RE = /\s*\{[^{}]+\}\s*$/;
|
||||
|
||||
// Append an autocomplete completion to a field, optionally stripping a trailing
|
||||
// `{...}` hint block from the existing text first.
|
||||
export function applyCompletion(current, completion, hintsEnabled) {
|
||||
const safe = String(current ?? '');
|
||||
const base = hintsEnabled ? safe.replace(AUTOCOMPLETE_HINT_RE, '') : safe;
|
||||
return base + completion;
|
||||
}
|
||||
@@ -594,6 +594,57 @@ class TestCreatorContextualGenerateMethod:
|
||||
assert response is not None
|
||||
creator.client.send_prompt.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contextual_generate_extracts_hint_from_partial(
|
||||
self, active_context
|
||||
):
|
||||
"""Trailing `{...}` is stripped from partial and rendered as EDITOR HINTS."""
|
||||
creator = active_context
|
||||
creator.client.send_prompt.return_value = "<CONTENT>extended text</CONTENT>"
|
||||
|
||||
generation_context = ContentGenerationContext(
|
||||
context="character detail:background",
|
||||
character="Elena",
|
||||
partial="She grew up in the forest {haunted, lost sister}",
|
||||
length=100,
|
||||
)
|
||||
|
||||
await creator.contextual_generate(generation_context)
|
||||
|
||||
prompt_text = str(creator.client.send_prompt.call_args[0][0])
|
||||
# Partial mutated in place: clean version visible to renderer
|
||||
assert generation_context.partial == "She grew up in the forest"
|
||||
# Hint surfaced in the EDITOR HINTS section
|
||||
assert "editor hints" in prompt_text.lower()
|
||||
assert "haunted, lost sister" in prompt_text
|
||||
# Brace block does not leak into the DRAFT context
|
||||
assert "{haunted, lost sister}" not in prompt_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_contextual_generate_hint_disabled_leaves_partial_intact(
|
||||
self, active_context
|
||||
):
|
||||
"""When hints_enabled toggle is off, trailing `{...}` stays in partial."""
|
||||
creator = active_context
|
||||
creator.actions["autocomplete"].config["hints_enabled"].value = False
|
||||
creator.client.send_prompt.return_value = "<CONTENT>extended text</CONTENT>"
|
||||
|
||||
original_partial = "She grew up in the forest {haunted, lost sister}"
|
||||
generation_context = ContentGenerationContext(
|
||||
context="character detail:background",
|
||||
character="Elena",
|
||||
partial=original_partial,
|
||||
length=100,
|
||||
)
|
||||
|
||||
await creator.contextual_generate(generation_context)
|
||||
|
||||
prompt_text = str(creator.client.send_prompt.call_args[0][0])
|
||||
# Partial untouched
|
||||
assert generation_context.partial == original_partial
|
||||
# No hints section rendered
|
||||
assert "EDITOR HINTS" not in prompt_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_character_attribute_wrapper(
|
||||
self, active_context, mock_scene
|
||||
@@ -805,3 +856,31 @@ class TestCreatorAgentProperties:
|
||||
length = creator_agent.autocomplete_narrative_suggestion_length
|
||||
assert isinstance(length, int)
|
||||
assert length > 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"context_type, expected_property",
|
||||
[
|
||||
("character attribute", "autocomplete_character_attribute_suggestion_length"),
|
||||
("character detail", "autocomplete_character_detail_suggestion_length"),
|
||||
("scene intro", "autocomplete_scene_intro_suggestion_length"),
|
||||
(
|
||||
"context_investigation",
|
||||
"autocomplete_context_investigation_suggestion_length",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_autocomplete_contextual_length_for_known_types(
|
||||
self, creator_agent, context_type, expected_property
|
||||
):
|
||||
"""Each known context_type dispatches to its dedicated length config."""
|
||||
expected = getattr(creator_agent, expected_property)
|
||||
assert creator_agent.autocomplete_contextual_length_for(context_type) == expected
|
||||
|
||||
def test_autocomplete_contextual_length_for_unknown_falls_back_to_detail(
|
||||
self, creator_agent
|
||||
):
|
||||
"""Unmapped context_types fall back to character_detail's length."""
|
||||
fallback = creator_agent.autocomplete_character_detail_suggestion_length
|
||||
assert (
|
||||
creator_agent.autocomplete_contextual_length_for("scene title") == fallback
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user