Compare commits

...

33 Commits

Author SHA1 Message Date
zhayujie
6211e63f90 fix(desktop): avoid false init failure after long background 2026-06-24 19:19:24 +08:00
zhayujie
44b61684ed feat(desktop): first-run onboarding, OS-language default, native polish 2026-06-24 16:38:51 +08:00
zhayujie
ab6f49a822 fix(desktop): correct electron-updater import for commonjs 2026-06-24 11:01:45 +08:00
zhayujie
02517e4a01 fix(ci): never mark pre-release versions as latest 2026-06-24 10:32:12 +08:00
zhayujie
2599966cf7 fix(ci): migrate retired macos-13 runner to macos-15-intel 2026-06-24 10:11:18 +08:00
zhayujie
6c68931892 fix(ci): publish per-platform instead of all-or-nothing 2026-06-24 10:04:05 +08:00
zhayujie
41855ed511 fix(ci): only enable mac signing when certificate secret is set
GitHub injects unset secrets as empty strings, and electron-builder treats
an empty CSC_LINK as a (broken) certificate path, aborting the mac build
with "desktop not a file". Export the signing vars only when non-empty so
unsigned builds fall back cleanly, matching local behavior. Windows builds
already passed; guarded the same way for consistency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 20:57:01 +08:00
zhayujie
02bc91f4af fix(ci): track desktop backend packaging sources
The global build/ gitignore rule was silently excluding the PyInstaller
spec, desktop requirements, and build script under desktop/build/, so the
release workflow failed at "pip install -r requirements-desktop.txt" with
a missing-file error. Re-include just those source files while keeping the
build outputs (dist/, build-work/) and local venv ignored.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 20:46:14 +08:00
zhayujie
e9352e6984 feat(desktop): add auto-update via electron-updater + manual CI trigger 2026-06-23 20:37:27 +08:00
zhayujie
ec4c36f450 feat(desktop): redirect writable data to ~/.cow for packaged app
Introduce get_data_root() driven by the COW_DATA_DIR env var so the
packaged desktop build stores config.json, run.log, user data and
WeChat credentials under ~/.cow — surviving app updates and keeping the
app bundle read-only. Source deployments leave COW_DATA_DIR unset and
fall back to the repo root, so existing behavior is unchanged.
2026-06-23 17:22:53 +08:00
zhayujie
215ed24401 feat(desktop): add log viewer entry points (NavRail + Help menu) 2026-06-22 16:30:11 +08:00
zhayujie
c432681b2b fix(desktop): prevent backend zombie process and IPC listener leaks 2026-06-22 15:46:49 +08:00
zhayujie
49452e035d feat(desktop): native desktop enhancements
- Add a minimal application menu with common items and shortcuts:
  New Chat (Cmd+N), Settings (Cmd+,), Reload, etc.
- Add system tray with show window, new chat, and quit; click icon to restore
- Add single-instance lock so relaunching focuses the existing window
- Implement close-to-tray: closing the window hides it; only a real quit destroys it
- Explicitly define Window menu's Close Window bound to Cmd+W / Ctrl+W to reliably trigger hide-to-tray
- Listen for menu-action in the renderer to handle menu-triggered new chat / open settings
2026-06-22 15:32:22 +08:00
zhayujie
d1336b872e feat(desktop): rework Skills page with semantic theming 2026-06-21 19:33:11 +08:00
zhayujie
e1e29b32e9 feat(desktop): add QR scan login for channels 2026-06-21 17:27:18 +08:00
zhayujie
214dcaf141 feat(desktop): rework Scheduled Tasks page with edit & delete 2026-06-20 17:34:50 +08:00
zhayujie
77a196de8b feat(desktop): rework Memory page to match web console 2026-06-20 17:08:23 +08:00
zhayujie
108d04398b feat(desktop): implement Knowledge Base page 2026-06-20 16:35:06 +08:00
zhayujie
c9c16298ec feat(desktop): merge settings and models into tabs; improve model config and chat scroll
Merge the "Models" menu item into the "Settings" page with top tabs
(Basic / Models), reducing one nav entry. Basic settings now only handles
provider + model selection; API key/base are consolidated into the Models
tab. Unconfigured providers are marked in the dropdown and guide the user
to configure them.

The Models tab covers all 7 capabilities (chat/vision/image/asr/tts/
embedding/search) with vendor credentials and routing. The vendor section
follows the web client's unified design: built-in and custom providers
share one grid, "set as default" is removed, and credentials are configured
via modals.

- Add settings/ directory: SettingsPage (tab shell), BasicSettings, ModelsTab,
  CapabilityCard, primitives, modelsHelpers; remove the old ConfigPage
- Rewrite ModelEntry/ModelProvider/CapabilityState/SearchCapabilityState in
  types.ts to match the real /api/models shape (mixed string|{value,hint})
- Fix empty chat model dropdown: chat has no provider_models, so fall back
  to the top-level providers[].models
- Fix chat scroll sliding from top on session switch: snap instantly to
  bottom on switch, smooth-scroll only for streaming updates
- Rename the Knowledge menu label and align model/config copy with the web
  client (vendor credentials, main model, etc.)
- Clean up orphaned i18n keys
2026-06-20 11:35:47 +08:00
zhayujie
2ef31d5d33 fix(desktop): adapt remaining pages to updated types 2026-06-20 00:40:15 +08:00
zhayujie
e9d9b566a4 feat(desktop): chat core with streaming, sessions, tool steps and markdown-it rendering 2026-06-20 00:40:11 +08:00
zhayujie
3baa3252bc feat(desktop): align API client and types with latest web backend endpoints 2026-06-20 00:39:47 +08:00
zhayujie
90d9db0f83 feat(desktop): platform-aware shell, design tokens and three-column layout 2026-06-20 00:39:38 +08:00
zhayujie
8bff4f1658 Merge branch 'master' into feat-cow-desktop 2026-06-19 21:35:29 +08:00
zhayujie
a0e20ef311 Merge branch 'pr-2909' 2026-06-19 21:07:32 +08:00
zhayujie
6996215d3b feat(models): custom model explanation standardization 2026-06-19 21:06:56 +08:00
zhayujie
03ffa2db7d refactor(web): drop hardcoded preset models for custom providers & fix handler bug
- Remove vendor-specific preset model lists for custom embedding/vision
  providers; users now type the model id manually (lists vary per vendor).
- Strip third-party vendor names from comments.
- Fix AttributeError in _set_vision/_set_embedding: the provider constants
  live on ModelsHandler, not ConfigHandler, so selecting a built-in
  provider crashed. Also replace the fragile [:-1] slice with an explicit
  non-custom filter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 20:59:10 +08:00
zhayujie
75e3110e8c Merge pull request #2905 from Jiangrong-W/harness-fix/net-egress-cowagent-browser-navigate-no-internal-url-guard
fix(browser): block link-local / cloud-metadata navigation (SSRF guard)
2026-06-19 20:36:28 +08:00
christop
033480eef1 fix(browser): block link-local / cloud-metadata navigation (SSRF guard)
The browser tool navigates to a model-supplied URL via Playwright
page.goto and then auto-snapshots the page back to the model. The
http(s) navigate path performed no filtering, so a model tool call
(including under prompt injection) could point the agent-driven browser
at the cloud-metadata endpoint (169.254.169.254) and read the
credentials back through the snapshot.

Unlike the vision/web_fetch tools, the browser legitimately needs local
pages — a dev server on localhost / 127.0.0.1 / a LAN IP — so a blanket
"block all internal" policy is the wrong default here. The guard is
therefore narrow: it resolves the hostname and rejects only link-local
addresses (169.254.0.0/16, which includes the 169.254.169.254 metadata
endpoint, and IPv6 fe80::/10) plus the AWS IPv6 IMDS address
(fd00:ec2::254), before navigation. Loopback and RFC1918/LAN stay
reachable so local dev works out of the box.

Only http/https targets are validated; the documented non-HTTP scheme
handling (about:/data:/file:) is unchanged. An operator who deliberately
needs the link-local/metadata target can opt out with
tools.browser.allow_private_targets = true.

tests/test_security_ssrf_browser_navigate.py asserts link-local/metadata
targets are blocked while loopback, RFC1918/LAN and public targets
navigate through (browser service and DNS are stubbed; no real
browser/network).

Signed-off-by: christop <825583681@qq.com>
2026-06-19 20:15:27 +08:00
HnBigVolibear
8ddfcbb125 feat(web): add custom provider support for embedding & vision models, and fix memory_get Windows path bug!
1. Embedding model: support custom provider
   - Add "custom" entry to EMBEDDING_VENDORS with supports_dim_param=False
   - Parse custom:<id> credentials and model fallback in agent_initializer
   - Expand custom_providers as custom:<id> entries in Web UI dropdown

2. Vision model: support custom provider
   - Add custom:<id> routing in _route_by_provider_id
   - Add _build_custom_provider reading credentials from custom_providers
   - Expand custom_providers in Web UI dropdown, add validation in _set_vision

3. Fix memory_get Windows path validation bug!
   - str.startswith(path+'/') always False on Windows due to backslashes, So All Users can not use "memory_get" tool in Windows.
   - Use os.path.realpath + os.sep, consistent with MemoryService

4. Fix historical needsModel:false bug preventing embedding provider switch
   - Change embedding needsModel to true in console.js
   - Support custom:<id> resolution in cow_cli /memory status, also for adding custom provider support

Closes #2908, Closes #2880
2026-06-19 18:35:44 +08:00
zhayujie
a5aaecc48d Merge pull request #2901 from 6vision/master
docs(wecom_bot): mention webhook (callback) mode in channel docs
2026-06-19 17:12:13 +08:00
6vision
a1e733080d docs(wecom_bot): mention webhook (callback) mode in channel docs 2026-06-17 20:37:51 +08:00
zhayujie
3bc6e89b74 feat: cow desktop first version 2026-03-21 16:11:05 +08:00
87 changed files with 19324 additions and 90 deletions

274
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,274 @@
name: Release Desktop
# Tag-driven release: push a tag like `v1.2.0` to build and publish the
# desktop client for macOS (arm64 + x64) and Windows (x64). The tag is the
# single source of truth for the version — it's written into package.json at
# build time, so the maintainer never edits the version by hand.
on:
push:
tags:
- "v*"
# Manual trigger for testing the full pipeline without cutting a real tag.
workflow_dispatch:
inputs:
version:
description: "Version to stamp (e.g. 1.0.0-test). Used for package.json and R2 path."
type: string
default: "0.0.0-dev"
publish_r2:
description: "Upload installers to R2 + register in D1 (needs Cloudflare secrets)"
type: boolean
default: false
permissions:
contents: write
jobs:
build:
name: Build ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
# Don't cancel the other platforms if one fails — we want to see all
# failures in a single run.
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-14
platform: mac
arch: arm64
eb_flags: --mac --arm64
- name: macOS x64
os: macos-15-intel
platform: mac
arch: x64
eb_flags: --mac --x64
- name: Windows x64
os: windows-latest
platform: win
arch: x64
eb_flags: --win --x64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive version
# Tag push: strip the leading "v" from GITHUB_REF_NAME (e.g. v1.2.0).
# Manual dispatch: use the provided version input.
id: ver
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
ref="${GITHUB_REF_NAME:-}"
echo "version=${ref#v}" >> "$GITHUB_OUTPUT"
else
echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
fi
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Build Python backend (PyInstaller)
shell: bash
run: |
python -m pip install --upgrade pip
pip install -r desktop/build/requirements-desktop.txt
pip install pyinstaller
# Run from repo root so the spec's relative datas resolve correctly.
pyinstaller desktop/build/cowagent-backend.spec \
--noconfirm \
--distpath desktop/build/dist \
--workpath desktop/build/build-work
- name: Install desktop deps
working-directory: desktop
run: npm ci
- name: Write version into package.json
working-directory: desktop
shell: bash
run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version
- name: Build & publish (electron-builder)
working-directory: desktop
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Signing secrets are passed through as-is; we only export them to the
# environment below when non-empty. An empty CSC_LINK would make
# electron-builder try to load a bogus certificate and fail, so unset
# is the correct state for unsigned builds.
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
run: |
npm run build
# Only export signing vars when provided. Empty strings are NOT the
# same as unset to electron-builder: an empty CSC_LINK is treated as
# a (broken) certificate path and aborts the build. Leaving them
# unset makes electron-builder fall back to an unsigned build.
if [ -n "$MAC_CSC_LINK" ]; then
export CSC_LINK="$MAC_CSC_LINK"
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
fi
if [ -z "$WIN_CSC_LINK" ]; then
unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD
fi
# Publish to the GitHub Release on tag pushes; otherwise build only.
if [ "${{ github.event_name }}" = "push" ]; then
PUBLISH=always
else
PUBLISH=never
fi
npx electron-builder ${{ matrix.eb_flags }} --publish "$PUBLISH"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
# One bundle per platform/arch so the publish job can collect them all.
name: cowagent-${{ matrix.platform }}-${{ matrix.arch }}
path: |
desktop/release/*.dmg
desktop/release/*.exe
desktop/release/*.yml
desktop/release/*.blockmap
if-no-files-found: ignore
retention-days: 7
# Mirror the release installers to R2 (CDN-backed) and register them in D1 so
# cowagent.ai/download/{platform}/latest can resolve and count downloads.
# Runs only on tag pushes, and is a no-op (skips) until the Cloudflare secrets
# are configured, so it never blocks unsigned/dry builds.
publish-r2:
name: Publish to R2 + D1
# Require every platform in the build matrix to succeed before publishing,
# so a release on R2/D1 is always complete (all installers present) rather
# than partial. needs: build already gates on all matrix jobs succeeding.
needs: build
runs-on: ubuntu-latest
# Run on a tag push, or on a manual dispatch when publish_r2 is checked.
if: >-
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.publish_r2 == 'true')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Guard on Cloudflare secrets
id: guard
env:
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
if [ -n "$CF_TOKEN" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish."
fi
- name: Derive version
if: steps.guard.outputs.enabled == 'true'
id: ver
run: |
if [ "${{ github.event_name }}" = "push" ]; then
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
else
echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
fi
- name: Download all build artifacts
if: steps.guard.outputs.enabled == 'true'
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Stage installers
if: steps.guard.outputs.enabled == 'true'
id: stage
run: |
mkdir -p dist
# Flatten installers from every per-platform artifact dir; only the
# user-facing installers go to R2 (updater .yml/.blockmap stay on the
# GitHub Release, which electron-updater reads directly).
find artifacts -type f \( -name '*.dmg' -o -name '*.exe' \) -exec cp {} dist/ \;
echo "Staged files:"; ls -la dist
# When the whole matrix failed there's nothing to publish; flag it so
# the R2/D1 steps skip instead of writing an empty/partial release.
if [ -n "$(ls -A dist 2>/dev/null)" ]; then
echo "has_files=true" >> "$GITHUB_OUTPUT"
else
echo "has_files=false" >> "$GITHUB_OUTPUT"
echo "::warning::No installers found in any artifact — skipping R2/D1 publish."
fi
- name: Upload installers to R2
if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VER: ${{ steps.ver.outputs.version }}
run: |
# Reuse the existing cow-skills bucket under a desktop/ prefix; this
# is served by the cdn.cowagent.ai custom domain.
for f in dist/*; do
base="$(basename "$f")"
key="desktop/v${VER}/${base}"
echo "==> Uploading $base -> r2://cow-skills/$key"
npx --yes wrangler@latest r2 object put "cow-skills/$key" \
--file "$f" --remote
done
- name: Register release rows in D1
if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VER: ${{ steps.ver.outputs.version }}
run: |
# Map each installer filename to a platform id. dmg arch is in the
# name (…-arm64.dmg / …-x64.dmg); .exe is the Windows installer.
sql_file="$(mktemp)"
# Pre-releases (e.g. 1.0.0-test / -beta / -rc.1 / -alpha / -dev) are
# recorded but NEVER marked latest, so /download/<p>/latest keeps
# serving the last stable build. They also must not clear an existing
# stable's latest flag. Only a final version (no pre-release suffix)
# becomes the new latest and clears the previous one per platform.
case "$VER" in
*-*) is_latest=0; echo "==> $VER is a pre-release; not marking latest." ;;
*) is_latest=1; echo "==> $VER is a stable release; marking latest." ;;
esac
for f in dist/*; do
base="$(basename "$f")"
size="$(stat -c%s "$f")"
case "$base" in
*arm64.dmg) platform="mac-arm64" ;;
*x64.dmg) platform="mac-x64" ;;
*.exe) platform="win" ;;
*) echo "Skipping unrecognized artifact: $base"; continue ;;
esac
key="v${VER}/${base}"
# Stable only: clear the previous latest for THIS platform first, so
# a partial backfill never wipes other platforms' latest flag.
if [ "$is_latest" = "1" ]; then
echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file"
fi
echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, ${is_latest});" >> "$sql_file"
done
echo "==> D1 statements:"; cat "$sql_file"
npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file"

13
.gitignore vendored
View File

@@ -45,3 +45,16 @@ dist/
build/
*.egg-info/
.cow.pid
# Desktop backend packaging: keep the source files (spec/requirements/script)
# tracked even though the generic build/ rule above ignores them, but never
# track the build outputs or local venv.
!desktop/build/
desktop/build/*
!desktop/build/cowagent-backend.spec
!desktop/build/requirements-desktop.txt
!desktop/build/build-backend.sh
# Icon authoring scratch dir: intermediate assets used to produce the final
# icons. Only the finished icons under desktop/resources/ should be committed.
desktop/resources/.icon-work/

View File

@@ -7,10 +7,14 @@ Supports multiple OpenAI-compatible embedding vendors:
- dashscope (Aliyun Tongyi text-embedding-v4)
- doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark)
- zhipu (ZhipuAI embedding-3)
- custom (any OpenAI-compatible endpoint)
Vendor keys here intentionally match the project's bot_type constants in
common.const (OPENAI, LINKAI, QWEN_DASHSCOPE, DOUBAO, ZHIPU_AI).
Custom providers (bot_type "custom" or "custom:<id>") reuse the same
OpenAI-compatible REST client with user-supplied api_key / api_base.
All providers share a single OpenAI-compatible REST client. Vendor-specific
behaviors (truncation, query instruction prefix) are configured via metadata.
"""
@@ -138,6 +142,22 @@ EMBEDDING_VENDORS = {
"query_instruction": "",
"max_batch_size": 64,
},
# Custom provider — any OpenAI-compatible /embeddings endpoint. The
# user must supply api_key + api_base + model via the web console
# (stored in custom_providers list or legacy custom_api_key / custom_api_base).
# Dimensions defaults to 1024 but can be overridden via config's
# embedding_dimensions. No dim-param support assumption — safest
# default for unknown endpoints.
"custom": {
"default_base_url": "",
"default_model": "",
"default_dimensions": 1024,
"supports_dim_param": False,
"needs_client_truncate": False,
"needs_client_normalize": True,
"query_instruction": "",
"max_batch_size": 64,
},
}
@@ -472,10 +492,19 @@ def create_embedding_provider(
)
final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"]
resolved_model = model or meta["default_model"]
resolved_base = api_base or meta["default_base_url"]
# Custom providers require explicit api_base and model — they cannot
# fall back to OpenAI defaults like built-in vendors do.
if provider == "custom":
if not resolved_base:
raise ValueError("Custom embedding provider requires an api_base URL")
if not resolved_model:
raise ValueError("Custom embedding provider requires a model name")
return OpenAIEmbeddingProvider(
model=model or meta["default_model"],
model=resolved_model,
api_key=api_key,
api_base=api_base or meta["default_base_url"],
api_base=resolved_base,
extra_headers=extra_headers,
dimensions=final_dim,
supports_dim_param=meta["supports_dim_param"],

View File

@@ -15,15 +15,24 @@ Launch modes (configured under `tools.browser` in config.json):
- fresh: Set `persistent` to false to fall back to a clean context every run.
"""
import ipaddress
import json
import os
import socket
from typing import Dict, Any, Optional
from urllib.parse import urlparse
from agent.tools.base_tool import BaseTool, ToolResult
from agent.tools.browser.browser_service import BrowserService
from common.log import logger
# Cloud-metadata endpoints worth blocking even though they are not link-local.
# (169.254.169.254 — AWS/GCP/Azure IMDS — is already covered by is_link_local;
# fd00:ec2::254 is the AWS IPv6 IMDS address.)
_CLOUD_METADATA_IPS = frozenset({ipaddress.ip_address("fd00:ec2::254")})
class BrowserTool(BaseTool):
"""Single tool exposing all browser actions via an 'action' parameter."""
@@ -121,6 +130,61 @@ class BrowserTool(BaseTool):
BrowserTool._shared_service = self._service
return self._service
def _allow_private_targets(self) -> bool:
"""Whether the link-local / cloud-metadata guard is disabled.
Defaults to False (guard active). Loopback and RFC1918/LAN targets are
always reachable so local dev servers work out of the box; this opt-out
only lifts the remaining block on link-local / cloud-metadata targets,
for an operator who deliberately needs them, by setting
``allow_private_targets: true`` under ``tools.browser`` in config.json.
"""
return bool(self.config.get("allow_private_targets", False))
@staticmethod
def _validate_url_safe(url: str) -> None:
"""Reject URLs that target link-local / cloud-metadata addresses (SSRF guard).
Resolves the hostname to its IP address(es) and blocks any that are
link-local (169.254.0.0/16 — which includes the 169.254.169.254
cloud-metadata endpoint — and IPv6 fe80::/10) or a known IPv6
cloud-metadata address. Also rejects URLs with no host, non-HTTP(S)
schemes, or hosts that fail DNS resolution.
Loopback and RFC1918/LAN targets are intentionally left reachable:
unlike the vision/web_fetch tools, the browser legitimately opens local
pages (a dev server on ``localhost`` / ``127.0.0.1`` / a LAN IP), so a
blanket "block all internal" policy would break that core workflow.
Raises:
ValueError: if the URL targets a disallowed address.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
hostname = parsed.hostname
if not hostname:
raise ValueError("URL has no hostname")
try:
# Resolve all addresses for the hostname.
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
raise ValueError(f"Cannot resolve hostname: {hostname}")
for family, _, _, _, sockaddr in addr_infos:
ip_str = sockaddr[0]
ip = ipaddress.ip_address(ip_str)
# Block only the high-risk targets — link-local (incl. the
# 169.254.169.254 cloud-metadata endpoint) and the IPv6 metadata
# address. Loopback and RFC1918/LAN stay reachable for local dev.
if ip.is_link_local or ip in _CLOUD_METADATA_IPS:
raise ValueError(
f"URL resolves to a link-local / cloud-metadata address "
f"({ip_str}), request blocked for security"
)
def execute(self, args: Dict[str, Any]) -> ToolResult:
action = args.get("action", "").strip().lower()
if not action:
@@ -148,6 +212,16 @@ class BrowserTool(BaseTool):
# Only auto-prepend https:// for bare hosts; preserve file://, about:, data:, etc.
if "://" not in url and not url.startswith(("about:", "data:")):
url = "https://" + url
# SSRF guard: for http(s) targets, reject hosts that resolve to
# link-local / cloud-metadata addresses before the browser navigates
# (and then auto-snapshots the page back to the model). Loopback and
# RFC1918/LAN are allowed so local dev servers work. Non-HTTP schemes
# (about:/data:/file:/chrome:) are not network-egress targets here.
if url.split(":", 1)[0].lower() in ("http", "https") and not self._allow_private_targets():
try:
self._validate_url_safe(url)
except ValueError as e:
return ToolResult.fail(f"Error: {e}")
timeout = args.get("timeout", 30000)
service = self._get_service()
result = service.navigate(url, timeout=timeout)

View File

@@ -4,6 +4,8 @@ Memory get tool
Allows agents to read specific sections from memory files
"""
import os
from agent.tools.base_tool import BaseTool
@@ -87,8 +89,13 @@ class MemoryGetTool(BaseTool):
file_path = (workspace_dir / path).resolve()
workspace_resolved = workspace_dir.resolve()
if not str(file_path).startswith(str(workspace_resolved) + '/') and file_path != workspace_resolved:
# Use os.path.realpath + os.sep for cross-platform path validation.
# str(Path).startswith(str + '/') fails on Windows where Path uses
# backslashes — see MemoryService._resolve_path for the same pattern.
real_file = os.path.realpath(str(file_path))
real_workspace = os.path.realpath(str(workspace_resolved))
if real_file != real_workspace and not real_file.startswith(real_workspace + os.sep):
return ToolResult.fail(f"Error: Access denied: path outside workspace")
if not file_path.exists():

View File

@@ -331,6 +331,12 @@ class Vision(BaseTool):
- None : unknown provider id, or the bot can't be created.
Caller falls through to model-name-based routing.
"""
# Custom OpenAI-compatible providers — read credentials from
# custom_providers list, same pattern as embedding.
if provider_id.startswith("custom:"):
p = self._build_custom_provider(provider_id, user_model)
return [p] if p else None
display_name = _PROVIDER_ID_TO_DISPLAY.get(provider_id)
if not display_name:
return None
@@ -596,6 +602,34 @@ class Vision(BaseTool):
model_override=preferred_model,
)
def _build_custom_provider(self, provider_id: str, preferred_model: Optional[str] = None) -> Optional[VisionProvider]:
"""Build a VisionProvider from a custom:<id> entry in custom_providers.
Uses the standard OpenAI /chat/completions endpoint — any
OpenAI-compatible multimodal endpoint works."""
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_id)
if not custom_id:
return None
entry = _find_provider_by_id(get_custom_providers(), custom_id)
if not entry:
logger.warning(f"[Vision] custom provider '{provider_id}' not found in custom_providers")
return None
api_key = (entry.get("api_key") or "").strip()
api_base = (entry.get("api_base") or "").strip()
if not api_key or not api_base:
logger.warning(f"[Vision] custom provider '{provider_id}' missing api_key or api_base")
return None
model = preferred_model or entry.get("model") or ""
if not model:
logger.warning(f"[Vision] custom provider '{provider_id}' has no model configured")
return None
return VisionProvider(
name=entry.get("name") or provider_id,
api_key=api_key,
api_base=self._ensure_v1(api_base.rstrip("/")),
model_override=model,
)
def _call_via_bot(self, model: str, question: str, image_content: dict,
provider: Optional[VisionProvider] = None) -> ToolResult:
"""

21
app.py
View File

@@ -15,6 +15,11 @@ import threading
_channel_mgr = None
# Desktop mode: a lighter runtime for the packaged Electron client. The plugin
# framework is still bundled (it's tiny and on the web channel's import path),
# but we skip loading actual plugins and MCP tools to keep startup fast.
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
def get_channel_manager():
return _channel_mgr
@@ -75,7 +80,7 @@ class ChannelManager:
if self._primary_channel is None and channels:
self._primary_channel = channels[0][1]
if first_start:
if first_start and not DESKTOP_MODE:
PluginManager().load_plugins()
# Cloud client is optional. It is only started when
@@ -364,10 +369,18 @@ def run():
_sync_builtin_skills()
# Kick off MCP server loading in the background so first-message
# latency isn't dominated by npx package downloads.
_warmup_mcp_tools()
# latency isn't dominated by npx package downloads. Skipped in desktop
# mode (MCP relies on external npx/uvx runtimes that aren't bundled).
if not DESKTOP_MODE:
_warmup_mcp_tools()
_warmup_scheduler()
if DESKTOP_MODE:
# Defer the (heavy) AgentBridge/scheduler warmup to a background
# thread so the web API becomes available within a couple seconds.
# The scheduler still starts; it just doesn't block UI readiness.
threading.Thread(target=_warmup_scheduler, daemon=True).start()
else:
_warmup_scheduler()
logger.info(f"[App] Starting channels: {channel_names}")

View File

@@ -395,7 +395,13 @@ class AgentInitializer:
from agent.memory.embedding import EMBEDDING_VENDORS
from config import conf
meta = EMBEDDING_VENDORS.get(provider_key)
# Custom providers ("custom:<id>") resolve credentials
# from the custom_providers list.
resolved_provider_key = provider_key
if provider_key.startswith("custom:"):
resolved_provider_key = "custom"
meta = EMBEDDING_VENDORS.get(resolved_provider_key)
if meta is None:
logger.error(
f"[AgentInitializer] Unknown embedding_provider '{provider_key}'. "
@@ -414,7 +420,17 @@ class AgentInitializer:
)
return None
model = (conf().get("embedding_model") or "").strip() or meta["default_model"]
model = (conf().get("embedding_model") or "").strip()
# Custom providers without a model fall back to the provider's default.
if not model and resolved_provider_key == "custom":
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_key)
if custom_id:
entry = _find_provider_by_id(get_custom_providers(), custom_id)
if entry and entry.get("model"):
model = entry["model"]
if not model and resolved_provider_key != "custom":
model = meta["default_model"]
try:
cfg_dim = int(conf().get("embedding_dimensions") or 0)
except (TypeError, ValueError):
@@ -423,7 +439,7 @@ class AgentInitializer:
try:
provider = create_embedding_provider(
provider=provider_key,
provider=resolved_provider_key,
model=model,
api_key=api_key,
api_base=api_base,
@@ -450,6 +466,17 @@ class AgentInitializer:
"""Pick the API key for an explicit embedding provider from config."""
from config import conf
# Custom providers ("custom:<id>") resolve from the custom_providers list.
if provider_key.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_key)
if custom_id:
providers = get_custom_providers()
entry = _find_provider_by_id(providers, custom_id)
if entry:
return entry.get("api_key", "")
return ""
key_map = {
"openai": "open_ai_api_key",
"linkai": "linkai_api_key",
@@ -470,6 +497,17 @@ class AgentInitializer:
"""Pick the API base for an explicit embedding provider from config."""
from config import conf
# Custom providers ("custom:<id>") resolve from the custom_providers list.
if provider_key.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_key)
if custom_id:
providers = get_custom_providers()
entry = _find_provider_by_id(providers, custom_id)
if entry and entry.get("api_base"):
return entry["api_base"]
return default_base
base_map = {
"openai": "open_ai_api_base",
"linkai": "linkai_api_base",

View File

@@ -4884,7 +4884,7 @@ const MODELS_CAPABILITY_DEFS = [
iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' },
{ id: 'tts', icon: 'fa-volume-high', editable: true, needsModel: true, titleKey: 'models_capability_tts', descKey: 'models_capability_tts_desc',
iconChip: 'bg-amber-50 dark:bg-amber-900/30', iconGlyph: 'text-amber-500' },
{ id: 'embedding', icon: 'fa-vector-square', editable: true, needsModel: false, titleKey: 'models_capability_embedding', descKey: 'models_capability_embedding_desc',
{ id: 'embedding', icon: 'fa-vector-square', editable: true, needsModel: true, titleKey: 'models_capability_embedding', descKey: 'models_capability_embedding_desc',
iconChip: 'bg-purple-50 dark:bg-purple-900/30', iconGlyph: 'text-purple-500' },
{ id: 'search', icon: 'fa-magnifying-glass', editable: true, needsModel: false, titleKey: 'models_capability_search', descKey: 'models_capability_search_desc',
iconChip: 'bg-orange-50 dark:bg-orange-900/30', iconGlyph: 'text-orange-500' },
@@ -5605,8 +5605,10 @@ function renderCapabilityBody(def, cap, body) {
if (def.needsModel) {
rebuildCapabilityModelDropdown(def, initialProviderValue, cap.current_model || '', body);
// Hide model picker in auto mode — fallback hint below covers it.
setCapabilityModelPickerVisible(def, initialProviderValue !== '' || !capabilitySupportsAuto(def.id), body);
// Embedding: hide model picker when no provider is selected.
const showModel = def.id === 'embedding' ? initialProviderValue !== '' :
(initialProviderValue !== '' || !capabilitySupportsAuto(def.id));
setCapabilityModelPickerVisible(def, showModel, body);
}
if (def.id === 'tts') {
@@ -5901,6 +5903,9 @@ function rebuildCapabilityModelDropdown(def, providerId, selectedModel, scope) {
let rawList;
if (capModelMap[providerId]) {
rawList = capModelMap[providerId].slice();
} else if (providerId.startsWith('custom:') && capModelMap['custom']) {
// Expanded custom:<id> entries share the same preset model list
rawList = capModelMap['custom'].slice();
} else {
const provider = modelsState.providers.find(p => p.id === providerId);
rawList = (provider && provider.models) ? provider.models.slice() : [];
@@ -6031,12 +6036,13 @@ function rebuildCapabilityVoiceDropdown(providerId, selectedVoice, scope, modelI
function onCapabilityProviderChange(def, providerId, scope) {
if (def.needsModel) {
// Empty sentinel hides the model picker (capability is in auto mode).
const isAuto = providerId === '' && capabilitySupportsAuto(def.id);
if (!isAuto) {
// Embedding: hide model picker when no provider is selected.
const showModel = def.id === 'embedding' ? providerId !== '' :
!(providerId === '' && capabilitySupportsAuto(def.id));
if (showModel) {
rebuildCapabilityModelDropdown(def, providerId, '', scope);
}
setCapabilityModelPickerVisible(def, !isAuto, scope);
setCapabilityModelPickerVisible(def, showModel, scope);
}
if (def.id === 'tts') {
rebuildCapabilityVoiceDropdown(providerId, '', scope);
@@ -6071,7 +6077,9 @@ function saveCapability(capId) {
// hidden and any value left in it is stale; persist an empty model so
// the backend treats this as "fall back to the runtime chain".
const isAuto = provider === '' && capabilitySupportsAuto(capId);
const model = isAuto ? '' : getCapabilityModelValue(def);
// Embedding without a provider similarly means "cleared" — don't leak
// a stale model value into config.
const model = (isAuto || (capId === 'embedding' && !provider)) ? '' : getCapabilityModelValue(def);
// TTS carries an extra voice timbre (supports free-text custom ids).
let voice = '';
if (capId === 'tts' && !isAuto) {

View File

@@ -24,7 +24,7 @@ from common import const
from common import i18n
from common.log import logger
from common.singleton import singleton
from config import conf
from config import conf, get_data_root, get_weixin_credentials_path
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
@@ -1114,18 +1114,25 @@ class WebChannel(ChatChannel):
else:
logger.info(f"[WebChannel] 🔒 Listening on {host} only (local access). For public access, set web_host to 0.0.0.0 and configure web_password")
try:
import webbrowser
webbrowser.open(f"http://localhost:{port}")
logger.debug(f"[WebChannel] Opened browser at http://localhost:{port}")
except Exception as e:
logger.debug(f"[WebChannel] Could not open browser: {e}")
# In desktop mode the Electron shell renders the UI, so don't pop a
# browser window (also avoids issues when running detached/headless).
if os.environ.get("COW_DESKTOP") != "1":
try:
import webbrowser
webbrowser.open(f"http://localhost:{port}")
logger.debug(f"[WebChannel] Opened browser at http://localhost:{port}")
except Exception as e:
logger.debug(f"[WebChannel] Could not open browser: {e}")
# 确保静态文件目录存在
# Ensure the static dir exists. In a packaged build it ships read-only
# inside the bundle, so swallow errors instead of failing startup.
static_dir = os.path.join(os.path.dirname(__file__), 'static')
if not os.path.exists(static_dir):
os.makedirs(static_dir)
logger.debug(f"[WebChannel] Created static directory: {static_dir}")
try:
os.makedirs(static_dir)
logger.debug(f"[WebChannel] Created static directory: {static_dir}")
except OSError as e:
logger.debug(f"[WebChannel] Skipped creating static dir (read-only bundle?): {e}")
urls = (
'/', 'RootHandler',
@@ -1730,8 +1737,7 @@ class ConfigHandler:
if not applied:
return json.dumps({"status": "error", "message": "no valid keys to update"})
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "config.json")
config_path = os.path.join(get_data_root(), "config.json")
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f)
@@ -2062,7 +2068,20 @@ class ModelsHandler:
],
},
}
_EMBEDDING_PROVIDERS = ["openai", "dashscope", "doubao", "zhipu", "linkai"]
_EMBEDDING_PROVIDERS = ["openai", "dashscope", "doubao", "zhipu", "linkai", "custom"]
# Embedding model catalog per provider. Mirrors the default_model in
# agent/memory/embedding/provider.py::EMBEDDING_VENDORS.
# Custom providers have no preset list — model names vary per vendor,
# so the user always types the model id manually.
_EMBEDDING_PROVIDER_MODELS = {
"openai": ["text-embedding-3-small", "text-embedding-3-large"],
"dashscope": ["text-embedding-v4"],
"doubao": ["doubao-embedding-vision-251215"],
"zhipu": ["embedding-3"],
"linkai": ["text-embedding-3-small"],
"custom": [],
}
# Capability-scoped model catalogs. The chat dropdown can reuse the
# provider's generic model list, but vision and image generation are
@@ -2112,6 +2131,9 @@ class ModelsHandler:
const.CLAUDE_4_6_SONNET,
const.GEMINI_31_FLASH_LITE_PRE,
],
# Custom OpenAI-compatible providers have no preset list — model
# names vary per vendor, so the user types the model id manually.
"custom": [],
}
# Image-generation catalog. Source of truth: skills/image-generation/SKILL.md.
@@ -2148,10 +2170,7 @@ class ModelsHandler:
@staticmethod
def _config_path() -> str:
return os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"config.json",
)
return os.path.join(get_data_root(), "config.json")
@classmethod
def _read_file_config(cls) -> dict:
@@ -2425,18 +2444,31 @@ class ModelsHandler:
user_specified = (vision_conf.get("model") or "").strip()
explicit_provider = (vision_conf.get("provider") or "").strip()
# Build provider list: built-in providers + expanded custom:<id> entries.
# Same pattern as _embedding_capability — each user-created custom
# provider gets its own dropdown entry showing the user-chosen name.
providers = []
custom_cards = cls._custom_provider_cards(local_config)
for pid in cls._VISION_PROVIDER_MODELS:
if pid == "custom":
if custom_cards:
providers.extend(c["id"] for c in custom_cards)
else:
providers.append(pid)
# Provider resolution priority:
# 1. Explicit `tools.vision.provider` (persisted via UI; supports
# custom model names that prefix-inference can't recognize).
# 2. Scan per-provider model lists by model name.
# Empty provider keeps the dropdown on "auto" when we can't tell.
inferred_provider = ""
if explicit_provider and explicit_provider in cls._VISION_PROVIDER_MODELS:
if explicit_provider and explicit_provider in providers:
inferred_provider = explicit_provider
elif user_specified:
for pid, models in cls._VISION_PROVIDER_MODELS.items():
if user_specified in models:
inferred_provider = pid
# For "custom" key, map to the first custom card
inferred_provider = custom_cards[0]["id"] if pid == "custom" and custom_cards else pid
break
# In auto mode the hint should reflect what vision.py will actually
@@ -2452,7 +2484,7 @@ class ModelsHandler:
"current_model": user_specified,
"fallback_provider": predicted["provider"],
"fallback_model": predicted["model"],
"providers": list(cls._VISION_PROVIDER_MODELS.keys()),
"providers": providers,
"provider_models": cls._VISION_PROVIDER_MODELS,
}
@@ -2525,18 +2557,40 @@ class ModelsHandler:
suggested = ""
if not explicit:
for pid in cls._EMBEDDING_PROVIDERS:
if pid == "custom":
continue
meta = ConfigHandler.PROVIDER_MODELS.get(pid) or {}
key_field = meta.get("api_key_field")
if key_field and cls._is_real_key(local_config.get(key_field, "")):
suggested = pid
break
if not suggested:
custom_cards = cls._custom_provider_cards(local_config)
if custom_cards:
suggested = custom_cards[0]["id"]
# Build provider list: built-in providers + expanded custom:<id> entries
# Same pattern as _chat_capability — each user-created custom provider
# gets its own dropdown entry showing the user-chosen name.
providers = []
custom_cards = cls._custom_provider_cards(local_config)
for pid in cls._EMBEDDING_PROVIDERS:
if pid == "custom":
if custom_cards:
providers.extend(c["id"] for c in custom_cards)
# No custom providers configured — skip the bare "custom" entry
# since the runtime cannot resolve its credentials.
else:
providers.append(pid)
return {
"editable": True,
"current_provider": explicit,
"suggested_provider": suggested,
"current_model": local_config.get("embedding_model", "") or "",
"current_dim": int(local_config.get("embedding_dimensions") or 0) or None,
"providers": cls._EMBEDDING_PROVIDERS,
"providers": providers,
"provider_models": cls._EMBEDDING_PROVIDER_MODELS,
}
# Auto-fallback order for image generation. Mirrors the global priority
@@ -2898,10 +2952,10 @@ class ModelsHandler:
{
"action": "set_custom_provider",
"id": "3f2a9c1b", # required for edit; omit for create
"name": "siliconflow", # required, display label
"name": "my-provider", # required, display label
"api_base": "https://...", # required when creating
"api_key": "sk-...", # optional on edit (keep existing)
"model": "deepseek-ai/...", # optional default model
"model": "model-name", # optional default model
"make_active": true # optional, also activate it
}
"""
@@ -3122,6 +3176,25 @@ class ModelsHandler:
# is persisted so users picking a custom model under a specific vendor
# still get routed there — runtime falls back to model-name prefix
# inference only when provider is empty.
# Validate provider_id — mirrors _set_chat / _set_embedding pattern.
if provider_id.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type
_, custom_id = parse_custom_bot_type(provider_id)
providers = self._normalize_custom_providers(conf().get("custom_providers"))
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
if custom_provider is None:
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
if not model:
model = custom_provider.get("model") or ""
elif provider_id and provider_id not in {k for k in ModelsHandler._VISION_PROVIDER_MODELS if k != "custom"}:
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
if provider_id and not model:
return json.dumps({
"status": "error",
"message": "vision model is required when a provider is selected",
})
local_config = conf()
file_cfg = self._read_file_config()
self._set_nested_namespace_value(file_cfg, "tools", "vision", "model", model)
@@ -3247,7 +3320,20 @@ class ModelsHandler:
logger.warning(f"[ModelsHandler] Bridge voice refresh failed: {e}")
def _set_embedding(self, provider_id: str, model: str) -> str:
# Two valid states: both empty (reset to pick-or-empty) OR both set.
# Validate provider_id — mirrors _set_chat's validation pattern.
if provider_id.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type
_, custom_id = parse_custom_bot_type(provider_id)
providers = self._normalize_custom_providers(conf().get("custom_providers"))
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
if custom_provider is None:
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
# Fall back to the custom provider's default model when none is given.
if not model:
model = custom_provider.get("model") or ""
elif provider_id and provider_id not in {p for p in ModelsHandler._EMBEDDING_PROVIDERS if p != "custom"}:
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
# A provider without a model leaves the runtime in a broken half-state,
# so reject that explicitly instead of silently writing it through.
if provider_id and not model:
@@ -3467,8 +3553,12 @@ class ChannelsHandler:
try:
local_config = conf()
active_channels = self._active_channel_set()
# Desktop build ships without lark-oapi, so hide Feishu from the list.
desktop_mode = os.environ.get("COW_DESKTOP") == "1"
channels = []
for ch_name, ch_def in self.CHANNEL_DEFS.items():
if desktop_mode and ch_name == "feishu":
continue
fields_out = []
for f in ch_def["fields"]:
raw_val = local_config.get(f["key"], f.get("default", ""))
@@ -3550,8 +3640,7 @@ class ChannelsHandler:
if not applied:
return json.dumps({"status": "error", "message": "no valid fields to update"})
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "config.json")
config_path = os.path.join(get_data_root(), "config.json")
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f)
@@ -3621,8 +3710,7 @@ class ChannelsHandler:
new_channel_type = ",".join(existing)
local_config["channel_type"] = new_channel_type
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "config.json")
config_path = os.path.join(get_data_root(), "config.json")
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f)
@@ -3677,8 +3765,7 @@ class ChannelsHandler:
local_config = conf()
local_config["channel_type"] = new_channel_type
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "config.json")
config_path = os.path.join(get_data_root(), "config.json")
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f)
@@ -3828,9 +3915,7 @@ class WeixinQrHandler:
if not bot_token or not bot_id:
return json.dumps({"status": "error", "message": "Login confirmed but missing token"})
cred_path = os.path.expanduser(
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
cred_path = get_weixin_credentials_path()
from channel.weixin.weixin_channel import _save_credentials
_save_credentials(cred_path, {
"token": bot_token,
@@ -4494,8 +4579,7 @@ class LogsHandler:
web.header('Cache-Control', 'no-cache')
web.header('X-Accel-Buffering', 'no')
from config import get_root
log_path = os.path.join(get_root(), "run.log")
log_path = os.path.join(get_data_root(), "run.log")
def generate():
if not os.path.isfile(log_path):

View File

@@ -24,7 +24,7 @@ from channel.weixin.weixin_message import WeixinMessage
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from config import conf
from config import conf, get_weixin_credentials_path
MAX_CONSECUTIVE_FAILURES = 3
BACKOFF_DELAY = 30
@@ -96,9 +96,7 @@ class WeixinChannel(ChatChannel):
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
token = conf().get("weixin_token", "")
self._credentials_path = os.path.expanduser(
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
self._credentials_path = get_weixin_credentials_path()
# Always load credentials so we can restore context_tokens even when
# the bot token itself comes from config.

View File

@@ -21,7 +21,7 @@ from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from linkai import LinkAIClient, PushMsg
from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root
from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root, get_weixin_credentials_path
from plugins import PluginManager
import threading
import time
@@ -336,9 +336,7 @@ class CloudClient(LinkAIClient):
@staticmethod
def _remove_weixin_credentials():
"""Remove the weixin token credentials file so next connect triggers QR login."""
cred_path = os.path.expanduser(
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
cred_path = get_weixin_credentials_path()
try:
if os.path.exists(cred_path):
os.remove(cred_path)

View File

@@ -1,8 +1,21 @@
import logging
import os
import sys
import io
def _log_path():
# Mirror config.get_data_root() without importing config (avoids a circular
# import, since config imports this module). The desktop build sets
# COW_DATA_DIR (e.g. ~/.cow); source deployments fall back to CWD.
data_dir = os.environ.get("COW_DATA_DIR")
if data_dir:
data_dir = os.path.expanduser(data_dir)
os.makedirs(data_dir, exist_ok=True)
return os.path.join(data_dir, "run.log")
return "run.log"
def _reset_logger(log):
for handler in log.handlers:
handler.close()
@@ -20,7 +33,7 @@ def _reset_logger(log):
datefmt="%Y-%m-%d %H:%M:%S",
)
)
file_handle = logging.FileHandler("run.log", encoding="utf-8")
file_handle = logging.FileHandler(_log_path(), encoding="utf-8")
file_handle.setFormatter(
logging.Formatter(
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",

View File

@@ -1,18 +1,21 @@
import os
import pathlib
from common.utils import expand_path
from config import conf
class TmpDir(object):
"""A temporary directory that is deleted when the object is destroyed."""
"""Temporary directory for transient artifacts (e.g. synthesized voice).
tmpFilePath = pathlib.Path("./tmp/")
Resolves to ``<agent_workspace>/tmp`` (default ``~/cow/tmp``) so temp files
land inside the agent workspace instead of a CWD-relative ``./tmp``, which
is unreliable for the packaged desktop app where CWD is undefined.
"""
def __init__(self):
pathExists = os.path.exists(self.tmpFilePath)
if not pathExists:
os.makedirs(self.tmpFilePath)
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
self.tmpFilePath = os.path.join(ws_root, "tmp")
os.makedirs(self.tmpFilePath, exist_ok=True)
def path(self):
return str(self.tmpFilePath) + "/"

View File

@@ -5,6 +5,7 @@ import json
import logging
import os
import pickle
import sys
from common.log import logger
from common import i18n
@@ -27,7 +28,7 @@ available_setting = {
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field
# Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:<id>".
# Each item: {"id": "3f2a9c1b", "name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}
# Each item: {"id": "3f2a9c1b", "name": "my-provider", "api_key": "sk-...", "api_base": "https://api.example.com/v1", "model": "model-name"}
"custom_providers": [],
"proxy": "", # proxy used by openai
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
@@ -377,10 +378,16 @@ def load_config():
logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|")
logger.info(" |___/ ")
logger.info("")
config_path = "./config.json"
# User config lives in the data root: source deployments use CWD (./), while
# the desktop build points COW_DATA_DIR at ~/.cow so config survives updates.
config_path = os.path.join(get_data_root(), "config.json")
if not os.path.exists(config_path):
logger.info("config file not found, falling back to config-template.json")
config_path = "./config-template.json"
# Resolve the template via get_resource_root() so it works both from
# source and from a frozen (PyInstaller) bundle, where the template
# ships inside the bundle (sys._MEIPASS) and CWD may differ.
template_path = os.path.join(get_resource_root(), "config-template.json")
config_path = template_path if os.path.exists(template_path) else "./config-template.json"
config_str = read_file(config_path)
logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str)))
@@ -620,6 +627,34 @@ def get_root():
return os.path.dirname(os.path.abspath(__file__))
def get_resource_root():
"""Directory holding bundled read-only resources (e.g. config-template.json).
Under PyInstaller, data files live in sys._MEIPASS (the onedir _internal
folder), which differs from get_root() — the latter is used for writable
user data and should stay next to the executable, not inside the bundle.
"""
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return sys._MEIPASS
return os.path.dirname(os.path.abspath(__file__))
def get_data_root():
"""Directory for writable user data (config.json, user_datas.pkl, run.log).
The desktop build sets COW_DATA_DIR (e.g. ~/.cow) so data lives in the
user's home rather than inside the read-only app bundle and survives app
updates. When unset (source deployment), it falls back to get_root(), so
existing behavior is unchanged.
"""
data_dir = os.environ.get("COW_DATA_DIR")
if data_dir:
data_dir = os.path.expanduser(data_dir)
os.makedirs(data_dir, exist_ok=True)
return data_dir
return get_root()
def read_file(path):
with open(path, mode="r", encoding="utf-8-sig") as f:
return f.read()
@@ -630,13 +665,29 @@ def conf():
def get_appdata_dir():
data_path = os.path.join(get_root(), conf().get("appdata_dir", ""))
data_path = os.path.join(get_data_root(), conf().get("appdata_dir", ""))
if not os.path.exists(data_path):
logger.info("[INIT] data path not exists, create it: {}".format(data_path))
os.makedirs(data_path)
return data_path
def get_weixin_credentials_path():
"""Resolve the Weixin credentials (token) file path.
Honors an explicit ``weixin_credentials_path`` from config. Otherwise the
packaged desktop build (COW_DATA_DIR set) keeps it under the data dir
(~/.cow) so all user data stays together, while source deployments retain
the legacy ~/.weixin_cow_credentials.json default unchanged.
"""
configured = conf().get("weixin_credentials_path")
if configured:
return os.path.expanduser(configured)
if os.environ.get("COW_DATA_DIR"):
return os.path.join(get_data_root(), "weixin_credentials.json")
return os.path.expanduser("~/.weixin_cow_credentials.json")
def subscribe_msg():
trigger_prefix = conf().get("single_chat_prefix", [""])[0]
msg = conf().get("subscribe_msg", "")

6
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
release/
*.log
.DS_Store
Thumbs.db

81
desktop/README.md Normal file
View File

@@ -0,0 +1,81 @@
# CowAgent Desktop
Cross-platform desktop client for CowAgent, built with Electron + React + TypeScript.
## Development
### Prerequisites
- Node.js 18+
- npm or yarn
- Python 3.7+ (for the backend)
### Setup
```bash
cd desktop
npm install
```
### Run in Development
Start the renderer dev server and Electron together:
```bash
npm run dev
```
Or run them separately:
```bash
# Terminal 1: Start Vite dev server
npm run dev:renderer
# Terminal 2: Start Electron (after renderer is ready)
npm run dev:main
```
The app will automatically start the Python backend from the parent directory.
### Build
```bash
# Build for current platform
npm run dist
# Build for macOS only
npm run dist:mac
# Build for Windows only
npm run dist:win
```
Build outputs are placed in the `release/` directory.
## Architecture
```
desktop/
├── src/
│ ├── main/ # Electron main process
│ │ ├── index.ts # Window management, IPC
│ │ ├── python-manager.ts # Python backend lifecycle
│ │ └── preload.ts # Context bridge for renderer
│ └── renderer/ # React UI (Vite)
│ └── src/
│ ├── api/ # HTTP client for backend APIs
│ ├── components/ # Reusable UI components
│ ├── hooks/ # React hooks
│ ├── pages/ # Page components
│ └── types.ts # TypeScript types
├── resources/ # App icons
├── package.json # Dependencies and build config
└── vite.config.ts # Vite config
```
### How it Works
1. Electron main process starts and creates the app window
2. It spawns the Python backend (`app.py`) as a child process
3. The React UI communicates with the Python backend via HTTP APIs
4. SSE (Server-Sent Events) is used for streaming chat responses and live logs

79
desktop/build/build-backend.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# Build the desktop backend into a self-contained onedir bundle via PyInstaller.
# Run from anywhere; paths are resolved relative to the repo root.
#
# Usage:
# bash desktop/build/build-backend.sh # build
# PYTHON=python3.11 bash desktop/build/build-backend.sh # pick interpreter
#
# Output: desktop/build/dist/cowagent-backend/ (folder with the executable)
set -euo pipefail
# --- resolve paths --------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
BUILD_DIR="$SCRIPT_DIR"
VENV_DIR="$BUILD_DIR/.venv-build"
# Prefer Python 3.11 when available: on 3.13+ web.py must be installed from a
# GitHub git source (the PyPI build fails), which is flaky on some networks.
# 3.11 installs web.py straight from PyPI and has the best PyInstaller support.
if [ -z "${PYTHON:-}" ]; then
for cand in \
"/Library/Frameworks/Python.framework/Versions/3.11/bin/python3.11" \
"python3.11" \
"python3.12" \
"python3"; do
if command -v "$cand" >/dev/null 2>&1; then
PYTHON="$cand"
break
fi
done
fi
# Prefer Python 3.11: it installs web.py from PyPI (no GitHub clone) and avoids
# 3.13's removed-cgi compatibility shims. Override with PYTHON=... if needed.
pick_python() {
if [ -n "${PYTHON:-}" ]; then echo "$PYTHON"; return; fi
for c in python3.11 python3.12 python3.10 python3; do
if command -v "$c" >/dev/null 2>&1; then echo "$c"; return; fi
done
echo python3
}
PYTHON="$(pick_python)"
echo "==> Repo root: $ROOT"
echo "==> Using Python: $($PYTHON --version 2>&1) ($PYTHON)"
# --- isolated build venv --------------------------------------------------
if [ ! -d "$VENV_DIR" ]; then
echo "==> Creating build venv at $VENV_DIR"
"$PYTHON" -m venv "$VENV_DIR"
fi
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
echo "==> Installing build dependencies"
pip install -q --upgrade pip
# Don't leave a half-populated venv behind if deps fail (e.g. flaky network):
# the next run would otherwise reuse a broken venv.
if ! pip install -q -r "$BUILD_DIR/requirements-desktop.txt"; then
echo "!! Dependency install failed. Removing the build venv so a retry starts clean." >&2
deactivate || true
rm -rf "$VENV_DIR"
exit 1
fi
pip install -q pyinstaller
# --- run pyinstaller from repo root so relative datas resolve -------------
cd "$ROOT"
echo "==> Running PyInstaller (onedir)"
pyinstaller "$BUILD_DIR/cowagent-backend.spec" \
--noconfirm \
--distpath "$BUILD_DIR/dist" \
--workpath "$BUILD_DIR/build-work"
echo ""
echo "==> Done. Bundle at: $BUILD_DIR/dist/cowagent-backend/"
du -sh "$BUILD_DIR/dist/cowagent-backend/" 2>/dev/null || true
echo "==> Smoke test: COW_DESKTOP=1 \"$BUILD_DIR/dist/cowagent-backend/cowagent-backend\""

View File

@@ -0,0 +1,145 @@
# -*- mode: python ; coding: utf-8 -*-
"""
PyInstaller spec for the CowAgent desktop backend (onedir).
Produces a self-contained `cowagent-backend` folder that the Electron app
spawns directly, so end users don't need Python installed.
onedir is chosen over onefile because the backend reads data files via paths
relative to the source tree (e.g. config-template.json, skills/, chat.html);
onedir preserves that layout, while onefile's temp-extraction would break it.
Build from the repo root:
pyinstaller desktop/build/cowagent-backend.spec --noconfirm
"""
import os
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
# Resolve the repo root from the spec's own location (desktop/build/ -> root),
# independent of the current working directory. PyInstaller exposes SPECPATH.
ROOT = os.path.abspath(os.path.join(SPECPATH, '..', '..'))
def rp(*parts):
"""Absolute path under the repo root."""
return os.path.join(ROOT, *parts)
# --- Hidden imports -------------------------------------------------------
# Channels are imported dynamically by channel_factory via string names, so
# PyInstaller's static analysis can't see them. List every channel we ship
# (Feishu is intentionally excluded — lark-oapi is dropped from the desktop
# build to save ~116MB).
hiddenimports = [
# channels (dynamic import in channel/channel_factory.py)
'channel.web.web_channel',
'channel.terminal.terminal_channel',
'channel.weixin.weixin_channel',
'channel.wechatmp.wechatmp_channel',
'channel.wechatcom.wechatcomapp_channel',
'channel.wechat_kf.wechat_kf_channel',
'channel.dingtalk.dingtalk_channel',
'channel.wecom_bot.wecom_bot_channel',
'channel.qq.qq_channel',
'channel.telegram.telegram_channel',
'channel.slack.slack_channel',
'channel.discord.discord_channel',
]
# Agent tools and model providers are imported lazily in places; collect their
# submodules so nothing is missed at runtime.
hiddenimports += collect_submodules('agent.tools')
hiddenimports += collect_submodules('models')
hiddenimports += collect_submodules('voice')
hiddenimports += collect_submodules('bridge')
# Plugin framework: WebChannel -> ChatChannel imports `from plugins import *`,
# so the framework package must be present even though desktop mode never loads
# actual plugins (it's only ~tens of KB of code).
hiddenimports += [
'plugins',
'plugins.event',
'plugins.plugin',
'plugins.plugin_manager',
]
# Third-party SDKs that use lazy/conditional imports internally.
hiddenimports += collect_submodules('dashscope')
hiddenimports += [
'tiktoken_ext',
'tiktoken_ext.openai_public',
]
# --- Data files -----------------------------------------------------------
# Runtime-read files/dirs that must travel with the executable. Paths are
# (source, dest_dir_in_bundle).
datas = [
(rp('config-template.json'), '.'),
(rp('skills'), 'skills'),
# Web console served on the backend port: ship chat.html plus its static
# assets (~1.9MB) so the browser-based console works as a debug/fallback
# entry alongside the Electron UI.
(rp('channel', 'web', 'chat.html'), 'channel/web'),
(rp('channel', 'web', 'static'), 'channel/web/static'),
]
# Some libraries (tiktoken encodings, etc.) ship data files.
datas += collect_data_files('tiktoken_ext', include_py_files=False)
# --- Excludes -------------------------------------------------------------
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
# mode), tests/docs, and dev-only packages.
excludes = [
'lark_oapi', # Feishu — ~116MB, excluded from desktop build
'tests',
'pip',
'wheel',
'pytest',
'playwright', # browser tool is opt-in, not bundled
]
block_cipher = None
a = Analysis(
[rp('app.py')],
pathex=[ROOT],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=excludes,
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='cowagent-backend',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=True,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name='cowagent-backend',
)

View File

@@ -0,0 +1,52 @@
# Desktop backend dependencies (slimmed down from the full requirements).
#
# Goal: keep the package light. The desktop client only needs the web channel
# (which Electron talks to) plus the agent core; the remaining IM channels are
# cheap (~27MB total) so we keep them, but Feishu's `lark-oapi` (~116MB) is
# dropped — it is by far the heaviest dependency and not needed for a C-end
# desktop app. Feishu is hidden in desktop mode (see COW_DESKTOP in app.py).
# ---- core ----
numpy>=1.21
aiohttp>=3.8.6,<3.10
requests>=2.28.2
chardet>=5.1.0
Pillow
python-dotenv>=1.0.0
PyYAML>=6.0
croniter>=2.0.0
click>=8.0
qrcode
json-repair
# ---- web framework (web channel) ----
# web.py 0.62 fails to build on Python 3.13+ (cgi removed); use the GitHub fix.
web.py; python_version < "3.13"
web.py @ git+https://github.com/webpy/webpy.git ; python_version >= "3.13"
legacy-cgi; python_version >= "3.13"
# ---- AI model SDKs ----
zai-sdk
dashscope
tenacity # used by some dashscope submodules (retry logic)
google-generativeai
tiktoken>=0.3.2
# ---- voice (TTS/ASR) — kept per product decision ----
pydub>=0.25.1
gTTS>=2.3.1
# ---- document parsing (web_fetch / knowledge) ----
pypdf
python-docx
openpyxl
python-pptx
# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ----
wechatpy
pycryptodome
dingtalk_stream
websocket-client>=1.4.0
python-telegram-bot
slack_bolt
discord.py

8300
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

103
desktop/package.json Normal file
View File

@@ -0,0 +1,103 @@
{
"name": "cowagent-desktop",
"version": "1.0.0",
"description": "CowAgent Desktop Client - AI Agent on your desktop",
"main": "dist/main/index.js",
"author": "CowAgent",
"license": "MIT",
"scripts": {
"dev": "npm run build && electron .",
"dev:hot": "concurrently \"npm run dev:renderer\" \"sleep 2 && npm run dev:main\"",
"dev:main": "tsc -p tsconfig.main.json && electron .",
"dev:renderer": "vite",
"build": "npm run build:renderer && npm run build:main",
"build:main": "tsc -p tsconfig.main.json",
"build:renderer": "vite build",
"dist": "npm run build && electron-builder",
"dist:mac": "npm run build && electron-builder --mac",
"dist:win": "npm run build && electron-builder --win"
},
"dependencies": {
"electron-updater": "^6.8.9",
"highlight.js": "^11.11.1",
"lucide-react": "^1.21.0",
"markdown-it": "^14.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"electron": "^33.2.0",
"electron-builder": "^25.1.8",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.7.2",
"vite": "^6.0.3"
},
"build": {
"appId": "com.cowagent.desktop",
"productName": "CowAgent",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"resources/**/*"
],
"extraResources": [
{
"from": "build/dist/cowagent-backend",
"to": "backend/cowagent-backend"
},
{
"from": "resources",
"to": ".",
"filter": [
"icon.png"
]
}
],
"mac": {
"category": "public.app-category.productivity",
"icon": "resources/icon.icns",
"target": [
{
"target": "dmg",
"arch": [
"arm64",
"x64"
]
}
]
},
"win": {
"icon": "resources/icon.ico",
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"publish": {
"provider": "github",
"owner": "zhayujie",
"repo": "chatgpt-on-wechat"
}
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
desktop/resources/icon.icns Normal file

Binary file not shown.

BIN
desktop/resources/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
desktop/resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

307
desktop/src/main/index.ts Normal file
View File

@@ -0,0 +1,307 @@
import { app, BrowserWindow, shell, ipcMain, dialog, nativeImage } from 'electron'
import path from 'path'
import fs from 'fs'
import http from 'http'
import { PythonBackend } from './python-manager'
import { buildAppMenu } from './menu'
import { createTray, destroyTray } from './tray'
import { initUpdater, checkForUpdates, startDownload, quitAndInstall } from './updater'
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
// where the default Electron binary would otherwise report "Electron".
app.setName('CowAgent')
let mainWindow: BrowserWindow | null = null
let pythonBackend: PythonBackend | null = null
// True once the user explicitly quits (menu/tray), so close-to-tray is bypassed.
let isQuitting = false
const isDev = !app.isPackaged
const VITE_DEV_PORTS = [5173, 5174, 5175, 5176]
function probePort(port: number): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(`http://localhost:${port}`, (res) => {
resolve(res.statusCode !== undefined)
})
req.on('error', () => resolve(false))
req.setTimeout(500, () => { req.destroy(); resolve(false) })
})
}
async function findViteDevServer(): Promise<string | null> {
for (const port of VITE_DEV_PORTS) {
if (await probePort(port)) {
return `http://localhost:${port}`
}
}
return null
}
function getIconPath(ext: string = 'png'): string | undefined {
const iconFile = `icon.${ext}`
const iconPath = isDev
? path.resolve(__dirname, '../../resources', iconFile)
: path.join(process.resourcesPath, iconFile)
if (fs.existsSync(iconPath)) return iconPath
return undefined
}
const isMac = process.platform === 'darwin'
const isWin = process.platform === 'win32'
// Persisted window bounds
const windowStateFile = () => path.join(app.getPath('userData'), 'window-state.json')
function loadWindowState(): { width: number; height: number; x?: number; y?: number } {
try {
const raw = fs.readFileSync(windowStateFile(), 'utf-8')
const s = JSON.parse(raw)
if (typeof s.width === 'number' && typeof s.height === 'number') return s
} catch {
/* first run or unreadable */
}
return { width: 1280, height: 800 }
}
function saveWindowState() {
if (!mainWindow || mainWindow.isDestroyed()) return
if (mainWindow.isMinimized() || mainWindow.isFullScreen()) return
const b = mainWindow.getBounds()
try {
fs.writeFileSync(windowStateFile(), JSON.stringify(b))
} catch {
/* ignore */
}
}
function createWindow() {
const state = loadWindowState()
mainWindow = new BrowserWindow({
width: state.width,
height: state.height,
x: state.x,
y: state.y,
minWidth: 900,
minHeight: 600,
// macOS: native traffic lights inset into our custom titlebar.
// Windows: fully frameless; we render custom window controls in-app.
titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
trafficLightPosition: isMac ? { x: 14, y: 16 } : undefined,
frame: isMac ? undefined : false,
backgroundColor: '#0e0e10',
icon: getIconPath(),
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
const persist = () => saveWindowState()
mainWindow.on('resize', persist)
mainWindow.on('move', persist)
mainWindow.on('maximize', emitMaximizeState)
mainWindow.on('unmaximize', emitMaximizeState)
const rendererHtml = path.join(__dirname, '../renderer/index.html')
if (isDev) {
findViteDevServer().then((devUrl) => {
if (devUrl) {
console.log(`[Electron] Loading Vite dev server: ${devUrl}`)
mainWindow?.loadURL(devUrl)
mainWindow?.webContents.openDevTools()
} else if (fs.existsSync(rendererHtml)) {
console.log('[Electron] Vite dev server not found, loading built files')
mainWindow?.loadFile(rendererHtml)
} else {
console.error('[Electron] No renderer available. Run "npm run build:renderer" first.')
}
})
} else {
mainWindow.loadFile(rendererHtml)
}
mainWindow.once('ready-to-show', () => {
mainWindow?.show()
})
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
// Close-to-tray: hide the window instead of destroying it, so the tray's
// "Show" can bring it back. Only a real Quit (menu/tray/Cmd+Q) destroys it.
mainWindow.on('close', (e) => {
if (!isQuitting) {
e.preventDefault()
mainWindow?.hide()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
}
function getBackendPath(): string {
if (isDev) {
return path.resolve(__dirname, '../../..')
}
return path.join(process.resourcesPath, 'backend')
}
async function startBackend() {
const backendPath = getBackendPath()
pythonBackend = new PythonBackend(backendPath)
pythonBackend.on('ready', (port: number) => {
mainWindow?.webContents.send('backend-status', { status: 'ready', port })
})
pythonBackend.on('error', (error: string) => {
mainWindow?.webContents.send('backend-status', { status: 'error', error })
})
pythonBackend.on('log', (line: string) => {
mainWindow?.webContents.send('backend-log', line)
})
await pythonBackend.start()
}
function setupIPC() {
ipcMain.handle('get-backend-port', () => {
return pythonBackend?.getPort() ?? null
})
ipcMain.handle('get-backend-status', () => {
return pythonBackend?.getStatus() ?? 'stopped'
})
ipcMain.handle('restart-backend', async () => {
await pythonBackend?.restart()
return true
})
ipcMain.handle('select-directory', async () => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
})
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('select-file', async (_event, filters?: Electron.FileFilter[]) => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: filters || [{ name: 'All Files', extensions: ['*'] }],
})
return result.canceled ? null : result.filePaths[0]
})
// Custom window controls (used by Windows frameless titlebar)
ipcMain.handle('window-minimize', () => mainWindow?.minimize())
ipcMain.handle('window-maximize', () => {
if (!mainWindow) return false
if (mainWindow.isMaximized()) mainWindow.unmaximize()
else mainWindow.maximize()
return mainWindow.isMaximized()
})
ipcMain.handle('window-close', () => mainWindow?.close())
ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false)
// Auto-update controls (renderer-driven: check, then opt-in download/install)
ipcMain.handle('update-check', () => checkForUpdates())
ipcMain.handle('update-download', () => startDownload())
ipcMain.handle('update-install', () => quitAndInstall())
// Synchronous OS locale lookup (e.g. "zh-CN", "en-US"). Used by the renderer
// to pick a sensible default UI language on first run before any paint.
ipcMain.on('get-system-locale', (event) => {
event.returnValue = app.getLocale() || app.getSystemLocale?.() || ''
})
}
function emitMaximizeState() {
const max = mainWindow?.isMaximized() ?? false
mainWindow?.webContents.send('window-maximize-changed', max)
}
// Single-instance lock: focus the existing window instead of opening a second app.
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus()
}
})
}
app.whenReady().then(async () => {
// Set Dock icon on macOS (PNG is most reliable for nativeImage)
if (process.platform === 'darwin') {
const pngPath = getIconPath('png')
if (pngPath) {
const icon = nativeImage.createFromPath(pngPath)
if (!icon.isEmpty()) {
app.dock.setIcon(icon)
console.log('[Electron] Dock icon set:', pngPath)
} else {
console.warn('[Electron] Dock icon loaded but empty:', pngPath)
}
} else {
console.warn('[Electron] Dock icon not found in resources/')
}
}
setupIPC()
createWindow()
buildAppMenu(() => mainWindow)
// No menu-bar tray on macOS — the Dock + window controls are enough there.
// Keep the tray on Windows/Linux where minimizing to a tray icon is expected.
if (!isMac) {
createTray({
getWindow: () => mainWindow,
iconPath: getIconPath('png'),
onQuit: () => {
isQuitting = true
app.quit()
},
})
}
await startBackend()
// Wire auto-update and do a first silent check a few seconds after launch so
// it doesn't compete with backend startup for resources.
initUpdater(() => mainWindow)
setTimeout(() => checkForUpdates(), 5000)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
} else {
mainWindow?.show()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('before-quit', () => {
isQuitting = true
saveWindowState()
destroyTray()
pythonBackend?.stop()
})

112
desktop/src/main/menu.ts Normal file
View File

@@ -0,0 +1,112 @@
import { app, Menu, BrowserWindow, shell } from 'electron'
import type { MenuItemConstructorOptions } from 'electron'
const isMac = process.platform === 'darwin'
const SKILL_HUB_URL = 'https://skills.cowagent.ai/'
const DOCS_URL = 'https://docs.cowagent.ai'
// Send a menu-triggered action to the renderer (e.g. new chat, open settings).
function emit(win: BrowserWindow | null, action: string) {
win?.webContents.send('menu-action', action)
}
/**
* Build a minimal, purpose-built application menu. We intentionally drop most of
* Electron's verbose defaults and keep only items that are actually useful for
* this app, plus the shortcuts users expect (New Chat, Settings, Reload, etc).
*/
export function buildAppMenu(getWindow: () => BrowserWindow | null) {
const win = () => getWindow()
const appMenu: MenuItemConstructorOptions[] = isMac
? [
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ label: 'Settings…', accelerator: 'Cmd+,', click: () => emit(win(), 'open-settings') },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
]
: []
const fileMenu: MenuItemConstructorOptions = {
label: 'File',
submenu: [
{ label: 'New Chat', accelerator: 'CmdOrCtrl+N', click: () => emit(win(), 'new-chat') },
...(!isMac
? ([
{ label: 'Settings', accelerator: 'Ctrl+,', click: () => emit(win(), 'open-settings') },
{ type: 'separator' },
{ role: 'quit' },
] as MenuItemConstructorOptions[])
: []),
],
}
const editMenu: MenuItemConstructorOptions = {
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
}
const viewMenu: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
}
const windowMenu: MenuItemConstructorOptions = {
label: 'Window',
submenu: [
{ role: 'minimize' },
...(isMac ? ([{ role: 'zoom' }] as MenuItemConstructorOptions[]) : []),
{ type: 'separator' },
// Explicit Close so Cmd/Ctrl+W reliably triggers our close-to-tray hide.
{ label: 'Close Window', accelerator: 'CmdOrCtrl+W', click: () => win()?.close() },
],
}
const helpMenu: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
{ label: 'View Logs', click: () => emit(win(), 'view-logs') },
{ type: 'separator' },
{ label: 'Documentation', click: () => shell.openExternal(DOCS_URL) },
{ label: 'Skill Hub', click: () => shell.openExternal(SKILL_HUB_URL) },
],
}
const template: MenuItemConstructorOptions[] = [
...appMenu,
fileMenu,
editMenu,
viewMenu,
windowMenu,
helpMenu,
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}

View File

@@ -0,0 +1,62 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
getBackendPort: () => ipcRenderer.invoke('get-backend-port'),
getBackendStatus: () => ipcRenderer.invoke('get-backend-status'),
restartBackend: () => ipcRenderer.invoke('restart-backend'),
selectDirectory: () => ipcRenderer.invoke('select-directory'),
selectFile: (filters?: Electron.FileFilter[]) => ipcRenderer.invoke('select-file', filters),
// Each listener registrar returns an unsubscribe fn so renderers can clean
// up on unmount / effect re-run and avoid accumulating duplicate handlers.
onBackendStatus: (callback: (data: { status: string; port?: number; error?: string }) => void) => {
const handler = (_event: unknown, data: { status: string; port?: number; error?: string }) => callback(data)
ipcRenderer.on('backend-status', handler)
return () => ipcRenderer.removeListener('backend-status', handler)
},
onBackendLog: (callback: (line: string) => void) => {
const handler = (_event: unknown, line: string) => callback(line)
ipcRenderer.on('backend-log', handler)
return () => ipcRenderer.removeListener('backend-log', handler)
},
// Window controls (custom titlebar on Windows)
windowMinimize: () => ipcRenderer.invoke('window-minimize'),
windowMaximize: () => ipcRenderer.invoke('window-maximize'),
windowClose: () => ipcRenderer.invoke('window-close'),
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
onMaximizeChange: (callback: (maximized: boolean) => void) => {
const handler = (_event: unknown, max: boolean) => callback(max)
ipcRenderer.on('window-maximize-changed', handler)
return () => ipcRenderer.removeListener('window-maximize-changed', handler)
},
// App menu / shortcut actions forwarded from the main process.
onMenuAction: (callback: (action: string) => void) => {
const handler = (_event: unknown, action: string) => callback(action)
ipcRenderer.on('menu-action', handler)
return () => ipcRenderer.removeListener('menu-action', handler)
},
// Auto-update: trigger checks/download/install and subscribe to status.
checkForUpdate: () => ipcRenderer.invoke('update-check'),
downloadUpdate: () => ipcRenderer.invoke('update-download'),
installUpdate: () => ipcRenderer.invoke('update-install'),
onUpdateStatus: (callback: (status: unknown) => void) => {
const handler = (_event: unknown, status: unknown) => callback(status)
ipcRenderer.on('update-status', handler)
return () => ipcRenderer.removeListener('update-status', handler)
},
platform: process.platform,
// OS UI language (e.g. "zh-CN"), read synchronously so the renderer can pick
// a default language on first run. Falls back to '' if unavailable.
systemLocale: (() => {
try {
return ipcRenderer.sendSync('get-system-locale') as string
} catch {
return ''
}
})(),
})

View File

@@ -0,0 +1,255 @@
import { ChildProcess, spawn } from 'child_process'
import { EventEmitter } from 'events'
import path from 'path'
import os from 'os'
import fs from 'fs'
import http from 'http'
// Writable data dir for the packaged app (config.json, run.log, user data).
// Lives in the user's home so it survives app updates and avoids writing into
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
const COW_DATA_DIR = path.join(os.homedir(), '.cow')
export class PythonBackend extends EventEmitter {
private process: ChildProcess | null = null
private backendPath: string
private port: number = 9899
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
constructor(backendPath: string) {
super()
this.backendPath = backendPath
}
getPort(): number {
return this.port
}
getStatus(): string {
return this.status
}
/**
* Locate the packaged onedir backend executable shipped with the app.
* Returns null when not present (e.g. during local development), so we can
* fall back to running app.py with a system/venv Python.
*/
private findBundledBackend(): string | null {
const exeName = process.platform === 'win32' ? 'cowagent-backend.exe' : 'cowagent-backend'
const candidates = [
path.join(this.backendPath, 'cowagent-backend', exeName),
path.join(this.backendPath, exeName),
]
for (const p of candidates) {
if (fs.existsSync(p)) {
return p
}
}
return null
}
private findPython(): string {
const venvPaths = [
path.join(this.backendPath, '.venv', 'bin', 'python'),
path.join(this.backendPath, '.venv', 'Scripts', 'python.exe'),
path.join(this.backendPath, 'venv', 'bin', 'python'),
path.join(this.backendPath, 'venv', 'Scripts', 'python.exe'),
]
for (const p of venvPaths) {
if (fs.existsSync(p)) {
return p
}
}
return process.platform === 'win32' ? 'python' : 'python3'
}
/**
* Resolve config.json from the given data dir to read the web port. The
* packaged build keeps config in COW_DATA_DIR (~/.cow); dev reads it from the
* repo path. Returns the default port when no config (or web_port) is found.
*/
private readPort(dataDir: string): number {
try {
const configPath = path.join(dataDir, 'config.json')
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (config.web_port) {
return config.web_port
}
}
} catch {
// ignore
}
return 9899
}
async start(): Promise<void> {
if (this.status === 'ready' || this.status === 'starting') {
return
}
this.status = 'starting'
// Prefer the packaged self-contained backend (production); fall back to
// running app.py with a Python interpreter (local development).
const bundled = this.findBundledBackend()
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
this.port = this.readPort(dataDir)
const alreadyRunning = await this.probeHealth()
if (alreadyRunning) {
this.status = 'ready'
this.emit('log', `Backend already running on port ${this.port}`)
this.emit('ready', this.port)
return
}
let command: string
let args: string[]
let cwd: string
if (bundled) {
command = bundled
args = []
// The onedir bundle reads data files relative to the executable's dir.
cwd = path.dirname(bundled)
this.emit('log', `Starting bundled backend: ${bundled}`)
} else {
const pythonPath = this.findPython()
const appPath = path.join(this.backendPath, 'app.py')
if (!fs.existsSync(appPath)) {
this.status = 'error'
this.emit('error', `app.py not found at ${appPath}`)
return
}
command = pythonPath
args = [appPath]
cwd = this.backendPath
this.emit('log', `Starting Python backend: ${pythonPath} ${appPath}`)
}
this.process = spawn(command, args, {
cwd,
// COW_DESKTOP enables the lighter desktop runtime (no plugins, no MCP).
// COW_DATA_DIR (packaged only) redirects writable data to ~/.cow so the
// app bundle stays read-only; dev runs omit it and keep using the repo.
env: {
...process.env,
PYTHONUNBUFFERED: '1',
COW_DESKTOP: '1',
...(bundled ? { COW_DATA_DIR } : {}),
},
stdio: ['pipe', 'pipe', 'pipe'],
})
this.process.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(Boolean)
for (const line of lines) {
this.emit('log', line)
}
})
this.process.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(Boolean)
for (const line of lines) {
this.emit('log', line)
}
})
this.process.on('exit', (code) => {
this.status = 'stopped'
this.emit('log', `Python process exited with code ${code}`)
if (code !== 0 && code !== null) {
this.emit('error', `Python process exited with code ${code}`)
}
})
this.process.on('error', (err) => {
this.status = 'error'
this.emit('error', `Failed to start Python: ${err.message}`)
})
await this.waitForReady()
}
private probeHealth(): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
resolve(res.statusCode === 200)
})
req.on('error', () => resolve(false))
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
})
}
private waitForReady(): Promise<void> {
return new Promise((resolve) => {
// Wall-clock deadline rather than an attempt counter: if the machine
// sleeps/suspends, the 1s timers stretch out and a counter would give up
// far too early. Time-based bounding tracks real elapsed time instead.
const timeoutMs = 120_000
const startedAt = Date.now()
const check = () => {
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
if (res.statusCode === 200) {
this.status = 'ready'
this.emit('log', `Backend ready on port ${this.port}`)
this.emit('ready', this.port)
resolve()
} else {
retry()
}
})
req.on('error', () => retry())
req.setTimeout(2000, () => {
req.destroy()
retry()
})
}
const retry = () => {
if (this.status === 'stopped' || this.status === 'ready') {
resolve()
return
}
if (Date.now() - startedAt >= timeoutMs) {
this.status = 'error'
this.emit('error', `Backend failed to start within ${Math.round(timeoutMs / 1000)} seconds`)
resolve()
return
}
setTimeout(check, 1000)
}
setTimeout(check, 2000)
})
}
stop(): void {
const proc = this.process
if (proc) {
proc.kill('SIGTERM')
// Keep a local ref so the SIGKILL fallback can still reach the process
// even after we clear `this.process`; otherwise a stuck backend would
// never be force-killed and leak as a zombie.
setTimeout(() => {
if (!proc.killed) {
proc.kill('SIGKILL')
}
}, 5000)
this.process = null
}
this.status = 'stopped'
}
async restart(): Promise<void> {
this.stop()
await new Promise((resolve) => setTimeout(resolve, 2000))
await this.start()
}
}

59
desktop/src/main/tray.ts Normal file
View File

@@ -0,0 +1,59 @@
import { app, Tray, Menu, BrowserWindow, nativeImage } from 'electron'
let tray: Tray | null = null
interface TrayDeps {
getWindow: () => BrowserWindow | null
// Colored icon used on Windows/Linux trays.
iconPath?: string
// Called when the user picks "Quit" so the app can fully exit.
onQuit: () => void
}
// Build a system tray icon with a minimal menu (Windows/Linux only — macOS
// uses the Dock instead). Lets users restore the window after closing it to the
// background and start a new chat quickly.
export function createTray({ getWindow, iconPath, onQuit }: TrayDeps): Tray | null {
if (tray) return tray
if (!iconPath) return null
let image = nativeImage.createFromPath(iconPath)
if (image.isEmpty()) return null
// Tray icons render small; resize to avoid an oversized image on some platforms.
image = image.resize({ width: 18, height: 18 })
tray = new Tray(image)
tray.setToolTip(app.name)
const showWindow = () => {
const win = getWindow()
if (!win) return
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
const contextMenu = Menu.buildFromTemplate([
{ label: 'Show CowAgent', click: showWindow },
{
label: 'New Chat',
click: () => {
showWindow()
getWindow()?.webContents.send('menu-action', 'new-chat')
},
},
{ type: 'separator' },
{ label: 'Quit', click: onQuit },
])
tray.setContextMenu(contextMenu)
// Single click restores the window (common Windows/Linux behavior).
tray.on('click', showWindow)
return tray
}
export function destroyTray() {
tray?.destroy()
tray = null
}

View File

@@ -0,0 +1,73 @@
import { app, BrowserWindow } from 'electron'
// electron-updater is CommonJS: its members live on module.exports, with no
// meaningful default export. Under module=commonjs + esModuleInterop, a named
// import compiles to `electron_updater_1.autoUpdater` and resolves correctly,
// whereas `import pkg from 'electron-updater'` yields undefined.
import { autoUpdater } from 'electron-updater'
// Status payloads pushed to the renderer over the 'update-status' channel.
// The renderer drives the NavRail badge + update panel from these.
export type UpdateStatus =
| { state: 'checking' }
| { state: 'available'; version: string; notes?: string }
| { state: 'not-available' }
| { state: 'downloading'; percent: number }
| { state: 'downloaded'; version: string }
| { state: 'error'; message: string }
let getWindow: () => BrowserWindow | null = () => null
function send(status: UpdateStatus) {
getWindow()?.webContents.send('update-status', status)
}
export function initUpdater(windowGetter: () => BrowserWindow | null): void {
getWindow = windowGetter
// In dev (not packaged) there's no update feed; skip wiring entirely so
// electron-updater doesn't throw on the missing app-update.yml.
if (!app.isPackaged) {
return
}
// User-driven flow: we surface "available" and let the user opt in to the
// download, rather than pulling bytes silently in the background.
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('checking-for-update', () => send({ state: 'checking' }))
autoUpdater.on('update-available', (info) =>
send({ state: 'available', version: info.version, notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined })
)
autoUpdater.on('update-not-available', () => send({ state: 'not-available' }))
autoUpdater.on('download-progress', (p) =>
send({ state: 'downloading', percent: Math.round(p.percent) })
)
autoUpdater.on('update-downloaded', (info) =>
send({ state: 'downloaded', version: info.version })
)
autoUpdater.on('error', (err) =>
send({ state: 'error', message: err == null ? 'unknown' : (err.message || String(err)) })
)
}
// Silent check shortly after launch; safe to call when not packaged (no-op).
export function checkForUpdates(): void {
if (!app.isPackaged) return
autoUpdater.checkForUpdates().catch((err) => {
send({ state: 'error', message: err?.message || String(err) })
})
}
export function startDownload(): void {
if (!app.isPackaged) return
autoUpdater.downloadUpdate().catch((err) => {
send({ state: 'error', message: err?.message || String(err) })
})
}
export function quitAndInstall(): void {
if (!app.isPackaged) return
// isSilent=false (show installer), isForceRunAfter=true (relaunch after).
autoUpdater.quitAndInstall(false, true)
}

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:*; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*;" />
<title>CowAgent</title>
<!-- Local fonts & icons (offline, no CDN) served from publicDir -->
<link rel="stylesheet" href="./vendor/fonts/inter/inter.css" />
<link rel="stylesheet" href="./vendor/fontawesome/css/all.min.css" />
<script>
// Resolve theme before first paint to avoid flash-of-wrong-theme.
(function () {
try {
var pref = localStorage.getItem('cow_theme') || 'dark';
var resolved = pref === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: pref;
if (resolved === 'dark') document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
} catch (e) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body class="h-screen overflow-hidden">
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,140 @@
import React, { useState, useCallback, useEffect } from 'react'
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'
import { PanelLeftOpen } from 'lucide-react'
import NavRail from './layout/NavRail'
import SessionList from './layout/SessionList'
import WindowControls from './layout/WindowControls'
import StatusScreen from './components/StatusScreen'
import { useBackend } from './hooks/useBackend'
import { usePlatform } from './hooks/usePlatform'
import { useUIStore } from './store/uiStore'
import { useSessionStore } from './store/sessionStore'
import { initUpdateListener } from './store/updateStore'
import { useOnboardingStore } from './store/onboardingStore'
import OnboardingWizard from './components/OnboardingWizard'
import apiClient from './api/client'
import { t } from './i18n'
import ChatPage from './pages/ChatPage'
import SettingsPage from './pages/SettingsPage'
import KnowledgePage from './pages/KnowledgePage'
import SkillsPage from './pages/SkillsPage'
import MemoryPage from './pages/MemoryPage'
import ChannelsPage from './pages/ChannelsPage'
import TasksPage from './pages/TasksPage'
import LogsPage from './pages/LogsPage'
const App: React.FC = () => {
const backend = useBackend()
const location = useLocation()
const navigate = useNavigate()
const { isWin } = usePlatform()
const { sessionsCollapsed, toggleSessions } = useUIStore()
const onboardingOpen = useOnboardingStore((s) => s.open)
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
const [, forceUpdate] = useState(0)
useEffect(() => {
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
}, [backend.status, backend.baseUrl])
// First-run check: once the backend is ready, decide whether to show the
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
// configured (and not dismissed earlier this session); no persisted flag.
useEffect(() => {
if (backend.status !== 'ready') return
let cancelled = false
apiClient
.getModels()
.then((data) => {
if (cancelled) return
const chat = data.capabilities?.chat
// "Configured" needs a chat provider+model AND that provider's API key
// set. A default config can ship a model name with no key, which
// shouldn't count as ready — otherwise we'd skip onboarding for users
// who still need to enter a key.
const providerId = chat?.current_provider
const provider = data.providers?.find((p) => p.id === providerId)
const keyReady = !!provider && (provider.configured || (provider.is_custom && !!provider.custom_name))
const configured = !!providerId && !!chat?.current_model && keyReady
maybeOpenOnboarding(configured)
})
.catch(() => {
// If models can't be loaded, fall back to the flag-only decision.
if (!cancelled) maybeOpenOnboarding(false)
})
return () => {
cancelled = true
}
}, [backend.status, maybeOpenOnboarding])
// Subscribe to auto-update status from the main process (no-op in dev).
useEffect(() => initUpdateListener(), [])
// Handle app-menu / shortcut actions forwarded from the main process.
useEffect(() => {
const off = window.electronAPI?.onMenuAction?.((action) => {
if (action === 'new-chat') {
useSessionStore.getState().newSession()
navigate('/')
} else if (action === 'open-settings') {
navigate('/settings')
} else if (action === 'view-logs') {
navigate('/logs')
}
})
return off
}, [navigate])
const handleLangChange = useCallback(() => forceUpdate((n) => n + 1), [])
if (backend.status !== 'ready') {
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
}
const isChat = location.pathname === '/'
const showSessions = isChat && !sessionsCollapsed
return (
<div className="flex h-screen overflow-hidden bg-base text-content">
{onboardingOpen && <OnboardingWizard onDone={handleLangChange} />}
<NavRail onLangChange={handleLangChange} />
{showSessions && <SessionList />}
<div className="flex-1 flex flex-col min-w-0 h-screen">
{/* Top titlebar strip — drag region + Windows controls */}
<header className="h-[44px] flex items-center gap-1 px-2 flex-shrink-0 titlebar-drag bg-base border-b border-default">
{isChat && sessionsCollapsed && (
<button
onClick={toggleSessions}
title={t('nav_expand')}
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors"
>
<PanelLeftOpen size={16} />
</button>
)}
<div className="flex-1 min-w-0" />
{isWin && <WindowControls />}
</header>
{/* Content */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-base">
<Routes>
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
<Route path="/knowledge" element={<KnowledgePage baseUrl={backend.baseUrl} />} />
<Route path="/memory" element={<MemoryPage baseUrl={backend.baseUrl} />} />
<Route path="/skills" element={<SkillsPage baseUrl={backend.baseUrl} />} />
<Route path="/channels" element={<ChannelsPage baseUrl={backend.baseUrl} />} />
<Route path="/tasks" element={<TasksPage baseUrl={backend.baseUrl} />} />
<Route path="/settings" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
{/* Legacy /models route now lives as a tab inside settings */}
<Route path="/models" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
</Routes>
</div>
</div>
</div>
)
}
export default App

View File

@@ -0,0 +1,404 @@
import type {
ConfigData,
ChannelInfo,
ChannelAction,
SkillInfo,
ToolInfo,
MemoryItem,
MemoryCategory,
MemoryPage,
SchedulerTask,
Attachment,
SessionsPage,
HistoryPage,
ModelsData,
ModelsAction,
KnowledgeList,
KnowledgeGraph,
KnowledgeAction,
} from '../types'
interface ApiResult {
status: string
message?: string
}
class ApiClient {
private baseUrl = 'http://127.0.0.1:9899'
setBaseUrl(url: string) {
this.baseUrl = url
}
getBaseUrl() {
return this.baseUrl
}
private async request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
// Send cookies for future web_password auth support
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return res.json()
}
// ---------------------------------------------------------
// Chat / messages
// ---------------------------------------------------------
async sendMessage(
sessionId: string,
message: string,
opts?: { stream?: boolean; attachments?: Attachment[]; isVoice?: boolean; lang?: string }
): Promise<{ status: string; request_id: string; stream: boolean; inline_reply?: string }> {
return this.request('/message', {
method: 'POST',
body: JSON.stringify({
session_id: sessionId,
message,
stream: opts?.stream ?? true,
attachments: opts?.attachments,
is_voice: opts?.isVoice ?? false,
lang: opts?.lang,
}),
})
}
async poll(sessionId: string): Promise<{
status: string
has_content: boolean
content?: string
request_id?: string
timestamp?: number
}> {
return this.request('/poll', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
})
}
async cancel(opts: { requestId?: string; sessionId?: string; lang?: string }): Promise<{ status: string; cancelled: number }> {
return this.request('/cancel', {
method: 'POST',
body: JSON.stringify({ request_id: opts.requestId, session_id: opts.sessionId, lang: opts.lang }),
})
}
createSSEStream(requestId: string): EventSource {
return new EventSource(`${this.baseUrl}/stream?request_id=${requestId}`)
}
async deleteMessage(opts: {
sessionId: string
userSeq: number
deleteUser?: boolean
cascade?: boolean
}): Promise<{ status: string; deleted: number }> {
return this.request('/api/messages/delete', {
method: 'POST',
body: JSON.stringify({
session_id: opts.sessionId,
user_seq: opts.userSeq,
delete_user: opts.deleteUser ?? true,
cascade: opts.cascade ?? false,
}),
})
}
// ---------------------------------------------------------
// Upload / files
// ---------------------------------------------------------
async uploadFile(file: File, sessionId?: string): Promise<{
status: string
file_path: string
file_name: string
file_type: string
preview_url: string
}> {
const formData = new FormData()
formData.append('file', file)
if (sessionId) formData.append('session_id', sessionId)
const res = await fetch(`${this.baseUrl}/upload`, {
method: 'POST',
body: formData,
credentials: 'include',
})
return res.json()
}
getFileUrl(previewUrl: string): string {
if (/^https?:\/\//.test(previewUrl)) return previewUrl
return `${this.baseUrl}${previewUrl}`
}
getServeFileUrl(absPath: string): string {
return `${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`
}
// ---------------------------------------------------------
// Sessions
// ---------------------------------------------------------
async getSessions(page = 1, pageSize = 50): Promise<SessionsPage> {
return this.request<{ status: string } & SessionsPage>(`/api/sessions?page=${page}&page_size=${pageSize}`)
}
async deleteSession(sessionId: string): Promise<ApiResult> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' })
}
async renameSession(sessionId: string, title: string): Promise<ApiResult> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
method: 'PUT',
body: JSON.stringify({ title }),
})
}
async generateSessionTitle(sessionId: string, userMessage: string, assistantReply?: string): Promise<{ status: string; title: string }> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/generate_title`, {
method: 'POST',
body: JSON.stringify({ user_message: userMessage, assistant_reply: assistantReply }),
})
}
async clearContext(sessionId: string): Promise<{ status: string; context_start_seq: number }> {
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/clear_context`, { method: 'POST' })
}
async getHistory(sessionId: string, page = 1, pageSize = 20): Promise<HistoryPage> {
return this.request<{ status: string } & HistoryPage>(
`/api/history?session_id=${encodeURIComponent(sessionId)}&page=${page}&page_size=${pageSize}`
)
}
// ---------------------------------------------------------
// Config
// ---------------------------------------------------------
async getConfig(): Promise<ConfigData> {
return this.request<{ status: string } & ConfigData>('/config')
}
async updateConfig(updates: Record<string, unknown>): Promise<{ status: string; applied: Record<string, unknown> }> {
return this.request('/config', {
method: 'POST',
body: JSON.stringify({ updates }),
})
}
// ---------------------------------------------------------
// Models console
// ---------------------------------------------------------
async getModels(): Promise<ModelsData> {
return this.request<{ status: string } & ModelsData>('/api/models')
}
async modelsAction(action: ModelsAction): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/models', {
method: 'POST',
body: JSON.stringify(action),
})
}
// ---------------------------------------------------------
// Channels
// ---------------------------------------------------------
async getChannels(): Promise<ChannelInfo[]> {
const data = await this.request<{ status: string; channels: ChannelInfo[] }>('/api/channels')
return data.channels
}
async channelAction(
action: ChannelAction,
channel: string,
config?: Record<string, unknown>
): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/channels', {
method: 'POST',
body: JSON.stringify({ action, channel, config }),
})
}
// Weixin QR login
async getWeixinQr(): Promise<{ status: string; qrcode_url?: string; qr_image?: string; source?: string; message?: string }> {
return this.request('/api/weixin/qrlogin')
}
async weixinQrAction(action: 'poll' | 'refresh'): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/weixin/qrlogin', {
method: 'POST',
body: JSON.stringify({ action }),
})
}
// Feishu one-click register
async getFeishuRegister(): Promise<{ status: string; qrcode_url?: string; qr_image?: string; expire_in?: number; message?: string }> {
return this.request('/api/feishu/register')
}
async feishuRegisterPoll(): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/feishu/register', {
method: 'POST',
body: JSON.stringify({ action: 'poll' }),
})
}
// ---------------------------------------------------------
// Tools & skills
// ---------------------------------------------------------
async getTools(): Promise<ToolInfo[]> {
const data = await this.request<{ status: string; tools: ToolInfo[] }>('/api/tools')
return data.tools
}
async getSkills(): Promise<SkillInfo[]> {
const data = await this.request<{ status: string; skills: SkillInfo[] }>('/api/skills')
return data.skills
}
async toggleSkill(name: string, action: 'open' | 'close'): Promise<ApiResult> {
return this.request('/api/skills', {
method: 'POST',
body: JSON.stringify({ action, name }),
})
}
// ---------------------------------------------------------
// Memory
// ---------------------------------------------------------
async getMemoryList(page = 1, pageSize = 20, category: MemoryCategory = 'memory'): Promise<MemoryPage> {
return this.request<{ status: string } & MemoryPage>(
`/api/memory?page=${page}&page_size=${pageSize}&category=${category}`
)
}
async getMemoryContent(filename: string, category: MemoryCategory = 'memory'): Promise<string> {
const data = await this.request<{ status: string; content: string }>(
`/api/memory/content?filename=${encodeURIComponent(filename)}&category=${category}`
)
return data.content
}
// ---------------------------------------------------------
// Knowledge
// ---------------------------------------------------------
async getKnowledgeList(): Promise<KnowledgeList> {
return this.request<{ status: string } & KnowledgeList>('/api/knowledge/list')
}
async readKnowledge(path: string): Promise<{ status: string; content: string; path: string }> {
return this.request(`/api/knowledge/read?path=${encodeURIComponent(path)}`)
}
async getKnowledgeGraph(): Promise<KnowledgeGraph> {
return this.request<KnowledgeGraph>('/api/knowledge/graph')
}
async knowledgeAction(req: KnowledgeAction): Promise<Record<string, unknown> & { status: string }> {
return this.request('/api/knowledge/action', {
method: 'POST',
body: JSON.stringify(req),
})
}
// ---------------------------------------------------------
// Scheduler
// ---------------------------------------------------------
async getSchedulerTasks(): Promise<SchedulerTask[]> {
const data = await this.request<{ status: string; tasks: SchedulerTask[] }>('/api/scheduler')
return data.tasks
}
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
return this.request('/api/scheduler/toggle', {
method: 'POST',
body: JSON.stringify({ task_id: taskId, enabled }),
})
}
async updateTask(taskId: string, updates: Partial<Pick<SchedulerTask, 'name' | 'enabled' | 'schedule' | 'action'>>): Promise<{ status: string; task: SchedulerTask }> {
return this.request('/api/scheduler/update', {
method: 'POST',
body: JSON.stringify({ task_id: taskId, ...updates }),
})
}
async deleteTask(taskId: string): Promise<ApiResult> {
return this.request('/api/scheduler/delete', {
method: 'POST',
body: JSON.stringify({ task_id: taskId }),
})
}
// ---------------------------------------------------------
// Voice
// ---------------------------------------------------------
async voiceAsr(audio: File | Blob): Promise<{ status: string; text?: string; audio_url?: string; message?: string }> {
const formData = new FormData()
formData.append('file', audio, 'recording.webm')
const res = await fetch(`${this.baseUrl}/api/voice/asr`, {
method: 'POST',
body: formData,
credentials: 'include',
})
return res.json()
}
async voiceTts(text: string, sessionId?: string): Promise<{ status: string; audio_url?: string; message?: string }> {
return this.request('/api/voice/tts', {
method: 'POST',
body: JSON.stringify({ text, session_id: sessionId }),
})
}
// ---------------------------------------------------------
// Logs / version
// ---------------------------------------------------------
createLogStream(): EventSource {
return new EventSource(`${this.baseUrl}/api/logs`)
}
async getVersion(): Promise<string> {
const data = await this.request<{ version: string }>('/api/version')
return data.version
}
// ---------------------------------------------------------
// Auth (web_password) — placeholder for future use
// ---------------------------------------------------------
async authCheck(): Promise<{ status: string; auth_required: boolean; authenticated?: boolean }> {
return this.request('/auth/check')
}
async authLogin(password: string): Promise<ApiResult> {
return this.request('/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
}
async authLogout(): Promise<ApiResult> {
return this.request('/auth/logout', { method: 'POST' })
}
}
export const apiClient = new ApiClient()
export default apiClient

View File

@@ -0,0 +1,326 @@
import React, { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
import { Plus, Paperclip, Send, Square, X, File as FileIcon, Loader2 } from 'lucide-react'
import { t } from '../i18n'
import type { Attachment } from '../types'
import apiClient from '../api/client'
export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
interface SlashCommand {
cmd: string
desc: string
action: 'new' | 'clear'
}
interface ChatInputProps {
onSend: (message: string, attachments: Attachment[]) => void
onNewChat: () => void
onStop: () => void
onClearContext: () => void
isStreaming: boolean
sessionId: string
}
const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
{ onSend, onNewChat, onStop, onClearContext, isStreaming, sessionId },
ref
) {
const [text, setText] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [uploading, setUploading] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [slashOpen, setSlashOpen] = useState(false)
const [slashIndex, setSlashIndex] = useState(0)
const composingRef = useRef(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const slashCommands: SlashCommand[] = [
{ cmd: '/new', desc: t('session_new'), action: 'new' },
{ cmd: '/clear', desc: t('chat_clear_context'), action: 'clear' },
]
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
const resetHeight = () => {
if (textareaRef.current) textareaRef.current.style.height = '42px'
}
// Allow the parent to load a draft (e.g. when editing a past user message).
useImperativeHandle(ref, () => (draft: string, atts: Attachment[]) => {
setText(draft)
setAttachments(atts)
requestAnimationFrame(() => {
const el = textareaRef.current
if (el) {
el.focus()
el.style.height = '42px'
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
}
})
})
const runSlash = (c: SlashCommand) => {
setText('')
setSlashOpen(false)
resetHeight()
if (c.action === 'new') onNewChat()
else if (c.action === 'clear') onClearContext()
}
const handleSubmit = useCallback(() => {
const trimmed = text.trim()
if (!trimmed && attachments.length === 0) return
if (isStreaming) return
onSend(trimmed, attachments)
setText('')
setAttachments([])
setSlashOpen(false)
resetHeight()
}, [text, attachments, isStreaming, onSend])
const handleKeyDown = (e: React.KeyboardEvent) => {
// Slash menu navigation
if (slashOpen && filtered.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSlashIndex((i) => (i + 1) % filtered.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setSlashIndex((i) => (i - 1 + filtered.length) % filtered.length)
return
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
runSlash(filtered[slashIndex])
return
}
if (e.key === 'Escape') {
setSlashOpen(false)
return
}
}
// Don't submit while IME is composing (Chinese input)
if (e.key === 'Enter' && !e.shiftKey && !composingRef.current) {
e.preventDefault()
handleSubmit()
}
}
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const v = e.target.value
setText(v)
const el = e.target
el.style.height = '42px'
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
// open slash menu when the input starts with "/" and has no space
setSlashOpen(v.startsWith('/') && !v.includes(' '))
setSlashIndex(0)
}
const uploadFiles = async (files: File[]) => {
if (!files.length) return
setUploading(true)
try {
for (const file of files) {
const result = await apiClient.uploadFile(file, sessionId)
if (result.status === 'success') {
setAttachments((prev) => [
...prev,
{
file_path: result.file_path,
file_name: result.file_name,
file_type: result.file_type as Attachment['file_type'],
preview_url: result.preview_url,
},
])
}
}
} catch (err) {
console.error('Upload failed:', err)
} finally {
setUploading(false)
}
}
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files) await uploadFiles(Array.from(files))
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragOver(false)
const files = Array.from(e.dataTransfer.files || [])
if (files.length) uploadFiles(files)
}
const handlePaste = (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
const files: File[] = []
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const f = item.getAsFile()
if (f) files.push(f)
}
}
if (files.length) {
e.preventDefault()
uploadFiles(files)
}
}
const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index))
}
// keep slash index in range
useEffect(() => {
if (slashIndex >= filtered.length) setSlashIndex(0)
}, [filtered.length, slashIndex])
const canSend = !isStreaming && (!!text.trim() || attachments.length > 0)
return (
<div className="flex-shrink-0 border-t border-default bg-surface px-4 py-3">
<div
className={`max-w-3xl mx-auto relative rounded-2xl transition-all ${
dragOver ? 'ring-2 ring-accent ring-offset-2 ring-offset-surface' : ''
}`}
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
{dragOver && (
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-2xl bg-accent-soft text-accent text-sm font-medium pointer-events-none">
{t('input_placeholder')}
</div>
)}
{/* Slash command menu */}
{slashOpen && filtered.length > 0 && (
<div className="absolute bottom-full left-0 mb-2 w-64 rounded-xl border border-default bg-elevated shadow-lg overflow-hidden z-30">
{filtered.map((c, i) => (
<button
key={c.cmd}
onMouseEnter={() => setSlashIndex(i)}
onClick={() => runSlash(c)}
className={`w-full flex items-center gap-3 px-3 py-2 text-left cursor-pointer transition-colors ${
i === slashIndex ? 'bg-accent-soft' : 'hover:bg-surface-2'
}`}
>
<span className="text-sm font-medium text-accent">{c.cmd}</span>
<span className="text-xs text-content-tertiary">{c.desc}</span>
</button>
))}
</div>
)}
{/* Attachment preview */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{attachments.map((att, i) => (
<div key={i} className="relative">
{att.file_type === 'image' && att.preview_url ? (
<div className="relative">
<img
src={apiClient.getFileUrl(att.preview_url)}
alt={att.file_name}
className="w-16 h-16 rounded-lg object-cover border border-default"
/>
<button
onClick={() => removeAttachment(i)}
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-danger text-white flex items-center justify-center cursor-pointer"
>
<X size={10} />
</button>
</div>
) : (
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-inset border border-default rounded-lg text-xs text-content-secondary max-w-[180px] relative pr-7">
<FileIcon size={12} />
<span className="truncate">{att.file_name}</span>
<button
onClick={() => removeAttachment(i)}
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-danger text-white flex items-center justify-center cursor-pointer"
>
<X size={10} />
</button>
</div>
)}
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<div className="flex items-center flex-shrink-0 gap-0.5 pb-0.5">
<button
onClick={onNewChat}
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-accent hover:bg-accent-soft cursor-pointer transition-colors"
title={t('session_new')}
>
<Plus size={18} />
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-accent hover:bg-accent-soft cursor-pointer transition-colors disabled:opacity-50"
title={t('chat_attach')}
>
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Paperclip size={18} />}
</button>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
multiple
onChange={handleFileSelect}
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md"
/>
<textarea
ref={textareaRef}
id="chat-input"
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={() => (composingRef.current = false)}
placeholder={t('input_placeholder')}
rows={1}
className="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-strong bg-inset text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent text-sm leading-relaxed transition-colors resize-none"
/>
{isStreaming ? (
<button
onClick={onStop}
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-btn bg-surface-2 text-content hover:bg-inset cursor-pointer transition-colors"
title={t('msg_stop')}
>
<Square size={15} className="fill-current" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!canSend}
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
title={t('chat_send')}
>
<Send size={17} />
</button>
)}
</div>
</div>
</div>
)
})
export default ChatInput

View File

@@ -0,0 +1,407 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import type { KnowledgeGraph as KnowledgeGraphData } from '../types'
interface SimNode {
id: string
label: string
category: string
x: number
y: number
vx: number
vy: number
fx: number | null
fy: number | null
degree: number
}
interface KnowledgeGraphProps {
data: KnowledgeGraphData
onSelect: (id: string, label: string) => void
}
// d3.schemeTableau10 — keep the web client's palette for visual parity.
const TABLEAU10 = [
'#4e79a7',
'#f28e2c',
'#e15759',
'#76b7b2',
'#59a14f',
'#edc949',
'#af7aa1',
'#ff9da7',
'#9c755f',
'#bab0ab',
]
const nodeRadius = (degree: number) => Math.max(4, Math.min(12, 4 + degree * 1.4))
// A dependency-free force-directed graph with wheel zoom, canvas pan and node
// drag. The physics loop writes positions DIRECTLY to the DOM (like d3) instead
// of calling setState per frame, so React never re-renders during the
// simulation — this is what keeps it from flickering.
const KnowledgeGraph: React.FC<KnowledgeGraphProps> = ({ data, onSelect }) => {
const wrapRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const sizeRef = useRef({ w: 800, h: 560 })
const [hover, setHover] = useState<string | null>(null)
// Bumping this re-arms the physics loop (used while dragging) without
// rebuilding the model, preserving current node positions.
const [warmTick, setWarmTick] = useState(0)
// View transform (pan + zoom).
const viewRef = useRef({ k: 1, x: 0, y: 0 })
// Build the immutable model once per data change.
const model = useMemo(() => {
const degree = new Map<string, number>()
data.links.forEach((l) => {
degree.set(l.source, (degree.get(l.source) || 0) + 1)
degree.set(l.target, (degree.get(l.target) || 0) + 1)
})
const categories = Array.from(new Set(data.nodes.map((n) => n.category || 'default')))
const colorOf = (cat: string) => TABLEAU10[categories.indexOf(cat) % TABLEAU10.length]
const n = data.nodes.length || 1
const { w, h } = sizeRef.current
const cx = w / 2
const cy = h / 2
const nodes: SimNode[] = data.nodes.map((nd, i) => {
const angle = (i / n) * Math.PI * 2
const radius = Math.min(w, h) * 0.32
return {
id: nd.id,
label: nd.label,
category: nd.category || 'default',
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
vx: 0,
vy: 0,
fx: null,
fy: null,
degree: degree.get(nd.id) || 0,
}
})
const valid = new Set(nodes.map((x) => x.id))
const byId = new Map(nodes.map((x) => [x.id, x]))
const links = data.links
.filter((l) => valid.has(l.source) && valid.has(l.target))
.map((l, i) => ({ key: i, a: byId.get(l.source)!, b: byId.get(l.target)! }))
const adjacency = new Map<string, Set<string>>()
data.links.forEach((l) => {
if (!valid.has(l.source) || !valid.has(l.target)) return
if (!adjacency.has(l.source)) adjacency.set(l.source, new Set())
if (!adjacency.has(l.target)) adjacency.set(l.target, new Set())
adjacency.get(l.source)!.add(l.target)
adjacency.get(l.target)!.add(l.source)
})
return { nodes, links, adjacency, categories, colorOf, byId }
}, [data])
// DOM refs for imperative position updates.
const rootRef = useRef<SVGGElement>(null)
const lineEls = useRef(new Map<number, SVGLineElement>())
const groupEls = useRef(new Map<string, SVGGElement>())
// Track container size in a ref; never triggers a re-render on its own.
useEffect(() => {
const el = wrapRef.current
if (!el) return
const apply = () => {
sizeRef.current = { w: el.clientWidth || 800, h: el.clientHeight || 560 }
}
apply()
const ro = new ResizeObserver(apply)
ro.observe(el)
return () => ro.disconnect()
}, [])
// Physics loop. Restarts only when the model (data) changes. Writes to DOM.
// Uses d3-style alpha cooling so it always settles and stops the rAF.
useEffect(() => {
const { nodes, links } = model
if (nodes.length === 0) return
let raf = 0
let alive = true
// Global cooling factor; decays toward 0 and scales how far nodes move.
let alpha = 1
const alphaDecay = 0.018
const alphaMin = 0.005
const paint = () => {
links.forEach(({ key, a, b }) => {
const el = lineEls.current.get(key)
if (!el) return
el.setAttribute('x1', String(a.x))
el.setAttribute('y1', String(a.y))
el.setAttribute('x2', String(b.x))
el.setAttribute('y2', String(b.y))
})
nodes.forEach((node) => {
const el = groupEls.current.get(node.id)
if (el) el.setAttribute('transform', `translate(${node.x},${node.y})`)
})
}
const step = () => {
if (!alive) return
const { w, h } = sizeRef.current
const cx = w / 2
const cy = h / 2
const repulsion = 9000
const springLen = 80
const spring = 0.04
const centering = 0.012
const dragging = nodes.some((node) => node.fx != null)
// Reset accumulated velocity each tick (alpha-scaled displacement) so the
// system can't accumulate energy and oscillate.
nodes.forEach((node) => {
node.vx = 0
node.vy = 0
})
// Repulsion + collision: nodes push apart, never overlap their radii.
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i]
const ra = nodeRadius(a.degree)
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j]
let dx = a.x - b.x
let dy = a.y - b.y
let d2 = dx * dx + dy * dy
if (d2 < 0.01) {
dx = Math.random() - 0.5
dy = Math.random() - 0.5
d2 = 0.01
}
let d = Math.sqrt(d2)
let f = repulsion / d2
// Hard collision: strongly separate if closer than combined radii.
const minDist = ra + nodeRadius(b.degree) + 14
if (d < minDist) f += (minDist - d) * 0.6
a.vx += (dx / d) * f
a.vy += (dy / d) * f
b.vx -= (dx / d) * f
b.vy -= (dy / d) * f
}
}
links.forEach(({ a, b }) => {
const dx = b.x - a.x
const dy = b.y - a.y
const d = Math.sqrt(dx * dx + dy * dy) || 1
const f = (d - springLen) * spring
a.vx += (dx / d) * f
a.vy += (dy / d) * f
b.vx -= (dx / d) * f
b.vy -= (dy / d) * f
})
// Weak centering so the whole graph stays in view without collapsing.
nodes.forEach((node) => {
node.vx += (cx - node.x) * centering
node.vy += (cy - node.y) * centering
})
// Apply alpha-scaled displacement; pinned nodes stay put. Cap per-tick
// movement so strong initial forces don't fling nodes off-screen.
const maxStep = 30
nodes.forEach((node) => {
if (node.fx != null) {
node.x = node.fx
node.y = node.fy as number
return
}
let dx = node.vx * alpha
let dy = node.vy * alpha
const m = Math.hypot(dx, dy)
if (m > maxStep) {
dx = (dx / m) * maxStep
dy = (dy / m) * maxStep
}
node.x += dx
node.y += dy
})
paint()
alpha += (0 - alpha) * alphaDecay
// Keep running while cooling, or while a node is being dragged.
if (alpha > alphaMin || dragging) {
raf = requestAnimationFrame(step)
}
}
raf = requestAnimationFrame(step)
return () => {
alive = false
cancelAnimationFrame(raf)
}
// warmTick re-arms the loop on demand (e.g. while dragging) without
// rebuilding the model, so positions are preserved.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model, warmTick])
// Apply the view transform imperatively (no re-render needed).
const applyView = () => {
const v = viewRef.current
if (rootRef.current) rootRef.current.setAttribute('transform', `translate(${v.x},${v.y}) scale(${v.k})`)
}
useEffect(applyView)
// Convert a pointer event to graph (pre-transform) coordinates.
const toGraph = (clientX: number, clientY: number) => {
const rect = svgRef.current!.getBoundingClientRect()
const v = viewRef.current
return { x: (clientX - rect.left - v.x) / v.k, y: (clientY - rect.top - v.y) / v.k }
}
// Wheel zoom centered on the cursor (matches d3.zoom scaleExtent [0.2, 5]).
const onWheel = (e: React.WheelEvent) => {
e.preventDefault()
const v = viewRef.current
const rect = svgRef.current!.getBoundingClientRect()
const px = e.clientX - rect.left
const py = e.clientY - rect.top
const factor = Math.exp(-e.deltaY * 0.0015)
const k = Math.min(5, Math.max(0.2, v.k * factor))
viewRef.current = { k, x: px - ((px - v.x) / v.k) * k, y: py - ((py - v.y) / v.k) * k }
applyView()
}
// Drag: on a node moves the node, on background pans the canvas.
const dragRef = useRef<
| { mode: 'node'; node: SimNode; moved: boolean }
| { mode: 'pan'; startX: number; startY: number; ox: number; oy: number }
| null
>(null)
const kick = () => setWarmTick((v) => v + 1)
const onPointerDownNode = (e: React.PointerEvent, node: SimNode) => {
e.stopPropagation()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const p = toGraph(e.clientX, e.clientY)
node.fx = p.x
node.fy = p.y
dragRef.current = { mode: 'node', node, moved: false }
kick()
}
const onPointerDownBg = (e: React.PointerEvent) => {
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const v = viewRef.current
dragRef.current = { mode: 'pan', startX: e.clientX, startY: e.clientY, ox: v.x, oy: v.y }
}
const onPointerMove = (e: React.PointerEvent) => {
const drag = dragRef.current
if (!drag) return
if (drag.mode === 'node') {
const p = toGraph(e.clientX, e.clientY)
drag.node.fx = p.x
drag.node.fy = p.y
drag.moved = true
// Keep the loop warm for live dragging.
const el = groupEls.current.get(drag.node.id)
if (el) el.setAttribute('transform', `translate(${p.x},${p.y})`)
} else {
viewRef.current = { k: viewRef.current.k, x: drag.ox + (e.clientX - drag.startX), y: drag.oy + (e.clientY - drag.startY) }
applyView()
}
}
const onPointerUp = (e: React.PointerEvent, node?: SimNode) => {
const drag = dragRef.current
if (drag?.mode === 'node') {
drag.node.fx = null
drag.node.fy = null
if (node && !drag.moved) onSelect(node.id, node.label)
kick()
}
dragRef.current = null
try {
;(e.target as Element).releasePointerCapture(e.pointerId)
} catch {
/* noop */
}
}
const { nodes, links, adjacency, categories, colorOf } = model
const { w, h } = sizeRef.current
const isDimmed = (id: string) => hover != null && hover !== id && !adjacency.get(hover)?.has(id)
const isLinkActive = (aId: string, bId: string) => hover === aId || hover === bId
return (
<div ref={wrapRef} className="w-full h-full relative overflow-hidden">
<svg
ref={svgRef}
width={w}
height={h}
className="select-none block cursor-grab active:cursor-grabbing"
onWheel={onWheel}
onPointerDown={onPointerDownBg}
onPointerMove={onPointerMove}
onPointerUp={(e) => onPointerUp(e)}
>
<g ref={rootRef}>
{links.map(({ key, a, b }) => {
const active = isLinkActive(a.id, b.id)
return (
<line
key={key}
ref={(el) => {
if (el) lineEls.current.set(key, el)
else lineEls.current.delete(key)
}}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke="#94a3b8"
strokeOpacity={hover ? (active ? 0.8 : 0.1) : 0.3}
strokeWidth={1}
/>
)
})}
{nodes.map((n) => {
const r = nodeRadius(n.degree)
const dim = isDimmed(n.id)
return (
<g
key={n.id}
ref={(el) => {
if (el) groupEls.current.set(n.id, el)
else groupEls.current.delete(n.id)
}}
transform={`translate(${n.x},${n.y})`}
className="cursor-pointer"
opacity={dim ? 0.2 : 1}
onMouseEnter={() => setHover(n.id)}
onMouseLeave={() => setHover(null)}
onPointerDown={(e) => onPointerDownNode(e, n)}
onPointerUp={(e) => onPointerUp(e, n)}
>
<circle r={r} fill={colorOf(n.category)} stroke="#fff" strokeWidth={1.5} />
{(hover === n.id || n.degree >= 3) && (
<text x={r + 4} y={3} className="fill-content-secondary" fontSize={9} style={{ pointerEvents: 'none' }}>
{n.label.length > 15 ? n.label.slice(0, 14) + '…' : n.label}
</text>
)}
</g>
)
})}
</g>
</svg>
{/* Category legend, mirrors the web client. */}
{categories.length > 0 && (
<div className="absolute bottom-3 left-3 flex flex-wrap gap-x-3 gap-y-1 max-w-[60%] rounded-lg bg-surface px-3 py-2 border border-subtle shadow-sm">
{categories.map((cat) => (
<span key={cat} className="inline-flex items-center gap-1.5 text-[11px] text-content-secondary">
<span className="w-2.5 h-2.5 rounded-full" style={{ background: colorOf(cat) }} />
{cat}
</span>
))}
</div>
)}
</div>
)
}
export default KnowledgeGraph

View File

@@ -0,0 +1,109 @@
import React, { useMemo, useRef, useCallback } from 'react'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import { t } from '../i18n'
/**
* Markdown renderer aligned 1:1 with the web console (markdown-it + highlight.js
* + GitHub themes). Using the same engine guarantees identical line-break,
* linkify and code-highlight behavior across web and desktop.
*/
const md: MarkdownIt = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
typographer: true,
highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value
} catch {
/* fall through */
}
}
try {
return hljs.highlightAuto(str).value
} catch {
return ''
}
},
})
// Open links in a new tab safely.
const defaultLinkOpen =
md.renderer.rules.link_open ||
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrPush(['target', '_blank'])
tokens[idx].attrPush(['rel', 'noopener noreferrer'])
return defaultLinkOpen(tokens, idx, options, env, self)
}
// Wrap fenced code blocks so we can render a header (lang + copy button).
const defaultFence =
md.renderer.rules.fence ||
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.fence = function (tokens, idx, options, env, self) {
const token = tokens[idx]
const info = token.info ? token.info.trim().split(/\s+/)[0] : ''
// Ensure the `hljs` class is present so the GitHub theme background/base
// color applies (markdown-it only adds language-* by default).
let rendered = defaultFence(tokens, idx, options, env, self)
if (rendered.includes('<code class="')) {
rendered = rendered.replace('<code class="', '<code class="hljs ')
} else {
rendered = rendered.replace('<code>', '<code class="hljs">')
}
return (
`<div class="code-block-wrapper">` +
`<div class="code-block-header">` +
`<span class="code-block-lang">${info || 'text'}</span>` +
`<button type="button" class="code-copy-btn" data-code-id="cb-${idx}" aria-label="Copy code">${t('msg_copy')}</button>` +
`</div>` +
rendered +
`</div>`
)
}
interface MarkdownProps {
content: string
}
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
const rootRef = useRef<HTMLDivElement>(null)
const html = useMemo(() => md.render(content || ''), [content])
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
const btn = target.closest('.code-copy-btn') as HTMLElement | null
if (!btn) return
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
if (!pre) return
navigator.clipboard.writeText(pre.textContent || '')
const original = btn.textContent
btn.textContent = t('msg_copied')
btn.classList.add('copied')
setTimeout(() => {
btn.textContent = original
btn.classList.remove('copied')
}, 1600)
}, [])
return (
<div
ref={rootRef}
className="msg-content text-sm text-content leading-relaxed break-words"
onClick={handleClick}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
export default Markdown

View File

@@ -0,0 +1,156 @@
import React, { useState } from 'react'
import { Copy, Check, RefreshCw, Pencil, Trash2, File as FileIcon, Sprout } from 'lucide-react'
import type { ChatMessage } from '../types'
import { t } from '../i18n'
import apiClient from '../api/client'
import Markdown from './Markdown'
import MessageSteps, { ThinkingStep } from './MessageSteps'
interface MessageBubbleProps {
message: ChatMessage
onRegenerate?: (id: string) => void
onEdit?: (id: string) => void
onDelete?: (msg: ChatMessage) => void
}
function fmtTime(ts: number): string {
if (!ts) return ''
const d = new Date(ts * 1000)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
const HoverAction: React.FC<{ onClick: () => void; title: string; danger?: boolean; children: React.ReactNode }> = ({
onClick,
title,
danger,
children,
}) => (
<button
onClick={onClick}
title={title}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md cursor-pointer transition-colors text-content-tertiary ${
danger ? 'hover:text-danger hover:bg-danger-soft' : 'hover:text-content hover:bg-surface-2'
}`}
>
{children}
</button>
)
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete }) => {
const isUser = message.role === 'user'
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard.writeText(message.content)
setCopied(true)
setTimeout(() => setCopied(false), 1800)
}
if (isUser) {
return (
<div className="group flex flex-col items-end px-4 sm:px-6 py-2">
{message.attachments && message.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-1.5 justify-end max-w-[75%]">
{message.attachments.map((att, i) =>
att.file_type === 'image' && att.preview_url ? (
<img
key={i}
src={apiClient.getFileUrl(att.preview_url)}
alt={att.file_name}
className="max-w-[180px] max-h-[150px] rounded-xl object-cover border border-default"
/>
) : (
<div key={i} className="flex items-center gap-1.5 px-3 py-2 bg-surface-2 rounded-xl text-xs text-content-secondary">
<FileIcon size={13} />
{att.file_name}
</div>
)
)}
</div>
)}
<div className="max-w-[75%] rounded-2xl rounded-br-md px-4 py-2.5 bg-[var(--user-bubble-bg)] text-content">
<div className="text-sm whitespace-pre-wrap break-words">{message.content}</div>
</div>
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
{onEdit && message.userSeq != null && (
<HoverAction onClick={() => onEdit(message.id)} title={t('msg_edit')}>
<Pencil size={13} />
</HoverAction>
)}
{onDelete && message.userSeq != null && (
<HoverAction onClick={() => onDelete(message)} title={t('msg_delete')} danger>
<Trash2 size={13} />
</HoverAction>
)}
</div>
</div>
)
}
// Assistant
const showCursor = message.isStreaming && !message.content && (!message.steps || message.steps.length === 0)
const hasSteps = !!(message.steps && message.steps.length > 0)
const hasLiveReasoning = !!(message.reasoning && message.isStreaming)
return (
<div className="group flex gap-3 px-4 sm:px-6 py-2">
<img src="./logo.jpg" alt="CowAgent" className="w-7 h-7 rounded-lg flex-shrink-0 mt-1" />
<div className="flex-1 min-w-0 max-w-[calc(100%-2.5rem)]">
<div className="inline-block w-full rounded-2xl border border-default bg-surface px-4 py-3">
{message.kind === 'evolution' && (
<div className="inline-flex items-center gap-1 mb-1.5 text-[11px] text-content-tertiary">
<Sprout size={11} />
{t('msg_self_learned')}
</div>
)}
{/* Steps area (thinking / tools / intermediate content), web-aligned:
muted, separated from the final answer by a dashed divider. */}
{(hasSteps || hasLiveReasoning) && (
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
{hasSteps && <MessageSteps steps={message.steps!} />}
</div>
)}
{/* Final answer */}
{message.content && <Markdown content={message.content} />}
{showCursor && (
<div className="flex items-center gap-1 py-0.5">
<span className="typing-dot" />
<span className="typing-dot" />
<span className="typing-dot" />
</div>
)}
{message.isStreaming && message.content && (
<span className="inline-block w-[6px] h-[14px] bg-accent ml-0.5 align-middle animate-blink" />
)}
{message.isCancelled && <div className="text-xs text-warning mt-1">{t('msg_cancelled')}</div>}
{message.error && <div className="text-xs text-danger mt-1">{message.error}</div>}
</div>
{/* Hover actions (only when finished) */}
{!message.isStreaming && (message.content || message.error) && (
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
<HoverAction onClick={copy} title={t('msg_copy')}>
{copied ? <Check size={13} /> : <Copy size={13} />}
</HoverAction>
{onRegenerate && (
<HoverAction onClick={() => onRegenerate(message.id)} title={t('msg_regenerate')}>
<RefreshCw size={13} />
</HoverAction>
)}
</div>
)}
</div>
</div>
)
}
export default MessageBubble

View File

@@ -0,0 +1,110 @@
import React, { useState } from 'react'
import { ChevronRight, Loader2, Check, X, Brain, Wrench } from 'lucide-react'
import type { MessageStep } from '../types'
import Markdown from './Markdown'
/**
* Assistant reasoning / tool steps, styled to match the web console: small,
* muted, collapsible rows with an indented detail panel.
*/
const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ content, streaming }) => {
const [expanded, setExpanded] = useState(false)
return (
<div className="text-xs text-content-tertiary mb-1 last:mb-0">
<div
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
onClick={() => setExpanded((v) => !v)}
>
<Brain size={12} className="flex-shrink-0" />
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
</div>
{expanded && (
<pre className="mt-1.5 ml-4 p-2 rounded-md bg-inset border border-subtle whitespace-pre-wrap leading-relaxed max-h-[260px] overflow-y-auto font-sans text-content-tertiary">
{content}
</pre>
)}
</div>
)
}
const ToolStep: React.FC<{ step: MessageStep }> = ({ step }) => {
const [expanded, setExpanded] = useState(false)
const running = step.status === 'running'
const isError = step.is_error || (!!step.status && step.status !== 'success' && !running)
const icon = running ? (
<Loader2 size={12} className="text-accent animate-spin" />
) : isError ? (
<X size={12} className="text-danger" />
) : (
<Check size={12} className="text-accent" />
)
return (
<div className="text-xs text-content-tertiary mb-1 last:mb-0">
<div
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
onClick={() => setExpanded((v) => !v)}
>
<span className="flex-shrink-0">{icon}</span>
<Wrench size={11} className="flex-shrink-0 opacity-70" />
<span className={`font-medium ${isError ? 'text-danger' : ''}`}>{step.name}</span>
{step.execution_time !== undefined && (
<span className="opacity-60">{step.execution_time}s</span>
)}
<ChevronRight size={11} className={`ml-auto transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
</div>
{expanded && (
<div className="mt-1.5 ml-4 p-2 rounded-md bg-inset border border-subtle space-y-2">
{step.arguments && Object.keys(step.arguments).length > 0 && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60 mb-1">Input</div>
<pre className="font-mono text-[11px] whitespace-pre-wrap break-all max-h-[200px] overflow-y-auto leading-relaxed">
{JSON.stringify(step.arguments, null, 2)}
</pre>
</div>
)}
{step.result && (
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60 mb-1">
{isError ? 'Error' : 'Output'}
</div>
<pre
className={`font-mono text-[11px] whitespace-pre-wrap break-all max-h-[240px] overflow-y-auto leading-relaxed ${
isError ? 'text-danger' : ''
}`}
>
{step.result.length > 4000 ? step.result.slice(0, 4000) + '\n… (truncated)' : step.result}
</pre>
</div>
)}
</div>
)}
</div>
)
}
/** Renders an ordered list of assistant steps (thinking / content / tool). */
const MessageSteps: React.FC<{ steps: MessageStep[] }> = ({ steps }) => {
if (!steps.length) return null
return (
<div>
{steps.map((step, i) => {
if (step.type === 'thinking') return <ThinkingStep key={i} content={step.content || ''} />
if (step.type === 'tool') return <ToolStep key={i} step={step} />
if (step.type === 'content' && step.content)
return (
<div key={i} className="mb-2 pb-2 border-b border-dashed border-default last:border-0 last:mb-0 last:pb-0">
<Markdown content={step.content} />
</div>
)
return null
})}
</div>
)
}
export { ThinkingStep, ToolStep }
export default MessageSteps

View File

@@ -0,0 +1,293 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Sparkles, KeyRound, Loader2, ArrowRight, ArrowLeft, ExternalLink } from 'lucide-react'
import { t, getLang, setLang, type Lang } from '../i18n'
import apiClient from '../api/client'
import type { ModelsData } from '../types'
import { Field, Dropdown, TextInput, type DropdownOption } from '../pages/settings/primitives'
import { resolveModels, providerLabel } from '../pages/settings/modelsHelpers'
import { useOnboardingStore } from '../store/onboardingStore'
interface OnboardingWizardProps {
// Called after the wizard finishes so the host can refresh language/state.
onDone: () => void
}
const TOTAL_STEPS = 2
// Optional "where to get an API key" console link, per provider.
const PROVIDER_KEY_CONSOLE: Record<string, string> = {
linkai: 'https://link-ai.tech/console/interface',
}
// First-run guided setup: language -> chat model (provider + key + model).
// After saving the model the user goes straight into the chat (no extra
// confirmation step). Rendered as a full-screen overlay above the main UI;
// reuses the same models API and primitives as the settings page.
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onDone }) => {
const finish = useOnboardingStore((s) => s.finish)
const [step, setStep] = useState(1)
const [lang, setLangState] = useState<Lang>(getLang())
const [models, setModels] = useState<ModelsData | null>(null)
// Step 2 form state.
const [provider, setProvider] = useState('')
const [apiKey, setApiKey] = useState('')
const [apiBase, setApiBase] = useState('')
const [model, setModel] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
// Load the models console data once for the provider/model dropdowns.
useEffect(() => {
apiClient
.getModels()
.then(setModels)
.catch(() => setError(t('onboarding_save_failed')))
}, [])
// Persist the auto-detected default language on first show so the pre-selected
// option (driven by OS locale) also reaches the backend, even if the user
// doesn't tap the language buttons.
useEffect(() => {
if (!localStorage.getItem('cow_lang')) switchLang(lang)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const providerOptions: DropdownOption[] = useMemo(() => {
const chat = models?.capabilities?.chat
const ids = chat?.providers || []
return ids.map((id) => ({ value: id, label: providerLabel(models, id) }))
}, [models])
const modelOptions: DropdownOption[] = useMemo(() => {
return resolveModels(models, provider, models?.capabilities?.chat?.provider_models).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
}, [models, provider])
// The currently selected provider's api_base placeholder/default, if any.
const providerMeta = models?.providers?.find((p) => p.id === provider)
const apiBasePlaceholder = providerMeta?.api_base_placeholder || providerMeta?.api_base_default
const handleProvider = (id: string) => {
setProvider(id)
setApiBase('')
const first = resolveModels(models, id, models?.capabilities?.chat?.provider_models)[0]
setModel(first?.value || '')
}
const switchLang = (next: Lang) => {
setLang(next)
setLangState(next)
// Mirror the choice to the backend so the agent/logs use the same language
// (matches BasicSettings). Non-blocking: the UI already switched locally.
apiClient.updateConfig({ cow_lang: next }).catch(() => {})
}
// Step 1 (language) can always advance; step 2 needs a provider, key, model.
const canNext = step === 1 || (!!provider && !!apiKey.trim() && !!model)
const goNext = async () => {
setError('')
// Step 1 (language) just advances to the model step.
if (step === 1) {
setStep(2)
return
}
// Step 2 is the last step: persist the provider credentials, point the chat
// capability at it, then finish straight into the chat (no extra step).
setSaving(true)
try {
await apiClient.modelsAction({
action: 'set_provider',
provider_id: provider,
api_key: apiKey.trim(),
...(apiBase.trim() ? { api_base: apiBase.trim() } : {}),
})
await apiClient.modelsAction({
action: 'set_capability',
capability: 'chat',
provider_id: provider,
model,
})
} catch {
setSaving(false)
setError(t('onboarding_save_failed'))
return
}
setSaving(false)
complete()
}
const goBack = () => {
setError('')
setStep((s) => Math.max(1, s - 1))
}
const complete = () => {
finish()
onDone()
}
const stepLabel = t('onboarding_step').replace('{n}', String(step)).replace('{total}', String(TOTAL_STEPS))
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-base">
<div className="w-full max-w-lg px-8">
{/* Progress dots */}
<div className="flex items-center justify-center gap-2 mb-8">
{Array.from({ length: TOTAL_STEPS }).map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-all ${
i + 1 === step ? 'w-8 bg-accent' : i + 1 < step ? 'w-4 bg-accent/50' : 'w-4 bg-surface-2'
}`}
/>
))}
</div>
{step === 1 && (
<div className="text-center space-y-6">
<div className="w-16 h-16 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mx-auto">
<Sparkles size={30} />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold text-content">{t('onboarding_welcome_title')}</h1>
<p className="text-sm text-content-secondary">{t('onboarding_welcome_desc')}</p>
</div>
<div className="max-w-xs mx-auto text-left">
<Field label={t('onboarding_lang_label')}>
<div className="grid grid-cols-2 gap-2">
{(['zh', 'en'] as Lang[]).map((l) => (
<button
key={l}
onClick={() => switchLang(l)}
className={`px-4 py-2.5 rounded-btn border text-sm font-medium cursor-pointer transition-colors ${
lang === l
? 'border-accent bg-accent-soft text-accent'
: 'border-strong text-content-secondary hover:bg-surface-2'
}`}
>
{l === 'zh' ? '简体中文' : 'English'}
</button>
))}
</div>
</Field>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-6">
<div className="text-center space-y-2">
<div className="w-16 h-16 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mx-auto">
<KeyRound size={28} />
</div>
<h1 className="text-2xl font-bold text-content">{t('onboarding_model_title')}</h1>
<p className="text-sm text-content-secondary">{t('onboarding_model_desc')}</p>
</div>
<div className="space-y-4">
<Field label={t('onboarding_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('onboarding_select_provider')}
onChange={handleProvider}
/>
</Field>
{provider && (
<>
<Field label={t('onboarding_apikey')}>
<TextInput
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t('onboarding_apikey_placeholder')}
className="font-mono"
/>
{PROVIDER_KEY_CONSOLE[provider] && (
<a
href={PROVIDER_KEY_CONSOLE[provider]}
target="_blank"
rel="noreferrer"
className="mt-1.5 inline-flex items-center gap-1 text-xs text-accent hover:underline"
>
{t('onboarding_key_guide')}
<ExternalLink size={11} />
</a>
)}
</Field>
{providerMeta?.api_base_field && (
<Field label={t('onboarding_apibase')}>
<TextInput
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder={apiBasePlaceholder || ''}
className="font-mono"
/>
</Field>
)}
<Field label={t('onboarding_model')}>
<Dropdown
value={model}
options={modelOptions}
placeholder={t('onboarding_select_model')}
onChange={setModel}
/>
</Field>
</>
)}
{error && <p className="text-sm text-danger">{error}</p>}
</div>
</div>
)}
{/* Footer controls */}
<div className="mt-10 flex items-center justify-between">
<div className="text-xs text-content-tertiary">{stepLabel}</div>
<div className="flex items-center gap-2">
{/* Step 2: back to language. */}
{step === 2 && (
<button
onClick={goBack}
disabled={saving}
className="px-4 py-2 rounded-btn border border-strong text-content-secondary hover:bg-surface-2 text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-1.5"
>
<ArrowLeft size={15} />
{t('onboarding_back')}
</button>
)}
{/* Skip is available on every step: dismiss and go straight to chat. */}
<button
onClick={complete}
disabled={saving}
className="px-4 py-2 rounded-btn text-sm font-medium text-content-tertiary hover:text-content cursor-pointer transition-colors disabled:opacity-50"
>
{t('onboarding_skip')}
</button>
{/* Primary action: advance on step 1, save + finish on the last step. */}
<button
onClick={goNext}
disabled={!canNext || saving}
className="px-5 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
>
{saving && <Loader2 size={15} className="animate-spin" />}
{saving
? t('onboarding_saving')
: step === TOTAL_STEPS
? t('onboarding_finish')
: t('onboarding_next')}
{!saving && <ArrowRight size={15} />}
</button>
</div>
</div>
</div>
</div>
)
}
export default OnboardingWizard

View File

@@ -0,0 +1,222 @@
import React, { useEffect, useRef, useState } from 'react'
import { Loader2, Check, RotateCcw } from 'lucide-react'
import { t } from '../i18n'
import apiClient from '../api/client'
import { Modal } from '../pages/settings/primitives'
type Provider = 'weixin' | 'feishu'
type Phase = 'loading' | 'waiting' | 'scanned' | 'success' | 'error'
interface QrLoginModalProps {
provider: Provider
onClose: () => void
// Fired once the channel is connected so the page can refresh.
onConnected: () => void
}
const POLL_INTERVAL = 2000
// Shared QR-login / QR-register modal for WeChat and Feishu. Mirrors the web
// console flow: fetch a QR, poll status, then connect the channel on success.
const QrLoginModal: React.FC<QrLoginModalProps> = ({ provider, onClose, onConnected }) => {
const [phase, setPhase] = useState<Phase>('loading')
const [qr, setQr] = useState('')
const [openLink, setOpenLink] = useState('')
const [errMsg, setErrMsg] = useState('')
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const aliveRef = useRef(true)
const stopPoll = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
const fail = (msg: string) => {
if (!aliveRef.current) return
setPhase('error')
setErrMsg(msg)
}
// ---- WeChat: GET qr, POST poll {scaned|confirmed|expired} -----------------
const pollWeixin = () => {
timerRef.current = setTimeout(async () => {
if (!aliveRef.current) return
try {
const data = await apiClient.weixinQrAction('poll')
if (!aliveRef.current) return
if (data.status !== 'success') return pollWeixin()
const s = data.qr_status as string
if (s === 'confirmed') {
setPhase('success')
await apiClient.channelAction('connect', 'weixin', {})
if (aliveRef.current) onConnected()
} else if (s === 'expired' && (data.qr_image || data.qrcode_url)) {
setQr((data.qr_image as string) || (data.qrcode_url as string))
setPhase('waiting')
pollWeixin()
} else if (s === 'scaned') {
setPhase('scanned')
pollWeixin()
} else {
pollWeixin()
}
} catch {
pollWeixin()
}
}, POLL_INTERVAL)
}
const startWeixin = async () => {
setPhase('loading')
try {
const data = await apiClient.getWeixinQr()
if (!aliveRef.current) return
if (data.status !== 'success') return fail(data.message || t('weixin_scan_fail'))
setQr(data.qr_image || data.qrcode_url || '')
setPhase('waiting')
pollWeixin()
} catch {
fail(t('weixin_scan_fail'))
}
}
// ---- Feishu: GET qr, POST poll {done|expired|denied|error} ----------------
const pollFeishu = () => {
timerRef.current = setTimeout(async () => {
if (!aliveRef.current) return
try {
const data = await apiClient.feishuRegisterPoll()
if (!aliveRef.current) return
if (data.status !== 'success') return fail((data.message as string) || t('feishu_scan_fail'))
const rs = data.register_status as string
if (rs === 'done') {
setPhase('success')
await apiClient.channelAction('connect', 'feishu', {
feishu_app_id: data.app_id,
feishu_app_secret: data.app_secret,
})
if (aliveRef.current) onConnected()
} else if (rs === 'expired') {
fail(t('feishu_scan_expired'))
} else if (rs === 'denied') {
fail(t('feishu_scan_denied'))
} else if (rs === 'error') {
fail((data.message as string) || t('feishu_scan_fail'))
} else {
pollFeishu()
}
} catch {
pollFeishu()
}
}, POLL_INTERVAL)
}
const startFeishu = async () => {
setPhase('loading')
try {
const data = await apiClient.getFeishuRegister()
if (!aliveRef.current) return
if (data.status !== 'success') return fail(data.message || t('feishu_scan_fail'))
setQr(data.qr_image || data.qrcode_url || '')
setOpenLink(data.qrcode_url || '')
setPhase('waiting')
pollFeishu()
} catch {
fail(t('feishu_scan_fail'))
}
}
const start = () => {
stopPoll()
if (provider === 'weixin') void startWeixin()
else void startFeishu()
}
useEffect(() => {
aliveRef.current = true
start()
return () => {
aliveRef.current = false
stopPoll()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider])
const title = provider === 'weixin' ? t('weixin_scan_title') : t('feishu_scan_title')
const desc = provider === 'weixin' ? t('weixin_scan_desc') : t('feishu_scan_desc')
const tip = provider === 'weixin' ? t('weixin_qr_tip') : t('feishu_scan_tip')
const statusText = (): string => {
if (provider === 'weixin') {
if (phase === 'scanned') return t('weixin_scan_scanned')
return t('weixin_scan_waiting')
}
return t('feishu_scan_waiting')
}
return (
<Modal open title={title} onClose={onClose}>
<div className="flex flex-col items-center py-2">
{phase === 'loading' && (
<div className="flex items-center text-content-tertiary py-10">
<Loader2 size={18} className="animate-spin mr-2" />
{provider === 'weixin' ? t('weixin_scan_loading') : t('feishu_scan_loading')}
</div>
)}
{(phase === 'waiting' || phase === 'scanned') && (
<>
<p className="text-sm text-content-secondary mb-4 text-center">{desc}</p>
<div className="bg-white p-3 rounded-card border border-subtle mb-3">
{qr ? (
<img src={qr} alt="QR" className="w-48 h-48" style={{ imageRendering: 'pixelated' }} />
) : (
<div className="w-48 h-48 flex items-center justify-center text-content-tertiary text-xs">QR</div>
)}
</div>
<p className={`text-xs mb-1 ${phase === 'scanned' ? 'text-accent' : 'text-warning'}`}>{statusText()}</p>
<p className="text-xs text-content-tertiary">{tip}</p>
{openLink && (
<a
href={openLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-info hover:underline mt-2"
>
{t('feishu_scan_open_link')}
</a>
)}
</>
)}
{phase === 'success' && (
<div className="flex flex-col items-center py-8">
<div className="w-12 h-12 rounded-full bg-accent-soft flex items-center justify-center mb-3">
<Check size={22} className="text-accent" />
</div>
<p className="text-sm font-medium text-accent">
{provider === 'weixin' ? t('weixin_scan_success') : t('feishu_scan_success')}
</p>
</div>
)}
{phase === 'error' && (
<div className="flex flex-col items-center py-8">
<p className="text-sm text-danger text-center mb-3">{errMsg}</p>
<button
onClick={start}
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn border border-strong text-sm text-content-secondary hover:bg-inset cursor-pointer transition-colors"
>
<RotateCcw size={13} />
{t('feishu_scan_retry')}
</button>
</div>
)}
</div>
</Modal>
)
}
export default QrLoginModal

View File

@@ -0,0 +1,58 @@
import React from 'react'
import { t } from '../i18n'
interface StatusScreenProps {
status: 'connecting' | 'error'
error?: string
onRetry: () => void
}
const StatusScreen: React.FC<StatusScreenProps> = ({ status, error, onRetry }) => {
return (
<div className="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-[#111111]">
<div className="text-center space-y-6 max-w-md px-8">
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mx-auto shadow-lg shadow-primary-500/20" />
{status === 'connecting' && (
<>
<div className="space-y-2">
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">
{t('status_starting')}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{t('status_starting_desc')}
</p>
</div>
<div className="flex justify-center gap-1">
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0s' }} />
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0.2s' }} />
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0.4s' }} />
</div>
</>
)}
{status === 'error' && (
<>
<div className="space-y-2">
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">
{t('status_error')}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{error || t('status_error_desc')}
</p>
</div>
<button
onClick={onRetry}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm font-medium cursor-pointer"
>
<i className="fas fa-rotate-right text-xs" />
{t('status_retry')}
</button>
</>
)}
</div>
</div>
)
}
export default StatusScreen

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react'
import { Download, RefreshCw, X, Loader2 } from 'lucide-react'
import { t } from '../i18n'
import { useUpdateStore, hasPendingUpdate } from '../store/updateStore'
// Compact update panel anchored to the NavRail footer. Only mounts content
// when there's a pending update; otherwise renders nothing so it stays out of
// the way until electron-updater reports a new version.
const UpdateBanner: React.FC = () => {
const state = useUpdateStore()
const [open, setOpen] = useState(false)
const pending = hasPendingUpdate(state)
const status = state.status
// Auto-open the panel the moment a new version is first detected.
useEffect(() => {
if (status?.state === 'available') setOpen(true)
}, [status?.state])
if (!pending) return null
const version = state.version
const downloading = status?.state === 'downloading'
const downloaded = status?.state === 'downloaded'
return (
<div className="absolute bottom-14 left-2 right-2 z-40">
{/* Collapsed pill: a red-dotted button that re-opens the panel. */}
{!open && (
<button
onClick={() => setOpen(true)}
className="relative w-full flex items-center gap-2 rounded-btn bg-accent-soft text-accent px-3 py-2 text-[13px] font-medium cursor-pointer hover:bg-accent-soft/80 transition-colors"
>
<span className="absolute -top-1 -left-1 h-2 w-2 rounded-full bg-danger" />
<Download size={15} />
<span className="truncate">{t('update_available')}</span>
</button>
)}
{open && (
<div className="rounded-lg border border-default bg-elevated shadow-lg p-3 space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[13px] font-semibold text-content">{t('update_available')}</p>
{version && <p className="text-xs text-content-tertiary mt-0.5">v{version}</p>}
</div>
<button
onClick={() => {
setOpen(false)
state.dismiss()
}}
className="text-content-tertiary hover:text-content cursor-pointer flex-shrink-0"
title={t('update_later')}
>
<X size={15} />
</button>
</div>
{downloading && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-content-secondary">
<Loader2 size={13} className="animate-spin" />
<span>{t('update_downloading')} {state.percent}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-surface-2 overflow-hidden">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${state.percent}%` }} />
</div>
</div>
)}
{!downloading && !downloaded && (
<button
onClick={() => state.download()}
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
>
<Download size={15} />
{t('update_download')}
</button>
)}
{downloaded && (
<button
onClick={() => state.install()}
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
>
<RefreshCw size={15} />
{t('update_restart')}
</button>
)}
</div>
)}
</div>
)
}
export default UpdateBanner

View File

@@ -0,0 +1,189 @@
/* highlight.js github themes, scoped for light/dark. Generated; do not edit. */
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em
}
code.hljs {
padding: 3px 5px
}
/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/
.hljs {
color: #24292e;
background: #ffffff
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
/* prettylights-syntax-keyword */
color: #d73a49
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
/* prettylights-syntax-entity */
color: #6f42c1
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-variable,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id {
/* prettylights-syntax-constant */
color: #005cc5
}
.hljs-regexp,
.hljs-string,
.hljs-meta .hljs-string {
/* prettylights-syntax-string */
color: #032f62
}
.hljs-built_in,
.hljs-symbol {
/* prettylights-syntax-variable */
color: #e36209
}
.hljs-comment,
.hljs-code,
.hljs-formula {
/* prettylights-syntax-comment */
color: #6a737d
}
.hljs-name,
.hljs-quote,
.hljs-selector-tag,
.hljs-selector-pseudo {
/* prettylights-syntax-entity-tag */
color: #22863a
}
.hljs-subst {
/* prettylights-syntax-storage-modifier-import */
color: #24292e
}
.hljs-section {
/* prettylights-syntax-markup-heading */
color: #005cc5;
font-weight: bold
}
.hljs-bullet {
/* prettylights-syntax-markup-list */
color: #735c0f
}
.hljs-emphasis {
/* prettylights-syntax-markup-italic */
color: #24292e;
font-style: italic
}
.hljs-strong {
/* prettylights-syntax-markup-bold */
color: #24292e;
font-weight: bold
}
.hljs-addition {
/* prettylights-syntax-markup-inserted */
color: #22863a;
background-color: #f0fff4
}
.hljs-deletion {
/* prettylights-syntax-markup-deleted */
color: #b31d28;
background-color: #ffeef0
}
.hljs-char.escape_,
.hljs-link,
.hljs-params,
.hljs-property,
.hljs-punctuation,
.hljs-tag {
/* purposely ignored */
}
.dark pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em
}.dark code.hljs {
padding: 3px 5px
}.dark /*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/
.hljs {
color: #c9d1d9;
background: #0d1117
}.dark .hljs-doctag, .dark .hljs-keyword, .dark .hljs-meta .hljs-keyword, .dark .hljs-template-tag, .dark .hljs-template-variable, .dark .hljs-type, .dark .hljs-variable.language_ {
/* prettylights-syntax-keyword */
color: #ff7b72
}.dark .hljs-title, .dark .hljs-title.class_, .dark .hljs-title.class_.inherited__, .dark .hljs-title.function_ {
/* prettylights-syntax-entity */
color: #d2a8ff
}.dark .hljs-attr, .dark .hljs-attribute, .dark .hljs-literal, .dark .hljs-meta, .dark .hljs-number, .dark .hljs-operator, .dark .hljs-variable, .dark .hljs-selector-attr, .dark .hljs-selector-class, .dark .hljs-selector-id {
/* prettylights-syntax-constant */
color: #79c0ff
}.dark .hljs-regexp, .dark .hljs-string, .dark .hljs-meta .hljs-string {
/* prettylights-syntax-string */
color: #a5d6ff
}.dark .hljs-built_in, .dark .hljs-symbol {
/* prettylights-syntax-variable */
color: #ffa657
}.dark .hljs-comment, .dark .hljs-code, .dark .hljs-formula {
/* prettylights-syntax-comment */
color: #8b949e
}.dark .hljs-name, .dark .hljs-quote, .dark .hljs-selector-tag, .dark .hljs-selector-pseudo {
/* prettylights-syntax-entity-tag */
color: #7ee787
}.dark .hljs-subst {
/* prettylights-syntax-storage-modifier-import */
color: #c9d1d9
}.dark .hljs-section {
/* prettylights-syntax-markup-heading */
color: #1f6feb;
font-weight: bold
}.dark .hljs-bullet {
/* prettylights-syntax-markup-list */
color: #f2cc60
}.dark .hljs-emphasis {
/* prettylights-syntax-markup-italic */
color: #c9d1d9;
font-style: italic
}.dark .hljs-strong {
/* prettylights-syntax-markup-bold */
color: #c9d1d9;
font-weight: bold
}.dark .hljs-addition {
/* prettylights-syntax-markup-inserted */
color: #aff5b4;
background-color: #033a16
}.dark .hljs-deletion {
/* prettylights-syntax-markup-deleted */
color: #ffdcd7;
background-color: #67060c
}.dark .hljs-char.escape_, .dark .hljs-link, .dark .hljs-params, .dark .hljs-property, .dark .hljs-punctuation, .dark .hljs-tag {
/* purposely ignored */
}

View File

@@ -0,0 +1,137 @@
import { useState, useEffect, useCallback, useRef } from 'react'
interface BackendState {
status: 'connecting' | 'ready' | 'error'
port: number
error?: string
}
export function useBackend() {
const [state, setState] = useState<BackendState>({
status: 'connecting',
port: 9899,
})
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
try {
const res = await fetch(`http://127.0.0.1:${port}/config`, {
signal: AbortSignal.timeout(3000),
})
return res.ok
} catch {
return false
}
}, [])
// True once the backend has answered at least once. After this we never flip
// back to "error" from polling — a hidden/backgrounded window throttles JS
// timers, so attempt counters are unreliable and would otherwise produce a
// false "failed to start" even though the backend is alive.
const readyRef = useRef(false)
// Holds the latest resolved port so the visibility handler (registered once)
// always probes the correct port without re-running the effect.
const portRef = useRef(9899)
useEffect(() => {
let cancelled = false
let offStatus: (() => void) | undefined
const api = window.electronAPI
// Use a wall-clock deadline instead of an attempt counter so timer
// throttling (when the window is in the background) can't fast-forward us
// into a false failure. Only give up if we genuinely can't reach the
// backend for this long.
const startPolling = async (port: number) => {
portRef.current = port
const deadline = Date.now() + 90_000
const poll = async () => {
if (cancelled) return
const ready = await probeBackend(port)
if (cancelled) return
if (ready) {
readyRef.current = true
setState({ status: 'ready', port })
return
}
// Backend already answered before but is briefly unreachable (e.g.
// window was asleep): keep retrying, never surface an error.
if (!readyRef.current && Date.now() >= deadline) {
// Leave error undefined so StatusScreen shows the localized,
// user-friendly message instead of a raw technical string.
setState({ status: 'error', port })
return
}
pollingRef.current = setTimeout(poll, 1000)
}
await poll()
}
if (api) {
api.getBackendPort().then((port) => {
const p = port || 9899
portRef.current = p
setState((prev) => ({ ...prev, port: p }))
startPolling(p)
})
offStatus = api.onBackendStatus((data) => {
if (data.status === 'ready' && data.port) {
readyRef.current = true
portRef.current = data.port
setState({ status: 'ready', port: data.port })
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
}
} else if (data.status === 'error' && !readyRef.current) {
// Ignore late "error" from the main process once we've been ready —
// it usually means the window was backgrounded, not a real failure.
// Drop the raw technical message; StatusScreen shows a localized one.
setState((prev) => ({ ...prev, status: 'error' }))
}
})
} else {
startPolling(9899)
}
// When the window comes back to the foreground, re-probe immediately so a
// user returning after a while sees the real (ready) state right away
// instead of waiting for the throttled timer to catch up.
const onVisible = () => {
if (cancelled || document.visibilityState !== 'visible') return
probeBackend(portRef.current).then((ready) => {
if (cancelled || !ready) return
readyRef.current = true
setState((prev) => ({ ...prev, status: 'ready' }))
})
}
document.addEventListener('visibilitychange', onVisible)
return () => {
cancelled = true
if (pollingRef.current) {
clearTimeout(pollingRef.current)
}
offStatus?.()
document.removeEventListener('visibilitychange', onVisible)
}
}, [probeBackend])
const restart = useCallback(async () => {
setState((prev) => ({ ...prev, status: 'connecting', error: undefined }))
if (window.electronAPI) {
await window.electronAPI.restartBackend()
}
}, [])
const baseUrl = `http://127.0.0.1:${state.port}`
return { ...state, baseUrl, restart }
}

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
export type Platform = 'mac' | 'win' | 'linux'
function detectPlatform(): Platform {
const p = window.electronAPI?.platform
if (p === 'darwin') return 'mac'
if (p === 'win32') return 'win'
if (p === 'linux') return 'linux'
// Fallback for browser dev without electron
if (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform)) return 'mac'
return 'win'
}
/**
* Resolves the host platform and applies a `.platform-*` class on <html>
* so CSS can branch on platform (titlebar layout, scrollbars, etc.).
*/
export function usePlatform(): { platform: Platform; isMac: boolean; isWin: boolean } {
const [platform] = useState<Platform>(detectPlatform)
useEffect(() => {
const root = document.documentElement
root.classList.remove('platform-mac', 'platform-win', 'platform-linux')
root.classList.add(`platform-${platform}`)
}, [platform])
return { platform, isMac: platform === 'mac', isWin: platform === 'win' }
}

View File

@@ -0,0 +1,57 @@
import { useState, useEffect, useCallback } from 'react'
export type ThemePref = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
const STORAGE_KEY = 'cow_theme'
function getSystemTheme(): ResolvedTheme {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function readStored(): ThemePref {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
// Default to dark to match the app's flagship look
return 'dark'
}
function applyTheme(resolved: ResolvedTheme) {
const root = document.documentElement
root.classList.toggle('dark', resolved === 'dark')
}
export function useTheme() {
const [pref, setPref] = useState<ThemePref>(readStored)
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
readStored() === 'system' ? getSystemTheme() : (readStored() as ResolvedTheme)
)
useEffect(() => {
const next: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref
setResolved(next)
applyTheme(next)
localStorage.setItem(STORAGE_KEY, pref)
}, [pref])
// Follow system changes only when preference is "system"
useEffect(() => {
if (pref !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => {
const next = getSystemTheme()
setResolved(next)
applyTheme(next)
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [pref])
const toggleTheme = useCallback(() => {
setPref(resolved === 'dark' ? 'light' : 'dark')
}, [resolved])
const setTheme = useCallback((next: ThemePref) => setPref(next), [])
return { theme: resolved, pref, toggleTheme, setTheme }
}

View File

@@ -0,0 +1,627 @@
const translations: Record<string, Record<string, string>> = {
zh: {
console: '控制台',
nav_chat: '对话',
nav_manage: '管理',
nav_monitor: '监控',
menu_chat: '对话',
menu_config: '配置',
menu_skills: '技能',
menu_memory: '记忆',
menu_channels: '通道',
menu_tasks: '定时',
menu_logs: '日志',
menu_models: '模型',
menu_knowledge: '知识',
menu_settings: '设置',
// knowledge
knowledge_title: '知识库',
knowledge_desc: '浏览和探索你的知识库',
knowledge_tab_docs: '文档',
knowledge_tab_graph: '图谱',
knowledge_search: '搜索文档...',
knowledge_stats: '{pages} 篇 · {size}',
knowledge_select_hint: '从左侧选择一个文档查看',
knowledge_empty: '知识库还是空的',
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent它会自动整理到你的知识库中',
knowledge_go_chat: '开始对话',
knowledge_loading: '加载知识库中...',
knowledge_graph_empty: '暂无关联图谱',
knowledge_disabled: '知识库未启用',
knowledge_doc_load_error: '文档加载失败',
nav_expand: '展开侧栏',
nav_collapse: '收起侧栏',
update_available: '发现新版本',
update_download: '下载更新',
update_downloading: '正在下载',
update_restart: '重启以更新',
update_later: '稍后',
update_latest: '已是最新版本',
// onboarding
onboarding_welcome_title: '欢迎使用 CowAgent',
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
onboarding_lang_label: '界面语言',
onboarding_model_title: '配置对话模型',
onboarding_model_desc: '选择模型厂商并填入 API Key即可开始使用。',
onboarding_provider: '模型厂商',
onboarding_select_provider: '选择厂商',
onboarding_apikey: 'API Key',
onboarding_apikey_placeholder: '输入 API 密钥',
onboarding_key_guide: '没有秘钥?前往创建',
onboarding_apibase: 'API 地址(可选)',
onboarding_model: '模型',
onboarding_select_model: '选择模型',
onboarding_done_title: '一切就绪',
onboarding_done_desc: '配置完成,开始你的第一次对话吧。',
onboarding_next: '下一步',
onboarding_back: '上一步',
onboarding_skip: '跳过',
onboarding_finish: '开始对话',
onboarding_step: '第 {n} / {total} 步',
onboarding_saving: '保存中...',
onboarding_save_failed: '保存失败,请检查后重试',
sessions_title: '会话',
session_new: '新对话',
session_rename: '重命名',
session_delete: '删除',
session_empty: '暂无会话',
session_today: '今天',
session_yesterday: '昨天',
session_earlier: '更早',
msg_copy: '复制',
msg_copied: '已复制',
msg_regenerate: '重新生成',
msg_edit: '编辑',
msg_delete: '删除',
msg_cancelled: '已中止',
msg_self_learned: '自主学习',
msg_stop: '停止',
chat_clear_context: '清除上下文',
chat_load_earlier: '加载更早的消息',
chat_send: '发送',
chat_attach: '添加附件',
slash_hint: '输入 / 查看命令',
chat_welcome: '有什么可以帮你的?',
chat_empty_hint: '发送一条消息开始对话',
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
example_sys_title: '系统管理',
example_sys_text: '帮我查看工作空间里有哪些文件',
example_task_title: '技能系统',
example_task_text: '查看所有支持的工具和技能',
example_code_title: '编程助手',
example_code_text: '帮我编写一个Python爬虫脚本',
input_placeholder: '输入消息...',
config_title: '配置管理',
config_desc: '管理模型和 Agent 配置',
config_model: '模型配置',
config_agent: 'Agent 配置',
config_provider: '模型厂商',
config_model_name: '模型',
config_custom_model_hint: '输入自定义模型名称',
config_save: '保存',
config_saved: '已保存',
config_save_error: '保存失败',
config_custom_option: '自定义',
config_max_tokens: '最大上下文 Token',
config_max_turns: '最大记忆轮次',
config_max_steps: '最大执行步数',
config_max_tokens_hint: '对话中 Agent 能输入的最大 Token 长度,超过后会智能压缩处理',
config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理',
config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数',
config_thinking: '深度思考',
config_thinking_hint: '是否启用深度思考模式',
config_evolution: '自主进化',
config_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项',
config_security: '安全设置',
config_password: '访问密码',
config_password_hint: '留空则不启用密码保护',
config_password_placeholder: '留空表示不设密码',
config_password_saved: '密码已更新,请重新登录',
config_password_cleared: '密码已清除',
config_language: '语言',
config_language_hint: '界面与回复语言',
config_credentials_link: 'API Key 与接口地址请在「模型配置」中设置',
config_goto_models: '前往配置',
config_provider_unconfigured: '未配置',
config_provider_unconfigured_hint: '该厂商尚未配置 API Key请先前往配置',
config_cancel: '取消',
// settings tabs
settings_tab_basic: '基础配置',
settings_tab_models: '模型配置',
// models tab
models_vendors: '厂商凭据',
models_vendors_sub: '一处配置,多个模型能力共享',
models_configured: '已配置',
models_no_vendor: '尚未配置任何厂商',
models_add_vendor: '添加厂商',
models_custom_vendor: '自定义',
models_add_custom: '添加自定义厂商',
models_add_custom_hint: '接口需遵循 OpenAI API 协议',
models_edit_custom: '编辑自定义厂商',
models_custom_name: '名称',
models_custom_base_hint: '接口需遵循 OpenAI API 协议',
models_clear: '清除凭据',
models_delete: '删除',
models_clear_confirm: '确认清除该厂商的 API Key 与 Base URL 吗?相关能力将不再可用。',
models_delete_confirm: '确定删除该自定义厂商吗?此操作无法撤销。',
models_provider: '厂商',
models_model: '模型',
models_voice: '音色',
models_select_provider: '待选择',
models_select_model: '请选择模型',
models_select_voice: '请选择音色',
models_no_options: '暂无可选项',
models_auto: '自动',
models_asr_auto: '自动(跟随主模型)',
models_disabled: '不启用',
models_fallback: '兜底',
models_cap_chat: '主模型',
models_cap_chat_sub: '用于基础对话和 Agent 推理',
models_cap_vision: '图像理解',
models_cap_vision_sub: '识别图片内容,用于图像识别工具',
models_cap_image: '图像生成',
models_cap_image_sub: '生成图片,用于图像生成技能',
models_cap_asr: '语音识别',
models_cap_asr_sub: '语音转文字',
models_cap_tts: '语音合成',
models_cap_tts_sub: '文字转语音',
models_cap_embedding: '向量',
models_cap_embedding_sub: '用于记忆与知识的向量化检索',
models_cap_search: '联网搜索',
models_cap_search_sub: '实时网页检索能力,用于搜索工具',
models_tts_reply_mode: '语音回复模式',
models_tts_reply_mode_hint: '决定何时以语音回复用户',
models_tts_mode_off: '关闭',
models_tts_mode_if_voice: '仅当用户发语音时',
models_tts_mode_always: '始终语音回复',
models_embedding_dim: '维度',
models_embedding_rebuild_hint: '切换向量模型后,已有索引将失效,需要重建',
models_search_strategy: '策略',
models_search_auto: '自动',
models_search_fixed: '指定',
models_search_provider: '搜索厂商',
models_search_bocha_key: '配置博查 API Key',
models_search_bocha_hint: '前往博查开放平台创建 API Key',
skills_title: '技能管理',
skills_desc: '查看、启用或禁用 Agent 工具和技能',
skills_hub_btn: '探索技能广场',
tools_section_title: '内置工具',
skills_section_title: '技能',
tools_loading: '加载工具中...',
skills_loading: '加载技能中...',
skills_loading_desc: '技能将在加载后显示',
skill_enabled: '已启用',
skill_disabled: '已禁用',
tools_empty: '暂无内置工具',
skills_empty: '暂无技能',
memory_title: '记忆管理',
memory_desc: '查看 Agent 记忆文件和内容',
memory_tab_files: '记忆文件',
memory_tab_dreams: '自主进化',
memory_loading: '加载记忆文件中...',
memory_loading_desc: '记忆文件将在加载后显示',
memory_col_name: '文件名',
memory_col_type: '类型',
memory_col_size: '大小',
memory_col_updated: '更新时间',
memory_back: '返回列表',
memory_empty_files: '暂无记忆文件',
memory_empty_evolution: '暂无进化记录',
memory_type_global: '全局',
memory_type_daily: '每日',
memory_type_evolution: '进化',
memory_type_dream: '梦境',
memory_doc_load_error: '内容加载失败',
memory_prev: '上一页',
memory_next: '下一页',
channels_title: '通道管理',
channels_desc: '查看和管理消息通道',
channels_add: '添加通道',
channels_connected: '已连接',
channels_disconnected: '未连接',
channels_connect: '连接',
channels_disconnect: '断开',
channels_save: '保存',
channels_loading: '加载通道中...',
channels_connected_section: '已连接',
channels_available_section: '可添加',
channels_empty_connected: '暂无已连接的通道',
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
channels_save_ok: '已保存',
channels_save_error: '保存失败',
channels_connect_error: '连接失败',
channels_scan_login: '扫码登录',
channels_scan_register: '扫码注册',
weixin_scan_title: '微信扫码登录',
weixin_scan_desc: '使用微信扫描下方二维码登录',
weixin_scan_loading: '正在获取二维码...',
weixin_scan_waiting: '等待扫码',
weixin_scan_scanned: '已扫描,请在手机上确认',
weixin_scan_success: '登录成功',
weixin_scan_expired: '二维码已过期,正在刷新...',
weixin_scan_fail: '获取二维码失败',
weixin_qr_tip: '请使用登录的微信扫码',
feishu_scan_title: '飞书扫码注册',
feishu_scan_desc: '扫码后将自动创建并连接飞书机器人',
feishu_scan_loading: '正在生成二维码...',
feishu_scan_waiting: '请使用飞书扫码授权',
feishu_scan_tip: '扫码后在飞书中确认授权',
feishu_scan_open_link: '打开授权链接',
feishu_scan_success: '注册成功',
feishu_scan_expired: '二维码已过期',
feishu_scan_denied: '授权被拒绝',
feishu_scan_fail: '注册失败',
feishu_scan_retry: '重试',
tasks_title: '定时任务',
tasks_desc: '查看和管理定时任务',
tasks_active: '运行中',
tasks_paused: '已暂停',
tasks_empty: '暂无定时任务',
tasks_empty_guide: '在对话中告诉 Agent「每天 9 点提醒我…」即可创建定时任务',
tasks_go_chat: '去对话创建',
tasks_next_run: '下次执行',
tasks_loading: '加载定时任务中...',
task_edit_title: '编辑任务',
task_name: '名称',
task_enabled: '启用',
task_schedule_type: '调度类型',
task_type_cron: 'Cron 表达式',
task_type_interval: '固定间隔',
task_type_once: '单次执行',
task_cron_expr: 'Cron 表达式',
task_cron_hint: '如 0 9 * * * 表示每天 9 点',
task_interval_seconds: '间隔(秒)',
task_once_time: '执行时间',
task_action_type: '动作类型',
task_action_send: '发送消息',
task_action_agent: 'Agent 任务',
task_message_content: '消息内容',
task_task_description: '任务描述',
task_channel: '通道',
task_receiver: '接收者',
task_channel_locked: '通道与接收者创建后不可修改',
task_save: '保存',
task_cancel: '取消',
task_delete: '删除',
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
task_save_error: '保存失败',
logs_title: '日志',
logs_desc: '实时日志输出 (run.log)',
logs_live: '实时',
logs_connecting: '日志流将在连接后显示...',
status_starting: '正在启动 CowAgent...',
status_starting_desc: '正在初始化客户端,请稍候',
status_error: '初始化失败',
status_error_desc: '客户端初始化失败,请重试',
status_retry: '重试',
},
en: {
console: 'Console',
nav_chat: 'Chat',
nav_manage: 'Management',
nav_monitor: 'Monitor',
menu_chat: 'Chat',
menu_config: 'Config',
menu_skills: 'Skills',
menu_memory: 'Memory',
menu_channels: 'Channels',
menu_tasks: 'Tasks',
menu_logs: 'Logs',
menu_models: 'Models',
menu_knowledge: 'Knowledge',
// knowledge
knowledge_title: 'Knowledge Base',
knowledge_desc: 'Browse and explore your knowledge base',
knowledge_tab_docs: 'Documents',
knowledge_tab_graph: 'Graph',
knowledge_search: 'Search documents...',
knowledge_stats: '{pages} pages · {size}',
knowledge_select_hint: 'Select a document to view',
knowledge_empty: 'Your knowledge base is empty',
knowledge_empty_guide: 'Send documents, links or topics to the agent in chat — it will organize them into your knowledge base',
knowledge_go_chat: 'Start chatting',
knowledge_loading: 'Loading knowledge base...',
knowledge_graph_empty: 'No graph available',
knowledge_disabled: 'Knowledge base is disabled',
knowledge_doc_load_error: 'Failed to load document',
menu_settings: 'Settings',
nav_expand: 'Expand sidebar',
nav_collapse: 'Collapse sidebar',
update_available: 'New version available',
update_download: 'Download update',
update_downloading: 'Downloading',
update_restart: 'Restart to update',
update_later: 'Later',
update_latest: 'You are up to date',
// onboarding
onboarding_welcome_title: 'Welcome to CowAgent',
onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',
onboarding_lang_label: 'Language',
onboarding_model_title: 'Set up your chat model',
onboarding_model_desc: 'Pick a provider and paste its API key to get started.',
onboarding_provider: 'Provider',
onboarding_select_provider: 'Select a provider',
onboarding_apikey: 'API Key',
onboarding_apikey_placeholder: 'Enter your API key',
onboarding_key_guide: 'No key yet? Create one',
onboarding_apibase: 'API base (optional)',
onboarding_model: 'Model',
onboarding_select_model: 'Select a model',
onboarding_done_title: 'All set',
onboarding_done_desc: 'Setup complete. Start your first conversation.',
onboarding_next: 'Next',
onboarding_back: 'Back',
onboarding_skip: 'Skip',
onboarding_finish: 'Start chatting',
onboarding_step: 'Step {n} of {total}',
onboarding_saving: 'Saving...',
onboarding_save_failed: 'Save failed, please check and retry',
sessions_title: 'Chats',
session_new: 'New chat',
session_rename: 'Rename',
session_delete: 'Delete',
session_empty: 'No conversations yet',
session_today: 'Today',
session_yesterday: 'Yesterday',
session_earlier: 'Earlier',
msg_copy: 'Copy',
msg_copied: 'Copied',
msg_regenerate: 'Regenerate',
msg_edit: 'Edit',
msg_delete: 'Delete',
msg_cancelled: 'Cancelled',
msg_self_learned: 'Self-learned',
msg_stop: 'Stop',
chat_clear_context: 'Clear context',
chat_load_earlier: 'Load earlier messages',
chat_send: 'Send',
chat_attach: 'Attach file',
slash_hint: 'Type / for commands',
chat_welcome: 'How can I help you?',
chat_empty_hint: 'Send a message to start the conversation',
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills,\nand keep growing through long-term memory.',
example_sys_title: 'System',
example_sys_text: 'Show me the files in the workspace',
example_task_title: 'Skills',
example_task_text: 'Show current tools and skills',
example_code_title: 'Coding',
example_code_text: 'Write a Python web scraper script',
input_placeholder: 'Type a message...',
config_title: 'Configuration',
config_desc: 'Manage model and agent settings',
config_model: 'Model Configuration',
config_agent: 'Agent Configuration',
config_provider: 'Provider',
config_model_name: 'Model',
config_custom_model_hint: 'Enter custom model name',
config_save: 'Save',
config_saved: 'Saved',
config_save_error: 'Save failed',
config_custom_option: 'Custom',
config_max_tokens_hint: 'Max token length the Agent can take in; longer context is compressed automatically',
config_max_turns_hint: 'One question and answer is a turn; older turns are compressed automatically',
config_max_steps_hint: 'Max tool calls the Agent can make in one turn',
config_thinking: 'Deep Thinking',
config_thinking_hint: 'Whether to enable deep thinking mode',
config_evolution: 'Self-Evolution',
config_evolution_hint: 'Review automatically when idle: consolidate memory, refine skills, finish pending tasks',
config_security: 'Security',
config_password: 'Access Password',
config_password_hint: 'Leave empty to disable password protection',
config_password_placeholder: 'Leave empty for no password',
config_password_saved: 'Password updated, please log in again',
config_password_cleared: 'Password cleared',
config_language: 'Language',
config_language_hint: 'Interface and reply language',
config_credentials_link: 'Set API key and endpoint in "Models"',
config_goto_models: 'Configure',
config_provider_unconfigured: 'Not configured',
config_provider_unconfigured_hint: 'This provider has no API key yet — configure it first',
config_cancel: 'Cancel',
// settings tabs
settings_tab_basic: 'Basic',
settings_tab_models: 'Models',
// models tab
models_vendors: 'Provider Credentials',
models_vendors_sub: 'Configured once, shared by multiple model capabilities',
models_configured: 'configured',
models_no_vendor: 'No provider configured yet',
models_add_vendor: 'Add Provider',
models_custom_vendor: 'Custom',
models_add_custom: 'Add custom provider',
models_add_custom_hint: 'Endpoint must follow the OpenAI API protocol',
models_edit_custom: 'Edit custom provider',
models_custom_name: 'Name',
models_custom_base_hint: 'Endpoint must follow the OpenAI API protocol',
models_clear: 'Clear credentials',
models_delete: 'Delete',
models_clear_confirm: 'Clear the API key and base URL for this provider? Related capabilities will stop working.',
models_delete_confirm: 'Delete this custom provider? This cannot be undone.',
models_provider: 'Provider',
models_model: 'Model',
models_voice: 'Voice',
models_select_provider: 'Select',
models_select_model: 'Select a model',
models_select_voice: 'Select a voice',
models_no_options: 'No options',
models_auto: 'Auto',
models_asr_auto: 'Auto (follow main model)',
models_disabled: 'Disabled',
models_fallback: 'Fallback',
models_cap_chat: 'Main Model',
models_cap_chat_sub: 'Used for basic chat and agent reasoning',
models_cap_vision: 'Image Understanding',
models_cap_vision_sub: 'Recognizes image content, used by image recognition tools',
models_cap_image: 'Image Generation',
models_cap_image_sub: 'Generates images, used by image generation skills',
models_cap_asr: 'Speech Recognition',
models_cap_asr_sub: 'Voice to text',
models_cap_tts: 'Speech Synthesis',
models_cap_tts_sub: 'Text to voice',
models_cap_embedding: 'Embedding',
models_cap_embedding_sub: 'Used for vectorized retrieval of memory and knowledge',
models_cap_search: 'Web Search',
models_cap_search_sub: 'Real-time web retrieval, used by search tools',
models_tts_reply_mode: 'Voice Reply Mode',
models_tts_reply_mode_hint: 'When to reply with voice',
models_tts_mode_off: 'Off',
models_tts_mode_if_voice: 'Only when user sends voice',
models_tts_mode_always: 'Always reply with voice',
models_embedding_dim: 'Dimension',
models_embedding_rebuild_hint: 'Existing index becomes invalid and must be rebuilt after changing the model',
models_search_strategy: 'Strategy',
models_search_auto: 'Auto',
models_search_fixed: 'Pinned',
models_search_provider: 'Search provider',
models_search_bocha_key: 'Configure Bocha API Key',
models_search_bocha_hint: 'Create a key at the Bocha open platform',
config_max_tokens: 'Max Context Tokens',
config_max_turns: 'Max Memory Turns',
config_max_steps: 'Max Steps',
skills_title: 'Skills',
skills_desc: 'View, enable, or disable agent tools and skills',
skills_hub_btn: 'Skill Hub',
tools_section_title: 'Built-in Tools',
skills_section_title: 'Skills',
tools_loading: 'Loading tools...',
skills_loading: 'Loading skills...',
skills_loading_desc: 'Skills will be displayed here after loading',
skill_enabled: 'Enabled',
skill_disabled: 'Disabled',
tools_empty: 'No built-in tools',
skills_empty: 'No skills found',
memory_title: 'Memory',
memory_desc: 'View agent memory files and contents',
memory_tab_files: 'Memory Files',
memory_tab_dreams: 'Self-Evolution',
memory_loading: 'Loading memory files...',
memory_loading_desc: 'Memory files will be displayed here',
memory_col_name: 'Filename',
memory_col_type: 'Type',
memory_col_size: 'Size',
memory_col_updated: 'Updated',
memory_back: 'Back to list',
memory_empty_files: 'No memory files',
memory_empty_evolution: 'No evolution records yet',
memory_type_global: 'Global',
memory_type_daily: 'Daily',
memory_type_evolution: 'Evolution',
memory_type_dream: 'Dream',
memory_doc_load_error: 'Failed to load content',
memory_prev: 'Prev',
memory_next: 'Next',
channels_title: 'Channels',
channels_desc: 'View and manage messaging channels',
channels_add: 'Add channel',
channels_connected: 'Connected',
channels_disconnected: 'Disconnected',
channels_connect: 'Connect',
channels_disconnect: 'Disconnect',
channels_save: 'Save',
channels_loading: 'Loading channels...',
channels_connected_section: 'Connected',
channels_available_section: 'Available',
channels_empty_connected: 'No connected channels yet',
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
channels_save_ok: 'Saved',
channels_save_error: 'Failed to save',
channels_connect_error: 'Failed to connect',
channels_scan_login: 'QR login',
channels_scan_register: 'QR register',
weixin_scan_title: 'WeChat QR Login',
weixin_scan_desc: 'Scan the QR code below with WeChat to log in',
weixin_scan_loading: 'Fetching QR code...',
weixin_scan_waiting: 'Waiting for scan',
weixin_scan_scanned: 'Scanned, please confirm on your phone',
weixin_scan_success: 'Logged in',
weixin_scan_expired: 'QR code expired, refreshing...',
weixin_scan_fail: 'Failed to fetch QR code',
weixin_qr_tip: 'Scan with the WeChat account to log in',
feishu_scan_title: 'Feishu QR Register',
feishu_scan_desc: 'Scanning will create and connect a Feishu bot automatically',
feishu_scan_loading: 'Generating QR code...',
feishu_scan_waiting: 'Scan with Feishu to authorize',
feishu_scan_tip: 'Confirm the authorization in Feishu after scanning',
feishu_scan_open_link: 'Open authorization link',
feishu_scan_success: 'Registered',
feishu_scan_expired: 'QR code expired',
feishu_scan_denied: 'Authorization denied',
feishu_scan_fail: 'Registration failed',
feishu_scan_retry: 'Retry',
tasks_title: 'Scheduled Tasks',
tasks_desc: 'View and manage scheduled tasks',
tasks_active: 'Active',
tasks_paused: 'Paused',
tasks_empty: 'No scheduled tasks',
tasks_empty_guide: 'Tell the agent "remind me every day at 9am…" in chat to create a scheduled task',
tasks_go_chat: 'Create in chat',
tasks_next_run: 'Next run',
tasks_loading: 'Loading scheduled tasks...',
task_edit_title: 'Edit Task',
task_name: 'Name',
task_enabled: 'Enabled',
task_schedule_type: 'Schedule type',
task_type_cron: 'Cron',
task_type_interval: 'Interval',
task_type_once: 'Once',
task_cron_expr: 'Cron expression',
task_cron_hint: 'e.g. 0 9 * * * runs daily at 9am',
task_interval_seconds: 'Interval (seconds)',
task_once_time: 'Run at',
task_action_type: 'Action type',
task_action_send: 'Send message',
task_action_agent: 'Agent task',
task_message_content: 'Message content',
task_task_description: 'Task description',
task_channel: 'Channel',
task_receiver: 'Receiver',
task_channel_locked: 'Channel and receiver cannot be changed after creation',
task_save: 'Save',
task_cancel: 'Cancel',
task_delete: 'Delete',
task_delete_confirm: 'Delete this task? This cannot be undone.',
task_save_error: 'Failed to save',
logs_title: 'Logs',
logs_desc: 'Real-time log output (run.log)',
logs_live: 'Live',
logs_connecting: 'Log streaming will connect shortly...',
status_starting: 'Starting CowAgent...',
status_starting_desc: 'Initializing the client, please wait',
status_error: 'Initialization Failed',
status_error_desc: 'Failed to initialize the client, please retry',
status_retry: 'Retry',
},
}
export type Lang = 'zh' | 'en'
// First-run default: follow the OS language so zh-* systems start in Chinese
// and everyone else in English. Once the user picks a language it's persisted
// in cow_lang and always wins. Falls back to 'en' when the locale is unknown.
function detectDefaultLang(): Lang {
const locale = (window.electronAPI?.systemLocale || navigator.language || '').toLowerCase()
return locale.startsWith('zh') ? 'zh' : 'en'
}
const savedLang = localStorage.getItem('cow_lang') as Lang | null
let currentLang: Lang = savedLang === 'zh' || savedLang === 'en' ? savedLang : detectDefaultLang()
export function t(key: string): string {
return translations[currentLang]?.[key] || translations['en']?.[key] || key
}
export function getLang(): Lang {
return currentLang
}
export function setLang(lang: Lang) {
currentLang = lang
localStorage.setItem('cow_lang', lang)
}
/** Resolve a possibly-localized label ({zh,en} or plain string) for the current language. */
export function localizedLabel(label: string | { zh: string; en: string } | undefined): string {
if (!label) return ''
if (typeof label === 'string') return label
return label[currentLang] || label.en || label.zh || ''
}

View File

@@ -0,0 +1,338 @@
@import './highlight.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================================
Design tokens — semantic CSS variables driving both themes.
Components reference these via Tailwind semantic classes
(bg-surface, text-primary, border-default, etc.), so theme
switching only swaps variable values, never component code.
============================================================ */
:root {
/* Brand accent (CowAgent green), used sparingly */
--accent: #4abe6e;
--accent-hover: #35a85b;
--accent-active: #228547;
--accent-soft: rgba(74, 190, 110, 0.12);
--accent-contrast: #ffffff;
/* Status colors */
--success: #4abe6e;
--warning: #f59e0b;
--danger: #ef4444;
--danger-soft: rgba(239, 68, 68, 0.1);
--danger-border: rgba(239, 68, 68, 0.3);
--info: #3b82f6;
/* Light theme — layered neutral surfaces */
--bg-base: #fafafa; /* app background */
--bg-surface: #ffffff; /* panels, cards */
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
--bg-elevated: #ffffff; /* popovers, menus, modals */
--bg-inset: #f4f4f5; /* inputs, code blocks */
--text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */
--text-secondary: #52525b; /* body, labels */
--text-tertiary: #71717a; /* hints, captions */
--text-disabled: #a1a1aa;
--border-default: #e4e4e7;
--border-strong: #d4d4d8;
--border-subtle: #f0f0f1;
--overlay: rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
/* Chat-specific tokens (AI-Native UI) */
--user-bubble-bg: var(--accent-soft);
--ai-bubble-bg: transparent;
--message-gap: 16px;
color-scheme: light;
}
.dark {
/* Dark theme — layered greys instead of harsh pure black */
--bg-base: #0e0e10;
--bg-surface: #161618;
--bg-surface-2: #1c1c1f;
--bg-elevated: #1f1f23;
--bg-inset: #161618;
--text-primary: #f4f4f5; /* contrast > 7:1 on bg-base */
--text-secondary: #c4c4cc;
--text-tertiary: #8e8e96;
--text-disabled: #5a5a62;
--border-default: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.14);
--border-subtle: rgba(255, 255, 255, 0.04);
--accent-contrast: #0e0e10;
--overlay: rgba(0, 0, 0, 0.6);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
--user-bubble-bg: rgba(74, 190, 110, 0.16);
color-scheme: dark;
}
/* ============================================================
Base
============================================================ */
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background: var(--bg-base);
color: var(--text-primary);
font-family: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-size: 14px;
line-height: 1.6;
overflow: hidden;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Smooth theme transition (respect reduced motion) */
body,
body * {
transition-property: background-color, border-color, color, fill, stroke;
transition-duration: 200ms;
transition-timing-function: ease;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
/* ============================================================
Scrollbar — thin, low-key, theme-aware
============================================================ */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
background-clip: padding-box;
}
/* Windows scrollbars are slightly wider/more visible */
.platform-win ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
/* ============================================================
Titlebar drag regions (frameless window)
============================================================ */
.titlebar-drag {
-webkit-app-region: drag;
}
.titlebar-no-drag {
-webkit-app-region: no-drag;
}
/* ============================================================
Animations
============================================================ */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.animate-blink {
animation: blink 1s step-end infinite;
}
/* AI typing indicator — 3-dot pulse */
@keyframes typingPulse {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
.typing-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: var(--text-tertiary);
animation: typingPulse 1.4s infinite ease-in-out both;
}
.typing-dot:nth-child(2) { animation-delay: 0.16s; }
.typing-dot:nth-child(3) { animation-delay: 0.32s; }
/* Smooth content reveal */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-reveal {
animation: fadeInUp 0.25s ease both;
}
/* Skeleton shimmer */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
var(--bg-surface-2) 25%,
var(--border-subtle) 50%,
var(--bg-surface-2) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 6px;
}
/* ============================================================
Markdown content (assistant messages, memory/knowledge viewers)
============================================================ */
.msg-content > *:first-child { margin-top: 0; }
.msg-content > *:last-child { margin-bottom: 0; }
.msg-content p { margin: 0.5em 0; line-height: 1.7; }
.msg-content ul, .msg-content ol { padding-left: 1.4em; margin: 0.5em 0; }
.msg-content li { margin: 0.25em 0; }
.msg-content h1, .msg-content h2, .msg-content h3, .msg-content h4 {
font-weight: 600;
margin: 0.8em 0 0.4em;
color: var(--text-primary);
}
.msg-content h1 { font-size: 1.4em; }
.msg-content h2 { font-size: 1.25em; }
.msg-content h3 { font-size: 1.1em; }
.msg-content a { color: var(--accent); text-decoration: none; }
.msg-content a:hover { text-decoration: underline; }
.msg-content blockquote {
padding-left: 0.9em;
margin: 0.6em 0;
border-left: 3px solid var(--accent);
color: var(--text-secondary);
}
.msg-content table {
border-collapse: collapse;
width: 100%;
margin: 0.8em 0;
font-size: 0.9em;
}
.msg-content th, .msg-content td {
border: 1px solid var(--border-default);
padding: 0.4em 0.7em;
text-align: left;
}
.msg-content th { background: var(--bg-surface-2); font-weight: 600; }
.msg-content hr { border: none; border-top: 1px solid var(--border-default); margin: 1em 0; }
/* Inline code */
.msg-content :not(pre) > code {
padding: 0.12em 0.4em;
border-radius: 5px;
background: var(--bg-inset);
border: 1px solid var(--border-subtle);
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
font-size: 0.85em;
}
/* Fenced code blocks */
.msg-content .code-block-wrapper {
margin: 0.7em 0;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border-default);
background: var(--bg-inset);
}
.msg-content .code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px 5px 12px;
background: var(--bg-surface-2);
border-bottom: 1px solid var(--border-subtle);
}
.msg-content .code-block-lang {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
text-transform: lowercase;
letter-spacing: 0.02em;
}
.msg-content .code-copy-btn {
font-size: 11px;
color: var(--text-tertiary);
background: transparent;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: 5px;
transition: color 0.15s, background 0.15s;
}
.msg-content .code-copy-btn:hover { color: var(--text-secondary); background: var(--bg-inset); }
.msg-content .code-copy-btn.copied { color: var(--accent); }
.msg-content .code-block-wrapper pre {
margin: 0;
padding: 12px 14px;
overflow-x: auto;
background: transparent;
}
.msg-content .code-block-wrapper pre code,
.msg-content pre code.hljs {
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
font-size: 12.5px;
line-height: 1.6;
background: transparent;
padding: 0;
}

View File

@@ -0,0 +1,142 @@
import React from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import {
MessageSquare,
BookOpen,
Brain,
Zap,
Radio,
Clock,
Settings,
PanelLeftClose,
PanelLeftOpen,
Sun,
Moon,
ScrollText,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { t, getLang, setLang, Lang } from '../i18n'
import { useUIStore } from '../store/uiStore'
import { useTheme } from '../hooks/useTheme'
import UpdateBanner from '../components/UpdateBanner'
interface NavItem {
path: string
labelKey: string
icon: LucideIcon
}
const NAV_ITEMS: NavItem[] = [
{ path: '/', labelKey: 'menu_chat', icon: MessageSquare },
{ path: '/knowledge', labelKey: 'menu_knowledge', icon: BookOpen },
{ path: '/memory', labelKey: 'menu_memory', icon: Brain },
{ path: '/skills', labelKey: 'menu_skills', icon: Zap },
{ path: '/channels', labelKey: 'menu_channels', icon: Radio },
{ path: '/tasks', labelKey: 'menu_tasks', icon: Clock },
{ path: '/settings', labelKey: 'menu_settings', icon: Settings },
]
interface NavRailProps {
onLangChange: () => void
}
const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
const location = useLocation()
const navigate = useNavigate()
const { navCollapsed, toggleNav } = useUIStore()
const { theme, toggleTheme } = useTheme()
const collapsed = navCollapsed
const width = collapsed ? 'w-[56px]' : 'w-[208px]'
const toggleLanguage = () => {
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
setLang(next)
onLangChange()
}
return (
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
{/* Top: full-width drag strip; reserve space for macOS traffic lights.
No right border here so the divider doesn't cut across the traffic lights. */}
<div className="titlebar-drag h-[44px] flex-shrink-0" />
{/* Content area carries the right divider, starting below the titlebar */}
<div className="flex-1 flex flex-col min-h-0 border-r border-default">
{/* Nav items */}
<nav className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const isActive = location.pathname === item.path
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
title={collapsed ? t(item.labelKey) : undefined}
className={`group w-full flex items-center gap-3 rounded-btn cursor-pointer transition-colors h-9 ${
collapsed ? 'justify-center px-0' : 'px-3'
} ${
isActive
? 'bg-accent-soft text-accent'
: 'text-content-secondary hover:bg-surface-2 hover:text-content'
}`}
>
<Icon size={18} strokeWidth={isActive ? 2.2 : 1.8} className="flex-shrink-0" />
{!collapsed && <span className="text-[13px] truncate">{t(item.labelKey)}</span>}
</button>
)
})}
</nav>
{/* Update banner floats above the footer when a new version is pending */}
<div className="relative">
{!collapsed && <UpdateBanner />}
</div>
{/* Footer actions */}
<div className={`flex-shrink-0 px-2 py-2 border-t border-subtle ${collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}`}>
<FooterBtn
collapsed={collapsed}
onClick={() => navigate('/logs')}
title={t('menu_logs')}
active={location.pathname === '/logs'}
>
<ScrollText size={17} />
</FooterBtn>
<FooterBtn collapsed={collapsed} onClick={toggleTheme} title={theme === 'dark' ? 'Light' : 'Dark'}>
{theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
</FooterBtn>
<FooterBtn collapsed={collapsed} onClick={toggleLanguage} title="Language">
<span className="text-[13px] font-medium w-[18px] text-center">{getLang() === 'zh' ? 'EN' : '中'}</span>
</FooterBtn>
<div className={collapsed ? '' : 'flex-1'} />
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
</FooterBtn>
</div>
</div>
</aside>
)
}
const FooterBtn: React.FC<{
collapsed: boolean
onClick: () => void
title: string
active?: boolean
children: React.ReactNode
}> = ({ collapsed, onClick, title, active, children }) => (
<button
onClick={onClick}
title={title}
className={`inline-flex items-center gap-1.5 rounded-btn cursor-pointer transition-colors ${
active
? 'bg-accent-soft text-accent'
: 'text-content-tertiary hover:text-content hover:bg-surface-2'
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2'}`}
>
{children}
</button>
)
export default NavRail

View File

@@ -0,0 +1,180 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Plus, MessageSquare, Pencil, Trash2, Check, X, PanelLeftClose } from 'lucide-react'
import { t } from '../i18n'
import { useSessionStore } from '../store/sessionStore'
import { useUIStore } from '../store/uiStore'
import type { SessionItem } from '../types'
function groupByTime(sessions: SessionItem[]): { label: string; items: SessionItem[] }[] {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
const startOfYesterday = startOfToday - 86400
const today: SessionItem[] = []
const yesterday: SessionItem[] = []
const earlier: SessionItem[] = []
for (const s of sessions) {
const ts = s.last_active || s.created_at
if (ts >= startOfToday) today.push(s)
else if (ts >= startOfYesterday) yesterday.push(s)
else earlier.push(s)
}
return [
{ label: t('session_today'), items: today },
{ label: t('session_yesterday'), items: yesterday },
{ label: t('session_earlier'), items: earlier },
].filter((g) => g.items.length > 0)
}
const SessionList: React.FC = () => {
const { sessions, activeId, loading, loadSessions, loadMore, hasMore, setActive, newSession, rename, remove } =
useSessionStore()
const toggleSessions = useUIStore((s) => s.toggleSessions)
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
useEffect(() => {
loadSessions(1)
}, [loadSessions])
const groups = useMemo(() => groupByTime(sessions), [sessions])
const startEdit = (s: SessionItem) => {
setEditingId(s.session_id)
setEditValue(s.title || '')
}
const commitEdit = async () => {
if (editingId && editValue.trim()) {
await rename(editingId, editValue.trim())
}
setEditingId(null)
}
return (
<div className="w-[240px] flex-shrink-0 flex flex-col h-full bg-surface border-r border-default">
{/* Header */}
<div className="flex items-center justify-between px-2 h-[44px] flex-shrink-0 titlebar-drag">
<button
onClick={toggleSessions}
title={t('nav_collapse')}
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors"
>
<PanelLeftClose size={16} />
</button>
<button
onClick={() => newSession()}
title={t('session_new')}
className="titlebar-no-drag inline-flex items-center gap-1.5 px-2.5 h-7 rounded-btn text-[12px] font-medium text-accent hover:bg-accent-soft cursor-pointer transition-colors"
>
<Plus size={15} />
{t('session_new')}
</button>
</div>
{/* List */}
<div
className="flex-1 overflow-y-auto px-2 pb-2"
onScroll={(e) => {
const el = e.currentTarget
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80 && hasMore && !loading) loadMore()
}}
>
{sessions.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
<MessageSquare size={22} className="text-content-disabled mb-2" />
<p className="text-xs text-content-tertiary">{t('session_empty')}</p>
</div>
)}
{groups.map((group) => (
<div key={group.label} className="mb-2">
<div className="px-2 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-content-disabled">
{group.label}
</div>
{group.items.map((s) => {
const isActive = s.session_id === activeId
const isEditing = editingId === s.session_id
return (
<div
key={s.session_id}
onClick={() => !isEditing && setActive(s.session_id)}
className={`group flex items-center gap-2 px-2 h-9 rounded-btn cursor-pointer transition-colors ${
isActive ? 'bg-accent-soft' : 'hover:bg-surface-2'
}`}
>
{isEditing ? (
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commitEdit()
if (e.key === 'Escape') setEditingId(null)
}}
onClick={(e) => e.stopPropagation()}
className="flex-1 min-w-0 bg-inset border border-strong rounded px-1.5 py-0.5 text-[13px] text-content focus:outline-none focus:border-accent"
/>
) : (
<span
className={`flex-1 min-w-0 truncate text-[13px] ${
isActive ? 'text-accent font-medium' : 'text-content-secondary'
}`}
>
{s.title || s.session_id}
</span>
)}
{isEditing ? (
<div className="flex items-center gap-0.5">
<IconBtn onClick={(e) => { e.stopPropagation(); commitEdit() }}><Check size={13} /></IconBtn>
<IconBtn onClick={(e) => { e.stopPropagation(); setEditingId(null) }}><X size={13} /></IconBtn>
</div>
) : (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<IconBtn onClick={(e) => { e.stopPropagation(); startEdit(s) }} title={t('session_rename')}>
<Pencil size={13} />
</IconBtn>
<IconBtn onClick={(e) => { e.stopPropagation(); remove(s.session_id) }} title={t('session_delete')} danger>
<Trash2 size={13} />
</IconBtn>
</div>
)}
</div>
)
})}
</div>
))}
{loading && (
<div className="px-2 py-2 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="skeleton h-7 w-full" />
))}
</div>
)}
</div>
</div>
)
}
const IconBtn: React.FC<{
onClick: (e: React.MouseEvent) => void
title?: string
danger?: boolean
children: React.ReactNode
}> = ({ onClick, title, danger, children }) => (
<button
onClick={onClick}
title={title}
className={`inline-flex items-center justify-center w-6 h-6 rounded cursor-pointer transition-colors text-content-tertiary ${
danger ? 'hover:text-danger hover:bg-danger-soft' : 'hover:text-content hover:bg-surface'
}`}
>
{children}
</button>
)
export default SessionList

View File

@@ -0,0 +1,42 @@
import React, { useEffect, useState } from 'react'
import { Minus, Square, Copy, X } from 'lucide-react'
/**
* Custom window controls for the frameless Windows titlebar.
* On macOS the system renders traffic lights, so this returns null there.
*/
const WindowControls: React.FC = () => {
const [maximized, setMaximized] = useState(false)
const api = window.electronAPI
useEffect(() => {
api?.windowIsMaximized().then(setMaximized)
const off = api?.onMaximizeChange(setMaximized)
return off
}, [api])
if (api?.platform === 'darwin') return null
const btn =
'titlebar-no-drag inline-flex items-center justify-center w-11 h-full text-content-tertiary hover:text-content cursor-pointer transition-colors'
return (
<div className="flex items-stretch h-full">
<button className={`${btn} hover:bg-surface-2`} onClick={() => api?.windowMinimize()} aria-label="Minimize">
<Minus size={15} strokeWidth={2} />
</button>
<button className={`${btn} hover:bg-surface-2`} onClick={() => api?.windowMaximize()} aria-label="Maximize">
{maximized ? <Copy size={12} strokeWidth={2} /> : <Square size={12} strokeWidth={2} />}
</button>
<button
className={`${btn} hover:bg-danger hover:text-white`}
onClick={() => api?.windowClose()}
aria-label="Close"
>
<X size={16} strokeWidth={2} />
</button>
</div>
)
}
export default WindowControls

View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { HashRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>
)

View File

@@ -0,0 +1,271 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Loader2, Plug, QrCode } from 'lucide-react'
import { t, localizedLabel } from '../i18n'
import apiClient from '../api/client'
import type { ChannelInfo, ChannelField } from '../types'
import { Toggle, Btn } from './settings/primitives'
import QrLoginModal from '../components/QrLoginModal'
// Channels that connect via QR scanning rather than credential fields.
const QR_PROVIDERS: Record<string, 'weixin' | 'feishu'> = { weixin: 'weixin', feishu: 'feishu' }
interface ChannelsPageProps {
baseUrl: string
}
// A masked secret looks like "abcd****wxyz"; the backend skips such values.
const MASK_RE = /\*{2,}/
const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
const [channels, setChannels] = useState<ChannelInfo[]>([])
const [loading, setLoading] = useState(true)
const loadChannels = async () => {
try {
setLoading(true)
const data = await apiClient.getChannels()
setChannels(data || [])
} catch (err) {
console.error('Failed to load channels:', err)
setChannels([])
} finally {
setLoading(false)
}
}
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
void loadChannels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseUrl])
const { connected, available } = useMemo(() => {
const connected = channels.filter((c) => c.active)
const available = channels.filter((c) => !c.active)
return { connected, available }
}, [channels])
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="px-6 pt-5 pb-3 flex-shrink-0">
<h2 className="text-xl font-bold text-content">{t('channels_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
</div>
<div className="flex-1 overflow-y-auto border-t border-default">
<div className="max-w-3xl mx-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center py-20 text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('channels_loading')}
</div>
) : (
<div className="space-y-6">
<Section title={t('channels_connected_section')}>
{connected.length === 0 ? (
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
) : (
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
)}
</Section>
{available.length > 0 && (
<Section title={t('channels_available_section')}>
{available.map((ch) => (
<ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />
))}
</Section>
)}
</div>
)}
</div>
</div>
</div>
)
}
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-content-tertiary mb-2">{title}</h3>
<div className="space-y-3">{children}</div>
</div>
)
const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = ({ channel, onChanged }) => {
// Channels with no fields connect purely via QR (e.g. weixin).
const isQrLogin = channel.fields.length === 0
// QR provider supported by the desktop scan modal (weixin / feishu).
const qrProvider = QR_PROVIDERS[channel.name]
const [showQr, setShowQr] = useState(false)
const [expanded, setExpanded] = useState(false)
const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries(channel.fields.map((f) => [f.key, f.value != null ? String(f.value) : '']))
)
// Track which secret fields still hold the server-provided mask.
const [masked, setMasked] = useState<Record<string, boolean>>(() =>
Object.fromEntries(
channel.fields.map((f) => [f.key, f.type === 'secret' && !!f.value && MASK_RE.test(String(f.value))])
)
)
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState('')
const setField = (key: string, val: string) => setValues((p) => ({ ...p, [key]: val }))
// Only send fields the user actually changed; masked secrets are skipped so
// the backend keeps the stored value (mirrors the web console behavior).
const buildConfig = (): Record<string, unknown> => {
const cfg: Record<string, unknown> = {}
channel.fields.forEach((f) => {
const v = values[f.key]
if (f.type === 'secret' && masked[f.key]) return
if (v === '' || v == null) return
cfg[f.key] = f.type === 'number' ? Number(v) : v
})
return cfg
}
const run = async (action: 'save' | 'connect' | 'disconnect') => {
setBusy(true)
setStatus('')
try {
const cfg = action === 'disconnect' ? undefined : buildConfig()
const res = await apiClient.channelAction(action, channel.name, cfg)
if (res.status === 'success') {
if (action === 'save') {
setStatus(t('channels_save_ok'))
setTimeout(() => setStatus(''), 1600)
}
onChanged()
} else {
setStatus((res.message as string) || t(action === 'connect' ? 'channels_connect_error' : 'channels_save_error'))
}
} catch {
setStatus(t(action === 'connect' ? 'channels_connect_error' : 'channels_save_error'))
} finally {
setBusy(false)
}
}
return (
<div className="rounded-card border border-default bg-surface p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-inset flex items-center justify-center flex-shrink-0">
{isQrLogin ? <QrCode size={16} className="text-content-secondary" /> : <Plug size={16} className="text-content-secondary" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-content">{localizedLabel(channel.label)}</span>
<span className={`w-2 h-2 rounded-full ${channel.active ? 'bg-accent' : 'bg-content-tertiary'}`} />
</div>
<p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.name}</p>
</div>
{channel.active ? (
<Btn variant="danger" onClick={() => run('disconnect')} disabled={busy}>
{t('channels_disconnect')}
</Btn>
) : qrProvider ? (
<Btn variant="primary" onClick={() => setShowQr(true)}>
{qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')}
</Btn>
) : isQrLogin ? null : (
<Btn variant="ghost" onClick={() => setExpanded((v) => !v)}>
{t('channels_add')}
</Btn>
)}
</div>
{/* QR-login channels with no desktop support fall back to the web console. */}
{isQrLogin && !channel.active && !qrProvider && (
<p className="text-xs text-content-tertiary mt-3 pl-12">{t('channels_qr_hint')}</p>
)}
{/* Field-bearing QR channels (feishu) can also be configured manually. */}
{!isQrLogin && qrProvider && !channel.active && !expanded && (
<button
onClick={() => setExpanded(true)}
className="text-xs text-content-tertiary hover:text-content-secondary mt-3 pl-12 cursor-pointer transition-colors"
>
{t('channels_add')}
</button>
)}
{/* Field editor: always for connected channels with fields, on-demand for available ones. */}
{!isQrLogin && (channel.active || expanded) && (
<div className="mt-4 space-y-3">
{channel.fields.map((f) => (
<FieldRow
key={f.key}
field={f}
value={values[f.key] ?? ''}
onChange={(v) => setField(f.key, v)}
onFocusSecret={() => {
if (f.type === 'secret' && masked[f.key]) {
setField(f.key, '')
setMasked((p) => ({ ...p, [f.key]: false }))
}
}}
/>
))}
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs transition-opacity ${status ? 'opacity-100' : 'opacity-0'} ${status === t('channels_save_ok') ? 'text-accent' : 'text-danger'}`}>
{status || '\u00a0'}
</span>
{channel.active ? (
<Btn variant="primary" onClick={() => run('save')} disabled={busy}>
{t('channels_save')}
</Btn>
) : (
<Btn variant="primary" onClick={() => run('connect')} disabled={busy}>
{t('channels_connect')}
</Btn>
)}
</div>
</div>
)}
{showQr && qrProvider && (
<QrLoginModal
provider={qrProvider}
onClose={() => setShowQr(false)}
onConnected={() => {
setShowQr(false)
onChanged()
}}
/>
)}
</div>
)
}
const FieldRow: React.FC<{
field: ChannelField
value: string
onChange: (v: string) => void
onFocusSecret: () => void
}> = ({ field, value, onChange, onFocusSecret }) => {
if (field.type === 'bool') {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-content-secondary">{field.label}</span>
<Toggle checked={value === 'true' || value === '1'} onChange={(v) => onChange(v ? 'true' : 'false')} />
</div>
)
}
return (
<div>
<label className="block text-sm text-content-secondary mb-1.5">{field.label}</label>
<input
type={field.type === 'number' ? 'number' : 'text'}
value={value}
placeholder={field.label}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocusSecret}
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent font-mono transition-colors"
/>
</div>
)
}
export default ChannelsPage

View File

@@ -0,0 +1,229 @@
import React, { useEffect, useRef, useCallback, useState } from 'react'
import { ChevronUp, Loader2 } from 'lucide-react'
import MessageBubble from '../components/MessageBubble'
import ChatInput, { type ChatInputHandle } from '../components/ChatInput'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { Attachment, ChatMessage } from '../types'
import { useChatStore } from '../store/chatStore'
import { useSessionStore } from '../store/sessionStore'
interface ChatPageProps {
baseUrl: string
}
const SUGGESTIONS = ['example_sys', 'example_task', 'example_code'] as const
const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
const activeId = useSessionStore((s) => s.activeId)
const newSession = useSessionStore((s) => s.newSession)
const loadSessions = useSessionStore((s) => s.loadSessions)
const session = useChatStore((s) => s.sessions[activeId])
const send = useChatStore((s) => s.send)
const cancel = useChatStore((s) => s.cancel)
const regenerate = useChatStore((s) => s.regenerate)
const editUserMessage = useChatStore((s) => s.editUserMessage)
const deleteMessage = useChatStore((s) => s.deleteMessage)
const loadHistory = useChatStore((s) => s.loadHistory)
const ensureSession = useChatStore((s) => s.ensureSession)
const messages = session?.messages ?? []
const isStreaming = session?.isStreaming ?? false
const scrollRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const inputResetRef = useRef<ChatInputHandle>(null)
const [loadingMore, setLoadingMore] = useState(false)
const titlePendingRef = useRef(false)
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
}, [baseUrl])
// Load history when switching to a session that hasn't been loaded yet.
useEffect(() => {
ensureSession(activeId)
const s = useChatStore.getState().sessions[activeId]
if (s && !s.historyLoaded && !s.isStreaming) {
loadHistory(activeId, 1)
}
}, [activeId, ensureSession, loadHistory])
const scrollToBottom = useCallback((smooth = true) => {
const el = scrollRef.current
if (!el) return
// Jump straight to the bottom; instant for session switches, smooth for streaming.
if (smooth) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
} else {
el.scrollTop = el.scrollHeight
}
}, [])
// Snap to the bottom instantly when switching sessions (no top-to-bottom animation).
// History may load a frame later, so keep snapping instantly until content arrives.
const lastSessionRef = useRef('')
const lastLenRef = useRef(0)
const pendingSnapRef = useRef(false)
useEffect(() => {
const el = scrollRef.current
if (!el) return
if (lastSessionRef.current !== activeId) {
lastSessionRef.current = activeId
lastLenRef.current = messages.length
pendingSnapRef.current = true
}
if (pendingSnapRef.current) {
// Instant snap on switch and on the first content that lands afterwards.
lastLenRef.current = messages.length
requestAnimationFrame(() => scrollToBottom(false))
if (messages.length > 0) pendingSnapRef.current = false
return
}
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
const grew = messages.length !== lastLenRef.current
lastLenRef.current = messages.length
if (nearBottom || grew) scrollToBottom(true)
}, [messages, activeId, scrollToBottom])
const handleSend = useCallback(
async (text: string, attachments: Attachment[]) => {
const sid = activeId
const isFirst = (useChatStore.getState().sessions[sid]?.messages.length ?? 0) === 0
titlePendingRef.current = isFirst
await send(sid, text, attachments)
// After the first message, refresh the list and ask backend to title it.
if (isFirst) {
try {
await apiClient.generateSessionTitle(sid, text)
} catch {
/* ignore */
}
loadSessions(1)
titlePendingRef.current = false
}
},
[activeId, send, loadSessions]
)
const handleNewChat = useCallback(() => {
const id = newSession()
ensureSession(id)
loadHistory(id, 1)
}, [newSession, ensureSession, loadHistory])
const handleClearContext = useCallback(async () => {
try {
await apiClient.clearContext(activeId)
} catch {
/* ignore */
}
}, [activeId])
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
const handleRegenerate = useCallback((id: string) => regenerate(activeId, id), [regenerate, activeId])
const handleEdit = useCallback(
(id: string) => {
const result = editUserMessage(activeId, id)
if (result && inputResetRef.current) inputResetRef.current(result.text, result.attachments)
},
[editUserMessage, activeId]
)
const handleDelete = useCallback(
(msg: ChatMessage) => {
if (msg.userSeq != null) deleteMessage(activeId, msg.userSeq, true)
},
[deleteMessage, activeId]
)
const handleScroll = useCallback(
async (e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget
const s = useChatStore.getState().sessions[activeId]
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
setLoadingMore(true)
const prevHeight = el.scrollHeight
await loadHistory(activeId, s.historyPage + 1)
requestAnimationFrame(() => {
// preserve scroll position after prepending older messages
el.scrollTop = el.scrollHeight - prevHeight
setLoadingMore(false)
})
}
},
[activeId, loadHistory, loadingMore, isStreaming]
)
const isEmpty = messages.length === 0
return (
<div className="flex flex-col flex-1 min-h-0">
<div ref={scrollRef} className="flex-1 overflow-y-auto" onScroll={handleScroll}>
{loadingMore && (
<div className="flex items-center justify-center py-3 text-content-tertiary">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{isEmpty ? (
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 w-full max-w-2xl">
{SUGGESTIONS.map((key) => (
<button
key={key}
onClick={() => handleSend(t(`${key}_text` as Parameters<typeof t>[0]), [])}
className="text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all"
>
<div className="font-medium text-sm text-content mb-1">
{t(`${key}_title` as Parameters<typeof t>[0])}
</div>
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">
{t(`${key}_text` as Parameters<typeof t>[0])}
</p>
</button>
))}
</div>
</div>
) : (
<div className="py-3 max-w-3xl mx-auto">
{messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
onRegenerate={handleRegenerate}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
<div ref={bottomRef} />
</div>
)}
</div>
{/* Jump-to-bottom affordance could go here in a later pass */}
<ChatInput
onSend={handleSend}
onNewChat={handleNewChat}
onStop={handleStop}
onClearContext={handleClearContext}
isStreaming={isStreaming}
sessionId={activeId}
ref={inputResetRef}
/>
</div>
)
}
export default ChatPage

View File

@@ -0,0 +1,385 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
Loader2,
Search,
FileText,
ChevronRight,
ChevronDown,
MessageSquarePlus,
Network,
Files,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { KnowledgeDir, KnowledgeFile, KnowledgeList, KnowledgeGraph as KnowledgeGraphData } from '../types'
import Markdown from '../components/Markdown'
import KnowledgeGraph from '../components/KnowledgeGraph'
interface KnowledgePageProps {
baseUrl: string
}
type Tab = 'docs' | 'graph'
const formatSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
// Find the first document (root files first, then a DFS over the tree).
function firstFile(list: KnowledgeList): { path: string; title: string } | null {
const root = list.root_files?.[0]
if (root) return { path: root.name, title: root.title || root.name }
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
const f = dir.files[0]
if (f) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
for (const c of dir.children) {
const hit = walk(c, dirPath)
if (hit) return hit
}
return null
}
for (const d of list.tree || []) {
const hit = walk(d, '')
if (hit) return hit
}
return null
}
const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('docs')
const [data, setData] = useState<KnowledgeList | null>(null)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [activePath, setActivePath] = useState<string | null>(null)
const [docTitle, setDocTitle] = useState('')
const [content, setContent] = useState('')
const [docLoading, setDocLoading] = useState(false)
const [graph, setGraph] = useState<KnowledgeGraphData | null>(null)
const [graphLoading, setGraphLoading] = useState(false)
const openDoc = useCallback(async (path: string, title: string) => {
setActivePath(path)
setDocTitle(title)
setDocLoading(true)
setContent('')
try {
const res = await apiClient.readKnowledge(path)
setContent(res.content || '')
} catch {
setContent(`> ${t('knowledge_doc_load_error')}`)
} finally {
setDocLoading(false)
}
}, [])
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
let cancelled = false
;(async () => {
try {
setLoading(true)
const fresh = await apiClient.getKnowledgeList()
if (cancelled) return
setData(fresh)
// Auto-open the first document so the viewer isn't empty on entry.
const first = firstFile(fresh)
if (first) void openDoc(first.path, first.title)
} catch (e) {
console.error('Failed to load knowledge:', e)
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [baseUrl, openDoc])
const loadGraph = useCallback(async () => {
if (graph) return
setGraphLoading(true)
try {
setGraph(await apiClient.getKnowledgeGraph())
} catch (e) {
console.error('Failed to load graph:', e)
setGraph({ nodes: [], links: [] })
} finally {
setGraphLoading(false)
}
}, [graph])
const switchTab = (next: Tab) => {
setTab(next)
if (next === 'graph') void loadGraph()
}
// Jump from a graph node to its document.
const onGraphSelect = useCallback(
(id: string, label: string) => {
setTab('docs')
void openDoc(id, label)
},
[openDoc]
)
const totalPages = data?.stats?.pages ?? 0
const statsLabel = useMemo(() => {
if (!data) return ''
return t('knowledge_stats')
.replace('{pages}', String(data.stats?.pages ?? 0))
.replace('{size}', formatSize(data.stats?.size ?? 0))
}, [data])
if (loading) {
return (
<div className="flex-1 flex items-center justify-center text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('knowledge_loading')}
</div>
)
}
const isEmpty = !data || totalPages === 0
if (isEmpty) {
return (
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center">
<div className="w-14 h-14 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mb-5">
<Files size={26} />
</div>
<h2 className="text-lg font-semibold text-content mb-2">
{data?.enabled === false ? t('knowledge_disabled') : t('knowledge_empty')}
</h2>
<p className="text-sm text-content-tertiary max-w-md mb-6">{t('knowledge_empty_guide')}</p>
<button
onClick={() => navigate('/')}
className="inline-flex items-center gap-2 px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
>
<MessageSquarePlus size={15} />
{t('knowledge_go_chat')}
</button>
</div>
)
}
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-3 flex-shrink-0">
<div>
<h2 className="text-xl font-bold text-content">{t('knowledge_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{statsLabel}</p>
</div>
<div className="flex items-center gap-1 bg-inset rounded-btn p-0.5">
<TabBtn icon={Files} label={t('knowledge_tab_docs')} active={tab === 'docs'} onClick={() => switchTab('docs')} />
<TabBtn
icon={Network}
label={t('knowledge_tab_graph')}
active={tab === 'graph'}
onClick={() => switchTab('graph')}
/>
</div>
</div>
{tab === 'docs' ? (
<div className="flex-1 flex min-h-0 border-t border-default">
{/* Tree sidebar */}
<div className="w-72 flex-shrink-0 flex flex-col border-r border-default min-h-0">
<div className="p-3 flex-shrink-0">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-tertiary" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('knowledge_search')}
className="w-full pl-8 pr-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-3">
<Tree
data={data}
search={search.trim().toLowerCase()}
activePath={activePath}
onOpen={openDoc}
/>
</div>
</div>
{/* Document viewer */}
<div className="flex-1 min-w-0 overflow-y-auto">
{!activePath ? (
<div className="h-full flex flex-col items-center justify-center text-content-tertiary">
<FileText size={28} className="mb-3 opacity-50" />
<p className="text-sm">{t('knowledge_select_hint')}</p>
</div>
) : (
<div className="max-w-3xl mx-auto px-6 py-6">
<h1 className="text-lg font-semibold text-content mb-1">{docTitle}</h1>
<p className="text-xs text-content-tertiary mb-5 font-mono">{activePath}</p>
{docLoading ? (
<div className="flex items-center text-content-tertiary py-8">
<Loader2 size={16} className="animate-spin mr-2" />
</div>
) : (
<Markdown content={content} />
)}
</div>
)}
</div>
</div>
) : (
<div className="flex-1 min-h-0 border-t border-default relative">
{graphLoading ? (
<div className="absolute inset-0 flex items-center justify-center text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
</div>
) : graph && graph.nodes.length > 0 ? (
<KnowledgeGraph data={graph} onSelect={onGraphSelect} />
) : (
<div className="absolute inset-0 flex items-center justify-center text-content-tertiary text-sm">
{t('knowledge_graph_empty')}
</div>
)}
</div>
)}
</div>
)
}
const TabBtn: React.FC<{
icon: LucideIcon
label: string
active: boolean
onClick: () => void
}> = ({ icon: Icon, label, active, onClick }) => (
<button
onClick={onClick}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[6px] text-sm font-medium cursor-pointer transition-colors ${
active ? 'bg-surface text-content shadow-sm' : 'text-content-tertiary hover:text-content-secondary'
}`}
>
<Icon size={14} />
{label}
</button>
)
// ---- Tree rendering --------------------------------------------------------
const Tree: React.FC<{
data: KnowledgeList
search: string
activePath: string | null
onOpen: (path: string, title: string) => void
}> = ({ data, search, activePath, onOpen }) => {
const matches = (f: KnowledgeFile) => !search || f.title.toLowerCase().includes(search) || f.name.toLowerCase().includes(search)
return (
<div className="space-y-0.5">
{(data.root_files || []).filter(matches).map((f) => (
<FileLeaf
key={f.name}
path={f.name}
title={f.title || f.name}
active={activePath === f.name}
onOpen={onOpen}
/>
))}
{(data.tree || []).map((dir) => (
<DirNode key={dir.dir} dir={dir} prefix="" search={search} activePath={activePath} onOpen={onOpen} />
))}
</div>
)
}
// Count files in a dir subtree that match the search.
function countMatches(dir: KnowledgeDir, search: string): number {
const own = dir.files.filter(
(f) => !search || f.title.toLowerCase().includes(search) || f.name.toLowerCase().includes(search)
).length
return own + dir.children.reduce((acc, c) => acc + countMatches(c, search), 0)
}
const DirNode: React.FC<{
dir: KnowledgeDir
prefix: string
search: string
activePath: string | null
onOpen: (path: string, title: string) => void
}> = ({ dir, prefix, search, activePath, onOpen }) => {
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
const [open, setOpen] = useState(true)
const matchCount = search ? countMatches(dir, search) : dir.files.length + dir.children.length
if (search && matchCount === 0) return null
const visibleFiles = dir.files.filter(
(f) => !search || f.title.toLowerCase().includes(search) || f.name.toLowerCase().includes(search)
)
const expanded = open || !!search
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1 px-2 py-1.5 rounded-btn text-sm text-content-secondary hover:bg-surface-2 cursor-pointer transition-colors"
>
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
<span className="truncate font-medium">{dir.dir}</span>
<span className="ml-auto text-xs text-content-tertiary">{matchCount}</span>
</button>
{expanded && (
<div className="ml-3 border-l border-default pl-1.5 space-y-0.5">
{visibleFiles.map((f) => {
const fpath = `${dirPath}/${f.name}`
return (
<FileLeaf
key={fpath}
path={fpath}
title={f.title || f.name}
active={activePath === fpath}
onOpen={onOpen}
/>
)
})}
{dir.children.map((c) => (
<DirNode
key={c.dir}
dir={c}
prefix={dirPath}
search={search}
activePath={activePath}
onOpen={onOpen}
/>
))}
</div>
)}
</div>
)
}
const FileLeaf: React.FC<{
path: string
title: string
active: boolean
onOpen: (path: string, title: string) => void
}> = ({ path, title, active, onOpen }) => (
<button
onClick={() => onOpen(path, title)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-sm cursor-pointer transition-colors text-left ${
active ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
}`}
>
<FileText size={13} className="flex-shrink-0 opacity-70" />
<span className="truncate">{title}</span>
</button>
)
export default KnowledgePage

View File

@@ -0,0 +1,104 @@
import React, { useState, useEffect, useRef } from 'react'
import { t } from '../i18n'
import apiClient from '../api/client'
interface LogsPageProps {
baseUrl: string
}
const LogsPage: React.FC<LogsPageProps> = ({ baseUrl }) => {
const [logs, setLogs] = useState<string[]>([])
const [autoScroll, setAutoScroll] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
const es = apiClient.createLogStream()
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'init' && data.content) {
setLogs(data.content.split('\n').filter(Boolean))
} else if (data.type === 'line' && data.content) {
setLogs((prev) => {
const next = [...prev, data.content]
if (next.length > 2000) return next.slice(-1500)
return next
})
}
} catch { /* ignore */ }
}
return () => es.close()
}, [baseUrl])
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [logs, autoScroll])
const handleScroll = () => {
if (!containerRef.current) return
const { scrollTop, scrollHeight, clientHeight } = containerRef.current
setAutoScroll(scrollHeight - scrollTop - clientHeight < 50)
}
const getLogColor = (line: string) => {
if (line.includes('ERROR') || line.includes('error')) return 'text-red-400'
if (line.includes('WARNING') || line.includes('warn')) return 'text-amber-400'
if (line.includes('DEBUG')) return 'text-slate-500'
return 'text-slate-300'
}
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('logs_title')}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('logs_desc')}</p>
</div>
</div>
{/* Terminal-style log viewer */}
<div className="bg-slate-900 rounded-xl border border-slate-700 overflow-hidden shadow-lg">
{/* Terminal header */}
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-800 border-b border-slate-700">
<div className="flex gap-1.5">
<span className="w-3 h-3 rounded-full bg-red-500/80" />
<span className="w-3 h-3 rounded-full bg-amber-500/80" />
<span className="w-3 h-3 rounded-full bg-emerald-500/80" />
</div>
<span className="text-xs text-slate-400 ml-2 font-mono">run.log</span>
<div className="flex-1" />
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-xs text-slate-500">{t('logs_live')}</span>
</div>
</div>
{/* Log content */}
<div
ref={containerRef}
onScroll={handleScroll}
className="p-4 overflow-y-auto font-mono text-xs leading-relaxed whitespace-pre-wrap break-all"
style={{ height: 'calc(100vh - 272px)' }}
>
{logs.length > 0 ? (
logs.map((line, i) => (
<div key={i} className={getLogColor(line)}>{line}</div>
))
) : (
<p className="text-slate-500">{t('logs_connecting')}</p>
)}
</div>
</div>
</div>
</div>
)
}
export default LogsPage

View File

@@ -0,0 +1,256 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Loader2, ArrowLeft, Brain, Sprout, FileText, ChevronLeft, ChevronRight } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { MemoryItem, MemoryCategory } from '../types'
import Markdown from '../components/Markdown'
interface MemoryPageProps {
baseUrl: string
}
type Tab = 'files' | 'evolution'
const PAGE_SIZE = 10
const formatSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
return (bytes / 1024).toFixed(1) + ' KB'
}
// Map a file's `type` to its display badge.
const typeBadge = (type: string): { label: string; cls: string } => {
switch (type) {
case 'global':
return { label: t('memory_type_global'), cls: 'bg-accent-soft text-accent' }
case 'evolution':
return { label: t('memory_type_evolution'), cls: 'bg-inset text-success' }
case 'dream':
return { label: t('memory_type_dream'), cls: 'bg-inset text-info' }
default:
return { label: t('memory_type_daily'), cls: 'bg-inset text-content-secondary' }
}
}
const MemoryPage: React.FC<MemoryPageProps> = ({ baseUrl }) => {
const [tab, setTab] = useState<Tab>('files')
const [items, setItems] = useState<MemoryItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [viewing, setViewing] = useState<string | null>(null)
const [content, setContent] = useState('')
const [docLoading, setDocLoading] = useState(false)
const category: MemoryCategory = tab === 'evolution' ? 'evolution' : 'memory'
const loadList = useCallback(
async (cat: MemoryCategory, p: number) => {
try {
setLoading(true)
const data = await apiClient.getMemoryList(p, PAGE_SIZE, cat)
setItems(data.list || [])
setTotal(data.total || 0)
setPage(data.page || p)
} catch (err) {
console.error('Failed to load memory:', err)
setItems([])
setTotal(0)
} finally {
setLoading(false)
}
},
[]
)
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
void loadList(category, 1)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseUrl, tab])
const openFile = async (item: MemoryItem) => {
// In the evolution tab a file lives in its own dir (dream vs evolution).
const fileCategory: MemoryCategory =
item.type === 'dream' || item.type === 'evolution' ? (item.type as MemoryCategory) : category
setViewing(item.filename)
setDocLoading(true)
setContent('')
try {
const text = await apiClient.getMemoryContent(item.filename, fileCategory)
setContent(text)
} catch {
setContent(`> ${t('memory_doc_load_error')}`)
} finally {
setDocLoading(false)
}
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-3 flex-shrink-0">
<div>
<h2 className="text-xl font-bold text-content">{t('memory_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{t('memory_desc')}</p>
</div>
{!viewing && (
<div className="flex items-center gap-1 bg-inset rounded-btn p-0.5">
<TabBtn icon={Brain} label={t('memory_tab_files')} active={tab === 'files'} onClick={() => setTab('files')} />
<TabBtn
icon={Sprout}
label={t('memory_tab_dreams')}
active={tab === 'evolution'}
onClick={() => setTab('evolution')}
/>
</div>
)}
</div>
{viewing ? (
/* File viewer */
<div className="flex-1 flex flex-col min-h-0 border-t border-default">
<div className="flex items-center gap-3 px-6 py-3 flex-shrink-0 border-b border-subtle">
<button
onClick={() => setViewing(null)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-sm text-content-secondary hover:bg-inset border border-strong transition-colors cursor-pointer"
>
<ArrowLeft size={14} />
{t('memory_back')}
</button>
<h3 className="text-sm font-semibold text-content font-mono truncate">{viewing}</h3>
</div>
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-6">
{docLoading ? (
<div className="flex items-center text-content-tertiary py-8">
<Loader2 size={16} className="animate-spin mr-2" />
</div>
) : (
<Markdown content={content} />
)}
</div>
</div>
</div>
) : (
/* List */
<div className="flex-1 overflow-y-auto border-t border-default">
<div className="max-w-4xl mx-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center py-20 text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('memory_loading')}
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-content-tertiary">
{tab === 'evolution' ? <Sprout size={28} className="mb-3 opacity-60" /> : <Brain size={28} className="mb-3 opacity-60" />}
<p className="text-sm">{tab === 'evolution' ? t('memory_empty_evolution') : t('memory_empty_files')}</p>
</div>
) : (
<>
<div className="rounded-card border border-default overflow-hidden bg-surface">
<table className="w-full">
<thead>
<tr className="border-b border-default">
<Th>{t('memory_col_name')}</Th>
<Th>{t('memory_col_type')}</Th>
<Th>{t('memory_col_size')}</Th>
<Th>{t('memory_col_updated')}</Th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const badge = typeBadge(item.type)
return (
<tr
key={item.filename}
onClick={() => openFile(item)}
className="border-b border-subtle last:border-0 hover:bg-inset cursor-pointer transition-colors"
>
<td className="px-4 py-3 text-sm font-mono text-content-secondary">
<span className="inline-flex items-center gap-2">
<FileText size={13} className="text-content-tertiary flex-shrink-0" />
{item.filename}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${badge.cls}`}>{badge.label}</span>
</td>
<td className="px-4 py-3 text-sm text-content-tertiary">{formatSize(item.size)}</td>
<td className="px-4 py-3 text-sm text-content-tertiary">{item.updated_at}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm text-content-tertiary">
<span>
{page} / {totalPages}
</span>
<div className="flex gap-2">
<PageBtn icon={ChevronLeft} label={t('memory_prev')} disabled={page <= 1} onClick={() => loadList(category, page - 1)} />
<PageBtn
icon={ChevronRight}
label={t('memory_next')}
disabled={page >= totalPages}
onClick={() => loadList(category, page + 1)}
iconRight
/>
</div>
</div>
)}
</>
)}
</div>
</div>
)}
</div>
)
}
const Th: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<th className="text-left px-4 py-2.5 text-xs font-semibold uppercase tracking-wider text-content-tertiary">{children}</th>
)
const TabBtn: React.FC<{ icon: LucideIcon; label: string; active: boolean; onClick: () => void }> = ({
icon: Icon,
label,
active,
onClick,
}) => (
<button
onClick={onClick}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[6px] text-sm font-medium cursor-pointer transition-colors ${
active ? 'bg-surface text-content shadow-sm' : 'text-content-tertiary hover:text-content-secondary'
}`}
>
<Icon size={14} />
{label}
</button>
)
const PageBtn: React.FC<{
icon: LucideIcon
label: string
disabled: boolean
onClick: () => void
iconRight?: boolean
}> = ({ icon: Icon, label, disabled, onClick, iconRight }) => (
<button
onClick={onClick}
disabled={disabled}
className="inline-flex items-center gap-1 px-3 py-1 rounded-btn border border-strong text-xs text-content-secondary hover:bg-inset disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
{!iconRight && <Icon size={13} />}
{label}
{iconRight && <Icon size={13} />}
</button>
)
export default MemoryPage

View File

@@ -0,0 +1,20 @@
import React from 'react'
import { Construction } from 'lucide-react'
interface PlaceholderPageProps {
title: string
hint?: string
}
/** Temporary page for routes that will be implemented in later phases. */
const PlaceholderPage: React.FC<PlaceholderPageProps> = ({ title, hint }) => (
<div className="flex flex-col items-center justify-center h-full text-center px-8">
<div className="w-14 h-14 rounded-2xl bg-surface-2 flex items-center justify-center mb-4">
<Construction size={26} className="text-content-tertiary" />
</div>
<h2 className="text-lg font-semibold text-content mb-1">{title}</h2>
<p className="text-sm text-content-tertiary max-w-sm">{hint || 'Coming soon in this iteration.'}</p>
</div>
)
export default PlaceholderPage

View File

@@ -0,0 +1,60 @@
import React, { useState } from 'react'
import { useLocation } from 'react-router-dom'
import { t } from '../i18n'
import BasicSettings from './settings/BasicSettings'
import ModelsTab from './settings/ModelsTab'
interface SettingsPageProps {
baseUrl: string
onLangChange?: () => void
}
type Tab = 'basic' | 'models'
const SettingsPage: React.FC<SettingsPageProps> = ({ baseUrl, onLangChange }) => {
const location = useLocation()
// Allow deep-linking to the models tab via /settings?tab=models.
const initial: Tab = new URLSearchParams(location.search).get('tab') === 'models' ? 'models' : 'basic'
const [tab, setTab] = useState<Tab>(initial)
const tabs: { key: Tab; label: string }[] = [
{ key: 'basic', label: t('settings_tab_basic') },
{ key: 'models', label: t('settings_tab_models') },
]
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto">
<div className="mb-5">
<h2 className="text-xl font-bold text-content">{t('menu_settings')}</h2>
<p className="text-sm text-content-tertiary mt-1">{t('config_desc')}</p>
</div>
{/* Tab bar */}
<div className="flex items-center gap-1 mb-6 border-b border-default">
{tabs.map((tb) => (
<button
key={tb.key}
onClick={() => setTab(tb.key)}
className={`relative px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors -mb-px border-b-2 ${
tab === tb.key
? 'text-accent border-accent'
: 'text-content-tertiary border-transparent hover:text-content-secondary'
}`}
>
{tb.label}
</button>
))}
</div>
{tab === 'basic' ? (
<BasicSettings baseUrl={baseUrl} onLangChange={onLangChange} onOpenModels={() => setTab('models')} />
) : (
<ModelsTab baseUrl={baseUrl} />
)}
</div>
</div>
)
}
export default SettingsPage

View File

@@ -0,0 +1,142 @@
import React, { useEffect, useState } from 'react'
import { Loader2, Wrench, Zap, Puzzle } from 'lucide-react'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { ToolInfo, SkillInfo } from '../types'
import { Toggle } from './settings/primitives'
interface SkillsPageProps {
baseUrl: string
}
const SKILL_HUB_URL = 'https://skills.cowagent.ai/'
const SkillsPage: React.FC<SkillsPageProps> = ({ baseUrl }) => {
const [tools, setTools] = useState<ToolInfo[]>([])
const [skills, setSkills] = useState<SkillInfo[]>([])
const [loading, setLoading] = useState(true)
const loadData = async () => {
try {
setLoading(true)
const [toolsData, skillsData] = await Promise.all([apiClient.getTools(), apiClient.getSkills()])
setTools(toolsData || [])
setSkills(skillsData || [])
} catch (err) {
console.error('Failed to load skills:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
void loadData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseUrl])
const toggle = async (skill: SkillInfo, enabled: boolean) => {
// Optimistic flip; revert on failure.
setSkills((prev) => prev.map((s) => (s.name === skill.name ? { ...s, enabled } : s)))
try {
const res = await apiClient.toggleSkill(skill.name, enabled ? 'open' : 'close')
if (res.status !== 'success') throw new Error()
} catch {
setSkills((prev) => prev.map((s) => (s.name === skill.name ? { ...s, enabled: !enabled } : s)))
}
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between px-6 pt-5 pb-3 flex-shrink-0">
<div>
<h2 className="text-xl font-bold text-content">{t('skills_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{t('skills_desc')}</p>
</div>
<a
href={SKILL_HUB_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs font-medium text-accent bg-accent-soft hover:bg-accent-soft transition-colors"
>
<Puzzle size={12} />
{t('skills_hub_btn')}
</a>
</div>
<div className="flex-1 overflow-y-auto border-t border-default">
<div className="max-w-4xl mx-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center py-20 text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('skills_loading')}
</div>
) : (
<div className="space-y-8">
<Section title={t('tools_section_title')} count={tools.length}>
{tools.length === 0 ? (
<Empty text={t('tools_empty')} />
) : (
<div className="grid gap-3 sm:grid-cols-2">
{tools.map((tool) => (
<div key={tool.name} className="rounded-card border border-default bg-surface p-4">
<div className="flex items-center gap-2 mb-1.5">
<Wrench size={13} className="text-content-tertiary flex-shrink-0" />
<span className="text-sm font-medium text-content font-mono truncate">{tool.name}</span>
</div>
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">{tool.description || '--'}</p>
</div>
))}
</div>
)}
</Section>
<Section title={t('skills_section_title')} count={skills.length}>
{skills.length === 0 ? (
<Empty text={t('skills_empty')} />
) : (
<div className="grid gap-3 sm:grid-cols-2">
{skills.map((skill) => (
<div key={skill.name} className="rounded-card border border-default bg-surface p-4 flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-inset flex items-center justify-center flex-shrink-0">
<Zap size={15} className={skill.enabled ? 'text-accent' : 'text-content-tertiary'} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-content truncate flex-1">
{skill.display_name || skill.name}
</span>
<Toggle checked={skill.enabled} onChange={(v) => toggle(skill, v)} />
</div>
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">{skill.description || '--'}</p>
</div>
</div>
))}
</div>
)}
</Section>
</div>
)}
</div>
</div>
</div>
)
}
const Section: React.FC<{ title: string; count: number; children: React.ReactNode }> = ({ title, count, children }) => (
<div>
<div className="flex items-center gap-2 mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-content-tertiary">{title}</span>
{count > 0 && (
<span className="px-1.5 py-0.5 rounded-full text-xs bg-inset text-content-tertiary min-w-[20px] text-center">{count}</span>
)}
</div>
{children}
</div>
)
const Empty: React.FC<{ text: string }> = ({ text }) => (
<p className="text-sm text-content-tertiary py-2">{text}</p>
)
export default SkillsPage

View File

@@ -0,0 +1,314 @@
import React, { useEffect, useState } from 'react'
import { Loader2, Clock, CalendarClock } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { SchedulerTask, TaskSchedule, TaskAction } from '../types'
import { Modal, Btn, Toggle, TextInput, Dropdown } from './settings/primitives'
interface TasksPageProps {
baseUrl: string
}
// Human-readable schedule summary, mirroring the web console.
const scheduleSummary = (s: TaskSchedule): string => {
if (s.type === 'cron') return s.expression || 'cron'
if (s.type === 'interval') {
const sec = s.seconds || 0
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
const r = sec % 60
const parts: string[] = []
if (h) parts.push(`${h}h`)
if (m) parts.push(`${m}m`)
if (r || parts.length === 0) parts.push(`${r}s`)
return parts.join(' ')
}
return s.type || 'once'
}
const formatNextRun = (iso?: string): string => {
if (!iso) return '--'
const d = new Date(iso)
return isNaN(d.getTime()) ? '--' : d.toLocaleString()
}
const TasksPage: React.FC<TasksPageProps> = ({ baseUrl }) => {
const navigate = useNavigate()
const [tasks, setTasks] = useState<SchedulerTask[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<SchedulerTask | null>(null)
const loadTasks = async () => {
try {
setLoading(true)
const data = await apiClient.getSchedulerTasks()
setTasks(data || [])
} catch (err) {
console.error('Failed to load tasks:', err)
setTasks([])
} finally {
setLoading(false)
}
}
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
void loadTasks()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseUrl])
const toggle = async (task: SchedulerTask, enabled: boolean) => {
// Optimistic flip; revert on failure.
setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, enabled } : x)))
try {
await apiClient.toggleTask(task.id, enabled)
} catch {
setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, enabled: !enabled } : x)))
}
}
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="px-6 pt-5 pb-3 flex-shrink-0">
<h2 className="text-xl font-bold text-content">{t('tasks_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{t('tasks_desc')}</p>
</div>
<div className="flex-1 overflow-y-auto border-t border-default">
<div className="max-w-3xl mx-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center py-20 text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('tasks_loading')}
</div>
) : tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<CalendarClock size={32} className="mb-3 text-content-tertiary opacity-60" />
<p className="text-content font-medium mb-1">{t('tasks_empty')}</p>
<p className="text-sm text-content-tertiary max-w-sm mb-5">{t('tasks_empty_guide')}</p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
>
{t('tasks_go_chat')}
</button>
</div>
) : (
<div className="grid gap-3">
{tasks.map((task) => {
const content = task.action?.content || task.action?.task_description || ''
return (
<div
key={task.id}
onClick={() => setEditing(task)}
className={`rounded-card border border-default bg-surface p-4 cursor-pointer hover:border-strong transition-colors ${
task.enabled ? '' : 'opacity-60'
}`}
>
<div className="flex items-center gap-2 mb-2">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${task.enabled ? 'bg-accent' : 'bg-content-tertiary'}`} />
<span className="font-medium text-sm text-content truncate">{task.name || task.id}</span>
<div className="flex-1" />
<span className="text-xs font-mono text-content-tertiary">{scheduleSummary(task.schedule)}</span>
</div>
{content && <p className="text-xs text-content-secondary mb-2 line-clamp-2">{content}</p>}
<div
className="flex items-center gap-2 text-xs text-content-tertiary"
onClick={(e) => e.stopPropagation()}
>
<Clock size={12} />
<span>
{t('tasks_next_run')}: {formatNextRun(task.next_run_at)}
</span>
<div className="flex-1" />
<Toggle checked={task.enabled} onChange={(v) => toggle(task, v)} />
</div>
</div>
)
})}
</div>
)}
</div>
</div>
{editing && (
<TaskEditModal
task={editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null)
void loadTasks()
}}
onDeleted={() => {
setEditing(null)
void loadTasks()
}}
/>
)}
</div>
)
}
const TaskEditModal: React.FC<{
task: SchedulerTask
onClose: () => void
onSaved: () => void
onDeleted: () => void
}> = ({ task, onClose, onSaved, onDeleted }) => {
const [name, setName] = useState(task.name || '')
const [enabled, setEnabled] = useState(task.enabled)
const [schedType, setSchedType] = useState<TaskSchedule['type']>(task.schedule.type || 'cron')
const [cron, setCron] = useState(task.schedule.expression || '')
const [interval, setIntervalVal] = useState(task.schedule.seconds ? String(task.schedule.seconds) : '')
const [runAt, setRunAt] = useState(task.schedule.run_at ? task.schedule.run_at.slice(0, 16) : '')
const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const buildSchedule = (): TaskSchedule => {
if (schedType === 'cron') return { type: 'cron', expression: cron.trim() }
if (schedType === 'interval') return { type: 'interval', seconds: Number(interval) || 0 }
return { type: 'once', run_at: runAt }
}
const buildAction = (): TaskAction => {
const a: TaskAction = { ...task.action, type: actionType }
if (actionType === 'send_message') a.content = content
else a.task_description = content
return a
}
const save = async () => {
setSaving(true)
setError('')
try {
await apiClient.updateTask(task.id, {
name: name.trim(),
enabled,
schedule: buildSchedule(),
action: buildAction(),
})
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : t('task_save_error'))
} finally {
setSaving(false)
}
}
const del = async () => {
if (!window.confirm(t('task_delete_confirm'))) return
setSaving(true)
try {
await apiClient.deleteTask(task.id)
onDeleted()
} catch {
setSaving(false)
}
}
return (
<Modal
open
title={t('task_edit_title')}
onClose={onClose}
footer={
<>
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
{t('task_delete')}
</Btn>
<Btn variant="ghost" onClick={onClose} disabled={saving}>
{t('task_cancel')}
</Btn>
<Btn variant="primary" onClick={save} disabled={saving}>
{t('task_save')}
</Btn>
</>
}
>
<Field label={t('task_name')}>
<TextInput value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<div className="flex items-center justify-between">
<span className="text-sm text-content-secondary">{t('task_enabled')}</span>
<Toggle checked={enabled} onChange={setEnabled} />
</div>
<Field label={t('task_schedule_type')}>
<Dropdown
value={schedType}
onChange={(v) => setSchedType(v as TaskSchedule['type'])}
options={[
{ value: 'cron', label: t('task_type_cron') },
{ value: 'interval', label: t('task_type_interval') },
{ value: 'once', label: t('task_type_once') },
]}
/>
</Field>
{schedType === 'cron' && (
<Field label={t('task_cron_expr')} hint={t('task_cron_hint')}>
<TextInput value={cron} onChange={(e) => setCron(e.target.value)} placeholder="0 9 * * *" className="font-mono" />
</Field>
)}
{schedType === 'interval' && (
<Field label={t('task_interval_seconds')}>
<TextInput type="number" value={interval} onChange={(e) => setIntervalVal(e.target.value)} />
</Field>
)}
{schedType === 'once' && (
<Field label={t('task_once_time')}>
<TextInput type="datetime-local" value={runAt} onChange={(e) => setRunAt(e.target.value)} />
</Field>
)}
<Field label={t('task_action_type')}>
<Dropdown
value={actionType}
onChange={(v) => setActionType(v as TaskAction['type'])}
options={[
{ value: 'send_message', label: t('task_action_send') },
{ value: 'agent_task', label: t('task_action_agent') },
]}
/>
</Field>
<Field label={actionType === 'send_message' ? t('task_message_content') : t('task_task_description')}>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors resize-none"
/>
</Field>
{/* Channel and receiver are channel-bound and read-only after creation. */}
{(task.action.channel_type || task.action.receiver) && (
<div className="grid grid-cols-2 gap-3">
<Field label={t('task_channel')}>
<TextInput value={task.action.channel_type || 'web'} disabled />
</Field>
<Field label={t('task_receiver')}>
<TextInput value={task.action.receiver_name || task.action.receiver || '--'} disabled />
</Field>
</div>
)}
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
{error && <p className="text-xs text-danger">{error}</p>}
</Modal>
)
}
const Field: React.FC<{ label: string; hint?: string; children: React.ReactNode }> = ({ label, hint, children }) => (
<div>
<label className="block text-sm text-content-secondary mb-1.5">{label}</label>
{children}
{hint && <p className="text-xs text-content-tertiary mt-1">{hint}</p>}
</div>
)
export default TasksPage

View File

@@ -0,0 +1,331 @@
import React, { useState, useEffect } from 'react'
import { Cpu, Bot, ShieldCheck, Languages, Eye, EyeOff, ArrowRight, Loader2 } from 'lucide-react'
import { t, getLang, setLang, localizedLabel, type Lang } from '../../i18n'
import apiClient from '../../api/client'
import type { ConfigData, ProviderMeta } from '../../types'
import { Card, Field, Dropdown, Toggle, TextInput, SaveRow, MASK_RE } from './primitives'
interface BasicSettingsProps {
baseUrl: string
onLangChange?: () => void
onOpenModels?: () => void
}
const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, onOpenModels }) => {
const [config, setConfig] = useState<ConfigData | null>(null)
const [loading, setLoading] = useState(true)
// model card — credentials (key/base) now live in the Models tab
const [provider, setProvider] = useState('')
const [model, setModel] = useState('')
const [customModel, setCustomModel] = useState('')
const [showCustom, setShowCustom] = useState(false)
const [modelStatus, setModelStatus] = useState('')
// agent card
const [maxTokens, setMaxTokens] = useState(100000)
const [maxTurns, setMaxTurns] = useState(20)
const [maxSteps, setMaxSteps] = useState(20)
const [thinking, setThinking] = useState(false)
const [evolution, setEvolution] = useState(false)
const [agentStatus, setAgentStatus] = useState('')
// security card
const [password, setPassword] = useState('')
const [pwDirty, setPwDirty] = useState(false)
const [pwVisible, setPwVisible] = useState(false)
const [pwStatus, setPwStatus] = useState('')
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
loadConfig()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseUrl])
const providerMeta = (id: string): ProviderMeta | undefined => config?.providers?.[id] as ProviderMeta | undefined
const loadConfig = async () => {
try {
setLoading(true)
const data = await apiClient.getConfig()
setConfig(data)
setModel(data.model || '')
setMaxTokens(data.agent_max_context_tokens ?? 100000)
setMaxTurns(data.agent_max_context_turns ?? 20)
setMaxSteps(data.agent_max_steps ?? 20)
setThinking(!!data.enable_thinking)
setEvolution(!!data.self_evolution_enabled)
setPassword(data.web_password_masked || '')
setPwDirty(false)
const ids = data.providers ? Object.keys(data.providers) : []
const current = data.use_linkai ? 'linkai' : data.bot_type || ids[0] || ''
setProvider(current)
const meta = data.providers?.[current] as ProviderMeta | undefined
const presets = meta?.models || []
if (data.model && presets.length && !presets.includes(data.model)) {
setShowCustom(true)
setCustomModel(data.model)
}
} catch (err) {
console.error('Failed to load config:', err)
} finally {
setLoading(false)
}
}
const handleProviderChange = (id: string) => {
setProvider(id)
setShowCustom(false)
setCustomModel('')
if (config) {
const meta = config.providers?.[id] as ProviderMeta | undefined
const models = meta?.models || []
setModel(models[0] || '')
}
}
const handleModelChange = (val: string) => {
if (val === '__custom__') {
setShowCustom(true)
setModel('')
} else {
setShowCustom(false)
setModel(val)
setCustomModel('')
}
}
const saveModelConfig = async () => {
const finalModel = showCustom ? customModel.trim() : model
const isLinkai = provider === 'linkai'
try {
await apiClient.updateConfig({
model: finalModel,
use_linkai: isLinkai,
bot_type: isLinkai ? '' : provider,
})
setModelStatus(t('config_saved'))
const fresh = await apiClient.getConfig()
setConfig(fresh)
} catch {
setModelStatus(t('config_save_error'))
}
setTimeout(() => setModelStatus(''), 2000)
}
const saveAgentConfig = async () => {
try {
await apiClient.updateConfig({
agent_max_context_tokens: maxTokens,
agent_max_context_turns: maxTurns,
agent_max_steps: maxSteps,
enable_thinking: thinking,
self_evolution_enabled: evolution,
})
setAgentStatus(t('config_saved'))
} catch {
setAgentStatus(t('config_save_error'))
}
setTimeout(() => setAgentStatus(''), 2000)
}
const savePassword = async () => {
if (!pwDirty || MASK_RE.test(password)) return
try {
await apiClient.updateConfig({ web_password: password })
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
setPwDirty(false)
} catch {
setPwStatus(t('config_save_error'))
}
setTimeout(() => setPwStatus(''), 3000)
}
const changeLanguage = async (lang: Lang) => {
setLang(lang)
onLangChange?.()
try {
await apiClient.updateConfig({ cow_lang: lang })
} catch {
/* non-blocking */
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('skills_loading')}
</div>
)
}
// A provider counts as configured when its key field holds a value.
// Custom providers (no key field) carry their own credential, so treat as configured.
const isConfigured = (id: string): boolean => {
const meta = providerMeta(id)
const f = meta?.api_key_field
if (!f) return true
return !!config?.api_keys?.[f]
}
const providerIds = config?.providers ? Object.keys(config.providers) : []
const providerOptions = providerIds.map((id) => ({
value: id,
label: localizedLabel(providerMeta(id)?.label) || id,
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
}))
const currentMeta = providerMeta(provider)
const currentUnconfigured = !!provider && !isConfigured(provider)
const modelOptions = [
...(currentMeta?.models || []).map((m) => ({ value: m, label: m })),
{ value: '__custom__', label: t('config_custom_option') },
]
return (
<div className="grid gap-5">
{/* Model — provider/model selection only; credentials live in Models tab */}
<Card icon={<Cpu size={16} />} title={t('config_model')}>
<div className="space-y-4">
<Field label={t('config_provider')}>
<Dropdown value={provider} options={providerOptions} onChange={handleProviderChange} />
</Field>
<Field label={t('config_model_name')}>
<Dropdown value={showCustom ? '__custom__' : model} options={modelOptions} onChange={handleModelChange} />
{showCustom && (
<TextInput
className="mt-2 font-mono"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder={t('config_custom_model_hint')}
/>
)}
</Field>
{/* Guide users to the Models tab for API key / base config.
When the selected provider has no credentials, surface a warning. */}
{onOpenModels && (
<button
onClick={onOpenModels}
className={`w-full flex items-center justify-between gap-2 rounded-btn border px-3 py-2.5 cursor-pointer transition-colors text-left ${
currentUnconfigured
? 'border-danger-border bg-danger-soft hover:border-danger'
: 'border-default bg-inset hover:border-accent'
}`}
>
<span className={`text-xs ${currentUnconfigured ? 'text-danger' : 'text-content-tertiary'}`}>
{currentUnconfigured ? t('config_provider_unconfigured_hint') : t('config_credentials_link')}
</span>
<span
className={`flex-shrink-0 inline-flex items-center gap-1 text-xs ${
currentUnconfigured ? 'text-danger font-medium' : 'text-accent'
}`}
>
{t('config_goto_models')}
<ArrowRight size={13} />
</span>
</button>
)}
<SaveRow status={modelStatus} onSave={saveModelConfig} />
</div>
</Card>
{/* Agent */}
<Card icon={<Bot size={16} />} title={t('config_agent')}>
<div className="space-y-4">
<Field label={t('config_max_tokens')} hint={t('config_max_tokens_hint')}>
<TextInput
type="number"
className="font-mono"
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 0)}
/>
</Field>
<Field label={t('config_max_turns')} hint={t('config_max_turns_hint')}>
<TextInput
type="number"
className="font-mono"
value={maxTurns}
onChange={(e) => setMaxTurns(parseInt(e.target.value) || 0)}
/>
</Field>
<Field label={t('config_max_steps')} hint={t('config_max_steps_hint')}>
<TextInput
type="number"
className="font-mono"
value={maxSteps}
onChange={(e) => setMaxSteps(parseInt(e.target.value) || 0)}
/>
</Field>
<div className="flex items-center justify-between py-1">
<div>
<div className="text-sm font-medium text-content">{t('config_thinking')}</div>
<div className="text-xs text-content-tertiary mt-0.5">{t('config_thinking_hint')}</div>
</div>
<Toggle checked={thinking} onChange={setThinking} />
</div>
<div className="flex items-center justify-between py-1">
<div>
<div className="text-sm font-medium text-content">{t('config_evolution')}</div>
<div className="text-xs text-content-tertiary mt-0.5">{t('config_evolution_hint')}</div>
</div>
<Toggle checked={evolution} onChange={setEvolution} />
</div>
<SaveRow status={agentStatus} onSave={saveAgentConfig} />
</div>
</Card>
{/* Security */}
<Card icon={<ShieldCheck size={16} />} title={t('config_security')}>
<div className="space-y-4">
<Field label={t('config_password')} hint={t('config_password_hint')}>
<div className="relative">
<TextInput
type={pwVisible ? 'text' : 'password'}
className="pr-10"
value={password}
placeholder={t('config_password_placeholder')}
onFocus={() => {
if (!pwDirty && MASK_RE.test(password)) setPassword('')
}}
onBlur={() => {
if (!pwDirty) setPassword(config?.web_password_masked || '')
}}
onChange={(e) => {
setPassword(e.target.value)
setPwDirty(true)
}}
/>
<button
type="button"
onClick={() => setPwVisible((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-tertiary hover:text-content-secondary cursor-pointer p-1"
>
{pwVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</Field>
<SaveRow status={pwStatus} onSave={savePassword} />
</div>
</Card>
{/* Language */}
<Card icon={<Languages size={16} />} title={t('config_language')}>
<Field label={t('config_language')} hint={t('config_language_hint')}>
<Dropdown
value={getLang()}
options={[
{ value: 'zh', label: '简体中文' },
{ value: 'en', label: 'English' },
]}
onChange={(v) => changeLanguage(v as Lang)}
/>
</Field>
</Card>
</div>
)
}
export default BasicSettings

View File

@@ -0,0 +1,159 @@
import React, { useMemo, useState } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import { t } from '../../i18n'
import type { CapabilityState, ModelsData } from '../../types'
import { Card, Field, Dropdown, TextInput, type DropdownOption } from './primitives'
import { resolveModels, providerLabel, CUSTOM_OPTION } from './modelsHelpers'
// Generic provider+model capability card used by chat/vision/asr/embedding/image.
// tts (voice) and search have bespoke cards.
export interface CapabilityCardProps {
icon: LucideIcon
title: string
subtitle?: string
capKey: string
state: CapabilityState
data: ModelsData | null
// whether picking "no provider" (auto / disabled) is allowed
allowAuto?: boolean
autoLabel?: string
// whether to allow a free-form custom model entry
allowCustomModel?: boolean
busy?: boolean
status?: string
onSave: (providerId: string, model: string) => void
children?: React.ReactNode
}
const CapabilityCard: React.FC<CapabilityCardProps> = ({
icon: Icon,
title,
subtitle,
state,
data,
allowAuto,
autoLabel,
allowCustomModel,
busy,
status,
onSave,
children,
}) => {
const [provider, setProvider] = useState(state.current_provider || '')
const [model, setModel] = useState(state.current_model || '')
const [customModel, setCustomModel] = useState('')
const [showCustom, setShowCustom] = useState(false)
// A provider is configured when it has credentials (custom providers always
// carry their own). Unconfigured ones stay selectable but are flagged so the
// user is guided to set up the API key.
const isConfigured = (id: string): boolean => {
const p = data?.providers?.find((x) => x.id === id)
if (!p) return true
return p.configured || (p.is_custom && !!p.custom_name)
}
const providerOptions: DropdownOption[] = useMemo(() => {
const opts = (state.providers || []).map((id) => ({
value: id,
label: providerLabel(data, id),
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
}))
if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
return opts
}, [state.providers, data, allowAuto, autoLabel])
const currentUnconfigured = !!provider && !isConfigured(provider)
const modelOptions: DropdownOption[] = useMemo(() => {
const list = resolveModels(data, provider, state.provider_models).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
// Keep the currently-saved model selectable even if it's not in the preset list.
if (model && !showCustom && !list.some((o) => o.value === model)) {
list.unshift({ value: model, label: model, hint: undefined })
}
if (allowCustomModel) list.push({ value: CUSTOM_OPTION, label: t('config_custom_option'), hint: undefined })
return list
}, [data, state.provider_models, provider, allowCustomModel, model, showCustom])
const handleProvider = (id: string) => {
setProvider(id)
setShowCustom(false)
setCustomModel('')
const first = resolveModels(data, id, state.provider_models)[0]
setModel(first?.value || '')
}
const handleModel = (val: string) => {
if (val === CUSTOM_OPTION) {
setShowCustom(true)
setModel('')
} else {
setShowCustom(false)
setModel(val)
setCustomModel('')
}
}
const finalModel = showCustom ? customModel.trim() : model
const isAuto = allowAuto && !provider
return (
<Card icon={<Icon size={16} />} title={title} subtitle={subtitle}>
<div className="space-y-4">
<Field label={t('models_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('models_select_provider')}
onChange={handleProvider}
/>
{/* The provider's API key is configured in the vendor cards above on
this same tab, so warn instead of linking elsewhere. */}
{currentUnconfigured && (
<p className="text-xs text-danger mt-1.5">{t('config_provider_unconfigured_hint')}</p>
)}
</Field>
{!isAuto && (
<Field label={t('models_model')}>
<Dropdown
value={showCustom ? CUSTOM_OPTION : model}
options={modelOptions}
placeholder={t('models_select_model')}
onChange={handleModel}
/>
{showCustom && (
<TextInput
className="mt-2 font-mono"
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
placeholder={t('config_custom_model_hint')}
/>
)}
</Field>
)}
{children}
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>
{status}
</span>
<button
disabled={busy}
onClick={() => onSave(provider, finalModel)}
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('config_save')}
</button>
</div>
</div>
</Card>
)
}
export default CapabilityCard

View File

@@ -0,0 +1,833 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
MessageSquare,
Eye,
Image as ImageIcon,
Mic,
Volume2,
Database,
Search as SearchIcon,
Plus,
Check,
Loader2,
Pencil,
Eye as EyeIcon,
EyeOff,
} from 'lucide-react'
import { t, localizedLabel } from '../../i18n'
import apiClient from '../../api/client'
import type { CapabilityState, ModelsData, ModelProvider, SearchCapabilityState } from '../../types'
import { Card, Field, Dropdown, TextInput, Modal, Btn, MASK_RE } from './primitives'
import CapabilityCard from './CapabilityCard'
import { normEntries, providerLabel, resolveVoices, CUSTOM_OPTION } from './modelsHelpers'
interface ModelsTabProps {
baseUrl: string
}
const REPLY_MODES: { value: 'off' | 'voice_if_voice' | 'always'; key: string }[] = [
{ value: 'off', key: 'models_tts_mode_off' },
{ value: 'voice_if_voice', key: 'models_tts_mode_if_voice' },
{ value: 'always', key: 'models_tts_mode_always' },
]
const ModelsTab: React.FC<ModelsTabProps> = ({ baseUrl }) => {
const [data, setData] = useState<ModelsData | null>(null)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string>('') // capability key currently saving
const [statusMap, setStatusMap] = useState<Record<string, string>>({})
const load = useCallback(async () => {
try {
const fresh = await apiClient.getModels()
setData(fresh)
} catch (e) {
console.error('Failed to load models:', e)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
load()
}, [baseUrl, load])
const flash = (key: string, msg: string) => {
setStatusMap((m) => ({ ...m, [key]: msg }))
setTimeout(() => setStatusMap((m) => ({ ...m, [key]: '' })), 2000)
}
// Run a models action, then refresh and flash a status for the given key.
const run = async (key: string, action: Parameters<typeof apiClient.modelsAction>[0]) => {
setBusy(key)
try {
const res = await apiClient.modelsAction(action)
if (res.status === 'success') {
await load()
flash(key, t('config_saved'))
} else {
flash(key, (res.message as string) || t('config_save_error'))
}
} catch {
flash(key, t('config_save_error'))
} finally {
setBusy('')
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('skills_loading')}
</div>
)
}
if (!data) {
return <div className="text-center py-20 text-content-tertiary">{t('config_save_error')}</div>
}
const caps = data.capabilities
return (
<div className="grid gap-5">
<VendorSection data={data} onChanged={load} statusMap={statusMap} flash={flash} />
{/* Chat */}
<CapabilityCard
icon={MessageSquare}
title={t('models_cap_chat')}
subtitle={t('models_cap_chat_sub')}
capKey="chat"
state={caps.chat}
data={data}
allowCustomModel
busy={busy === 'chat'}
status={statusMap.chat}
onSave={(p, m) => run('chat', { action: 'set_capability', capability: 'chat', provider_id: p, model: m })}
/>
{/* Vision */}
<CapabilityCard
icon={Eye}
title={t('models_cap_vision')}
subtitle={t('models_cap_vision_sub')}
capKey="vision"
state={caps.vision}
data={data}
allowAuto
autoLabel={t('models_auto')}
busy={busy === 'vision'}
status={statusMap.vision}
onSave={(p, m) => run('vision', { action: 'set_capability', capability: 'vision', provider_id: p, model: m })}
>
<FallbackHint state={caps.vision} data={data} />
</CapabilityCard>
{/* Image */}
<CapabilityCard
icon={ImageIcon}
title={t('models_cap_image')}
subtitle={t('models_cap_image_sub')}
capKey="image"
state={caps.image}
data={data}
allowAuto
autoLabel={t('models_auto')}
busy={busy === 'image'}
status={statusMap.image}
onSave={(p, m) => run('image', { action: 'set_capability', capability: 'image', provider_id: p, model: m })}
>
<FallbackHint state={caps.image} data={data} />
</CapabilityCard>
{/* ASR */}
<CapabilityCard
icon={Mic}
title={t('models_cap_asr')}
subtitle={t('models_cap_asr_sub')}
capKey="asr"
state={caps.asr}
data={data}
allowAuto
autoLabel={t('models_asr_auto')}
busy={busy === 'asr'}
status={statusMap.asr}
onSave={(p, m) => run('asr', { action: 'set_capability', capability: 'asr', provider_id: p, model: m })}
/>
{/* TTS — bespoke (voice + reply mode) */}
<TtsCard
state={caps.tts}
data={data}
busy={busy === 'tts'}
status={statusMap.tts}
onSaveVoice={(p, m, v) =>
run('tts', { action: 'set_capability', capability: 'tts', provider_id: p, model: m, voice: v })
}
onSaveMode={(mode) => run('tts_mode', { action: 'set_voice_reply_mode', mode })}
modeStatus={statusMap.tts_mode}
modeBusy={busy === 'tts_mode'}
/>
{/* Embedding */}
<EmbeddingCard
state={caps.embedding}
data={data}
busy={busy === 'embedding'}
status={statusMap.embedding}
onSave={(p, m) => run('embedding', { action: 'set_capability', capability: 'embedding', provider_id: p, model: m })}
/>
{/* Search — bespoke */}
<SearchCard
state={caps.search}
busy={busy === 'search'}
status={statusMap.search}
onSaveStrategy={(strategy, provider) =>
run('search', { action: 'set_capability', capability: 'search', strategy, provider })
}
onSaveBochaKey={(key) => run('search_key', { action: 'set_search_credential', api_key: key })}
keyStatus={statusMap.search_key}
keyBusy={busy === 'search_key'}
/>
</div>
)
}
// ============================================================
// Layer 1 — vendor credentials
// ============================================================
interface VendorSectionProps {
data: ModelsData
onChanged: () => Promise<void>
statusMap: Record<string, string>
flash: (key: string, msg: string) => void
}
const VendorSection: React.FC<VendorSectionProps> = ({ data, onChanged }) => {
// Edit an existing built-in vendor.
const [editing, setEditing] = useState<ModelProvider | null>(null)
// Add flow: open the vendor modal with a provider picker.
const [adding, setAdding] = useState(false)
// Custom provider modal: 'new' to create, or a provider to edit.
const [customEditing, setCustomEditing] = useState<ModelProvider | 'new' | null>(null)
const isCustomCard = (p: ModelProvider) => p.is_custom && !!p.custom_name
// Unified grid: configured built-ins + all custom provider cards (web parity).
const shown = data.providers.filter((p) => p.configured || isCustomCard(p))
return (
<Card icon={<Database size={16} />} title={t('models_vendors')} subtitle={t('models_vendors_sub')}>
{shown.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 rounded-btn border border-dashed border-default">
<p className="text-sm text-content-tertiary">{t('models_no_vendor')}</p>
<button
onClick={() => setAdding(true)}
className="mt-3 inline-flex items-center gap-1 px-3 py-1.5 rounded-btn text-xs font-medium bg-accent-soft text-accent hover:bg-accent-soft/70 cursor-pointer transition-colors"
>
<Plus size={12} /> {t('models_add_vendor')}
</button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{shown.map((p) =>
isCustomCard(p) ? (
<VendorChip key={p.id} provider={p} onClick={() => setCustomEditing(p)} />
) : (
<VendorChip key={p.id} provider={p} onClick={() => setEditing(p)} />
)
)}
<button
onClick={() => setAdding(true)}
className="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-btn border border-dashed border-default text-content-tertiary hover:border-accent hover:text-accent cursor-pointer transition-colors text-sm"
>
<Plus size={14} /> {t('models_add_vendor')}
</button>
</div>
)}
<VendorModal
provider={editing}
addMode={adding}
data={data}
onClose={() => {
setEditing(null)
setAdding(false)
}}
onPickCustom={() => {
setAdding(false)
setCustomEditing('new')
}}
onSaved={onChanged}
/>
<CustomProviderModal target={customEditing} onClose={() => setCustomEditing(null)} onSaved={onChanged} />
</Card>
)
}
const VendorChip: React.FC<{ provider: ModelProvider; onClick: () => void }> = ({ provider, onClick }) => (
<button
onClick={onClick}
className="group flex items-center gap-2.5 px-3 py-2.5 rounded-btn border border-default bg-inset hover:border-accent cursor-pointer transition-colors text-left"
>
<span className="flex-shrink-0 w-7 h-7 rounded-lg bg-surface-2 text-content-secondary flex items-center justify-center text-xs font-bold">
{(localizedLabel(provider.label) || provider.id || '?').slice(0, 1).toUpperCase()}
</span>
<span className="flex-1 min-w-0 text-sm font-medium text-content truncate">{localizedLabel(provider.label)}</span>
<Pencil size={12} className="flex-shrink-0 text-content-tertiary group-hover:text-accent transition-colors" />
</button>
)
const CUSTOM_PICK = '__custom_new__'
const VendorModal: React.FC<{
provider: ModelProvider | null
addMode: boolean
data: ModelsData
onClose: () => void
onPickCustom: () => void
onSaved: () => Promise<void>
}> = ({ provider, addMode, data, onClose, onPickCustom, onSaved }) => {
const open = !!provider || addMode
// In add-mode the user first picks a built-in provider; that selection
// becomes the effective provider whose key/base fields we edit.
const builtins = useMemo(() => data.providers.filter((p) => !(p.is_custom && p.custom_name)), [data.providers])
const firstUnconfigured = builtins.find((p) => !p.configured) || builtins[0]
const [pickId, setPickId] = useState('')
const effective: ModelProvider | undefined = provider || builtins.find((p) => p.id === pickId)
const [apiKey, setApiKey] = useState('')
const [keyDirty, setKeyDirty] = useState(false)
const [keyVisible, setKeyVisible] = useState(false)
const [apiBase, setApiBase] = useState('')
const [saving, setSaving] = useState(false)
// Load fields whenever the effective provider changes.
useEffect(() => {
if (!open) return
const init = provider || (addMode ? firstUnconfigured : undefined)
setPickId(provider ? provider.id : firstUnconfigured?.id || '')
setApiKey(init?.api_key_masked || '')
setApiBase(init?.api_base || '')
setKeyDirty(false)
setKeyVisible(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider, addMode, open])
if (!open) return null
const pickOptions = [
...builtins.map((p) => ({
value: p.id,
label: localizedLabel(p.label),
hint: p.configured ? t('models_configured') : undefined,
})),
{ value: CUSTOM_PICK, label: t('models_custom_vendor'), hint: t('models_add_custom_hint') },
]
const onPick = (val: string) => {
if (val === CUSTOM_PICK) {
onPickCustom()
return
}
setPickId(val)
const p = builtins.find((x) => x.id === val)
setApiKey(p?.api_key_masked || '')
setApiBase(p?.api_base || '')
setKeyDirty(false)
}
const hasBase = !!effective?.api_base_field
const save = async () => {
if (!effective) return
setSaving(true)
try {
const payload: { action: 'set_provider'; provider_id: string; api_key?: string; api_base?: string } = {
action: 'set_provider',
provider_id: effective.id,
}
if (keyDirty && apiKey && !MASK_RE.test(apiKey)) payload.api_key = apiKey
if (hasBase) payload.api_base = apiBase
await apiClient.modelsAction(payload)
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
const clear = async () => {
if (!effective || !confirm(t('models_clear_confirm'))) return
setSaving(true)
try {
await apiClient.modelsAction({ action: 'delete_provider', provider_id: effective.id })
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
title={addMode ? t('models_add_vendor') : localizedLabel(effective?.label)}
onClose={onClose}
footer={
<>
{!addMode && effective?.configured && (
<Btn variant="danger" onClick={clear} disabled={saving}>
{t('models_clear')}
</Btn>
)}
<Btn variant="ghost" onClick={onClose}>
{t('config_cancel')}
</Btn>
<Btn variant="primary" onClick={save} disabled={saving || !effective}>
{saving ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
</Btn>
</>
}
>
{addMode && (
<Field label={t('models_provider')}>
<Dropdown value={pickId} options={pickOptions} onChange={onPick} />
</Field>
)}
<Field label="API Key">
<div className="relative">
<TextInput
type={keyVisible ? 'text' : 'password'}
className="pr-10 font-mono"
value={apiKey}
placeholder="sk-..."
onFocus={() => {
if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('')
}}
onBlur={() => {
if (!keyDirty) setApiKey(effective?.api_key_masked || '')
}}
onChange={(e) => {
setApiKey(e.target.value)
setKeyDirty(true)
}}
/>
<button
type="button"
onClick={() => setKeyVisible((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-tertiary hover:text-content-secondary cursor-pointer p-1"
>
{keyVisible ? <EyeOff size={14} /> : <EyeIcon size={14} />}
</button>
</div>
</Field>
{hasBase && (
<Field label="API Base">
<TextInput
className="font-mono"
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder={effective?.api_base_placeholder || 'https://...'}
/>
</Field>
)}
</Modal>
)
}
const CustomProviderModal: React.FC<{
target: ModelProvider | 'new' | null
onClose: () => void
onSaved: () => Promise<void>
}> = ({ target, onClose, onSaved }) => {
const editing = target && target !== 'new' ? target : null
const [name, setName] = useState('')
const [apiBase, setApiBase] = useState('')
const [apiKey, setApiKey] = useState('')
const [keyDirty, setKeyDirty] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!target) return
if (editing) {
setName(editing.custom_name || localizedLabel(editing.label))
setApiBase(editing.api_base || '')
setApiKey(editing.api_key_masked || '')
} else {
setName('')
setApiBase('')
setApiKey('')
}
setKeyDirty(false)
}, [target, editing])
if (!target) return null
const save = async () => {
if (!name.trim()) return
setSaving(true)
try {
const payload: {
action: 'set_custom_provider'
name: string
id?: string
api_base: string
api_key?: string
} = {
action: 'set_custom_provider',
name: name.trim(),
api_base: apiBase.trim(),
}
if (editing) payload.id = editing.custom_id
if (keyDirty && apiKey && !MASK_RE.test(apiKey)) payload.api_key = apiKey
await apiClient.modelsAction(payload)
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
const remove = async () => {
if (!editing || !confirm(t('models_delete_confirm'))) return
setSaving(true)
try {
await apiClient.modelsAction({ action: 'delete_custom_provider', id: editing.custom_id || '' })
await onSaved()
onClose()
} finally {
setSaving(false)
}
}
return (
<Modal
open={!!target}
title={editing ? t('models_edit_custom') : t('models_add_custom')}
onClose={onClose}
footer={
<>
{editing && (
<Btn variant="danger" onClick={remove} disabled={saving}>
{t('models_delete')}
</Btn>
)}
<Btn variant="ghost" onClick={onClose}>
{t('config_cancel')}
</Btn>
<Btn variant="primary" onClick={save} disabled={saving || !name.trim() || (!editing && !apiBase.trim())}>
{saving ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
</Btn>
</>
}
>
<Field label={t('models_custom_name')}>
<TextInput value={name} onChange={(e) => setName(e.target.value)} placeholder="My Provider" />
</Field>
<Field label="API Base" hint={t('models_custom_base_hint')}>
<TextInput
className="font-mono"
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder="https://...../v1"
/>
</Field>
<Field label="API Key">
<TextInput
type="text"
className="font-mono"
value={apiKey}
placeholder="sk-..."
onFocus={() => {
if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('')
}}
onChange={(e) => {
setApiKey(e.target.value)
setKeyDirty(true)
}}
/>
</Field>
</Modal>
)
}
// ============================================================
// Bespoke capability cards
// ============================================================
const FallbackHint: React.FC<{ state: CapabilityState; data: ModelsData }> = ({ state, data }) => {
if (!state.fallback_provider && !state.fallback_model) return null
const label = providerLabel(data, state.fallback_provider || '')
return (
<p className="text-xs text-content-tertiary">
{t('models_fallback')}: {label} {state.fallback_model ? `· ${state.fallback_model}` : ''}
</p>
)
}
const TtsCard: React.FC<{
state: CapabilityState
data: ModelsData
busy: boolean
status?: string
onSaveVoice: (provider: string, model: string, voice: string) => void
onSaveMode: (mode: 'off' | 'voice_if_voice' | 'always') => void
modeStatus?: string
modeBusy: boolean
}> = ({ state, data, busy, status, onSaveVoice, onSaveMode, modeStatus, modeBusy }) => {
const [provider, setProvider] = useState(state.current_provider || '')
const [model, setModel] = useState(state.current_model || '')
const [voice, setVoice] = useState(state.current_voice || '')
const [mode, setMode] = useState<'off' | 'voice_if_voice' | 'always'>(state.reply_mode || 'off')
const providerOptions = (state.providers || []).map((id) => ({ value: id, label: providerLabel(data, id) }))
const modelOptions = normEntries(state.provider_models?.[provider]).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
const voiceOptions = resolveVoices(provider, model, state.provider_voices).map((o) => ({
value: o.value,
label: o.value,
hint: o.hint,
}))
const handleProvider = (id: string) => {
setProvider(id)
const first = normEntries(state.provider_models?.[id])[0]
const fm = first?.value || ''
setModel(fm)
setVoice(resolveVoices(id, fm, state.provider_voices)[0]?.value || '')
}
const handleModel = (m: string) => {
setModel(m)
setVoice(resolveVoices(provider, m, state.provider_voices)[0]?.value || '')
}
return (
<Card icon={<Volume2 size={16} />} title={t('models_cap_tts')} subtitle={t('models_cap_tts_sub')}>
<div className="space-y-4">
{/* Reply mode — saved immediately */}
<Field label={t('models_tts_reply_mode')} hint={t('models_tts_reply_mode_hint')}>
<Dropdown
value={mode}
options={REPLY_MODES.map((m) => ({ value: m.value, label: t(m.key) }))}
onChange={(v) => {
const next = v as 'off' | 'voice_if_voice' | 'always'
setMode(next)
onSaveMode(next)
}}
disabled={modeBusy}
/>
{modeStatus && <span className="text-xs text-accent">{modeStatus}</span>}
</Field>
{mode !== 'off' && (
<>
<Field label={t('models_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('models_select_provider')}
onChange={handleProvider}
/>
</Field>
<Field label={t('models_model')}>
<Dropdown
value={model}
options={modelOptions}
placeholder={t('models_select_model')}
onChange={handleModel}
/>
</Field>
{voiceOptions.length > 0 && (
<Field label={t('models_voice')}>
<Dropdown value={voice} options={voiceOptions} placeholder={t('models_select_voice')} onChange={setVoice} />
</Field>
)}
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>
{status}
</span>
<button
disabled={busy}
onClick={() => onSaveVoice(provider, model, voice)}
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('config_save')}
</button>
</div>
</>
)}
</div>
</Card>
)
}
const EmbeddingCard: React.FC<{
state: CapabilityState
data: ModelsData
busy: boolean
status?: string
onSave: (provider: string, model: string) => void
}> = ({ state, data, busy, status, onSave }) => (
<CapabilityCard
icon={Database}
title={t('models_cap_embedding')}
subtitle={t('models_cap_embedding_sub')}
capKey="embedding"
state={state}
data={data}
allowAuto
autoLabel={t('models_disabled')}
busy={busy}
status={status}
onSave={onSave}
>
{state.current_dim != null && (
<p className="text-xs text-content-tertiary">
{t('models_embedding_dim')}: {state.current_dim} · {t('models_embedding_rebuild_hint')}
</p>
)}
</CapabilityCard>
)
const SearchCard: React.FC<{
state: SearchCapabilityState
busy: boolean
status?: string
onSaveStrategy: (strategy: string, provider: string) => void
onSaveBochaKey: (key: string) => void
keyStatus?: string
keyBusy: boolean
}> = ({ state, busy, status, onSaveStrategy, onSaveBochaKey, keyStatus, keyBusy }) => {
const [strategy, setStrategy] = useState<string>(state.strategy || 'auto')
const [provider, setProvider] = useState<string>(state.fixed_provider || state.current_provider || '')
const [bochaOpen, setBochaOpen] = useState(false)
const providerOptions = useMemo(
() => state.providers.map((p) => ({ value: p.id, label: localizedLabel(p.label) })),
[state.providers]
)
const bocha = state.providers.find((p) => p.id === 'bocha')
return (
<Card icon={<SearchIcon size={16} />} title={t('models_cap_search')} subtitle={t('models_cap_search_sub')}>
<div className="space-y-4">
<Field label={t('models_search_strategy')}>
<Dropdown
value={strategy}
options={[
{ value: 'auto', label: t('models_search_auto') },
{ value: 'fixed', label: t('models_search_fixed') },
]}
onChange={setStrategy}
/>
</Field>
{strategy === 'fixed' && (
<Field label={t('models_search_provider')}>
<Dropdown
value={provider}
options={providerOptions}
placeholder={t('models_select_provider')}
onChange={setProvider}
/>
</Field>
)}
<div className="flex items-center justify-between">
<button
onClick={() => setBochaOpen(true)}
className="text-xs text-accent hover:text-accent-hover cursor-pointer inline-flex items-center gap-1"
>
{t('models_search_bocha_key')}
{bocha?.configured && <Check size={12} />}
</button>
<div className="flex items-center gap-3">
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>
{status}
</span>
<button
disabled={busy || (strategy === 'fixed' && !provider)}
onClick={() => onSaveStrategy(strategy, provider)}
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('config_save')}
</button>
</div>
</div>
</div>
<BochaKeyModal
open={bochaOpen}
masked={bocha?.api_key_masked || ''}
busy={keyBusy}
status={keyStatus}
onClose={() => setBochaOpen(false)}
onSave={(k) => {
onSaveBochaKey(k)
setBochaOpen(false)
}}
/>
</Card>
)
}
const BochaKeyModal: React.FC<{
open: boolean
masked: string
busy: boolean
status?: string
onClose: () => void
onSave: (key: string) => void
}> = ({ open, masked, busy, onClose, onSave }) => {
const [key, setKey] = useState('')
const [dirty, setDirty] = useState(false)
useEffect(() => {
if (open) {
setKey(masked)
setDirty(false)
}
}, [open, masked])
return (
<Modal
open={open}
title={t('models_search_bocha_key')}
onClose={onClose}
footer={
<>
<Btn variant="ghost" onClick={onClose}>
{t('config_cancel')}
</Btn>
<Btn variant="primary" disabled={busy} onClick={() => onSave(dirty && !MASK_RE.test(key) ? key : '')}>
{busy ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
</Btn>
</>
}
>
<Field label="Bocha API Key" hint={t('models_search_bocha_hint')}>
<TextInput
className="font-mono"
value={key}
placeholder="sk-..."
onFocus={() => {
if (!dirty && MASK_RE.test(key)) setKey('')
}}
onChange={(e) => {
setKey(e.target.value)
setDirty(true)
}}
/>
</Field>
</Modal>
)
}
export default ModelsTab

View File

@@ -0,0 +1,57 @@
import type { ModelEntry, ModelOption, ModelProvider, ModelsData } from '../../types'
import { localizedLabel } from '../../i18n'
// Normalize a string|{value,hint} entry into a uniform option shape.
export function normEntry(e: ModelEntry): ModelOption {
return typeof e === 'string' ? { value: e } : e
}
export function normEntries(arr?: ModelEntry[]): ModelOption[] {
return (arr || []).map(normEntry)
}
// Resolve a human label for a provider id, falling back to the id itself.
// Handles expanded custom ids ("custom:<id>") via the providers overview.
export function providerLabel(data: ModelsData | null, id: string): string {
if (!id) return ''
const p = data?.providers?.find((x) => x.id === id)
if (p) return localizedLabel(p.label) || id
return id
}
export function findProvider(data: ModelsData | null, id: string): ModelProvider | undefined {
return data?.providers?.find((x) => x.id === id)
}
// Resolve the model list for a capability+provider, mirroring the web console:
// 1. capability-scoped provider_models[id] (vision/image/asr/tts/embedding)
// 2. provider_models['custom'] for expanded custom:<id> providers
// 3. fall back to the vendor's generic models[] (chat has no provider_models)
export function resolveModels(
data: ModelsData | null,
providerId: string,
providerModels?: Record<string, ModelEntry[]>
): ModelOption[] {
if (!providerId) return []
if (providerModels?.[providerId]) return normEntries(providerModels[providerId])
if (providerId.startsWith('custom:') && providerModels?.['custom']) {
return normEntries(providerModels['custom'])
}
return normEntries(findProvider(data, providerId)?.models)
}
// Voices for a tts provider may be a flat list or, for linkai, keyed by model.
export function resolveVoices(
provider: string,
model: string,
voicesMap?: Record<string, ModelEntry[] | Record<string, ModelEntry[]>>
): ModelOption[] {
const raw = voicesMap?.[provider]
if (!raw) return []
if (Array.isArray(raw)) return normEntries(raw)
// keyed by model (linkai)
const byModel = raw as Record<string, ModelEntry[]>
return normEntries(byModel[model] || [])
}
export const CUSTOM_OPTION = '__custom__'

View File

@@ -0,0 +1,196 @@
import React, { useState, useEffect, useRef } from 'react'
import { ChevronDown } from 'lucide-react'
import { t } from '../../i18n'
// Shared presentational building blocks for the settings tabs.
export const Card: React.FC<{ icon: React.ReactNode; title: string; subtitle?: string; children: React.ReactNode }> = ({
icon,
title,
subtitle,
children,
}) => (
<div className="rounded-card border border-default bg-surface p-5">
<div className="flex items-center gap-2.5 mb-4">
<div className="w-8 h-8 rounded-lg bg-accent-soft text-accent flex items-center justify-center">{icon}</div>
<div>
<h3 className="font-semibold text-content leading-tight">{title}</h3>
{subtitle && <p className="text-xs text-content-tertiary mt-0.5">{subtitle}</p>}
</div>
</div>
{children}
</div>
)
export const Field: React.FC<{ label: string; hint?: string; children: React.ReactNode }> = ({
label,
hint,
children,
}) => (
<div>
<label className="block text-sm font-medium text-content-secondary mb-1.5">{label}</label>
{children}
{hint && <p className="text-xs text-content-tertiary mt-1">{hint}</p>}
</div>
)
export interface DropdownOption {
value: string
label: string
hint?: string
}
export const Dropdown: React.FC<{
value: string
display?: string
placeholder?: string
options: DropdownOption[]
disabled?: boolean
onChange: (val: string) => void
}> = ({ value, display, placeholder, options, disabled, onChange }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const h = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
const current = display ?? options.find((o) => o.value === value)?.label ?? ''
return (
<div ref={ref} className="relative">
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setOpen((v) => !v)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-btn border bg-inset text-sm transition-colors ${
disabled
? 'border-default text-content-tertiary cursor-not-allowed opacity-70'
: 'border-strong text-content cursor-pointer hover:border-accent'
}`}
>
<span className={`truncate ${current ? '' : 'text-content-tertiary'}`}>{current || placeholder || '--'}</span>
<ChevronDown size={15} className={`text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute z-30 mt-1 w-full max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1">
{options.length === 0 && (
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
)}
{options.map((o) => (
<div
key={o.value}
onClick={() => {
onChange(o.value)
setOpen(false)
}}
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
o.value === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
}`}
>
<div className="truncate">{o.label}</div>
{o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>}
</div>
))}
</div>
)}
</div>
)
}
export const Toggle: React.FC<{ checked: boolean; onChange: (v: boolean) => void }> = ({ checked, onChange }) => (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors cursor-pointer ${
checked ? 'bg-accent' : 'bg-surface-2 border border-strong'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
)
export const TextInput: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = (props) => (
<input
{...props}
className={`w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors ${
props.className || ''
}`}
/>
)
export const SaveRow: React.FC<{ status: string; onSave: () => void; label?: string }> = ({
status,
onSave,
label,
}) => (
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>{status}</span>
<button
onClick={onSave}
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
>
{label ?? t('config_save')}
</button>
</div>
)
export const MASK_RE = /[*•]/
export const Modal: React.FC<{
open: boolean
title: string
onClose: () => void
children: React.ReactNode
footer?: React.ReactNode
}> = ({ open, title, onClose, children, footer }) => {
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div className="w-full max-w-md rounded-card border border-default bg-elevated shadow-xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-default">
<h3 className="font-semibold text-content">{title}</h3>
<button
onClick={onClose}
className="text-content-tertiary hover:text-content cursor-pointer text-lg leading-none px-1"
>
×
</button>
</div>
<div className="px-5 py-4 space-y-4 max-h-[60vh] overflow-y-auto">{children}</div>
{footer && <div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-default">{footer}</div>}
</div>
</div>
)
}
export const Btn: React.FC<
React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'ghost' | 'danger' }
> = ({ variant = 'ghost', className, children, ...props }) => {
const styles =
variant === 'primary'
? 'bg-accent text-accent-contrast hover:bg-accent-hover'
: variant === 'danger'
? 'bg-danger-soft text-danger hover:bg-danger/15 border border-danger-border'
: 'border border-strong text-content-secondary hover:bg-surface-2'
return (
<button
{...props}
className={`px-4 py-2 rounded-btn text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${styles} ${className || ''}`}
>
{children}
</button>
)
}

View File

@@ -0,0 +1,420 @@
import { create } from 'zustand'
import apiClient from '../api/client'
import type { ChatMessage, MessageStep, Attachment, StreamEvent, HistoryMessage } from '../types'
/**
* Per-session chat state. Supports parallel sessions: each session keeps its
* own message list and active stream, so switching sessions never interrupts a
* background run. The active EventSource lives in `streams` (outside React).
*/
interface SessionRuntime {
messages: ChatMessage[]
isStreaming: boolean
requestId: string | null
// history pagination
historyPage: number
historyHasMore: boolean
historyLoaded: boolean
}
interface ChatState {
sessions: Record<string, SessionRuntime>
getSession: (sid: string) => SessionRuntime
ensureSession: (sid: string) => void
send: (sid: string, text: string, attachments: Attachment[]) => Promise<void>
cancel: (sid: string) => Promise<void>
regenerate: (sid: string, botMessageId: string) => Promise<void>
editUserMessage: (sid: string, messageId: string) => { text: string; attachments: Attachment[] } | null
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
loadHistory: (sid: string, page?: number) => Promise<void>
clearLocal: (sid: string) => void
}
// EventSource instances kept outside the store (not serializable).
const streams: Record<string, EventSource> = {}
const EMPTY: SessionRuntime = {
messages: [],
isStreaming: false,
requestId: null,
historyPage: 0,
historyHasMore: false,
historyLoaded: false,
}
function uid(prefix: string): string {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
}
/**
* History keeps the English cancel marker for the LLM; strip it for display so
* the bubble shows a clean answer + a dedicated "cancelled" badge instead.
*/
function stripCancelMarker(text: string): string {
if (!text) return text
return text
.replace(/_\(Cancelled by user\)_/g, '')
.replace(/_\(Cancelled\)_/g, '')
.trim()
}
/** Convert a backend history message into a UI ChatMessage. */
function historyToMessage(m: HistoryMessage): ChatMessage {
if (m.role === 'user') {
return {
id: uid('user'),
role: 'user',
content: m.content,
timestamp: m.created_at,
userSeq: m._seq,
}
}
// The backend stores the final answer both as `content` and as the LAST
// `content` step. Strip that trailing content step so it isn't rendered
// twice (matches the web console's renderStepsHtml logic).
const raw = m.steps || []
let lastContentIdx = -1
for (let i = raw.length - 1; i >= 0; i--) {
if (raw[i].type === 'content') {
lastContentIdx = i
break
}
}
const steps: MessageStep[] = raw
.filter((_, i) => i !== lastContentIdx)
.map((s) => ({ ...s }))
const finalContent = m.content || (lastContentIdx >= 0 ? raw[lastContentIdx].content || '' : '')
return {
id: uid('assistant'),
role: 'assistant',
content: finalContent,
timestamp: m.created_at,
steps,
reasoning: m.reasoning,
kind: m.kind,
extras: m.extras,
botSeq: m._seq,
}
}
export const useChatStore = create<ChatState>((set, get) => {
// --- helpers operating on a single session immutably ---
const patchSession = (sid: string, patch: Partial<SessionRuntime>) =>
set((st) => ({
sessions: { ...st.sessions, [sid]: { ...(st.sessions[sid] || EMPTY), ...patch } },
}))
const patchMessages = (sid: string, fn: (msgs: ChatMessage[]) => ChatMessage[]) =>
set((st) => {
const cur = st.sessions[sid] || EMPTY
return { sessions: { ...st.sessions, [sid]: { ...cur, messages: fn(cur.messages) } } }
})
const updateMsg = (sid: string, id: string, fn: (m: ChatMessage) => ChatMessage) =>
patchMessages(sid, (msgs) => msgs.map((m) => (m.id === id ? fn(m) : m)))
/** Attach an EventSource for a request and wire all SSE events to a bot message. */
const attachStream = (sid: string, requestId: string, botId: string) => {
const es = apiClient.createSSEStream(requestId)
streams[sid] = es
let tailTimer: ReturnType<typeof setTimeout> | null = null
const closeStream = () => {
if (tailTimer) {
clearTimeout(tailTimer)
tailTimer = null
}
es.close()
if (streams[sid] === es) delete streams[sid]
}
// Mark the turn as complete: UI becomes interactive again immediately.
const completeTurn = () => {
patchSession(sid, { isStreaming: false, requestId: null })
updateMsg(sid, botId, (m) => ({ ...m, isStreaming: false }))
}
const finishStream = () => {
completeTurn()
closeStream()
}
es.onmessage = (event) => {
let data: StreamEvent
try {
data = JSON.parse(event.data)
} catch {
return // keepalive
}
switch (data.type) {
case 'reasoning':
updateMsg(sid, botId, (m) => ({ ...m, reasoning: (m.reasoning || '') + (data.content || '') }))
break
case 'delta':
updateMsg(sid, botId, (m) => ({ ...m, content: m.content + (data.content || '') }))
break
case 'message_end':
// Freeze accumulated text as a content step when tool calls follow,
// mirroring the web console's interleaved step model.
if (data.has_tool_calls) {
updateMsg(sid, botId, (m) => {
if (!m.content.trim()) return m
const steps = [...(m.steps || []), { type: 'content' as const, content: m.content.trim() }]
return { ...m, steps, content: '' }
})
}
break
case 'tool_start':
updateMsg(sid, botId, (m) => {
// commit any reasoning into a thinking step
const steps = [...(m.steps || [])]
if (m.reasoning && m.reasoning.trim()) {
steps.push({ type: 'thinking', content: m.reasoning.trim() })
}
steps.push({
type: 'tool',
id: data.tool_call_id,
name: data.tool,
arguments: data.arguments,
status: 'running',
})
return { ...m, steps, reasoning: '', content: '' }
})
break
case 'tool_progress':
updateMsg(sid, botId, (m) => ({
...m,
steps: (m.steps || []).map((s) =>
s.type === 'tool' && s.id === data.tool_call_id ? { ...s, result: data.content } : s
),
}))
break
case 'tool_end':
updateMsg(sid, botId, (m) => ({
...m,
steps: (m.steps || []).map((s) =>
s.type === 'tool' && s.id === data.tool_call_id
? {
...s,
status: data.status,
result: data.result ?? s.result,
execution_time: data.execution_time,
is_error: data.status !== 'success',
}
: s
),
}))
break
case 'cancelled':
updateMsg(sid, botId, (m) => ({ ...m, isCancelled: true }))
break
case 'done':
updateMsg(sid, botId, (m) => {
const next = stripCancelMarker(data.content || m.content)
return {
...m,
content: next,
botSeq: data.bot_seq ?? m.botSeq,
isStreaming: false,
}
})
// backfill the preceding user message's seq for edit/delete
if (data.user_seq != null) {
patchMessages(sid, (msgs) => {
const idx = msgs.findIndex((m) => m.id === botId)
for (let i = idx - 1; i >= 0; i--) {
if (msgs[i].role === 'user') {
msgs[i] = { ...msgs[i], userSeq: data.user_seq }
break
}
}
return [...msgs]
})
}
// The answer is final: free the UI now (don't wait for onerror).
completeTurn()
// Backend keeps the stream open for a short tail (e.g. TTS audio via
// voice_attach). Close it ourselves if nothing else arrives.
if (tailTimer) clearTimeout(tailTimer)
tailTimer = setTimeout(closeStream, 1500)
break
case 'voice_attach':
if (data.audio_url) {
updateMsg(sid, botId, (m) => ({
...m,
extras: { ...(m.extras || {}), audio: data.audio_url },
}))
}
finishStream()
break
case 'error':
updateMsg(sid, botId, (m) => ({ ...m, error: data.message || 'stream error', isStreaming: false }))
finishStream()
break
}
}
es.onerror = () => {
// Stream closed (often the normal end after `done`/tail). Finalize.
finishStream()
}
}
return {
sessions: {},
getSession: (sid) => get().sessions[sid] || EMPTY,
ensureSession: (sid) => {
if (!get().sessions[sid]) patchSession(sid, { ...EMPTY })
},
send: async (sid, text, attachments) => {
const userMsg: ChatMessage = {
id: uid('user'),
role: 'user',
content: text,
timestamp: Date.now() / 1000,
attachments: attachments.length ? attachments : undefined,
}
const botId = uid('assistant')
const botMsg: ChatMessage = {
id: botId,
role: 'assistant',
content: '',
timestamp: Date.now() / 1000,
steps: [],
isStreaming: true,
}
patchMessages(sid, (msgs) => [...msgs, userMsg, botMsg])
patchSession(sid, { isStreaming: true })
try {
const res = await apiClient.sendMessage(sid, text, {
stream: true,
attachments: attachments.length ? attachments : undefined,
})
if (res.status === 'success' && res.stream && res.request_id) {
patchSession(sid, { requestId: res.request_id })
attachStream(sid, res.request_id, botId)
} else if (res.inline_reply) {
updateMsg(sid, botId, (m) => ({ ...m, content: res.inline_reply || '', isStreaming: false }))
patchSession(sid, { isStreaming: false })
} else {
updateMsg(sid, botId, (m) => ({ ...m, error: 'send failed', isStreaming: false }))
patchSession(sid, { isStreaming: false })
}
} catch (err) {
updateMsg(sid, botId, (m) => ({ ...m, error: `${err}`, isStreaming: false }))
patchSession(sid, { isStreaming: false })
}
},
cancel: async (sid) => {
const s = get().sessions[sid]
if (!s?.requestId) return
try {
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
} catch {
/* ignore */
}
},
regenerate: async (sid, botMessageId) => {
const s = get().sessions[sid] || EMPTY
const idx = s.messages.findIndex((m) => m.id === botMessageId)
if (idx < 0) return
// find the user message that produced this bot reply
let userMsg: ChatMessage | null = null
for (let i = idx - 1; i >= 0; i--) {
if (s.messages[i].role === 'user') {
userMsg = s.messages[i]
break
}
}
if (!userMsg) return
// delete the turn on the backend (by the user's seq) then resend
if (userMsg.userSeq != null) {
try {
await apiClient.deleteMessage({ sessionId: sid, userSeq: userMsg.userSeq, deleteUser: true, cascade: true })
} catch {
/* ignore */
}
}
// drop the user+bot messages locally from idx-? : remove from the user msg onward
const userIdx = s.messages.indexOf(userMsg)
patchMessages(sid, (msgs) => msgs.slice(0, userIdx))
await get().send(sid, userMsg.content, userMsg.attachments || [])
},
editUserMessage: (sid, messageId) => {
const s = get().sessions[sid] || EMPTY
const msg = s.messages.find((m) => m.id === messageId)
if (!msg || msg.role !== 'user') return null
const userIdx = s.messages.indexOf(msg)
// cascade-delete this turn on the backend
if (msg.userSeq != null) {
apiClient
.deleteMessage({ sessionId: sid, userSeq: msg.userSeq, deleteUser: true, cascade: true })
.catch(() => {})
}
patchMessages(sid, (msgs) => msgs.slice(0, userIdx))
return { text: msg.content, attachments: msg.attachments || [] }
},
deleteMessage: async (sid, userSeq, cascade) => {
try {
await apiClient.deleteMessage({ sessionId: sid, userSeq, deleteUser: true, cascade })
} catch {
/* ignore */
}
// reload history to reflect server state
await get().loadHistory(sid, 1)
},
loadHistory: async (sid, page = 1) => {
try {
const res = await apiClient.getHistory(sid, page, 20)
const uiMsgs = res.messages.map(historyToMessage)
patchSession(sid, {
historyPage: res.page,
historyHasMore: res.has_more,
historyLoaded: true,
})
if (page === 1) {
patchMessages(sid, () => uiMsgs)
} else {
// older page: prepend
patchMessages(sid, (msgs) => [...uiMsgs, ...msgs])
}
} catch {
patchSession(sid, { historyLoaded: true })
}
},
clearLocal: (sid) => {
const es = streams[sid]
if (es) {
es.close()
delete streams[sid]
}
patchSession(sid, { ...EMPTY })
},
}
})

View File

@@ -0,0 +1,42 @@
import { create } from 'zustand'
// Onboarding is config-driven: the wizard auto-opens whenever the chat model
// isn't configured yet, and stops appearing once it is — no persisted "seen"
// flag that could strand a user who skipped without finishing setup.
//
// `dismissedThisSession` is an in-memory guard so that skipping doesn't
// immediately re-open the wizard within the same run; it resets on relaunch,
// so an unconfigured app will guide the user again next time.
interface OnboardingState {
// Whether the wizard overlay is currently visible.
open: boolean
// True if the user skipped/finished during THIS app session (not persisted).
dismissedThisSession: boolean
// Decide whether to auto-open on launch. Opens only when chat isn't
// configured AND it wasn't dismissed earlier this session.
maybeOpen: (chatConfigured: boolean) => void
// Open manually (e.g. from a "setup guide" entry point later).
openWizard: () => void
// Finish/skip: close and don't auto-reopen this session.
finish: () => void
// Close without marking dismissed (rarely used; kept for symmetry).
close: () => void
}
export const useOnboardingStore = create<OnboardingState>((set) => ({
open: false,
dismissedThisSession: false,
maybeOpen: (chatConfigured) =>
set((s) => {
if (chatConfigured || s.dismissedThisSession) return { open: false }
return { open: true }
}),
openWizard: () => set({ open: true }),
finish: () => set({ open: false, dismissedThisSession: true }),
close: () => set({ open: false }),
}))

View File

@@ -0,0 +1,86 @@
import { create } from 'zustand'
import apiClient from '../api/client'
import type { SessionItem } from '../types'
const ACTIVE_KEY = 'cow_session_id'
interface SessionState {
sessions: SessionItem[]
total: number
page: number
hasMore: boolean
loading: boolean
activeId: string
loadSessions: (page?: number) => Promise<void>
loadMore: () => Promise<void>
setActive: (id: string) => void
newSession: () => string
rename: (id: string, title: string) => Promise<void>
remove: (id: string) => Promise<void>
}
function genId(): string {
return `session_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
}
function readActive(): string {
return localStorage.getItem(ACTIVE_KEY) || genId()
}
export const useSessionStore = create<SessionState>((set, get) => ({
sessions: [],
total: 0,
page: 1,
hasMore: false,
loading: false,
activeId: readActive(),
loadSessions: async (page = 1) => {
set({ loading: true })
try {
const res = await apiClient.getSessions(page, 50)
set((s) => ({
sessions: page === 1 ? res.sessions : [...s.sessions, ...res.sessions],
total: res.total,
page: res.page,
hasMore: res.has_more,
loading: false,
}))
} catch {
set({ loading: false })
}
},
loadMore: async () => {
const { hasMore, loading, page } = get()
if (!hasMore || loading) return
await get().loadSessions(page + 1)
},
setActive: (id) => {
localStorage.setItem(ACTIVE_KEY, id)
set({ activeId: id })
},
newSession: () => {
const id = genId()
localStorage.setItem(ACTIVE_KEY, id)
set({ activeId: id })
return id
},
rename: async (id, title) => {
await apiClient.renameSession(id, title)
set((s) => ({
sessions: s.sessions.map((sess) => (sess.session_id === id ? { ...sess, title } : sess)),
}))
},
remove: async (id) => {
await apiClient.deleteSession(id)
set((s) => ({ sessions: s.sessions.filter((sess) => sess.session_id !== id) }))
// If we removed the active one, start a fresh session
if (get().activeId === id) get().newSession()
},
}))

View File

@@ -0,0 +1,48 @@
import { create } from 'zustand'
const NAV_KEY = 'cow_nav_collapsed'
const SESSIONS_KEY = 'cow_sessions_collapsed'
interface UIState {
/** Navigation rail collapsed (icon-only) vs expanded (icon + label). */
navCollapsed: boolean
toggleNav: () => void
setNavCollapsed: (v: boolean) => void
/** Session list panel collapsed (hidden) vs expanded. */
sessionsCollapsed: boolean
toggleSessions: () => void
/** Currently active session id (Chat page). */
activeSessionId: string | null
setActiveSessionId: (id: string | null) => void
}
function readBool(key: string): boolean {
return localStorage.getItem(key) === '1'
}
export const useUIStore = create<UIState>((set) => ({
navCollapsed: readBool(NAV_KEY),
toggleNav: () =>
set((s) => {
const next = !s.navCollapsed
localStorage.setItem(NAV_KEY, next ? '1' : '0')
return { navCollapsed: next }
}),
setNavCollapsed: (v) => {
localStorage.setItem(NAV_KEY, v ? '1' : '0')
set({ navCollapsed: v })
},
sessionsCollapsed: readBool(SESSIONS_KEY),
toggleSessions: () =>
set((s) => {
const next = !s.sessionsCollapsed
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
return { sessionsCollapsed: next }
}),
activeSessionId: null,
setActiveSessionId: (id) => set({ activeSessionId: id }),
}))

View File

@@ -0,0 +1,55 @@
import { create } from 'zustand'
import type { UpdateStatus } from '../types'
interface UpdateState {
status: UpdateStatus | null
/** Latest available version, kept across download progress updates. */
version: string | null
/** Download progress 0-100 while state === 'downloading'. */
percent: number
/** User dismissed the badge for this version (don't nag again until next). */
dismissedVersion: string | null
setStatus: (s: UpdateStatus) => void
dismiss: () => void
// Actions proxied to the main process via the preload bridge.
download: () => void
install: () => void
}
export const useUpdateStore = create<UpdateState>((set, get) => ({
status: null,
version: null,
percent: 0,
dismissedVersion: null,
setStatus: (s) =>
set(() => {
if (s.state === 'available') return { status: s, version: s.version, percent: 0 }
if (s.state === 'downloading') return { status: s, percent: s.percent }
if (s.state === 'downloaded') return { status: s, version: s.version, percent: 100 }
return { status: s }
}),
dismiss: () => set((st) => ({ dismissedVersion: st.version })),
download: () => window.electronAPI?.downloadUpdate?.(),
install: () => window.electronAPI?.installUpdate?.(),
}))
// Subscribe to main-process update events. Returns an unsubscribe fn.
export function initUpdateListener(): (() => void) | undefined {
return window.electronAPI?.onUpdateStatus?.((status) => {
useUpdateStore.getState().setStatus(status as UpdateStatus)
})
}
// Whether a new version should be surfaced (available/downloading/downloaded
// and not dismissed for that version).
export function hasPendingUpdate(state: UpdateState): boolean {
const s = state.status
if (!s) return false
const active = s.state === 'available' || s.state === 'downloading' || s.state === 'downloaded'
return active && state.dismissedVersion !== state.version
}

View File

@@ -0,0 +1,476 @@
// ============================================================
// Electron bridge
// ============================================================
export interface ElectronAPI {
getBackendPort: () => Promise<number | null>
getBackendStatus: () => Promise<string>
restartBackend: () => Promise<boolean>
selectDirectory: () => Promise<string | null>
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>
// Listener registrars return an unsubscribe fn for cleanup.
onBackendStatus: (callback: (data: BackendStatusEvent) => void) => () => void
onBackendLog: (callback: (line: string) => void) => () => void
windowMinimize: () => Promise<void>
windowMaximize: () => Promise<boolean>
windowClose: () => Promise<void>
windowIsMaximized: () => Promise<boolean>
onMaximizeChange: (callback: (maximized: boolean) => void) => () => void
onMenuAction?: (callback: (action: string) => void) => () => void
// Auto-update
checkForUpdate?: () => Promise<void>
downloadUpdate?: () => Promise<void>
installUpdate?: () => Promise<void>
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
platform: string
// OS UI language (e.g. "zh-CN"); used to default the language on first run.
systemLocale?: string
}
// Mirrors UpdateStatus in src/main/updater.ts.
export type UpdateStatus =
| { state: 'checking' }
| { state: 'available'; version: string; notes?: string }
| { state: 'not-available' }
| { state: 'downloading'; percent: number }
| { state: 'downloaded'; version: string }
| { state: 'error'; message: string }
export interface BackendStatusEvent {
status: 'ready' | 'error' | 'starting'
port?: number
error?: string
}
// ============================================================
// Chat / messages / streaming
// ============================================================
export type Role = 'user' | 'assistant'
/** A single ordered step inside an assistant turn (matches backend history). */
export interface MessageStep {
type: 'thinking' | 'content' | 'tool'
content?: string
// tool step fields
id?: string
name?: string
arguments?: Record<string, unknown>
result?: string
is_error?: boolean
status?: string
execution_time?: number
}
/** Local UI message model (superset of backend history message). */
export interface ChatMessage {
id: string
role: Role
content: string
/** Unix seconds. Backend history uses `created_at`; we normalize to `timestamp`. */
timestamp: number
attachments?: Attachment[]
/** Ordered steps (thinking / content / tool). Preferred over legacy toolCalls. */
steps?: MessageStep[]
/** Legacy live-stream tool events (kept for backward compat during streaming). */
toolCalls?: ToolCall[]
/** Reasoning text streamed via `reasoning` SSE events. */
reasoning?: string
/** Sequence numbers from backend (for delete/regenerate). */
userSeq?: number
botSeq?: number
/** Self-evolution bubble flag. */
kind?: 'evolution'
extras?: Record<string, unknown>
isStreaming?: boolean
isCancelled?: boolean
error?: string
}
export interface Attachment {
file_path: string
file_name: string
file_type: 'image' | 'video' | 'file' | 'directory'
preview_url?: string
}
/** Live tool event during SSE streaming. */
export interface ToolCall {
type: 'tool_start' | 'tool_end' | 'tool_progress'
tool: string
tool_call_id?: string
arguments?: Record<string, unknown>
result?: string
status?: string
execution_time?: number
}
/** All SSE event types emitted on /stream. */
export type StreamEventType =
| 'delta'
| 'reasoning'
| 'tool_start'
| 'tool_progress'
| 'tool_end'
| 'message_end'
| 'phase'
| 'file_to_send'
| 'image'
| 'video'
| 'file'
| 'text'
| 'done'
| 'cancelled'
| 'voice_attach'
| 'error'
export interface StreamEvent {
type: StreamEventType
content?: string
tool?: string
tool_call_id?: string
arguments?: Record<string, unknown>
status?: string
result?: string
execution_time?: number
has_tool_calls?: boolean
path?: string
file_name?: string
file_type?: string
web_url?: string
audio_url?: string
request_id?: string
timestamp?: number
user_seq?: number
bot_seq?: number
message?: string
}
// ============================================================
// Sessions / history
// ============================================================
export interface SessionItem {
session_id: string
title: string
created_at: number
last_active: number
msg_count: number
}
export interface SessionsPage {
sessions: SessionItem[]
total: number
page: number
page_size: number
has_more: boolean
}
/** Backend history message (as returned by /api/history). */
export interface HistoryMessage {
role: Role
content: string
created_at: number
steps?: MessageStep[]
tool_calls?: Array<{ id?: string; name: string; arguments?: Record<string, unknown>; result?: string }>
reasoning?: string
kind?: 'evolution'
extras?: Record<string, unknown>
/** Per-message sequence number used by delete/regenerate APIs. */
_seq?: number
}
export interface HistoryPage {
messages: HistoryMessage[]
total: number
page: number
page_size: number
has_more: boolean
context_start_seq?: number
}
// ============================================================
// Config
// ============================================================
/** A label that may be localized (some providers/channels return {zh,en}). */
export type LocalizedLabel = string | { zh: string; en: string }
export interface ProviderMeta {
label: LocalizedLabel
models: string[]
api_base_key?: string | null
api_base_default?: string | null
api_base_placeholder?: string
api_key_field?: string | null
[k: string]: unknown
}
export interface ConfigData {
use_agent: boolean
title: string
model: string
bot_type: string
use_linkai: boolean
channel_type: string
agent_max_context_tokens: number
agent_max_context_turns: number
agent_max_steps: number
enable_thinking?: boolean
self_evolution_enabled?: boolean
api_bases: Record<string, string>
api_keys: Record<string, string>
providers: Record<string, ProviderMeta>
web_password_masked?: string
}
// ============================================================
// Models console (/api/models)
// ============================================================
// A model/voice entry can be a bare id or an annotated {value, hint} object.
export interface ModelOption {
value: string
hint?: string
}
export type ModelEntry = string | ModelOption
export interface ModelProvider {
id: string
label: LocalizedLabel
configured: boolean
is_custom: boolean
custom_id?: string
custom_name?: string
active?: boolean
api_key_field?: string | null
api_base_field?: string | null
api_key_masked?: string
api_base?: string
api_base_default?: string
api_base_placeholder?: string
models: ModelEntry[]
}
export type CapabilityKey = 'chat' | 'vision' | 'asr' | 'tts' | 'embedding' | 'image' | 'search'
// Search providers are described as objects (unlike other capabilities which
// list provider ids only).
export interface SearchProviderMeta {
id: string
label: LocalizedLabel
configured: boolean
needs_dedicated_key: boolean
api_key_masked?: string
}
export interface CapabilityState {
editable?: boolean
current_provider?: string
current_model?: string
current_voice?: string
current_dim?: number | null
suggested_provider?: string
providers?: string[]
// provider_models entries are string | {value,hint}
provider_models?: Record<string, ModelEntry[]>
// tts only: voices keyed by provider; linkai keyed further by model id
provider_voices?: Record<string, ModelEntry[] | Record<string, ModelEntry[]>>
// vision/image
strategy?: string
user_specified_model?: string
fallback_provider?: string
fallback_model?: string
// tts
reply_mode?: 'off' | 'voice_if_voice' | 'always'
use_linkai?: boolean
// image
runtime_active?: boolean
note?: string
// search
fixed_provider?: string
configured_providers?: string[]
available?: boolean
[k: string]: unknown
}
export interface SearchCapabilityState {
editable?: boolean
providers: SearchProviderMeta[]
strategy?: 'auto' | 'fixed' | string
current_provider?: string
fixed_provider?: string
configured_providers?: string[]
available?: boolean
}
export interface ModelsData {
status?: string
providers: ModelProvider[]
capabilities: {
chat: CapabilityState
vision: CapabilityState
asr: CapabilityState
tts: CapabilityState
embedding: CapabilityState
image: CapabilityState
// search has a richer providers[] shape
search: SearchCapabilityState
}
}
export type ModelsAction =
| { action: 'set_provider'; provider_id: string; api_key?: string; api_base?: string }
| { action: 'delete_provider'; provider_id: string }
| { action: 'set_custom_provider'; name: string; id?: string; api_base: string; api_key?: string; model?: string; make_active?: boolean }
| { action: 'delete_custom_provider'; id: string }
| { action: 'set_active_custom_provider'; id: string }
| { action: 'set_capability'; capability: CapabilityKey; provider_id?: string; model?: string; voice?: string; strategy?: string; provider?: string }
| { action: 'set_voice_reply_mode'; mode: 'off' | 'voice_if_voice' | 'always' }
| { action: 'set_search_credential'; api_key: string }
// ============================================================
// Channels
// ============================================================
export interface ChannelField {
key: string
label: string
type: 'text' | 'secret' | 'number' | 'bool'
value?: string | number | boolean
default?: string | number | boolean
}
export interface ChannelInfo {
name: string
label: { zh: string; en: string }
icon: string
color: string
active: boolean
fields: ChannelField[]
login_status?: string
}
export type ChannelAction = 'save' | 'connect' | 'disconnect'
// ============================================================
// Tools / skills
// ============================================================
export interface ToolInfo {
name: string
description: string
}
export interface SkillInfo {
name: string
display_name?: string
description: string
source?: string
enabled: boolean
category?: string
}
// ============================================================
// Memory
// ============================================================
export type MemoryCategory = 'memory' | 'dream' | 'evolution'
export interface MemoryItem {
filename: string
type: string // global | daily | dream | evolution
size: number
updated_at: string
}
export interface MemoryPage {
list: MemoryItem[]
total: number
page: number
page_size: number
}
// ============================================================
// Knowledge
// ============================================================
export interface KnowledgeFile {
name: string
title: string
size: number
}
// A directory node in the knowledge tree (recursive).
export interface KnowledgeDir {
dir: string
files: KnowledgeFile[]
children: KnowledgeDir[]
}
export interface KnowledgeList {
root_files?: KnowledgeFile[]
tree: KnowledgeDir[]
stats: { pages: number; size: number }
enabled: boolean
}
export interface KnowledgeGraph {
nodes: Array<{ id: string; label: string; category?: string }>
links: Array<{ source: string; target: string }>
}
export type KnowledgeAction =
| { action: 'create_category'; payload: { path: string } }
| { action: 'rename_category'; payload: { path: string; new_path: string } }
| { action: 'delete_category'; payload: { path: string; confirm?: boolean } }
| { action: 'delete_documents'; payload: { paths: string[] } }
| { action: 'move_documents'; payload: { paths: string[]; target_category: string } }
// ============================================================
// Scheduler
// ============================================================
export interface TaskSchedule {
type: 'cron' | 'interval' | 'once'
expression?: string
seconds?: number
run_at?: string
}
export interface TaskAction {
type: 'send_message' | 'agent_task'
content?: string
task_description?: string
receiver?: string
receiver_name?: string
is_group?: boolean
channel_type?: string
}
export interface SchedulerTask {
id: string
name: string
enabled: boolean
created_at: string
updated_at: string
schedule: TaskSchedule
action: TaskAction
next_run_at?: string
}
// ============================================================
// Logs
// ============================================================
export interface LogEvent {
type: 'init' | 'line' | 'error'
content?: string
message?: string
}
declare global {
interface Window {
electronAPI?: ElectronAPI
}
}

View File

@@ -0,0 +1,80 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/renderer/**/*.{html,tsx,ts}'],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', '"PingFang SC"', '"Hiragino Sans GB"', '"Microsoft YaHei"', 'sans-serif'],
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
},
colors: {
'danger-soft': 'var(--danger-soft)',
'danger-border': 'var(--danger-border)',
// Brand accent (kept for backward compat + explicit accent usage)
primary: {
50: '#EDFDF3',
100: '#D4FAE2',
200: '#ABF4C7',
300: '#74E9A4',
400: '#4ABE6E',
500: '#35A85B',
600: '#228547',
700: '#1C6B3B',
800: '#1A5532',
900: '#16462A',
},
// Semantic tokens — driven by CSS variables, theme-aware
accent: {
DEFAULT: 'var(--accent)',
hover: 'var(--accent-hover)',
active: 'var(--accent-active)',
soft: 'var(--accent-soft)',
contrast: 'var(--accent-contrast)',
},
base: 'var(--bg-base)',
surface: {
DEFAULT: 'var(--bg-surface)',
2: 'var(--bg-surface-2)',
},
elevated: 'var(--bg-elevated)',
inset: 'var(--bg-inset)',
content: {
DEFAULT: 'var(--text-primary)',
secondary: 'var(--text-secondary)',
tertiary: 'var(--text-tertiary)',
disabled: 'var(--text-disabled)',
},
success: 'var(--success)',
warning: 'var(--warning)',
danger: 'var(--danger)',
info: 'var(--info)',
},
borderColor: {
DEFAULT: 'var(--border-default)',
default: 'var(--border-default)',
strong: 'var(--border-strong)',
subtle: 'var(--border-subtle)',
},
boxShadow: {
sm: 'var(--shadow-sm)',
md: 'var(--shadow-md)',
lg: 'var(--shadow-lg)',
},
borderRadius: {
card: '12px',
btn: '8px',
},
animation: {
'pulse-dot': 'pulseDot 1.4s infinite ease-in-out both',
},
keyframes: {
pulseDot: {
'0%, 80%, 100%': { transform: 'scale(0.6)', opacity: '0.4' },
'40%': { transform: 'scale(1)', opacity: '1' },
},
},
},
},
plugins: [],
}

22
desktop/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"esModuleInterop": true
},
"include": ["src/renderer"]
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "dist/main",
"rootDir": "src/main",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/main/**/*"]
}

22
desktop/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
root: path.resolve(__dirname, 'src/renderer'),
base: './',
publicDir: path.resolve(__dirname, '../channel/web/static'),
build: {
outDir: path.resolve(__dirname, 'dist/renderer'),
emptyOutDir: true,
},
server: {
port: 5173,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/renderer/src'),
},
},
})

View File

@@ -69,6 +69,20 @@ Create the AI Bot in WeCom and obtain the Bot ID and Secret, then connect via th
The log line `[WecomBot] Subscribe success` confirms the connection is established.
<Note>
A **webhook (HTTP callback) mode** is also supported: when creating the bot, choose **Use URL callback**, set the receive-message URL to `http(s)://<your-domain-or-ip>:9892/wecombot`, and copy the Token and EncodingAESKey from that page. This mode needs a publicly reachable address and does not support file sending or scheduled push, so the long connection is generally recommended. The corresponding `config.json`:
```json
{
"channel_type": "wecom_bot",
"wecom_bot_mode": "webhook",
"wecom_bot_token": "YOUR_TOKEN",
"wecom_bot_encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"wecom_bot_port": 9892
}
```
</Note>
## 2. Supported features
| Feature | Status |

View File

@@ -52,6 +52,20 @@ WeCom AI Bot を介して CowAgent を接続し、ダイレクトメッセージ
設定後、プログラムを起動します。ログに `[WecomBot] Subscribe success` と表示されれば接続成功です。
<Note>
ロングコネクションのほかに、**WebhookHTTP コールバック)モード**にも対応しています。Bot 作成時に **URL コールバックを使用**を選択し、受信メッセージ URL を `http(s)://<ドメインまたはIP>:9892/wecombot` に設定して、その画面の Token と EncodingAESKey をコピーします。このモードは外部からアクセス可能なアドレスが必要で、ファイル送信とスケジュール配信には対応していないため、通常はロングコネクションを推奨します。対応する `config.json`
```json
{
"channel_type": "wecom_bot",
"wecom_bot_mode": "webhook",
"wecom_bot_token": "YOUR_TOKEN",
"wecom_bot_encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"wecom_bot_port": 9892
}
```
</Note>
## 3. 対応機能
| 機能 | 状態 |

View File

@@ -69,6 +69,20 @@ description: 将 CowAgent 接入企业微信智能机器人(长连接模式)
日志显示 `[WecomBot] Subscribe success` 即表示连接成功。
<Note>
除长连接外,也支持**回调HTTP 回调)模式**:创建机器人时选择「使用 URL 回调」,将接收消息 URL 设为 `http(s)://<域名或IP>:9892/wecombot`,并复制该页面的 Token 和 EncodingAESKey。该模式需公网可达且不支持文件发送与定时推送一般推荐使用长连接。对应 `config.json` 配置如下:
```json
{
"channel_type": "wecom_bot",
"wecom_bot_mode": "webhook",
"wecom_bot_token": "YOUR_TOKEN",
"wecom_bot_encoding_aes_key": "YOUR_ENCODING_AES_KEY",
"wecom_bot_port": 9892
}
```
</Note>
## 二、功能说明
| 功能 | 支持情况 |

View File

@@ -14,10 +14,10 @@ Config model
{
"id": "3f2a9c1b", # server-generated short uuid (primary key)
"name": "siliconflow", # user-facing display label (not a key)
"name": "my-provider", # user-facing display label (not a key)
"api_key": "sk-...", # required
"api_base": "https://...", # required, must be OpenAI-compatible
"model": "deepseek-ai/DeepSeek-V3" # optional default model
"model": "model-name" # optional default model
}
Routing

View File

@@ -1334,8 +1334,19 @@ class CowCliPlugin(Plugin):
return "linkai (legacy)", "text-embedding-3-small", 1536
return "(legacy)", None, None
meta = EMBEDDING_VENDORS.get(provider_key) or {}
# Since we have added support for custom providers for vector models, this part should be modified accordingly:
# Custom providers ("custom:<id>") resolve to the "custom" vendor key.
resolved_key = "custom" if provider_key.startswith("custom:") else provider_key
meta = EMBEDDING_VENDORS.get(resolved_key) or {}
model = cfg_model or meta.get("default_model")
# Custom provider model fallback: read from custom_providers entry.
if not model and provider_key.startswith("custom:"):
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
_, custom_id = parse_custom_bot_type(provider_key)
if custom_id:
entry = _find_provider_by_id(get_custom_providers(), custom_id)
if entry and entry.get("model"):
model = entry["model"]
dim = cfg_dim if cfg_dim > 0 else meta.get("default_dimensions")
return provider_key, model, dim

View File

@@ -88,15 +88,15 @@ class TestResolveCustomCredentials(unittest.TestCase):
set_conf({
"bot_type": "custom:abc12345",
"custom_providers": [
{"id": "sf001", "name": "siliconflow", "api_key": "sf-key",
"api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"},
{"id": "abc12345", "name": "qiniu", "api_key": "qn-key",
"api_base": "https://api.qnaigc.com/v1", "model": "deepseek-v3"},
{"id": "sf001", "name": "provider-a", "api_key": "key-a",
"api_base": "https://api.example.com/v1", "model": "model-a"},
{"id": "abc12345", "name": "provider-b", "api_key": "key-b",
"api_base": "https://api.example.org/v1", "model": "model-b"},
],
})
self.assertEqual(
self.resolve(),
("qn-key", "https://api.qnaigc.com/v1", "deepseek-v3"),
("key-b", "https://api.example.org/v1", "model-b"),
)
def test_id_not_found_falls_back_to_legacy(self):
@@ -105,8 +105,8 @@ class TestResolveCustomCredentials(unittest.TestCase):
"custom_api_key": "legacy-key",
"custom_api_base": "https://legacy.example.com/v1",
"custom_providers": [
{"id": "sf001", "name": "siliconflow", "api_key": "sf-key",
"api_base": "https://api.siliconflow.cn/v1"},
{"id": "sf001", "name": "provider-a", "api_key": "key-a",
"api_base": "https://api.example.com/v1"},
],
})
self.assertEqual(

View File

@@ -74,8 +74,8 @@ class TestSetCustomProvider(unittest.TestCase):
def test_create_provider_does_not_hijack_bot_type(self):
"""Creating a provider without make_active must not change bot_type."""
res = self.h.call(action="set_custom_provider", name="siliconflow",
api_base="https://api.siliconflow.cn/v1", api_key="sf-key")
res = self.h.call(action="set_custom_provider", name="my-provider",
api_base="https://api.example.com/v1", api_key="key-a")
self.assertEqual(res["status"], "success")
self.assertTrue(res["created"])
self.assertIn("id", res)
@@ -85,13 +85,13 @@ class TestSetCustomProvider(unittest.TestCase):
providers = config_module.conf().get("custom_providers")
self.assertEqual(len(providers), 1)
self.assertEqual(providers[0]["id"], res["id"])
self.assertEqual(providers[0]["name"], "siliconflow")
self.assertEqual(providers[0]["name"], "my-provider")
self.assertEqual(self.h.bridge_resets, 1)
def test_create_with_make_active_switches_bot_type(self):
"""Creating a provider with make_active=true must switch bot_type."""
res = self.h.call(action="set_custom_provider", name="siliconflow",
api_base="https://api.siliconflow.cn/v1", api_key="sf-key",
res = self.h.call(action="set_custom_provider", name="my-provider",
api_base="https://api.example.com/v1", api_key="key-a",
make_active=True)
self.assertEqual(res["status"], "success")
bot_type = config_module.conf().get("bot_type")

View File

@@ -0,0 +1,172 @@
# encoding:utf-8
"""
Regression tests for browser-navigate SSRF protection.
The browser tool navigates to a model-supplied URL via Playwright
``page.goto`` and then auto-snapshots the page back to the model. Without a
guard, a model (including one under prompt injection) can point it at the
cloud-metadata endpoint (169.254.169.254) and read the credentials back
through the snapshot.
Unlike the vision / web_fetch tools, the browser legitimately needs local
pages — a dev server on ``localhost`` / ``127.0.0.1`` / a LAN IP. So the guard
is deliberately narrow: it blocks only **link-local** addresses
(169.254.0.0/16, which includes the metadata endpoint, plus IPv6 fe80::/10) and
the IPv6 cloud-metadata address, while leaving loopback and RFC1918/LAN
reachable.
These tests ensure ``BrowserTool``:
- blocks link-local / cloud-metadata targets *before* the navigation reaches
the browser service,
- still lets loopback, RFC1918/LAN and public URLs through to the (stubbed)
service,
- preserves the documented non-HTTP scheme behaviour (about:/data:), and
- honours an explicit opt-out (allow_private_targets).
No real browser / Playwright / network is used: the BrowserService that the
tool would create is replaced with a stub, and DNS resolution is mocked.
"""
import os
import sys
import types
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Stub 'requests' if not installed so sibling tool imports don't fail.
if "requests" not in sys.modules:
_requests_stub = types.ModuleType("requests")
_requests_stub.get = lambda *a, **k: None
sys.modules["requests"] = _requests_stub
def _gai(ip_str):
"""Build a socket.getaddrinfo return value for a single IPv4 address."""
return [(2, 1, 6, "", (ip_str, 0))]
class _StubService:
"""Stand-in for BrowserService that records navigation attempts."""
def __init__(self):
self.navigated = []
def navigate(self, url, timeout=30000):
self.navigated.append(url)
return {"url": url, "title": "page", "status": 200}
def snapshot(self, selector=None):
return "Page: page (http://page/)\nInteractive elements: 0\n---\ncontent"
class TestBrowserNavigateSSRF(unittest.TestCase):
"""Browser navigate blocks link-local/metadata but keeps local dev reachable."""
def setUp(self):
from agent.tools.browser.browser_tool import BrowserTool
self.tool = BrowserTool()
self.stub = _StubService()
# Force the tool to use our stub instead of a real BrowserService.
self.tool._service = self.stub
patcher = patch.object(BrowserTool, "_get_service", return_value=self.stub)
patcher.start()
self.addCleanup(patcher.stop)
# --- Link-local / cloud-metadata: rejected before any service call ---
def test_cloud_metadata_literal_blocked(self):
result = self.tool.execute(
{"action": "navigate", "url": "http://169.254.169.254/latest/meta-data/"}
)
self.assertEqual(result.status, "error")
self.assertIn("blocked for security", str(result.result))
self.assertEqual(self.stub.navigated, [])
def test_link_local_literal_blocked(self):
result = self.tool.execute({"action": "navigate", "url": "http://169.254.1.1/x"})
self.assertEqual(result.status, "error")
self.assertIn("blocked for security", str(result.result))
self.assertEqual(self.stub.navigated, [])
def test_ipv6_metadata_literal_blocked(self):
result = self.tool.execute(
{"action": "navigate", "url": "http://[fd00:ec2::254]/latest/"}
)
self.assertEqual(result.status, "error")
self.assertIn("blocked for security", str(result.result))
self.assertEqual(self.stub.navigated, [])
def test_metadata_hostname_blocked(self):
# A hostname that resolves to the metadata endpoint is blocked too.
with patch("socket.getaddrinfo", return_value=_gai("169.254.169.254")):
result = self.tool.execute(
{"action": "navigate", "url": "http://metadata.internal/latest/meta-data/"}
)
self.assertEqual(result.status, "error")
self.assertIn("blocked for security", str(result.result))
self.assertEqual(self.stub.navigated, [])
# --- Local dev targets stay reachable (the maintainer's core workflow) ---
def test_loopback_literal_allowed(self):
result = self.tool.execute({"action": "navigate", "url": "http://127.0.0.1:3000/"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["http://127.0.0.1:3000/"])
def test_ipv6_loopback_literal_allowed(self):
result = self.tool.execute({"action": "navigate", "url": "http://[::1]:5173/"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["http://[::1]:5173/"])
def test_localhost_bare_allowed(self):
"""A bare 'localhost' (no scheme) gets https:// prepended, then allowed."""
with patch("socket.getaddrinfo", return_value=_gai("127.0.0.1")):
result = self.tool.execute({"action": "navigate", "url": "localhost:3000"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["https://localhost:3000"])
def test_rfc1918_10_hostname_allowed(self):
with patch("socket.getaddrinfo", return_value=_gai("10.1.2.3")):
result = self.tool.execute({"action": "navigate", "url": "http://dev.lan/app"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["http://dev.lan/app"])
def test_rfc1918_192_168_hostname_allowed(self):
with patch("socket.getaddrinfo", return_value=_gai("192.168.0.5")):
result = self.tool.execute({"action": "navigate", "url": "http://router.lan/admin"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["http://router.lan/admin"])
# --- Public URL is allowed through to the (stubbed) service ---
def test_public_url_allowed(self):
with patch("socket.getaddrinfo", return_value=_gai("93.184.216.34")):
result = self.tool.execute({"action": "navigate", "url": "http://example.com/page"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["http://example.com/page"])
# --- Documented non-HTTP scheme behaviour preserved (not an egress path) ---
def test_about_blank_not_blocked(self):
result = self.tool.execute({"action": "navigate", "url": "about:blank"})
self.assertEqual(result.status, "success")
self.assertEqual(self.stub.navigated, ["about:blank"])
# --- Explicit opt-out lets an operator re-enable metadata/link-local ---
def test_opt_out_allows_metadata(self):
from agent.tools.browser.browser_tool import BrowserTool
tool = BrowserTool({"allow_private_targets": True})
stub = _StubService()
tool._service = stub
with patch.object(BrowserTool, "_get_service", return_value=stub):
result = tool.execute(
{"action": "navigate", "url": "http://169.254.169.254/latest/meta-data/"}
)
self.assertEqual(result.status, "success")
self.assertEqual(stub.navigated, ["http://169.254.169.254/latest/meta-data/"])
if __name__ == "__main__":
unittest.main()

View File

@@ -12,6 +12,7 @@ from dashscope import MultiModalConversation
from bridge.reply import Reply, ReplyType
from common.log import logger
from common.tmp_dir import TmpDir
from config import conf
from voice import audio_convert
from voice.voice import Voice
@@ -121,8 +122,7 @@ class DashScopeVoice(Voice):
@staticmethod
def _download_audio(url: str) -> Optional[str]:
try:
tmp_dir = os.path.join(os.getcwd(), "tmp")
os.makedirs(tmp_dir, exist_ok=True)
tmp_dir = TmpDir().path()
ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
ext = os.path.splitext(url.split("?", 1)[0])[1].lower() or ".wav"
if ext not in (".mp3", ".wav", ".m4a", ".aac", ".opus"):