1
0
Fork 0

Fix messages regen

This commit is contained in:
Pabloader 2026-04-07 18:35:29 +00:00
parent 12214dd2df
commit 0f326ac6ea
5 changed files with 89 additions and 52 deletions

View File

@ -19,11 +19,20 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
width: 100%;
min-width: 0;
} }
.titleWorld { .titleWorld {
color: var(--text-muted); color: var(--text-muted);
font-weight: normal; font-weight: normal;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
} }
.titleSep { .titleSep {
@ -151,6 +160,15 @@
} }
.title { .title {
padding: 0 16px 16px; padding: 0 16px 12px;
font-size: 22px;
}
.titleSep {
font-size: 18px;
}
.editor {
padding-top: 12px;
} }
} }

View File

@ -129,10 +129,14 @@ export const ChatPanel = () => {
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [currentStory, connection, model, input, currentStory?.chatMessages.length]); }, [currentStory, connection, model, input, currentStory?.chatMessages.length]);
const sendMessage = useCallback(async (newMessages: ChatMessage[]) => { const sendMessage = useCallback(async (
newMessages: Iterable<ChatMessage>,
excludedMessageIds: string[] = [],
) => {
if (!currentStory || !currentWorld || !connection || !model) return; if (!currentStory || !currentWorld || !connection || !model) return;
for (const message of newMessages) { for (const message of newMessages) {
if (excludedMessageIds.includes(message.id)) continue;
dispatch({ dispatch({
type: 'ADD_CHAT_MESSAGE', type: 'ADD_CHAT_MESSAGE',
worldId: currentWorld.id, worldId: currentWorld.id,
@ -154,7 +158,7 @@ export const ChatPanel = () => {
}, },
}); });
const request = Prompt.compilePrompt(appStateRef.current, newMessages); const request = Prompt.compilePrompt(appStateRef.current, newMessages, excludedMessageIds);
if (!request) { if (!request) {
setError('Failed to compile prompt'); setError('Failed to compile prompt');
@ -345,7 +349,7 @@ export const ChatPanel = () => {
const handleRegenerate = useCallback(async () => { const handleRegenerate = useCallback(async () => {
if (!currentStory || !connection || !model || isLoading) return; if (!currentStory || !connection || !model || isLoading) return;
// Only regenerate if last message is assistant // Only regenerate if last message is assistant
const lastMessage = currentStory.chatMessages.at(-1); const lastMessage = currentStory.chatMessages.at(-1);
if (!lastMessage || lastMessage.role !== 'assistant') return; if (!lastMessage || lastMessage.role !== 'assistant') return;
@ -363,9 +367,7 @@ export const ChatPanel = () => {
messageId: lastMessage.id, messageId: lastMessage.id,
}); });
// Find the message history before the deleted assistant message await sendMessage([], [lastMessage.id]);
const messages = currentStory.chatMessages.slice(0, -1);
await sendMessage(messages);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -389,16 +391,15 @@ export const ChatPanel = () => {
</div> </div>
) : ( ) : (
<div class={styles.messages} ref={messagesRef}> <div class={styles.messages} ref={messagesRef}>
{currentStory.chatMessages.map((message, index) => { {currentStory.chatMessages.map((message) => {
const isEditing = editingMessageId === message.id; const isEditing = editingMessageId === message.id;
const canEdit = message.role === 'user' || message.role === 'assistant'; const canEdit = message.role === 'user' || message.role === 'assistant';
const canDelete = index < currentStory.chatMessages.length - 1 || message.role === 'user' || message.role === 'assistant';
return ( return (
<div key={message.id} class={styles.message} data-role={message.role}> <div key={message.id} class={styles.message} data-role={message.role}>
<div class={styles.messageHeader}> <div class={styles.messageHeader}>
<RoleHeader message={message} chatMessages={currentStory.chatMessages} /> <RoleHeader message={message} chatMessages={currentStory.chatMessages} />
{!isLoading && canEdit && ( {!isLoading && canEdit && (
<div class={styles.messageActions}> <div class={styles.messageActions}>
<button <button

View File

@ -272,7 +272,7 @@ export const Menu = () => {
reader.onload = () => { reader.onload = () => {
try { try {
const parsed = JSON.parse(reader.result as string); const parsed = JSON.parse(reader.result as string);
if (CharacterCard.isV2(parsed)) { if (CharacterCard.isCardData(parsed)) {
dispatch({ type: 'IMPORT_WORLD', world: CharacterCard.toWorld(parsed) }); dispatch({ type: 'IMPORT_WORLD', world: CharacterCard.toWorld(parsed) });
} else if (isWorld(parsed)) { } else if (isWorld(parsed)) {
dispatch({ type: 'IMPORT_WORLD', world: parsed }); dispatch({ type: 'IMPORT_WORLD', world: parsed });

View File

@ -3,7 +3,7 @@ import type { World, Story } from "../contexts/state";
// ─── Spec Types ─────────────────────────────────────────────────────────────── // ─── Spec Types ───────────────────────────────────────────────────────────────
namespace CharacterCard { namespace CharacterCard {
export interface V2Data { export interface CharaCardData {
name: string; name: string;
description: string; description: string;
personality: string; personality: string;
@ -19,19 +19,20 @@ namespace CharacterCard {
character_version?: string; character_version?: string;
} }
export interface V2 { export interface CharaCard {
spec: "chara_card_v2"; spec: "chara_card_v2";
spec_version: string; spec_version: string;
data: V2Data; data: CharaCardData;
} }
// ─── Type Guard ─────────────────────────────────────────────────────────── // ─── Type Guard ───────────────────────────────────────────────────────────
export function isV2(obj: unknown): obj is V2 { export function isCardData(obj: unknown): obj is CharaCard {
if (typeof obj !== 'object' || obj === null) return false; if (typeof obj !== 'object' || obj === null) return false;
const c = obj as Record<string, unknown>; const c = obj as Record<string, unknown>;
return ( return (
c.spec === 'chara_card_v2' && typeof c.spec === 'string' &&
['chara_card_v2', 'chara_card_v3'].includes(c.spec) &&
typeof c.spec_version === 'string' && typeof c.spec_version === 'string' &&
typeof c.data === 'object' && c.data !== null && typeof c.data === 'object' && c.data !== null &&
typeof (c.data as Record<string, unknown>).name === 'string' typeof (c.data as Record<string, unknown>).name === 'string'
@ -44,7 +45,7 @@ namespace CharacterCard {
* Extracts and parses the chara_card_v2 JSON embedded in a PNG tEXt chunk. * Extracts and parses the chara_card_v2 JSON embedded in a PNG tEXt chunk.
* Returns null if the file is not a valid V2 character card. * Returns null if the file is not a valid V2 character card.
*/ */
export async function extractFromPng(file: File): Promise<V2 | null> { export async function extractFromPng(file: File): Promise<CharaCard | null> {
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
@ -79,8 +80,8 @@ namespace CharacterCard {
try { try {
const card = JSON.parse(json); const card = JSON.parse(json);
if (card?.spec === 'chara_card_v2' && card?.data) { if (['chara_card_v2', 'chara_card_v3'].includes(card?.spec) && card?.data) {
return card as V2; return card as CharaCard;
} }
} catch { } catch {
return null; return null;
@ -99,49 +100,57 @@ namespace CharacterCard {
function substituteVars(text: string, charName: string): string { function substituteVars(text: string, charName: string): string {
return text return text
.replaceAll('{{char}}', charName) .replaceAll('{{char}}', charName)
.replaceAll('{{Char}}', charName) .replaceAll('{{user}}', 'User');
.replaceAll('{{user}}', 'User')
.replaceAll('{{User}}', 'User');
} }
// ─── Formatting ─────────────────────────────────────────────────────────── // ─── Formatting ───────────────────────────────────────────────────────────
export const DEFAULT_SYSTEM_INSTRUCTION = export const DEFAULT_SYSTEM_INSTRUCTION =
`You are roleplaying as the character described below. Stay in character at all times. Write in first person from the character's perspective. Keep responses engaging and true to the character's personality and background.`; `You are {{char}}, a sentient, emotional being acting with free will. Engage in immersive roleplay with {{user}}, adhering to your role, the story's context, and {{user}}'s [OOC] instructions. Prioritize vivid sensory details, authentic emotional responses, and logical progression as you weave your character's traits, surroundings, and experiences into each moment. Maintain spatial awareness, body language, and varied sentence structure to create dynamic, engaging scenes. Respect {{user}}'s agency and autonomy while describing sights, sounds, and sensations thoroughly. Use explicit language for intense scenes, and ensure your responses flow naturally to create an immersive, cinematic roleplay experience. Remember, {{user}} is in control of their actions and reactions.
Key Guidelines:
1. Deeply embody {{char}} through actions, thoughts, and emotions.
2. Create vivid, dynamic scenes with rich sensory detail.
3. Vary language and pacing to enhance emotional depth.
4. Engage with {{user}}'s actions and cues naturally.
5. Advance the story logically, maintaining consistency.
6. Describe the world fully, respecting {{user}}'s autonomy.
7. Ensure responses flow smoothly for immersive roleplay.
8. Avoid repetition. If something has already been stated then come up with something new.
9. Concise Responses. Be succinct. Give short replies.`;
/** /**
* Builds the systemInstructionOverride from a V2 card's data fields. * Builds the systemInstructionOverride from a V2 card's data fields.
* Mirrors the formatting style used in prompt.ts. * Mirrors the formatting style used in prompt.ts.
*/ */
export function formatSystemPrompt(data: V2Data): string { export function formatSystemPrompt(data: CharaCardData): string {
const sub = (text: string) => substituteVars(text, data.name);
const parts: string[] = []; const parts: string[] = [];
parts.push(data.system_prompt ? sub(data.system_prompt.trim()) : DEFAULT_SYSTEM_INSTRUCTION); parts.push(data.system_prompt ? data.system_prompt.trim() : DEFAULT_SYSTEM_INSTRUCTION);
if (data.description?.trim()) { if (data.description?.trim()) {
parts.push(`## Description\n${sub(data.description.trim())}`); parts.push(`## {{char}}'s Description:\n${data.description.trim()}`);
} }
if (data.personality?.trim()) { if (data.personality?.trim()) {
parts.push(`## Personality\n${sub(data.personality.trim())}`); parts.push(`## {{char}}'s Personality:\n${data.personality.trim()}`);
} }
if (data.scenario?.trim()) { if (data.scenario?.trim()) {
parts.push(`## Scenario\n${sub(data.scenario.trim())}`); parts.push(`## Scenario:\n${data.scenario.trim()}`);
} }
if (data.mes_example?.trim()) { if (data.mes_example?.trim()) {
parts.push(`## Example Messages\n${sub(data.mes_example.trim())}`); parts.push(`## {{char}}'s Example Response:\n${data.mes_example.trim()}`);
} }
return parts.join('\n\n'); return `# **Roleplay Context**\n${substituteVars(parts.join('\n\n'), data.name)}\n### **End of Roleplay Context**`;
} }
// ─── World Builder ──────────────────────────────────────────────────────── // ─── World Builder ────────────────────────────────────────────────────────
function greetingTitle(mes: string): string { function greetingTitle(mes: string): string {
const snippet = mes.trim().slice(0, 40); const snippet = mes.trim().replace(/[*"_-]/gi, '').split(/[,;:.?!]/i)[0];
return snippet.length < mes.trim().length ? `${snippet}` : snippet; return snippet.length < mes.trim().length ? `${snippet}` : snippet;
} }
@ -149,7 +158,7 @@ namespace CharacterCard {
* Converts a V2 character card into a chat-only World. * Converts a V2 character card into a chat-only World.
* Creates one story per greeting (first_mes + alternate_greetings). * Creates one story per greeting (first_mes + alternate_greetings).
*/ */
export function toWorld(card: V2): World { export function toWorld(card: CharaCard): World {
const { data } = card; const { data } = card;
const greetings: string[] = [ const greetings: string[] = [

View File

@ -318,40 +318,49 @@ namespace Prompt {
return parts.join('\n\n'); return parts.join('\n\n');
} }
export function compilePrompt(state: AppState, newMessages: ChatMessage[] = []): LLM.ChatCompletionRequest | null { export function compilePrompt(
state: AppState,
newMessages: Iterable<ChatMessage> = [],
excludedMessageIds: Iterable<string> = [],
): LLM.ChatCompletionRequest | null {
const { currentStory, model, enableThinking } = state; const { currentStory, model, enableThinking } = state;
if (!currentStory || !model) { if (!currentStory || !model) {
return null; return null;
} }
const messages: ChatMessage[] = [];
const excludedMessages = new Set(excludedMessageIds);
for (const message of currentStory.chatMessages) {
if (!excludedMessages.has(message.id)) {
messages.push(message);
excludedMessages.add(message.id);
}
}
for (const message of newMessages) {
if (!excludedMessages.has(message.id)) {
messages.push(message);
excludedMessages.add(message.id);
}
}
// Estimate token budget for story text // Estimate token budget for story text
let storyTokenBudget = 0; let storyTokenBudget = 0;
if (model.max_context) { if (model.max_context) {
const nonStorySystem = formatSystemPrompt(state, 0); const nonStorySystem = formatSystemPrompt(state, 0);
const chatText = [...currentStory.chatMessages, ...newMessages] const chatText = messages.map(m => m.content).join('\n');
.map(m => m.content)
.join('');
const maxOutput = model.max_length ?? 2048; const maxOutput = model.max_length ?? 2048;
const otherTokens = approxTokens(nonStorySystem) + approxTokens(chatText) + maxOutput; const otherTokens = approxTokens(nonStorySystem) + approxTokens(chatText) + maxOutput;
storyTokenBudget = model.max_context - otherTokens; storyTokenBudget = model.max_context - otherTokens;
} }
const messages: ChatMessage[] = [ messages.unshift({
{ id: crypto.randomUUID(),
id: crypto.randomUUID(), role: 'system',
role: 'system', content: formatSystemPrompt(state, storyTokenBudget),
content: formatSystemPrompt(state, storyTokenBudget), });
},
...currentStory.chatMessages,
];
const presentMessages = new Set(messages.map(m => m.id));
for (const newMessage of newMessages) {
if (!presentMessages.has(newMessage.id)) {
messages.push(newMessage);
}
}
return { return {
model: model.id, model: model.id,