1
0
Fork 0

Compare commits

..

2 Commits

Author SHA1 Message Date
Pabloader 97533daa91 Adapt to polza.ai provider 2026-11-02 20:50:35 +00:00
Pabloader d5481e1a13 Global edit tools 2026-11-02 13:58:33 +00:00
9 changed files with 107 additions and 30 deletions

View File

@ -10,6 +10,7 @@
"@huggingface/jinja": "0.5.5",
"@huggingface/tokenizers": "0.1.1",
"@inquirer/select": "2.3.10",
"@ironarachne/made-up-names": "^3.0.0",
"@types/node": "^25.3.3",
"ace-builds": "1.36.3",
"clsx": "2.1.1",
@ -21,7 +22,7 @@
"@types/bun": "^1.3.14",
"@types/html-minifier": "4.0.5",
"@types/inquirer": "9.0.7",
"@types/web-bluetooth": "^0.0.21",
"@types/web-bluetooth": "0.0.21",
"browser-detect": "0.2.28",
"eruda": "3.2.3",
"html-minifier": "4.0.0",
@ -52,6 +53,12 @@
"@inquirer/type": ["@inquirer/type@1.4.0", "", { "dependencies": { "mute-stream": "^1.0.0" } }, "sha512-AjOqykVyjdJQvtfkNDGUyMYGF8xN50VUxftCQWsOyIo4DFRLr6VQhW0VItGI1JIyQGCGgIpKa7hMMwNhZb4OIw=="],
"@ironarachne/made-up-names": ["@ironarachne/made-up-names@3.0.0", "", { "dependencies": { "@ironarachne/rng": "^3.0.1", "@ironarachne/word-generator": "^3.0.1" } }, "sha512-EYwGC1bi9r8/VVbeDgm6jzp8/igC0vs1C2KGK0LIrBcSWg10UNbd9ue6+pXrKQeuCPVdpph2I7SRAnFQ80vIFA=="],
"@ironarachne/rng": ["@ironarachne/rng@3.0.1", "", {}, "sha512-256DtrY7UDMnSp9jTz2aM0JvOuXExcGd/SZlZwSutKR0Aehcq42Q/rGj4SpxxrOu6INb/jeiB6w2eIxHNQEA8Q=="],
"@ironarachne/word-generator": ["@ironarachne/word-generator@3.0.1", "", { "dependencies": { "@ironarachne/rng": "^3.0.1" } }, "sha512-A5NurF35phpZENXDy+DWUSjnRG/o8nhs1p2JCd5tBy01Ka0t2ANpYJUnDwMNXuZT80F7sLtx2wr9Ekmgxpre+g=="],
"@jimp/core": ["@jimp/core@1.6.1", "", { "dependencies": { "@jimp/file-ops": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^21.3.3", "mime": "3" } }, "sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A=="],
"@jimp/diff": ["@jimp/diff@1.6.1", "", { "dependencies": { "@jimp/plugin-resize": "1.6.1", "@jimp/types": "1.6.1", "@jimp/utils": "1.6.1", "pixelmatch": "^5.3.0" } }, "sha512-YkKDPdHjLgo1Api3+Bhc0GLAygldlpt97NfOKoNg1U6IUNXA6X2MgosCjPfSBiSvJvrrz1fsIR+/4cfYXBI/HQ=="],

View File

@ -23,6 +23,7 @@
"@huggingface/jinja": "0.5.5",
"@huggingface/tokenizers": "0.1.1",
"@inquirer/select": "2.3.10",
"@ironarachne/made-up-names": "^3.0.0",
"@types/node": "^25.3.3",
"ace-builds": "1.36.3",
"clsx": "2.1.1",

View File

@ -3,6 +3,7 @@
}
.root {
position: relative;
white-space: pre-wrap;
outline: none;
word-wrap: break-word;

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { useEffect, useRef } from "preact/hooks";
import type { JSX } from "preact";
import clsx from "clsx";
import styles from "../assets/content-editable.module.css";
@ -9,6 +9,7 @@ type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'value' | 'onInput'> & {
placeholder?: string;
autoLines?: boolean;
highlight?: boolean;
disabled?: boolean;
onInput?: JSX.EventHandler<JSX.TargetedInputEvent<HTMLDivElement>>;
};
@ -83,6 +84,7 @@ export const ContentEditable = ({
placeholder,
autoLines,
highlight: enableHighlight,
disabled,
onInput,
class: externalClass,
...props
@ -118,10 +120,10 @@ export const ContentEditable = ({
<div
ref={ref}
{...props}
contentEditable
contentEditable={!disabled}
data-placeholder={value?.replaceAll('\n', '').length ? undefined : placeholder}
class={clsx(styles.root, autoLines && styles.autoLines, externalClass)}
onInput={handleInput}
class={clsx(styles.root, autoLines && styles.autoLines, externalClass, disabled && styles.disabled)}
onInput={disabled ? undefined : handleInput}
/>
);
};

View File

@ -194,7 +194,8 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
const charName = currentWorld.title ?? 'Assistant';
const prefix = `${charName}: `;
let accumulatedContent = continuedMessage?.content ?? '';
let accumulatedReasoning = continuedMessage?.role === 'assistant' ? continuedMessage.reasoning_content ?? '' : '';
let accumulatedReasoning = continuedMessage?.role === 'assistant'
? continuedMessage.reasoning_content ?? continuedMessage.reasoning ?? '' : '';
let tool_calls: LLM.ToolCall[] | undefined;
for await (const chunk of LLM.generateStream(connection, request)) {
@ -207,7 +208,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
const delta = chunk.choices[0]?.delta;
if (delta?.tool_calls) {
tool_calls = delta.tool_calls;
tool_calls = Prompt.applyDelta(tool_calls, delta.tool_calls);
}
const content = delta?.content;
@ -217,7 +218,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
accumulatedContent = accumulatedContent.trimStart().slice(prefix.length).trimStart();
}
}
const reasoningContent = delta?.reasoning_content;
const reasoningContent = delta?.reasoning_content ?? delta?.reasoning;
if (reasoningContent) {
accumulatedReasoning += reasoningContent;
}
@ -231,6 +232,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
role: targetRole,
content: accumulatedContent,
reasoning_content: accumulatedReasoning,
reasoning: accumulatedReasoning,
tool_calls,
},
});
@ -241,6 +243,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
role: targetRole,
content: accumulatedContent,
reasoning_content: accumulatedReasoning,
reasoning: accumulatedReasoning,
tool_calls,
};
dispatch({
@ -251,6 +254,7 @@ export const ChatPanel = ({ visible }: { visible: boolean }) => {
});
if (tool_calls) {
console.log(tool_calls);
const toolMessages: ChatMessage[] = [];
for (const tool of tool_calls) {
if (abortControllerRef.current?.signal.aborted) {

View File

@ -4,7 +4,7 @@ import { useInputState } from "@common/hooks/useInputState";
import { useUpdate } from "@common/hooks/useUpdate";
import { fuzzyMatch } from "@common/utils";
import clsx from "clsx";
import { useEffect, useMemo, useRef } from "preact/hooks";
import { useCallback, useEffect, useMemo, useRef } from "preact/hooks";
import styles from "../../assets/settings-modal.module.css";
import { useAppState } from "../../contexts/state";
import LLM from "../../utils/llm";
@ -40,13 +40,15 @@ export const ConnectionSettings = () => {
const textModelsData = useMemo(() => modelsData?.filter(LLM.isTextModel), [modelsData]);
const imageModelsData = useMemo(() => modelsData?.filter(LLM.isImageModel), [modelsData]);
const isLoadingModels = connectionToFetch != null && textModelsData == undefined;
const param = useCallback((m: LLM.ModelInfoText, param: string) =>
(m.supported_parameters ?? m.top_provider?.supported_parameters)?.includes(param), []);
const [modelFilter, setModelFilter] = useInputState("");
const groupedModels = useMemo(() => {
const sorted = (textModelsData ?? []).sort((a, b) => {
const aWeight = Number(a.supported_parameters.includes('tools')) * 2 + Number(a.supported_parameters.includes('reasoning'));
const bWeight = Number(b.supported_parameters.includes('tools')) * 2 + Number(b.supported_parameters.includes('reasoning'));
const aWeight = Number(param(a, 'tools')) * 2 + Number(param(a, 'reasoning'));
const bWeight = Number(param(b, 'tools')) * 2 + Number(param(b, 'reasoning'));
if (aWeight !== bWeight) return bWeight - aWeight;
if (a.context_length !== b.context_length) return b.context_length - a.context_length;
return a.id.localeCompare(b.id);
@ -55,7 +57,7 @@ export const ConnectionSettings = () => {
return Array.from(groups.entries())
.sort((a, b) => b[0] - a[0])
.map(([context, models]) => ({ context, models }));
}, [textModelsData]);
}, [textModelsData, param]);
const filteredGroupedModels = useMemo(() => {
if (!modelFilter) return groupedModels;
@ -167,7 +169,7 @@ export const ConnectionSettings = () => {
<optgroup key={context} label={`${context} context`}>
{models.map(m => (
<option key={m.id} value={m.id}>
{m.supported_parameters.includes('tools') ? '🔨' : ''}{m.supported_parameters.includes('reasoning') ? '🧠' : ''}{m.id} {m.top_provider.max_completion_tokens ? `(len: ${m.top_provider.max_completion_tokens})` : ''}
{param(m, 'tools') ? '🔨' : ''}{param(m, 'reasoning') ? '🧠' : ''}{m.id} {m.top_provider.max_completion_tokens ? `(len: ${m.top_provider.max_completion_tokens})` : ''}
</option>
))}
</optgroup>

View File

@ -25,6 +25,7 @@ namespace LLM {
role: 'assistant';
content: string;
reasoning_content?: string;
reasoning?: string;
tool_calls?: ToolCall[];
}
@ -101,6 +102,7 @@ namespace LLM {
role?: string;
content?: string;
reasoning_content?: string;
reasoning?: string;
tool_calls?: ToolCall[];
};
finish_reason: 'stop' | 'tool_calls' | null;
@ -168,7 +170,7 @@ namespace LLM {
object: 'model';
created: number;
owned_by: string;
supported_parameters: string[];
supported_parameters?: string[];
architecture?: {
input_modalities: Modality[];
output_modalities: Modality[];
@ -181,6 +183,7 @@ namespace LLM {
context_length: number;
max_completion_tokens: number;
is_moderated: boolean;
supported_parameters?: string[];
};
}
@ -232,7 +235,7 @@ namespace LLM {
};
const url = new URL(connection.url);
url.pathname = path;
url.pathname = url.pathname.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '');
const response = await fetch(url, {
method,
@ -256,7 +259,7 @@ namespace LLM {
};
const url = new URL(connection.url);
url.pathname = path;
url.pathname = url.pathname.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '');
using sse = new SSE(url.toString(), {
headers,
@ -270,7 +273,7 @@ namespace LLM {
if (isMessageEvent(e)) {
if (e.data === '[DONE]') {
controller.close();
} else {
} else if (e.data) {
controller.enqueue(e.data);
}
}
@ -310,7 +313,7 @@ namespace LLM {
}
function isMessageEvent(e: unknown): e is { data: string } {
return e != null && typeof e === 'object' && 'data' in e && typeof e.data === 'string';
return e != null && typeof e === 'object' && 'data' in e && typeof e.data === 'string' && Boolean(e.data);
}
export async function getModels(connection: Connection): Promise<ModelsResponse<ModelInfo>> {

View File

@ -365,11 +365,12 @@ namespace Prompt {
newMessages?: Iterable<ChatMessage>;
excludedMessageIds?: Iterable<string>;
continueLast?: boolean;
keepThinking?: boolean;
}
export function compilePrompt(
state: AppState,
config: CompileConfig = {},
config: CompileConfig = { keepThinking: true },
): LLM.ChatCompletionRequest | null {
const { currentStory, model, enableThinking, currentWorld } = state;
const {
@ -401,7 +402,13 @@ namespace Prompt {
const charName = currentWorld?.title || 'Assistant';
const userName = state.userName || 'User';
const applyVars = (msgs: ChatMessage[]) =>
msgs.map(m => ({ ...m, content: stripDataUrlImages(substituteVars(state, m.content)) }));
msgs.map(m => ({
...m,
content: stripDataUrlImages(substituteVars(state, m.content)),
reasoning_content: config.keepThinking && ('reasoning_content' in m)
? m.reasoning_content
: undefined,
}));
// Chat-only world: format messages with name prefixes
if (currentWorld?.chatOnly) {
@ -455,6 +462,27 @@ namespace Prompt {
...state.generationSettings,
};
}
export function applyDelta(input: any, delta: any) {
if (Array.isArray(delta)) {
const result = [...(input ?? [])];
for (const item of delta) {
result[item.index] = applyDelta(result[item.index], item);
}
return result;
} else if (typeof delta === 'object') {
const result = { ...input };
for (const key of Object.keys(delta)) {
const value = delta[key];
result[key] = applyDelta(result[key], value);
}
return result;
} else if (typeof delta === 'string') {
return (input ?? '') + delta;
} else {
return delta;
}
}
}
export default Prompt;

View File

@ -2,6 +2,7 @@ import { formatErrorMessage } from "@common/errors";
import { Type, type Static, type TObject } from '@common/typebox';
import { CharacterRole, LocationScale, type AppState, type Character, type Location } from "../contexts/state";
import LLM from "./llm";
import { getClassicRaceNamePatternSet, getNameGeneratorForPatternSet, getSupportedClassicRaceNamePatternSets, type NameGeneratorPatternSet } from "@ironarachne/made-up-names";
const VALID_SCALES = Object.values(LocationScale);
const VALID_ROLES = Object.values(CharacterRole);
@ -101,7 +102,7 @@ export namespace Tools {
appState.dispatch({
type: 'ADD_CHARACTER',
worldId: appState.currentWorld.id,
storyId: appState.currentStory.id,
storyId: args.world ? null : appState.currentStory.id,
character: {
id: crypto.randomUUID(),
name: args.name.trim(),
@ -134,6 +135,7 @@ export namespace Tools {
}),
{ description: 'Optional list of relationships with other characters (allowed only when adding a new character)' }
)),
world: Type.Optional(Type.Boolean({ description: "If `true`, entry would be visible to all stories under that world" })),
}),
}),
'set_character_relation': tool({
@ -150,7 +152,7 @@ export namespace Tools {
return `Error: Target character "${args.target_name.trim()}" not found, please add it first.`;
}
const inStory = appState.currentStory.characters.some(c => c.id === character.id);
const storyId = inStory ? appState.currentStory.id : null;
const storyId = inStory && !args.world ? appState.currentStory.id : null;
const existingRelationIndex = character.relations.findIndex(r => r.name === args.target_name.trim());
if (existingRelationIndex !== -1) {
appState.dispatch({
@ -183,6 +185,7 @@ export namespace Tools {
character_name: Type.String({ description: "The character's full name to add/edit the relation for" }),
target_name: Type.String({ description: "The target character's full name" }),
relation: Type.String({ description: 'The relationship type (e.g., friend, enemy, sibling)' }),
world: Type.Optional(Type.Boolean({ description: "If `true`, entry would be visible to all stories under that world" })),
}),
}),
'get_location': tool({
@ -244,7 +247,7 @@ export namespace Tools {
appState.dispatch({
type: 'ADD_LOCATION',
worldId: appState.currentWorld.id,
storyId: appState.currentStory.id,
storyId: args.world ? null : appState.currentStory.id,
location: {
id: crypto.randomUUID(),
name: args.name.trim(),
@ -265,6 +268,7 @@ export namespace Tools {
scale: Type.Optional(Type.Enum(VALID_SCALES, {
description: `Location scale (enum): ${VALID_SCALES.join(', ')}`,
})),
world: Type.Optional(Type.Boolean({ description: "If `true`, entry would be visible to all stories under that world" })),
}),
}),
'set_lore_entry': tool({
@ -289,7 +293,7 @@ export namespace Tools {
appState.dispatch({
type: 'ADD_LORE_ENTRY',
worldId: appState.currentWorld.id,
storyId: appState.currentStory.id,
storyId: args.world ? null : appState.currentStory.id,
entry: {
id: crypto.randomUUID(),
title: args.title.trim(),
@ -304,6 +308,7 @@ export namespace Tools {
parameters: Type.Object({
title: Type.String({ description: "The lore entry's title" }),
text: Type.String({ description: 'The lore entry content.' }),
world: Type.Optional(Type.Boolean({ description: "If `true`, entry would be visible to all stories under that world" })),
}),
}),
'edit_text': tool({
@ -351,15 +356,14 @@ export namespace Tools {
}
}
if (!hasUserMessageSinceLastAppend) {
return `Error: You cannot add new text to the story until user approves your last edit. Stop right there.`;
return `Error: You're not allowed to add more text now. Do not retry nor continue.`;
}
}
const numLines = args.new_text.split('\n').filter(l => l.trim()).length;
const lines = args.new_text.split(/\n+/ig).filter(l => l.trim());
const numLines = lines.length;
let cropped = false;
if (!isScratchpad && numLines > LINES_LIMIT) {
const lines = args.new_text.split('\n');
let kept = 0;
const croppedLines: string[] = [];
for (const line of lines) {
@ -373,13 +377,13 @@ export namespace Tools {
cropped = true;
}
dispatchEdit(currentText + '\n' + args.new_text);
dispatchEdit(currentText.trim() + '\n' + args.new_text.trim());
appState.dispatch({ type: 'SET_CURRENT_TAB', tab });
let message = cropped
? `Added text:\n${args.new_text.split('\n').filter(l => l.trim()).map(l => '> ' + l).join('\n')}\n\nNote: The rest was cropped due to ${LINES_LIMIT} lines limit!`
: `Text appended to ${target} successfully.`;
message += `\nNote: you can't continue nor add more text until user's approval. Stop any attempts and wait.`
message += `\nTool note: you're not allowed to add more text now. Do not retry nor continue.`
return message;
}
@ -504,6 +508,31 @@ export namespace Tools {
prompt: Type.String({ description: 'The image generation prompt' }),
}),
}),
'generate_name': tool({
handler: async (args) => {
const gen = getClassicRaceNamePatternSet(args.family);
const kind = gen[args.kind];
if (!kind) {
return `Error: unknown kind: '${args.kind}'`;
}
const subGen = getNameGeneratorForPatternSet(`${args.family}_${args.kind}`, gen[args.kind]);
const names = subGen.generate(args.amount ?? 1);
return names;
},
description: 'Generate a name. Use this tool when you need a new name, instead of inventing the name yourself.',
parameters: Type.Object({
family: Type.Enum(getSupportedClassicRaceNamePatternSets(), { description: 'Theme or race to which name belongs to' }),
kind: Type.Enum((() => {
type NameGenField = keyof Omit<NameGeneratorPatternSet, 'name'>;
const gen = getClassicRaceNamePatternSet('elf');
const keys = Object.keys(gen);
return keys.filter(k => k != 'name') as NameGenField[];
})(), { description: 'What kind of name to generate' }),
amount: Type.Optional(Type.Number({ description: "Specify the amount of names to generate. You can generate multiple names and select the most fitting. Default: 1" })),
}),
}),
};
export type ToolName = keyof typeof TOOLS;