1
0
Fork 0

Think parsing & styles

This commit is contained in:
Pabloader 2026-03-23 09:24:16 +00:00
parent d32f675db8
commit 268e5cf5ea
10 changed files with 223 additions and 51 deletions

View File

@ -43,6 +43,12 @@
background: var(--bg-hover);
}
.message[data-role="tool"] .content {
font-size: 12px;
font-style: italic;
opacity: 0.5;
}
.role {
font-size: 11px;
font-weight: bold;
@ -50,6 +56,17 @@
text-transform: uppercase;
}
.reasoningContent {
font-size: 12px;
font-style: italic;
opacity: 0.5;
border-left: 2px solid currentColor;
padding-left: 0.5em;
margin-bottom: 0.5em;
white-space: pre-wrap;
word-wrap: break-word;
}
.content {
font-size: 13px;
color: var(--text);
@ -57,6 +74,22 @@
word-wrap: break-word;
}
.toolCalls {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.toolBadge {
font-size: 11px;
padding: 2px 8px;
background: var(--bg-active);
color: var(--text-dim);
border-radius: var(--radius);
font-weight: 500;
}
.loading {
color: var(--text-muted);
font-style: italic;
@ -83,6 +116,21 @@
border-top: 1px solid var(--border);
}
.toggleContainer {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text);
cursor: pointer;
user-select: none;
input[type="checkbox"] {
cursor: pointer;
accent-color: var(--accent);
}
}
.input {
width: 100%;
padding: 8px;
@ -128,7 +176,7 @@
border-radius: 4px;
cursor: pointer;
align-self: center;
margin-top: 8px;
margin: 8px 0 28px;
&:hover {
color: var(--text);

View File

@ -18,9 +18,10 @@
--radius: 4px;
--transition: 0.15s ease;
--textColor: #DCDCD2;
--textColor: #DCDCD2;
--italicColor: #AFAFAF;
--quoteColor: #D4E5FF;
--quoteColor: #D4E5FF;
--codeBg: #49483e;
}
* {

View File

@ -1,3 +1,4 @@
import { useInputState } from "@common/hooks/useInputState";
import { Sidebar } from "./sidebar";
import { useAppState, type ChatMessage } from "../contexts/state";
import styles from '../assets/chat-sidebar.module.css';
@ -10,9 +11,11 @@ import clsx from "clsx";
export const ChatSidebar = () => {
const appState = useAppState();
const { currentStory, dispatch, connection, model } = appState;
const [input, setInput] = useState('');
const { currentStory, dispatch, connection, model, enableThinking } = appState;
const [input, setInput] = useInputState('');
const [isLoading, setIsLoading] = useState(false);
const [isCollapsed, setCollapsed] = useState(false);
const [error, setError] = useState<string | null>(null);
const messagesRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController>(new AbortController());
@ -26,6 +29,12 @@ export const ChatSidebar = () => {
}
}, [currentStory?.chatMessages.length]);
useEffect(() => {
if (messagesRef.current) {
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
}
}, [isCollapsed]);
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
@ -50,7 +59,8 @@ export const ChatSidebar = () => {
message: {
id: assistantMessageId,
role: 'assistant',
content: 'Generating...',
content: '',
reasoning_content: 'Generating...',
},
});
@ -64,6 +74,7 @@ export const ChatSidebar = () => {
try {
let accumulatedContent = '';
let accumulatedReasoning = '';
let tool_calls: LLM.ToolCall[] | undefined;
for await (const chunk of LLM.generateStream(connection, request)) {
@ -76,6 +87,12 @@ export const ChatSidebar = () => {
const content = delta?.content;
if (content) {
accumulatedContent += content;
}
const reasoningContent = delta?.reasoning_content;
if (reasoningContent) {
accumulatedReasoning += reasoningContent;
}
if (content || reasoningContent) {
dispatch({
type: 'ADD_CHAT_MESSAGE',
storyId: currentStory.id,
@ -83,6 +100,7 @@ export const ChatSidebar = () => {
id: assistantMessageId,
role: 'assistant',
content: accumulatedContent,
reasoning_content: accumulatedReasoning,
tool_calls,
},
});
@ -95,6 +113,7 @@ export const ChatSidebar = () => {
id: assistantMessageId,
role: 'assistant',
content: accumulatedContent,
reasoning_content: accumulatedReasoning,
tool_calls,
};
dispatch({
@ -171,7 +190,7 @@ export const ChatSidebar = () => {
const isDisabled = !currentStory || !connection || !model || isLoading;
return (
<Sidebar side="right">
<Sidebar side="right" onCollapseChanged={setCollapsed}>
<div class={styles.chat}>
{!currentStory ? (
<div class={styles.placeholder}>
@ -190,10 +209,27 @@ export const ChatSidebar = () => {
{currentStory.chatMessages.map((message) => (
<div key={message.id} class={styles.message} data-role={message.role}>
<div class={styles.role}>{message.role}</div>
{message.role === 'assistant' && message.reasoning_content && (
<div class={styles.reasoningContent}>
{message.reasoning_content}
</div>
)}
<div
class={styles.content}
dangerouslySetInnerHTML={{ __html: highlight(message.content) }}
dangerouslySetInnerHTML={{ __html: highlight(message.content, false).trim() }}
/>
{message.role === 'assistant' && message.tool_calls && (
<div class={styles.toolCalls}>
{message.tool_calls.map((tool) => (
<span key={tool.id} class={styles.toolBadge}>
{tool.function.name}
</span>
))}
</div>
)}
</div>
))}
{error && (
@ -209,10 +245,24 @@ export const ChatSidebar = () => {
)}
{currentStory && (
<div class={styles.inputContainer}>
{model?.support_thinking &&
<label class={styles.toggleContainer}>
<input
type="checkbox"
checked={enableThinking}
onChange={(e) => dispatch({
type: 'SET_ENABLE_THINKING',
enable: (e.target as HTMLInputElement).checked,
})}
disabled={isDisabled}
/>
<span>Enable thinking</span>
</label>
}
<textarea
class={styles.input}
value={input}
onInput={(e) => setInput((e.target as HTMLTextAreaElement).value)}
onInput={setInput}
onKeyDown={handleKeyDown}
placeholder={isDisabled ? 'Connect to an LLM server to chat' : 'Type a message...'}
rows={3}

View File

@ -16,7 +16,7 @@ export const SettingsModal = ({ onClose }: Props) => {
const { connection, model, dispatch } = useAppState();
const [url, setUrl] = useInputState(connection?.url ?? "");
const [apiKey, setApiKey] = useInputState(connection?.apiKey ?? "");
const [selectedModel, setSelectedModel] = useInputState(model ?? "");
const [selectedModel, setSelectedModel] = useInputState(model?.id ?? "");
const [update, triggerFetch] = useUpdate();
const urlRef = useRef(url);
@ -43,17 +43,18 @@ export const SettingsModal = ({ onClose }: Props) => {
const isLoadingModels = connectionToFetch != null && modelsData == undefined;
const groupedModels = useMemo(() => {
const sorted = (modelsData ?? []).sort((a, b) => {
// Sort by tool support first (true before false)
if (a.support_tools !== b.support_tools) {
return a.support_tools ? -1 : 1;
const aWeight = Number(a.support_tools) * 2 + Number(a.support_thinking);
const bWeight = Number(b.support_tools) * 2 + Number(b.support_thinking);
if (aWeight !== bWeight) {
return bWeight - aWeight;
}
// Then by max context (bigger first, undefined treated as 0)
const aContext = a.max_context ?? 0;
const bContext = b.max_context ?? 0;
if (aContext !== bContext) {
return bContext - aContext;
}
// Then by name (alphabetically)
return a.id.localeCompare(b.id);
});
@ -83,9 +84,10 @@ export const SettingsModal = ({ onClose }: Props) => {
type: 'SET_CONNECTION',
connection: connectionToFetch,
});
const selectedModelInfo = modelsData?.find(m => m.id === selectedModel) ?? null;
dispatch({
type: 'SET_MODEL',
model: selectedModel || null,
model: selectedModelInfo,
});
onClose();
};
@ -153,7 +155,7 @@ export const SettingsModal = ({ onClose }: Props) => {
<optgroup key={context} label={`${context} context`}>
{models.map(m => (
<option key={m.id} value={m.id}>
{m.support_tools ? '🔧 ' : ''}{m.id} {m.max_length ? `(len: ${m.max_length})` : ''}
{m.support_tools ? '🔨' : ''}{m.support_thinking ? '🧠' : ''}{m.id} {m.max_length ? `(len: ${m.max_length})` : ''}
</option>
))}
</optgroup>

View File

@ -6,14 +6,20 @@ import styles from '../assets/sidebar.module.css';
interface Props {
side: 'left' | 'right';
children?: ComponentChildren;
onCollapseChanged?: (collapsed: boolean) => void;
}
export const Sidebar = ({ side, children }: Props) => {
export const Sidebar = ({ side, children, onCollapseChanged }: Props) => {
const open = useBool(true);
const handleToggle = () => {
open.toggle();
onCollapseChanged?.(!open.value);
};
return (
<div class={clsx(styles.sidebar, open.value ? styles.open : styles.closed)} data-side={side}>
<button class={styles.toggle} onClick={open.toggle}>
<button class={styles.toggle} onClick={handleToggle}>
{side === 'left' ? (open.value ? '◀' : '▶') : (open.value ? '▶' : '◀')}
</button>
{open.value && (

View File

@ -23,7 +23,8 @@ interface IState {
stories: Story[];
currentStoryId: string | null;
connection: LLM.Connection | null;
model: string | null;
model: LLM.ModelInfo | null;
enableThinking: boolean;
}
// ─── Actions ─────────────────────────────────────────────────────────────────
@ -32,12 +33,14 @@ type Action =
| { type: 'CREATE_STORY'; title: string }
| { type: 'RENAME_STORY'; id: string; title: string }
| { type: 'EDIT_STORY'; id: string; text: string }
| { type: 'APPEND_TO_STORY'; id: string; text: string }
| { type: 'DELETE_STORY'; id: string }
| { type: 'SELECT_STORY'; id: string }
| { type: 'ADD_CHAT_MESSAGE'; storyId: string; message: ChatMessage }
| { type: 'CLEAR_CHAT'; storyId: string }
| { type: 'SET_CONNECTION'; connection: LLM.Connection | null }
| { type: 'SET_MODEL'; model: string | null };
| { type: 'SET_MODEL'; model: LLM.ModelInfo | null }
| { type: 'SET_ENABLE_THINKING'; enable: boolean };
// ─── Initial State ───────────────────────────────────────────────────────────
@ -46,6 +49,7 @@ const DEFAULT_STATE: IState = {
currentStoryId: null,
connection: null,
model: null,
enableThinking: false,
};
// ─── Reducer ─────────────────────────────────────────────────────────────────
@ -121,6 +125,14 @@ function reducer(state: IState, action: Action): IState {
),
};
}
case 'APPEND_TO_STORY': {
return {
...state,
stories: state.stories.map(s =>
s.id === action.id ? { ...s, text: s.text + action.text } : s
),
};
}
case 'SET_CONNECTION': {
return {
...state,
@ -133,6 +145,12 @@ function reducer(state: IState, action: Action): IState {
model: action.model,
};
}
case 'SET_ENABLE_THINKING': {
return {
...state,
enableThinking: action.enable,
};
}
}
}
@ -142,7 +160,8 @@ export interface AppState {
stories: Story[];
currentStory: Story | null;
connection: LLM.Connection | null;
model: string | null;
model: LLM.ModelInfo | null;
enableThinking: boolean;
dispatch: (action: Action) => void;
}
@ -160,6 +179,7 @@ export const StateContextProvider = ({ children }: { children?: any }) => {
currentStory: state.stories.find(s => s.id === state.currentStoryId) ?? null,
connection: state.connection,
model: state.model,
enableThinking: state.enableThinking,
dispatch,
}), [state]);

View File

@ -1,39 +1,60 @@
export const highlight = (message: string): string => {
const replaceRegex = /(\*\*?|")/ig;
export const highlight = (message: string, keepMarkup = true): string => {
let resultHTML = '';
const replaceRegex = /(\*\*?|"|```|`)/ig;
const splitToken = '___SPLIT_AWOORWA___';
const preparedMessage = message.replace(replaceRegex, `${splitToken}$1${splitToken}`);
const parts = preparedMessage.split(splitToken);
const stack: string[] = [];
let resultHTML = '';
let inCodeBlock = false;
for (const part of parts) {
const isClose = stack.at(-1) === part;
const keepPart = keepMarkup || part === '"';
if (inCodeBlock) {
if (part === '```' && isClose) {
inCodeBlock = false;
stack.pop();
resultHTML += `${keepPart ? part : ''}</span>`;
} else {
resultHTML += part;
}
continue;
}
if (isClose) {
stack.pop();
if (part === '*' || part === '**' || part === '"') {
resultHTML += `${part}</span>`;
if (part === '*' || part === '**' || part === '"' || part === '`' || part === '```') {
resultHTML += `${keepPart ? part : ''}</span>`;
}
} else {
if (part === '*') {
stack.push(part);
resultHTML += `<span style="font-style:italic;color:var(--italicColor)">`;
resultHTML += `<span style="font-style:italic;color:var(--italicColor)">${keepPart ? part : ''}`;
} else if (part === '**') {
stack.push(part);
resultHTML += `<span style="font-weight:bold">`;
resultHTML += `<span style="font-weight:bold">${keepPart ? part : ''}`;
} else if (part === '"') {
stack.push(part);
resultHTML += `<span style="color:var(--quoteColor)">`;
resultHTML += `<span style="color:var(--quoteColor)">"`;
} else if (part === '```') {
stack.push(part);
inCodeBlock = true;
resultHTML += `<span style="font-family:monospace;background:var(--codeBg);padding:0.5em;display:block;border-radius:var(--radius)">`;
} else if (part === '`') {
stack.push(part);
resultHTML += `<span style="font-family:monospace;background:var(--codeBg);padding:0.1em 0.3em;border-radius:0.2em">`;
} else {
resultHTML += part;
}
resultHTML += part;
}
}
while (stack.length) {
const part = stack.pop();
if (part === '*' || part === '**' || part === '"') {
if (part === '*' || part === '**' || part === '"' || part === '`' || part === '```') {
resultHTML += `</span>`;
}
}

View File

@ -24,6 +24,7 @@ namespace LLM {
interface ChatMessageAssistant {
role: 'assistant';
content: string;
reasoning_content?: string;
tool_calls?: ToolCall[];
}
@ -78,11 +79,7 @@ namespace LLM {
function: {
name: string;
description?: string;
parameters: {
type: 'object';
properties: Record<string, ToolParameter>;
required?: string[];
};
parameters: ToolObjectParameter;
};
}
@ -97,6 +94,7 @@ namespace LLM {
top_p?: number;
frequency_penalty?: number;
presence_penalty?: number;
enable_thinking?: boolean;
}
export interface ChatCompletionChoice {
@ -121,7 +119,12 @@ namespace LLM {
export interface ChatCompletionChunkChoice {
index: number;
delta: { role?: string; content?: string; tool_calls?: ToolCall[] };
delta: {
role?: string;
content?: string;
reasoning_content?: string;
tool_calls?: ToolCall[];
};
finish_reason: 'stop' | 'tool_calls' | null;
}
@ -139,6 +142,8 @@ namespace LLM {
created: number;
owned_by: string;
support_tools: boolean;
support_infill: boolean;
support_thinking: boolean;
max_context?: number;
max_length?: number;
}

View File

@ -4,7 +4,7 @@ import { Tools } from "./tools";
namespace Prompt {
export function compilePrompt(state: AppState, newMessages: LLM.ChatMessage[] = []): LLM.ChatCompletionRequest | null {
const { currentStory, model } = state;
const { currentStory, model, enableThinking } = state;
if (!currentStory || !model) {
return null;
@ -19,10 +19,11 @@ namespace Prompt {
messages.push(...newMessages);
return {
model,
model: model.id,
messages,
tools: Tools.getTools(),
// TODO banned_tokens
enable_thinking: enableThinking,
};
}
}

View File

@ -1,6 +1,6 @@
import { formatError } from "@common/errors";
import type { AppState } from "../contexts/state";
import LLM from "./llm";
import type LLM from "./llm";
export namespace Tools {
interface Tool {
@ -10,20 +10,35 @@ export namespace Tools {
}
const TOOLS: Record<string, Tool> = {
'test': {
handler: async (args) => (
`Test successful, received: ${JSON.stringify(args)}`
),
description: 'A simple test function',
'append_to_story': {
handler: async (args, appState) => {
if (!args || typeof args !== 'object' || !('text' in args)) {
return 'Error: Missing required argument "text"';
}
const { text } = args as { text: string };
if (typeof text !== 'string') {
return 'Error: Argument "text" must be a string';
}
if (!appState.currentStory) {
return 'Error: No story selected';
}
appState.dispatch({
type: 'APPEND_TO_STORY',
id: appState.currentStory.id,
text
});
return 'Text appended successfully';
},
description: 'Append text to the current story',
parameters: {
type: 'object',
properties: {
message: {
text: {
type: 'string',
description: 'The test message',
description: 'The text to append to the story',
},
},
required: ['message'],
required: ['text'],
},
}
};
@ -57,6 +72,9 @@ export namespace Tools {
try {
const result = await handler(args, appState);
if (typeof result === 'string') {
return result;
}
return JSON.stringify(result);
} catch (err) {
return formatError(err, 'Error executing tool');