Implement autocomplete hints feature across various components and contexts

This commit is contained in:
vegu-ai-tools
2026-05-17 01:51:56 +03:00
parent 3133be1013
commit 220ec34a74
15 changed files with 232 additions and 33 deletions

View File

@@ -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."

View File

@@ -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)

View File

@@ -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" %}

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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";

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
},

View File

@@ -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);

View File

@@ -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);

View 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;
}

View File

@@ -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
)