Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.4] - 2026-06-06

### Fixed

- 🔄 **Chat streaming no longer drops final content.** Fixed a race where the `done` socket event arrived before the DB commit, causing a transient blank message. The streamed content now stays visible until the reload confirms it.
- 🔄 **Unflushed text no longer lost at end of chat responses.** The final text buffer is now properly flushed and emitted before marking a message as done, across all exit paths (normal completion, cancellation, and errors).
- 🔄 **Intermediate chat state persisted during tool loops.** Content and output items are now saved to the database between tool call iterations, so progress survives crashes or disconnects.
- 🧹 **In-memory task state cleaned up on completion.** The `_task_state` dict entry is now removed when a chat task finishes (done, cancelled, errored, or max iterations), preventing unbounded memory growth.
- 📱 **Sidebar stays closed on mobile.** The sidebar default breakpoint was raised to 1024px and an auto-close listener now collapses it whenever the viewport shrinks below 768px.
- 🔄 **Stale chat loads discarded.** Rapid chat switches no longer apply data from a slow earlier load, fixing a race condition that could show the wrong conversation.

### Changed

- ⚡ **All blocking filesystem I/O offloaded to threads.** File reads, writes, directory walks, search, archive creation, uploads, renames, and deletions in the workspace and tool routers now run via `asyncio.to_thread()`, preventing event-loop stalls under heavy file operations.
- ⚡ **Port scanner made fully async.** Platform-specific port scanning (Darwin `lsof`, Linux `/proc`, Windows `netstat`) and PID-to-process lookups now use `asyncio.create_subprocess_exec` or `asyncio.to_thread` instead of blocking `subprocess.run`.
- ⚡ **Welcome endpoint system info collected off the event loop.** The `/welcome` handler now gathers hostname, memory, disk, CPU, network, and process data in a background thread.

## [0.1.3] - 2026-06-06

### Fixed
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ Then open the URL printed in the logs, usually `http://localhost:8000/?token=...

The `:dev` image is also available and tracks the `main` branch.

## Security model

cptr is designed as **your computer, served to you**. Once authenticated, a user has full access to the host filesystem and shell, equivalent to an SSH session. There is no path sandboxing and no per-user isolation.

This is safe when you are the only user and you control the network. It is not safe if untrusted users share the instance, it is exposed to the public internet, or a reverse proxy forwards spoofable auth headers. Treat a shared cptr like an open SSH port.

## License

Open Use License. Source available. All rights reserved. See [LICENSE](LICENSE). [Enterprise licenses available](mailto:sales@openwebui.com).
4 changes: 2 additions & 2 deletions cptr/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cptr/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down
31 changes: 22 additions & 9 deletions cptr/frontend/src/lib/components/chat/AssistantMessage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,20 @@

type DisplayItem = ToolGroup | MessageItem | ArtifactItem;

const outputText = $derived.by((): string => {
return (output || [])
.filter((i: any) => i.type === 'message')
.flatMap((i: any) => i.content || [])
.map((c: any) => c.text || '')
.join('');
});

const unrenderedContent = $derived.by((): string => {
if (!content) return '';
if (!outputText) return content;
return content.startsWith(outputText) ? content.slice(outputText.length) : '';
});

const displayItems = $derived.by((): DisplayItem[] => {
if (!output?.length) return [];

Expand Down Expand Up @@ -317,6 +331,9 @@
class="inline-block w-[2px] h-3.5 bg-gray-400 dark:bg-gray-500 ml-0.5 animate-pulse align-text-bottom"
></span>
{:else}
{#if done && displayItems.length === 0 && content}
<MarkdownRenderer {content} />
{/if}
{#each displayItems as displayItem, groupIdx}
{#if displayItem.type === 'message_item'}
<MarkdownRenderer
Expand Down Expand Up @@ -805,15 +822,11 @@
</div>
{/if}
{/each}
{#if !done}
{@const flushedText = (output || [])
.filter((i: any) => i.type === 'message')
.flatMap((i: any) => i.content || [])
.map((c: any) => c.text)
.join('')}
{@const pendingText = content.slice(flushedText.length)}
{#if pendingText}
<MarkdownRenderer content={pendingText} />
{#if done && unrenderedContent && displayItems.length > 0}
<MarkdownRenderer content={unrenderedContent} />
{:else if !done}
{#if unrenderedContent}
<MarkdownRenderer content={unrenderedContent} />
{/if}
<span
class="inline-block w-[2px] h-3.5 bg-gray-400 dark:bg-gray-500 ml-0.5 animate-pulse align-text-bottom"
Expand Down
24 changes: 20 additions & 4 deletions cptr/frontend/src/lib/components/chat/ChatPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@
import { socketStore } from '$lib/stores/socket.svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { get } from 'svelte/store';
import { currentWorkspace, toolApprovalMode, streamingBehavior, selectedModelId } from '$lib/stores';
import {
currentWorkspace,
toolApprovalMode,
streamingBehavior,
selectedModelId
} from '$lib/stores';

import ChatInput from './ChatInput.svelte';
import UserMessage from './UserMessage.svelte';
Expand Down Expand Up @@ -107,8 +112,8 @@
const effectiveId =
currentMessageId && msgMap.has(currentMessageId)
? currentMessageId
: allMessages.length > 0
? allMessages[allMessages.length - 1].id
: displayMessages.length > 0
? displayMessages[displayMessages.length - 1].id
: null;

if (!effectiveId) return [];
Expand Down Expand Up @@ -203,8 +208,11 @@

// ── Load chat from DB ───────────────────────────────────────

let loadGeneration = 0;

async function loadChat(id: string) {
chatId = id;
const gen = ++loadGeneration;
// Only show loading spinner on initial load (no messages yet).
// On reloads (e.g. after cancel/done), keep the DOM intact to preserve scroll position.
const isInitialLoad = allMessages.length === 0;
Expand All @@ -215,10 +223,12 @@

try {
const data = await getChat(id);
// Discard stale response if a newer loadChat was called while we waited
if (gen !== loadGeneration) return;
allMessages = data.messages;
currentMessageId = data.chat.current_message_id;
} finally {
if (isInitialLoad) loading = false;
if (isInitialLoad && gen === loadGeneration) loading = false;
}

// On reloads, restore scroll position after the DOM re-renders.
Expand Down Expand Up @@ -347,6 +357,12 @@
cancelledMessageId = null;
return;
}

// Mark done optimistically, but keep the streamed content visible until
// the DB reload returns. This avoids a transient blank message if the
// final `done` socket event beats the commit/read path.
msg.done = true;
allMessages = [...allMessages];
loadChat(data.chat_id);
}
}
Expand Down
11 changes: 10 additions & 1 deletion cptr/frontend/src/lib/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,17 @@ export const workspaceList = writable<{ path: string; name: string }[]>([]);

/** Global user preferences. */
export const sidebarOpen = writable(
typeof window !== 'undefined' ? window.innerWidth >= 768 : false
typeof window !== 'undefined' ? window.innerWidth >= 1024 : false
);

// Auto-close sidebar when window is resized below mobile breakpoint
if (typeof window !== 'undefined') {
window.addEventListener('resize', () => {
if (window.innerWidth < 768) {
sidebarOpen.set(false);
}
});
}
export const sidebarWidth = writable(220);
export const theme = writable<Theme>('dark');
export const toolApprovalMode = writable<ToolApprovalMode>('auto');
Expand Down
27 changes: 16 additions & 11 deletions cptr/routers/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import asyncio
from pathlib import Path
from typing import List, Optional

Expand Down Expand Up @@ -77,14 +78,18 @@ async def list_chats(
if not chats_dir.exists():
return {"chats": [], "total": 0, "has_more": False}

# Collect chat IDs and their relative folder paths
chat_entries: list[dict] = []
for json_file in chats_dir.rglob("*.json"):
chat_id = json_file.stem
rel_folder = str(json_file.parent.relative_to(chats_dir))
if rel_folder == ".":
rel_folder = ""
chat_entries.append({"id": chat_id, "folder": rel_folder})
# Scan filesystem for chat JSON files in a thread
def _scan_chat_files() -> list[dict]:
entries = []
for json_file in chats_dir.rglob("*.json"):
chat_id = json_file.stem
rel_folder = str(json_file.parent.relative_to(chats_dir))
if rel_folder == ".":
rel_folder = ""
entries.append({"id": chat_id, "folder": rel_folder})
return entries

chat_entries = await asyncio.to_thread(_scan_chat_files)

if not chat_entries:
return {"chats": [], "total": 0, "has_more": False}
Expand Down Expand Up @@ -296,7 +301,7 @@ async def delete_chat(chat_id: str, request: Request):
workspace = chat.meta.get("workspace", "") if chat.meta else ""
if workspace:
marker = Path(workspace) / ".cptr" / "chats" / f"{chat_id}.json"
marker.unlink(missing_ok=True)
await asyncio.to_thread(marker.unlink, True) # missing_ok=True

await Chat.delete(chat_id)
return {"ok": True}
Expand Down Expand Up @@ -344,10 +349,10 @@ async def send_message(body: SendMessageRequest, request: Request):
)
# Ensure .cptr/chats/ dir exists (export will write the full JSON)
chats_dir = Path(body.workspace) / ".cptr" / "chats"
chats_dir.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(lambda: chats_dir.mkdir(parents=True, exist_ok=True))

# Auto-add .cptr to .gitignore if this is a git repo
_ensure_gitignore(body.workspace)
await asyncio.to_thread(_ensure_gitignore, body.workspace)

# Check if the chat has an in-progress assistant message.
# If so, queue this message instead of starting a new task.
Expand Down
Loading
Loading