diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9c47e45..35e170c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,12 +2,12 @@ name: Docker (GHCR) on: push: - branches: [main] + branches: [main, release] workflow_dispatch: concurrency: group: docker-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: true env: IMAGE: ghcr.io/${{ github.repository }} @@ -63,9 +63,19 @@ jobs: echo "No version change detected" fi - build-and-push-dev: + build-dev: if: github.ref == 'refs/heads/main' - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} steps: - name: Checkout uses: actions/checkout@v4 @@ -80,30 +90,75 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (dev tags, labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE }} - tags: | - type=raw,value=dev - type=sha,prefix=dev- - - - name: Build and push dev image + - name: Build and push ${{ matrix.platform }} dev image by digest + id: build uses: docker/build-push-action@v6 with: context: . - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - build-and-push-release: + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=dev-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=dev-${{ matrix.arch }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-dev-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge-dev: + if: github.ref == 'refs/heads/main' + needs: build-dev + runs-on: ubuntu-latest + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-dev-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create dev multi-arch manifest + working-directory: /tmp/digests + run: | + SHORT_SHA="${GITHUB_SHA::7}" + docker buildx imagetools create \ + -t "${{ env.IMAGE }}:dev" \ + -t "${{ env.IMAGE }}:dev-${SHORT_SHA}" \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) + + build-release: needs: detect-version-change if: github.ref == 'refs/heads/release' && needs.detect-version-change.outputs.version_changed == 'true' - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} steps: - name: Checkout uses: actions/checkout@v4 @@ -118,23 +173,59 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (release tags, labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE }} - tags: | - type=raw,value=latest - type=raw,value=${{ needs.detect-version-change.outputs.version }} - type=semver,pattern={{major}}.{{minor}},value=v${{ needs.detect-version-change.outputs.version }} - - - name: Build and push release image + - name: Build and push ${{ matrix.platform }} release image by digest + id: build uses: docker/build-push-action@v6 with: context: . - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=release-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-release-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge-release: + needs: [detect-version-change, build-release] + if: github.ref == 'refs/heads/release' && needs.detect-version-change.outputs.version_changed == 'true' + runs-on: ubuntu-latest + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-release-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create release multi-arch manifest + working-directory: /tmp/digests + run: | + VERSION="${{ needs.detect-version-change.outputs.version }}" + MAJOR_MINOR="${VERSION%.*}" + docker buildx imagetools create \ + -t "${{ env.IMAGE }}:latest" \ + -t "${{ env.IMAGE }}:${VERSION}" \ + -t "${{ env.IMAGE }}:${MAJOR_MINOR}" \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) diff --git a/Dockerfile b/Dockerfile index c6116f7..667340d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ── Stage 1: Build the SvelteKit frontend ────────────────── -FROM node:22-slim AS frontend-builder +FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-builder WORKDIR /build/frontend COPY cptr/frontend/package.json cptr/frontend/package-lock.json ./ @@ -9,7 +9,7 @@ RUN npm run build # ── Stage 2: Install Python dependencies & build wheel ───── -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS backend-builder +FROM --platform=$BUILDPLATFORM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS backend-builder WORKDIR /build COPY pyproject.toml uv.lock LICENSE README.md CHANGELOG.md ./ diff --git a/README.md b/README.md index 2723139..8f3e5bf 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,25 @@ Opens in your browser. From other devices: cptr run --host 0.0.0.0 ``` +## Docker + +Run cptr with Docker: + +```bash +docker run --rm -it \ + -p 8000:8000 \ + -v cptr-data:/data \ + -v "$PWD:/workspace" \ + -w /workspace \ + ghcr.io/open-webui/computer:latest +``` + +Then open the URL printed in the logs, usually `http://localhost:8000/?token=...`. + +`cptr` stores its state in `/data`. Mount your project into the container, like `-v "$PWD:/workspace"`, so cptr can access it. + +The `:dev` image is also available and tracks the `main` branch. + ## License Open Use License. Source available. All rights reserved. See [LICENSE](LICENSE). [Enterprise licenses available](mailto:sales@openwebui.com). diff --git a/cptr/env.py b/cptr/env.py index 253b269..cfbc5db 100644 --- a/cptr/env.py +++ b/cptr/env.py @@ -25,6 +25,8 @@ ENABLE_CHAT_RECONCILE_ON_STARTUP: bool = os.environ.get( "ENABLE_CHAT_RECONCILE_ON_STARTUP", "true" ).lower() in ("true", "1", "yes") +CHAT_TOOL_MAX_CHARS = int(os.environ.get("CHAT_TOOL_MAX_CHARS", "50000")) +CHAT_TOOL_COMMAND_MAX_CHARS = int(os.environ.get("CHAT_TOOL_COMMAND_MAX_CHARS", "8000")) # ── AI stream settings ────────────────────────────────────── STREAM_CONNECT_TIMEOUT_SECONDS = float(os.environ.get("CPTR_STREAM_CONNECT_TIMEOUT", "30")) diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index 8fdad08..ad4a84d 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -66,7 +66,7 @@ export const sendMessage = ( parentId?: string | null, params: { tool_approval_mode?: string } = {}, regenerationPrompt?: string, - files?: string[] + files?: { id: string; name: string; url: string; type: string }[] ) => fetchJSON( '/api/chats', diff --git a/cptr/frontend/src/lib/apis/files.ts b/cptr/frontend/src/lib/apis/files.ts index b382a7a..525bf0d 100644 --- a/cptr/frontend/src/lib/apis/files.ts +++ b/cptr/frontend/src/lib/apis/files.ts @@ -30,6 +30,9 @@ export const createEntry = (path: string, type: 'file' | 'directory') => export const uploadFiles = (path: string, form: FormData) => fetchHandler('/api/workspace/files/upload', { method: 'POST', body: form }); +export const uploadFile = (form: FormData) => + fetchJSON<{ id: string; url: string }>('/api/files', { method: 'POST', body: form }); + export const downloadArchive = (paths: string[]) => fetchHandler('/api/workspace/files/archive', { method: 'POST', ...jsonBody({ paths }) }); diff --git a/cptr/frontend/src/lib/components/Icon.svelte b/cptr/frontend/src/lib/components/Icon.svelte index 9162d19..a66384d 100644 --- a/cptr/frontend/src/lib/components/Icon.svelte +++ b/cptr/frontend/src/lib/components/Icon.svelte @@ -146,11 +146,11 @@ d="M16 12H17.4C17.7314 12 18 12.2686 18 12.6V19.4C18 19.7314 17.7314 20 17.4 20H6.6C6.26863 20 6 19.7314 6 19.4V12.6C6 12.2686 6.26863 12 6.6 12H8M16 12V8C16 6.66667 15.2 4 12 4C8.8 4 8 6.66667 8 8V12M16 12H8" /> {:else if name === 'docker'} - - - - - + {:else if name === 'empty-page'} ([]); + let isDragging = $state(false); + + async function processFiles(files: File[]) { + for (const file of files) { + const id = Math.random().toString(36).substring(7); + const isImage = file.type.startsWith('image/'); + const type = isImage ? 'image' : 'file'; + attachedUploads = [...attachedUploads, { id, name: file.name, url: '', type, loading: true }]; + + try { + const form = new FormData(); + form.append('file', file); + const res = await uploadFile(form); + if (res && res.id) { + attachedUploads = attachedUploads.map(u => + u.id === id ? { ...u, id: res.id, url: res.url, loading: false } : u + ); + } else { + attachedUploads = attachedUploads.filter(u => u.id !== id); + } + } catch (err) { + console.error("Upload failed", err); + attachedUploads = attachedUploads.filter(u => u.id !== id); + } + } + } + + function handleDrop(e: DragEvent) { + e.preventDefault(); + isDragging = false; + if (e.dataTransfer?.files) { + processFiles(Array.from(e.dataTransfer.files)); + } + } + + function handlePaste(e: ClipboardEvent) { + if (e.clipboardData?.items) { + const files: File[] = []; + for (const item of Array.from(e.clipboardData.items)) { + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) files.push(file); + } + } + if (files.length > 0) { + e.preventDefault(); // Stop TipTap from inserting base64 strings + processFiles(files); + } + } + } + + function removeUpload(id: string) { + attachedUploads = attachedUploads.filter(u => u.id !== id); + } + // ── @file mention suggestion ──────────────────── let popupEl: HTMLDivElement | null = null; let popupComponent: Record | null = null; @@ -290,17 +349,26 @@ // TipTap auto-sizes; no-op kept for API compat } - /** Get file paths from @mentions in the current editor content. */ - export function getFiles(): string[] { - if (!editor) return []; - return extractMentionedFiles(editor.getJSON()); + export function getFiles(): any[] { + return attachedUploads.filter(u => !u.loading); + } + + export function clearUploads() { + attachedUploads = []; } // Allow sending during streaming (message will be enqueued server-side) const canSend = $derived(inputText.trim() && selectedModel && !sending); -
+
{ e.preventDefault(); isDragging = true; }} + ondragleave={() => { isDragging = false; }} + role="presentation" +> {#if queuedMessages.length > 0}
+ + {#if attachedUploads.length > 0} +
+ {#each attachedUploads as upload} +
+ {#if upload.loading} +
+
+
+ {:else if upload.type === 'image'} + {upload.name} + {:else} +
+
+ +
+
+
+
{upload.name}
+
{upload.type === 'file' ? 'File' : upload.type}
+
+
+
+ {/if} + +
+ +
+
+ {/each} +
+ {/if}
{ - /* TODO: handle file uploads */ + if (files) processFiles(Array.from(files)); }} />
diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index 02724d2..1ea0447 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -473,6 +473,7 @@ (_: string, id: string, label: string) => `[${label}](file://${id})` ); inputText = ''; + chatInputEl?.clearUploads(); autoScroll = true; await tick(); chatInputEl?.resetHeight(); @@ -876,6 +877,7 @@ {#if msg.role === 'user'} handleNavigate(msg.id, dir)} diff --git a/cptr/frontend/src/lib/components/chat/UserMessage.svelte b/cptr/frontend/src/lib/components/chat/UserMessage.svelte index f4b36b6..044f77b 100644 --- a/cptr/frontend/src/lib/components/chat/UserMessage.svelte +++ b/cptr/frontend/src/lib/components/chat/UserMessage.svelte @@ -6,12 +6,13 @@ interface Props { content: string; + meta?: Record | null; siblingIndex?: number; siblingTotal?: number; onedit?: (content: string, submit: boolean) => void; onnavigate?: (direction: -1 | 1) => void; } - let { content, siblingIndex = 0, siblingTotal = 1, onedit, onnavigate }: Props = $props(); + let { content, meta = null, siblingIndex = 0, siblingTotal = 1, onedit, onnavigate }: Props = $props(); type Segment = { type: 'text'; value: string } | { type: 'file'; label: string; path: string }; @@ -127,6 +128,29 @@
{:else} + {#if meta?.files?.length > 0} +
+ {#each meta.files as upload} +
+ {#if upload.type === 'image'} + {upload.name + {:else} +
+
+ +
+
+
+
{upload.name || 'File'}
+
{upload.type === 'file' ? 'File' : (upload.type || 'File')}
+
+
+
+ {/if} +
+ {/each} +
+ {/if}
starts-with > contains. - Within each tier, shorter names rank higher (more specific match). + Results are ranked by where/how the query matches: exact name > name prefix > + exact path > path prefix > name contains > path contains. Within each tier, + shorter paths rank higher (more specific match). """ root = Path(path).resolve() if not root.exists() or not root.is_dir(): raise HTTPException(status_code=404, detail=f"Path not found: {path}") - query_lower = query.lower() + query_lower = query.strip().lower().replace("\\", "/") # Collect all matches first, then rank - matches: list[tuple[int, int, SearchResult]] = [] # (score, name_len, result) + matches: list[tuple[int, int, SearchResult]] = [] # (score, path_len, result) max_collect = limit * 10 # collect more than needed for ranking def walk(directory: Path, depth: int = 0): diff --git a/cptr/utils/ai.py b/cptr/utils/ai.py index e0f982b..45eb39d 100644 --- a/cptr/utils/ai.py +++ b/cptr/utils/ai.py @@ -148,7 +148,23 @@ def _to_anthropic_messages(messages: list[dict]) -> list[dict]: role = m["role"] if role == "system": continue # system goes in body.system + content = m.get("content", "") + if isinstance(content, list): + formatted_content = [] + for block in content: + if block.get("type") == "text": + formatted_content.append({"type": "text", "text": block.get("text", "")}) + elif block.get("type") == "image": + formatted_content.append({ + "type": "image", + "source": { + "type": "base64", + "media_type": block.get("media_type", "image/jpeg"), + "data": block.get("base64", "") + } + }) + content = formatted_content if role == "tool": # tool result → Anthropic tool_result block result.append( @@ -283,7 +299,24 @@ def _to_openai_messages(messages: list[dict], instructions: str) -> list[dict]: for m in messages: if m["role"] == "system": continue - result.append(m) + + content = m.get("content", "") + if isinstance(content, list): + formatted_content = [] + for block in content: + if block.get("type") == "text": + formatted_content.append({"type": "text", "text": block.get("text", "")}) + elif block.get("type") == "image": + data_uri = f"data:{block.get('media_type', 'image/jpeg')};base64,{block.get('base64', '')}" + formatted_content.append({ + "type": "image_url", + "image_url": {"url": data_uri} + }) + new_m = dict(m) + new_m["content"] = formatted_content + result.append(new_m) + else: + result.append(m) return result @@ -407,7 +440,22 @@ def _to_responses_input(messages: list[dict], instructions: str) -> list[dict]: } ) else: - items.append({"role": role, "content": m.get("content", "")}) + content = m.get("content", "") + if isinstance(content, list): + formatted_content = [] + for block in content: + if block.get("type") == "text": + formatted_content.append({"type": "input_text", "text": block.get("text", "")}) + elif block.get("type") == "image": + # Not all models support input_image, but this is the Responses API spec + data_uri = f"data:{block.get('media_type', 'image/jpeg')};base64,{block.get('base64', '')}" + formatted_content.append({ + "type": "input_image", + "image_url": data_uri + }) + items.append({"role": role, "content": formatted_content}) + else: + items.append({"role": role, "content": content}) return items diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index ecd9a21..2a7cc64 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -375,18 +375,58 @@ async def _load_message_history(chat_id: str, message_id: str) -> list[dict]: continue entry: dict = {"role": m.role, "content": m.content or ""} - # Inject @file mention contents into user messages + # Transform uploaded images into base64 multimodal blocks; inline text files if m.role == "user": attached_files = (m.meta or {}).get("files", []) - if attached_files: - file_parts = [] - for fpath in attached_files: - try: - text = Path(fpath).read_text(errors="replace")[:50_000] - file_parts.append(f"--- {fpath} ---\n{text}") - except Exception: - file_parts.append(f"--- {fpath} --- (unreadable)") - entry["content"] += "\n\n" + "\n\n".join(file_parts) + images = [ + f for f in attached_files + if isinstance(f, dict) and (f.get("type") == "image" or (f.get("content_type") or "").startswith("image/")) + ] + non_images = [ + f for f in attached_files + if isinstance(f, dict) and f not in images + ] + + if images or non_images: + from cptr.utils.storage import get_storage + import base64 + + text_content = entry["content"] + + # Append file:// references so the AI can read them with view_file + if non_images: + from cptr.utils.storage import UPLOADS_DIR + file_refs = [] + for f in non_images: + file_id = f.get("id") + if not file_id: + continue + name = f.get("name", "file") + file_path = UPLOADS_DIR / file_id + file_refs.append(f"[{name}](file://{file_path})") + if file_refs: + text_content += "\n\nAttached files:\n" + "\n".join(file_refs) + + content_blocks = [{"type": "text", "text": text_content}] if text_content else [] + + for img in images: + file_id = img.get("id") + if not file_id: + continue + data = await get_storage().get(file_id) + if data: + b64_str = base64.b64encode(data).decode("utf-8") + ctype = img.get("content_type") or "image/png" + content_blocks.append({ + "type": "image", + "media_type": ctype, + "base64": b64_str + }) + + if len(content_blocks) > (1 if text_content else 0): + entry["content"] = content_blocks + elif text_content != entry["content"]: + entry["content"] = text_content # Reconstruct tool calls from output items for the provider if m.output: diff --git a/cptr/utils/tools.py b/cptr/utils/tools.py index 56679f7..c95d1a2 100644 --- a/cptr/utils/tools.py +++ b/cptr/utils/tools.py @@ -14,6 +14,7 @@ import uuid from pathlib import Path from typing import get_type_hints +from cptr.env import CHAT_TOOL_COMMAND_MAX_CHARS, CHAT_TOOL_MAX_CHARS # ── Background task state ─────────────────────────────────── @@ -74,7 +75,8 @@ async def read_file( workspace: str, ) -> str: """Read file contents with optional line range. Lines are 1-indexed. - :param path: Path relative to workspace root. + Supports absolute paths for user-attached files. + :param path: Path relative to workspace root, or absolute path for attached files. :param start_line: First line to read (1-indexed, 0 = from beginning). :param end_line: Last line to read (inclusive, 0 = to end of file). """ @@ -145,11 +147,6 @@ async def list_directory( except OSError: sz = 0 entries.append(f"{rel / f} ({_human_size(sz)})") - if len(entries) > 500: - entries.append("... (truncated at 500)") - break - if len(entries) > 500: - break else: for item in sorted(full.iterdir()): if item.name in ignore: @@ -167,7 +164,8 @@ async def list_directory( sz = 0 entries.append(f"{item.name} ({_human_size(sz)})") - return "\n".join(entries) if entries else "(empty directory)" + res = "\n".join(entries) if entries else "(empty directory)" + return _truncate_output(res, max_chars=CHAT_TOOL_MAX_CHARS) async def search_files( @@ -194,10 +192,12 @@ async def search_files( # Try ripgrep first try: - return await _search_rg(query, full, regex, case_insensitive, include, filenames_only) + res = await _search_rg(query, full, regex, case_insensitive, include, filenames_only) except FileNotFoundError: # ripgrep not installed, fall back to Python - return await _search_python(query, full, case_insensitive) + res = await _search_python(query, full, case_insensitive) + + return _truncate_output(res, max_chars=CHAT_TOOL_MAX_CHARS) async def _search_rg( @@ -464,7 +464,7 @@ async def run_command( timeout = min(max(timeout, 5), 300) stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) output = stdout.decode(errors="replace").strip() - output = _truncate_output(output) + output = _truncate_output(output, max_chars=CHAT_TOOL_COMMAND_MAX_CHARS) if proc.returncode != 0: return f"Exit code {proc.returncode}\n{output}" @@ -488,7 +488,7 @@ async def check_task(task_id: str, *, workspace: str) -> str: proc = task["proc"] output = task["output"].decode(errors="replace") - output = _truncate_output(output) + output = _truncate_output(output, max_chars=CHAT_TOOL_COMMAND_MAX_CHARS) done = task.get("done", False) or proc.returncode is not None if done: @@ -544,7 +544,20 @@ async def read_url(url: str, *, workspace: str) -> str: def _resolve_path(path: str, workspace: str) -> Path: - """Resolve a relative path within the workspace. Rejects traversal.""" + """Resolve a path within the workspace or uploads dir. Rejects traversal.""" + from cptr.utils.storage import UPLOADS_DIR + + p = Path(path) + # Allow absolute paths to the uploads directory (for user-attached files) + if p.is_absolute(): + full = p.resolve() + uploads = str(UPLOADS_DIR.resolve()) + ws = str(Path(workspace).resolve()) + if str(full).startswith(uploads) or str(full).startswith(ws): + return full + raise ValueError(f"Path outside allowed directories: {path}") + + # Relative paths resolve against workspace ws = Path(workspace).resolve() full = (ws / path).resolve() if not str(full).startswith(str(ws)):