mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Compare commits
33 Commits
2.1.2
...
feat-cow-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6211e63f90 | ||
|
|
44b61684ed | ||
|
|
ab6f49a822 | ||
|
|
02517e4a01 | ||
|
|
2599966cf7 | ||
|
|
6c68931892 | ||
|
|
41855ed511 | ||
|
|
02bc91f4af | ||
|
|
e9352e6984 | ||
|
|
ec4c36f450 | ||
|
|
215ed24401 | ||
|
|
c432681b2b | ||
|
|
49452e035d | ||
|
|
d1336b872e | ||
|
|
e1e29b32e9 | ||
|
|
214dcaf141 | ||
|
|
77a196de8b | ||
|
|
108d04398b | ||
|
|
c9c16298ec | ||
|
|
2ef31d5d33 | ||
|
|
e9d9b566a4 | ||
|
|
3baa3252bc | ||
|
|
90d9db0f83 | ||
|
|
8bff4f1658 | ||
|
|
a0e20ef311 | ||
|
|
6996215d3b | ||
|
|
03ffa2db7d | ||
|
|
75e3110e8c | ||
|
|
033480eef1 | ||
|
|
8ddfcbb125 | ||
|
|
a5aaecc48d | ||
|
|
a1e733080d | ||
|
|
3bc6e89b74 |
274
.github/workflows/release.yml
vendored
Normal file
274
.github/workflows/release.yml
vendored
Normal 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
13
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
21
app.py
@@ -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}")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) + "/"
|
||||
|
||||
59
config.py
59
config.py
@@ -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
6
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
81
desktop/README.md
Normal file
81
desktop/README.md
Normal 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
79
desktop/build/build-backend.sh
Executable 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\""
|
||||
145
desktop/build/cowagent-backend.spec
Normal file
145
desktop/build/cowagent-backend.spec
Normal 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',
|
||||
)
|
||||
52
desktop/build/requirements-desktop.txt
Normal file
52
desktop/build/requirements-desktop.txt
Normal 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
8300
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
103
desktop/package.json
Normal file
103
desktop/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
desktop/postcss.config.js
Normal file
6
desktop/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
desktop/resources/icon.icns
Normal file
BIN
desktop/resources/icon.icns
Normal file
Binary file not shown.
BIN
desktop/resources/icon.ico
Normal file
BIN
desktop/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
desktop/resources/icon.png
Normal file
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
307
desktop/src/main/index.ts
Normal 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
112
desktop/src/main/menu.ts
Normal 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))
|
||||
}
|
||||
62
desktop/src/main/preload.ts
Normal file
62
desktop/src/main/preload.ts
Normal 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 ''
|
||||
}
|
||||
})(),
|
||||
})
|
||||
255
desktop/src/main/python-manager.ts
Normal file
255
desktop/src/main/python-manager.ts
Normal 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
59
desktop/src/main/tray.ts
Normal 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
|
||||
}
|
||||
73
desktop/src/main/updater.ts
Normal file
73
desktop/src/main/updater.ts
Normal 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)
|
||||
}
|
||||
31
desktop/src/renderer/index.html
Normal file
31
desktop/src/renderer/index.html
Normal 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>
|
||||
140
desktop/src/renderer/src/App.tsx
Normal file
140
desktop/src/renderer/src/App.tsx
Normal 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
|
||||
404
desktop/src/renderer/src/api/client.ts
Normal file
404
desktop/src/renderer/src/api/client.ts
Normal 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
|
||||
326
desktop/src/renderer/src/components/ChatInput.tsx
Normal file
326
desktop/src/renderer/src/components/ChatInput.tsx
Normal 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
|
||||
407
desktop/src/renderer/src/components/KnowledgeGraph.tsx
Normal file
407
desktop/src/renderer/src/components/KnowledgeGraph.tsx
Normal 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
|
||||
109
desktop/src/renderer/src/components/Markdown.tsx
Normal file
109
desktop/src/renderer/src/components/Markdown.tsx
Normal 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
|
||||
156
desktop/src/renderer/src/components/MessageBubble.tsx
Normal file
156
desktop/src/renderer/src/components/MessageBubble.tsx
Normal 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
|
||||
110
desktop/src/renderer/src/components/MessageSteps.tsx
Normal file
110
desktop/src/renderer/src/components/MessageSteps.tsx
Normal 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
|
||||
293
desktop/src/renderer/src/components/OnboardingWizard.tsx
Normal file
293
desktop/src/renderer/src/components/OnboardingWizard.tsx
Normal 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
|
||||
222
desktop/src/renderer/src/components/QrLoginModal.tsx
Normal file
222
desktop/src/renderer/src/components/QrLoginModal.tsx
Normal 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
|
||||
58
desktop/src/renderer/src/components/StatusScreen.tsx
Normal file
58
desktop/src/renderer/src/components/StatusScreen.tsx
Normal 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
|
||||
97
desktop/src/renderer/src/components/UpdateBanner.tsx
Normal file
97
desktop/src/renderer/src/components/UpdateBanner.tsx
Normal 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
|
||||
189
desktop/src/renderer/src/highlight.css
Normal file
189
desktop/src/renderer/src/highlight.css
Normal 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 */
|
||||
|
||||
}
|
||||
137
desktop/src/renderer/src/hooks/useBackend.ts
Normal file
137
desktop/src/renderer/src/hooks/useBackend.ts
Normal 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 }
|
||||
}
|
||||
29
desktop/src/renderer/src/hooks/usePlatform.ts
Normal file
29
desktop/src/renderer/src/hooks/usePlatform.ts
Normal 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' }
|
||||
}
|
||||
57
desktop/src/renderer/src/hooks/useTheme.ts
Normal file
57
desktop/src/renderer/src/hooks/useTheme.ts
Normal 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 }
|
||||
}
|
||||
627
desktop/src/renderer/src/i18n.ts
Normal file
627
desktop/src/renderer/src/i18n.ts
Normal 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 || ''
|
||||
}
|
||||
338
desktop/src/renderer/src/index.css
Normal file
338
desktop/src/renderer/src/index.css
Normal 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;
|
||||
}
|
||||
142
desktop/src/renderer/src/layout/NavRail.tsx
Normal file
142
desktop/src/renderer/src/layout/NavRail.tsx
Normal 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
|
||||
180
desktop/src/renderer/src/layout/SessionList.tsx
Normal file
180
desktop/src/renderer/src/layout/SessionList.tsx
Normal 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
|
||||
42
desktop/src/renderer/src/layout/WindowControls.tsx
Normal file
42
desktop/src/renderer/src/layout/WindowControls.tsx
Normal 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
|
||||
13
desktop/src/renderer/src/main.tsx
Normal file
13
desktop/src/renderer/src/main.tsx
Normal 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>
|
||||
)
|
||||
271
desktop/src/renderer/src/pages/ChannelsPage.tsx
Normal file
271
desktop/src/renderer/src/pages/ChannelsPage.tsx
Normal 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
|
||||
229
desktop/src/renderer/src/pages/ChatPage.tsx
Normal file
229
desktop/src/renderer/src/pages/ChatPage.tsx
Normal 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
|
||||
385
desktop/src/renderer/src/pages/KnowledgePage.tsx
Normal file
385
desktop/src/renderer/src/pages/KnowledgePage.tsx
Normal 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
|
||||
104
desktop/src/renderer/src/pages/LogsPage.tsx
Normal file
104
desktop/src/renderer/src/pages/LogsPage.tsx
Normal 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
|
||||
256
desktop/src/renderer/src/pages/MemoryPage.tsx
Normal file
256
desktop/src/renderer/src/pages/MemoryPage.tsx
Normal 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
|
||||
20
desktop/src/renderer/src/pages/PlaceholderPage.tsx
Normal file
20
desktop/src/renderer/src/pages/PlaceholderPage.tsx
Normal 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
|
||||
60
desktop/src/renderer/src/pages/SettingsPage.tsx
Normal file
60
desktop/src/renderer/src/pages/SettingsPage.tsx
Normal 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
|
||||
142
desktop/src/renderer/src/pages/SkillsPage.tsx
Normal file
142
desktop/src/renderer/src/pages/SkillsPage.tsx
Normal 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
|
||||
314
desktop/src/renderer/src/pages/TasksPage.tsx
Normal file
314
desktop/src/renderer/src/pages/TasksPage.tsx
Normal 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
|
||||
331
desktop/src/renderer/src/pages/settings/BasicSettings.tsx
Normal file
331
desktop/src/renderer/src/pages/settings/BasicSettings.tsx
Normal 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
|
||||
159
desktop/src/renderer/src/pages/settings/CapabilityCard.tsx
Normal file
159
desktop/src/renderer/src/pages/settings/CapabilityCard.tsx
Normal 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
|
||||
833
desktop/src/renderer/src/pages/settings/ModelsTab.tsx
Normal file
833
desktop/src/renderer/src/pages/settings/ModelsTab.tsx
Normal 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
|
||||
57
desktop/src/renderer/src/pages/settings/modelsHelpers.ts
Normal file
57
desktop/src/renderer/src/pages/settings/modelsHelpers.ts
Normal 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__'
|
||||
196
desktop/src/renderer/src/pages/settings/primitives.tsx
Normal file
196
desktop/src/renderer/src/pages/settings/primitives.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
420
desktop/src/renderer/src/store/chatStore.ts
Normal file
420
desktop/src/renderer/src/store/chatStore.ts
Normal 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 })
|
||||
},
|
||||
}
|
||||
})
|
||||
42
desktop/src/renderer/src/store/onboardingStore.ts
Normal file
42
desktop/src/renderer/src/store/onboardingStore.ts
Normal 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 }),
|
||||
}))
|
||||
86
desktop/src/renderer/src/store/sessionStore.ts
Normal file
86
desktop/src/renderer/src/store/sessionStore.ts
Normal 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()
|
||||
},
|
||||
}))
|
||||
48
desktop/src/renderer/src/store/uiStore.ts
Normal file
48
desktop/src/renderer/src/store/uiStore.ts
Normal 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 }),
|
||||
}))
|
||||
55
desktop/src/renderer/src/store/updateStore.ts
Normal file
55
desktop/src/renderer/src/store/updateStore.ts
Normal 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
|
||||
}
|
||||
476
desktop/src/renderer/src/types.ts
Normal file
476
desktop/src/renderer/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
80
desktop/tailwind.config.js
Normal file
80
desktop/tailwind.config.js
Normal 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
22
desktop/tsconfig.json
Normal 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"]
|
||||
}
|
||||
17
desktop/tsconfig.main.json
Normal file
17
desktop/tsconfig.main.json
Normal 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
22
desktop/vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 |
|
||||
|
||||
@@ -52,6 +52,20 @@ WeCom AI Bot を介して CowAgent を接続し、ダイレクトメッセージ
|
||||
|
||||
設定後、プログラムを起動します。ログに `[WecomBot] Subscribe success` と表示されれば接続成功です。
|
||||
|
||||
<Note>
|
||||
ロングコネクションのほかに、**Webhook(HTTP コールバック)モード**にも対応しています。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. 対応機能
|
||||
|
||||
| 機能 | 状態 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
## 二、功能说明
|
||||
|
||||
| 功能 | 支持情况 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
172
tests/test_security_ssrf_browser_navigate.py
Normal file
172
tests/test_security_ssrf_browser_navigate.py
Normal 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()
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user