Implement Notebook interface

This commit is contained in:
Leszek Hanusz 2026-01-31 22:01:16 +01:00
parent 41ea26144e
commit 6d96745375
8 changed files with 496 additions and 6 deletions

Binary file not shown.

View File

@ -118,6 +118,17 @@
<ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
</Sidebar.Header>
<div class="px-4 py-2">
<a
href="#/notebook"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
onclick={handleMobileSidebarItemClick}
>
<span class="i-lucide-book-open h-4 w-4"></span>
Notebook
</a>
</div>
<Sidebar.Group class="mt-4 space-y-2 p-0 px-4">
{#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
<Sidebar.GroupLabel>

View File

@ -73,3 +73,6 @@ export { default as ModelsSelector } from './models/ModelsSelector.svelte';
export { default as ServerStatus } from './server/ServerStatus.svelte';
export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
// Notebook
export { default as NotebookScreen } from './notebook/NotebookScreen.svelte';

View File

@ -0,0 +1,43 @@
<script lang="ts">
import { notebookStore } from '$lib/stores/notebook.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Textarea from '$lib/components/ui/textarea/textarea.svelte';
import { Play, Square } from '@lucide/svelte';
import { config } from '$lib/stores/settings.svelte';
let { content } = $state(notebookStore);
</script>
<div class="flex h-full flex-col p-4 md:p-6">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl font-semibold">Notebook</h1>
<div class="flex gap-2">
{#if notebookStore.isGenerating}
<Button variant="destructive" onclick={() => notebookStore.stop()}>
<Square class="mr-2 h-4 w-4" />
Stop
</Button>
{:else}
<Button onclick={() => notebookStore.generate()}>
<Play class="mr-2 h-4 w-4" />
Generate
</Button>
{/if}
</div>
</div>
<div class="flex-1 overflow-hidden rounded-lg border bg-background shadow-sm">
<textarea
class="h-full w-full resize-none border-0 bg-transparent p-4 font-mono text-sm focus:ring-0 focus-visible:ring-0"
placeholder="Enter your text here..."
bind:value={notebookStore.content}
></textarea>
</div>
<div class="mt-4 text-xs text-muted-foreground">
<p>
Model: {config().model || 'Default'} | Temperature: {config().temperature ?? 0.8} | Max Tokens: {config()
.max_tokens ?? -1}
</p>
</div>
</div>

View File

@ -247,6 +247,177 @@ export class ChatService {
}
}
/**
* Sends a completion request to the llama.cpp server.
* Supports both streaming and non-streaming responses.
*
* @param prompt - The text prompt to complete
* @param options - Configuration options for the completion request
* @returns {Promise<string | void>} that resolves to the complete response string (non-streaming) or void (streaming)
* @throws {Error} if the request fails or is aborted
*/
static async sendCompletion(
prompt: string,
options: SettingsChatServiceOptions = {},
signal?: AbortSignal
): Promise<string | void> {
const {
stream,
onChunk,
onComplete,
onError,
onModel,
onTimings,
// Generation parameters
temperature,
max_tokens,
// Sampling parameters
dynatemp_range,
dynatemp_exponent,
top_k,
top_p,
min_p,
xtc_probability,
xtc_threshold,
typ_p,
// Penalty parameters
repeat_last_n,
repeat_penalty,
presence_penalty,
frequency_penalty,
dry_multiplier,
dry_base,
dry_allowed_length,
dry_penalty_last_n,
// Other parameters
samplers,
backend_sampling,
custom,
timings_per_token
} = options;
const requestBody: ApiCompletionRequest = {
prompt,
stream
};
// Include model in request if provided
if (options.model) {
requestBody.model = options.model;
}
if (temperature !== undefined) requestBody.temperature = temperature;
if (max_tokens !== undefined) {
requestBody.max_tokens = max_tokens !== null && max_tokens !== 0 ? max_tokens : -1;
}
if (dynatemp_range !== undefined) requestBody.dynatemp_range = dynatemp_range;
if (dynatemp_exponent !== undefined) requestBody.dynatemp_exponent = dynatemp_exponent;
if (top_k !== undefined) requestBody.top_k = top_k;
if (top_p !== undefined) requestBody.top_p = top_p;
if (min_p !== undefined) requestBody.min_p = min_p;
if (xtc_probability !== undefined) requestBody.xtc_probability = xtc_probability;
if (xtc_threshold !== undefined) requestBody.xtc_threshold = xtc_threshold;
if (typ_p !== undefined) requestBody.typ_p = typ_p;
if (repeat_last_n !== undefined) requestBody.repeat_last_n = repeat_last_n;
if (repeat_penalty !== undefined) requestBody.repeat_penalty = repeat_penalty;
if (presence_penalty !== undefined) requestBody.presence_penalty = presence_penalty;
if (frequency_penalty !== undefined) requestBody.frequency_penalty = frequency_penalty;
if (dry_multiplier !== undefined) requestBody.dry_multiplier = dry_multiplier;
if (dry_base !== undefined) requestBody.dry_base = dry_base;
if (dry_allowed_length !== undefined) requestBody.dry_allowed_length = dry_allowed_length;
if (dry_penalty_last_n !== undefined) requestBody.dry_penalty_last_n = dry_penalty_last_n;
if (samplers !== undefined) {
requestBody.samplers =
typeof samplers === 'string'
? samplers.split(';').filter((s: string) => s.trim())
: samplers;
}
if (backend_sampling !== undefined) requestBody.backend_sampling = backend_sampling;
if (timings_per_token !== undefined) requestBody.timings_per_token = timings_per_token;
if (custom) {
try {
const customParams = typeof custom === 'string' ? JSON.parse(custom) : custom;
Object.assign(requestBody, customParams);
} catch (error) {
console.warn('Failed to parse custom parameters:', error);
}
}
try {
const response = await fetch(`./completion`, {
method: 'POST',
headers: getJsonHeaders(),
body: JSON.stringify(requestBody),
signal
});
if (!response.ok) {
const error = await ChatService.parseErrorResponse(response);
if (onError) {
onError(error);
}
throw error;
}
if (stream) {
await ChatService.handleCompletionStreamResponse(
response,
onChunk,
onComplete,
onError,
onModel,
onTimings,
signal
);
return;
} else {
return ChatService.handleCompletionNonStreamResponse(
response,
onComplete,
onError,
onModel
);
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
console.log('Completion request was aborted');
return;
}
let userFriendlyError: Error;
if (error instanceof Error) {
if (error.name === 'TypeError' && error.message.includes('fetch')) {
userFriendlyError = new Error(
'Unable to connect to server - please check if the server is running'
);
userFriendlyError.name = 'NetworkError';
} else if (error.message.includes('ECONNREFUSED')) {
userFriendlyError = new Error('Connection refused - server may be offline');
userFriendlyError.name = 'NetworkError';
} else if (error.message.includes('ETIMEDOUT')) {
userFriendlyError = new Error('Request timed out - the server took too long to respond');
userFriendlyError.name = 'TimeoutError';
} else {
userFriendlyError = error;
}
} else {
userFriendlyError = new Error('Unknown error occurred while sending completion');
}
console.error('Error in sendCompletion:', error);
if (onError) {
onError(userFriendlyError);
}
throw userFriendlyError;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Streaming
// ─────────────────────────────────────────────────────────────────────────────
@ -781,4 +952,147 @@ export class ChatService {
onTimingsCallback(timings, promptProgress);
}
/**
* Handles streaming response from the completion API
*/
private static async handleCompletionStreamResponse(
response: Response,
onChunk?: (chunk: string) => void,
onComplete?: (
response: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
toolCalls?: string
) => void,
onError?: (error: Error) => void,
onModel?: (model: string) => void,
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
abortSignal?: AbortSignal
): Promise<void> {
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let aggregatedContent = '';
let lastTimings: ChatMessageTimings | undefined;
let streamFinished = false;
let modelEmitted = false;
try {
let chunk = '';
while (true) {
if (abortSignal?.aborted) break;
const { done, value } = await reader.read();
if (done) break;
if (abortSignal?.aborted) break;
chunk += decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
chunk = lines.pop() || '';
for (const line of lines) {
if (abortSignal?.aborted) break;
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
streamFinished = true;
continue;
}
try {
const parsed: ApiCompletionStreamChunk = JSON.parse(data);
const content = parsed.content;
const timings = parsed.timings;
const model = parsed.model;
if (model && !modelEmitted) {
modelEmitted = true;
onModel?.(model);
}
if (timings) {
ChatService.notifyTimings(timings, undefined, onTimings);
lastTimings = timings;
}
if (content) {
aggregatedContent += content;
if (!abortSignal?.aborted) {
onChunk?.(content);
}
}
} catch (e) {
console.error('Error parsing JSON chunk:', e);
}
}
}
if (abortSignal?.aborted) break;
}
if (abortSignal?.aborted) return;
if (streamFinished) {
onComplete?.(aggregatedContent, undefined, lastTimings, undefined);
}
} catch (error) {
const err = error instanceof Error ? error : new Error('Stream error');
onError?.(err);
throw err;
} finally {
reader.releaseLock();
}
}
/**
* Handles non-streaming response from the completion API
*/
private static async handleCompletionNonStreamResponse(
response: Response,
onComplete?: (
response: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
toolCalls?: string
) => void,
onError?: (error: Error) => void,
onModel?: (model: string) => void
): Promise<string> {
try {
const responseText = await response.text();
if (!responseText.trim()) {
const noResponseError = new Error('No response received from server. Please try again.');
throw noResponseError;
}
const data: ApiCompletionResponse = JSON.parse(responseText);
if (data.model) {
onModel?.(data.model);
}
const content = data.content || '';
if (!content.trim()) {
const noResponseError = new Error('No response received from server. Please try again.');
throw noResponseError;
}
onComplete?.(content, undefined, data.timings, undefined);
return content;
} catch (error) {
const err = error instanceof Error ? error : new Error('Parse error');
onError?.(err);
throw err;
}
}
}

View File

@ -0,0 +1,51 @@
import { ChatService } from '$lib/services/chat';
import { config } from '$lib/stores/settings.svelte';
export class NotebookStore {
content = $state('');
isGenerating = $state(false);
abortController: AbortController | null = null;
async generate(model?: string) {
if (this.isGenerating) return;
this.isGenerating = true;
this.abortController = new AbortController();
try {
const currentConfig = config();
await ChatService.sendCompletion(
this.content,
{
...currentConfig,
model,
stream: true,
onChunk: (chunk) => {
this.content += chunk;
},
onComplete: () => {
this.isGenerating = false;
},
onError: (error) => {
console.error('Notebook generation error:', error);
this.isGenerating = false;
}
},
this.abortController.signal
);
} catch (error) {
console.error('Notebook generation failed:', error);
this.isGenerating = false;
}
}
stop() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
this.isGenerating = false;
}
}
export const notebookStore = new NotebookStore();

View File

@ -23,12 +23,12 @@ export interface ApiContextSizeError {
export interface ApiErrorResponse {
error:
| ApiContextSizeError
| {
code: number;
message: string;
type?: string;
};
| ApiContextSizeError
| {
code: number;
message: string;
type?: string;
};
}
export interface ApiChatMessageData {
@ -219,6 +219,39 @@ export interface ApiChatCompletionRequest {
timings_per_token?: boolean;
}
export interface ApiCompletionRequest {
prompt: string;
stream?: boolean;
model?: string;
// Generation parameters
temperature?: number;
max_tokens?: number;
// Sampling parameters
dynatemp_range?: number;
dynatemp_exponent?: number;
top_k?: number;
top_p?: number;
min_p?: number;
xtc_probability?: number;
xtc_threshold?: number;
typ_p?: number;
// Penalty parameters
repeat_last_n?: number;
repeat_penalty?: number;
presence_penalty?: number;
frequency_penalty?: number;
dry_multiplier?: number;
dry_base?: number;
dry_allowed_length?: number;
dry_penalty_last_n?: number;
// Sampler configuration
samplers?: string[];
backend_sampling?: boolean;
// Custom parameters (JSON string)
custom?: Record<string, unknown>;
timings_per_token?: boolean;
}
export interface ApiChatCompletionToolCallFunctionDelta {
name?: string;
arguments?: string;
@ -258,6 +291,32 @@ export interface ApiChatCompletionStreamChunk {
prompt_progress?: ChatMessagePromptProgress;
}
export interface ApiCompletionStreamChunk {
content: string;
stop: boolean;
model: string;
timings?: {
prompt_n?: number;
prompt_ms?: number;
predicted_n?: number;
predicted_ms?: number;
cache_n?: number;
};
}
export interface ApiCompletionResponse {
content: string;
stop: boolean;
model: string;
timings?: {
prompt_n?: number;
prompt_ms?: number;
predicted_n?: number;
predicted_ms?: number;
cache_n?: number;
};
}
export interface ApiChatCompletionResponse {
model?: string;
choices: Array<{

View File

@ -0,0 +1,9 @@
<script lang="ts">
import { NotebookScreen } from '$lib/components/app';
</script>
<svelte:head>
<title>Notebook - llama.cpp</title>
</svelte:head>
<NotebookScreen />