From 97533daa91774e663120dfc642790aa4feae3909 Mon Sep 17 00:00:00 2001 From: Pabloader Date: Mon, 2 Nov 2026 20:50:35 +0000 Subject: [PATCH] Adapt to polza.ai provider --- bun.lock | 9 ++- package.json | 1 + .../storywriter/components/chat-sidebar.tsx | 10 +++- .../components/settings/connection.tsx | 12 ++-- src/games/storywriter/utils/llm.ts | 13 +++-- src/games/storywriter/utils/prompt.ts | 32 +++++++++- src/games/storywriter/utils/tools.ts | 58 ++++++++++++++++++- 7 files changed, 118 insertions(+), 17 deletions(-) diff --git a/bun.lock b/bun.lock index 3673db9..2fae71d 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 33a86be..d45fdb7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/games/storywriter/components/chat-sidebar.tsx b/src/games/storywriter/components/chat-sidebar.tsx index badd52b..7c0e1c6 100644 --- a/src/games/storywriter/components/chat-sidebar.tsx +++ b/src/games/storywriter/components/chat-sidebar.tsx @@ -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) { diff --git a/src/games/storywriter/components/settings/connection.tsx b/src/games/storywriter/components/settings/connection.tsx index c8f0924..9ffa1f8 100644 --- a/src/games/storywriter/components/settings/connection.tsx +++ b/src/games/storywriter/components/settings/connection.tsx @@ -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 = () => { {models.map(m => ( ))} diff --git a/src/games/storywriter/utils/llm.ts b/src/games/storywriter/utils/llm.ts index 823f4ec..be27807 100644 --- a/src/games/storywriter/utils/llm.ts +++ b/src/games/storywriter/utils/llm.ts @@ -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> { diff --git a/src/games/storywriter/utils/prompt.ts b/src/games/storywriter/utils/prompt.ts index 2157ca8..22c2b64 100644 --- a/src/games/storywriter/utils/prompt.ts +++ b/src/games/storywriter/utils/prompt.ts @@ -365,11 +365,12 @@ namespace Prompt { newMessages?: Iterable; excludedMessageIds?: Iterable; 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; diff --git a/src/games/storywriter/utils/tools.ts b/src/games/storywriter/utils/tools.ts index 7e6805d..ab99b2a 100644 --- a/src/games/storywriter/utils/tools.ts +++ b/src/games/storywriter/utils/tools.ts @@ -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); @@ -328,6 +329,36 @@ export namespace Tools { // Append mode: when old_text is not provided, append new_text if (args.old_text == null) { + if (!isScratchpad) { + const isAppendToStory = (tc: LLM.ToolCall) => { + const jsonArgs = JSON.stringify(tc.function.arguments); + return tc.function.name === 'edit_text' + && ( + !jsonArgs.match(/\\?"old_text\\?"/) + || jsonArgs.match(/\\?"old_text\\?":\\?"\\?"/) + ) + && ( + !jsonArgs.match(/\\?"target\\?"/) + || jsonArgs.match(/\\?"target\\?":\\?"story\\?"/) + ); + } + + + // Check that there was a user message since the last edit_text append + const messages = appState.currentStory.chatMessages; + let hasUserMessageSinceLastAppend = true; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role === 'user') break; + if (m.role === 'assistant' && m.tool_calls?.some(isAppendToStory)) { + hasUserMessageSinceLastAppend = false; + break; + } + } + if (!hasUserMessageSinceLastAppend) { + return `Error: You're not allowed to add more text now. Do not retry nor continue.`; + } + } const lines = args.new_text.split(/\n+/ig).filter(l => l.trim()); const numLines = lines.length; @@ -352,7 +383,7 @@ export namespace Tools { ? `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, even if you think the work is not finished.` + message += `\nTool note: you're not allowed to add more text now. Do not retry nor continue.` return message; } @@ -477,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; + 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;