879 lines
41 KiB
PowerShell
879 lines
41 KiB
PowerShell
#Requires -Version 5.1
|
|
<#
|
|
.SYNOPSIS
|
|
CowAgent installer & management script for Windows.
|
|
.DESCRIPTION
|
|
One-liner install:
|
|
irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
|
|
Or from a local clone:
|
|
.\scripts\run.ps1 # install / configure
|
|
.\scripts\run.ps1 start # start service (delegates to cow CLI)
|
|
.\scripts\run.ps1 stop|restart|status|logs|config|update|help
|
|
#>
|
|
|
|
param(
|
|
[Parameter(Position = 0)]
|
|
[string]$Command = ""
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
# ── ensure UTF-8 everywhere on Windows ───────────────────────────
|
|
# Without this, Chinese text renders as mojibake (e.g. "éæ©") on Windows
|
|
# PowerShell 5.1, whose console defaults to the system ANSI code page (GBK on
|
|
# Chinese systems). Set the active code page AND the console encodings so both
|
|
# our output and any child process (git/python) speak UTF-8.
|
|
try { chcp 65001 | Out-Null } catch {}
|
|
try {
|
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
|
} catch {}
|
|
# $OutputEncoding controls how strings are piped to external programs.
|
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
|
$env:PYTHONIOENCODING = "utf-8"
|
|
|
|
# ── disable console QuickEdit mode ───────────────────────────────
|
|
# On Windows, QuickEdit is ON by default: a stray click/selection in the
|
|
# window FREEZES all output until the user presses a key. During long steps
|
|
# (git clone, pip install) this looks like a hang, and the wake-up key gets
|
|
# buffered and later auto-confirms a menu default. Turning QuickEdit off
|
|
# removes both the freeze and the phantom keystrokes.
|
|
try {
|
|
if (-not ([Console]::IsInputRedirected)) {
|
|
$quickEditType = @'
|
|
using System;
|
|
using System.Runtime.InteropServices;
|
|
public static class ConsoleQuickEdit {
|
|
const int STD_INPUT_HANDLE = -10;
|
|
const uint ENABLE_QUICK_EDIT = 0x0040;
|
|
const uint ENABLE_EXTENDED = 0x0080;
|
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
static extern IntPtr GetStdHandle(int nStdHandle);
|
|
[DllImport("kernel32.dll")]
|
|
static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
|
|
[DllImport("kernel32.dll")]
|
|
static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode);
|
|
public static void Disable() {
|
|
IntPtr handle = GetStdHandle(STD_INPUT_HANDLE);
|
|
uint mode;
|
|
if (!GetConsoleMode(handle, out mode)) { return; }
|
|
mode &= ~ENABLE_QUICK_EDIT; // clear QuickEdit
|
|
mode |= ENABLE_EXTENDED; // keep extended flags valid
|
|
SetConsoleMode(handle, mode);
|
|
}
|
|
}
|
|
'@
|
|
if (-not ([System.Management.Automation.PSTypeName]'ConsoleQuickEdit').Type) {
|
|
Add-Type -TypeDefinition $quickEditType -ErrorAction Stop
|
|
}
|
|
[ConsoleQuickEdit]::Disable()
|
|
}
|
|
} catch {}
|
|
|
|
# ── colours ──────────────────────────────────────────────────────
|
|
function Write-Cow { param([string]$M) Write-Host $M -ForegroundColor Green }
|
|
function Write-Warn { param([string]$M) Write-Host $M -ForegroundColor Yellow }
|
|
function Write-Err { param([string]$M) Write-Host $M -ForegroundColor Red }
|
|
function Write-Info { param([string]$M) Write-Host $M -ForegroundColor Cyan }
|
|
|
|
# ── i18n: install-flow language ──────────────────────────────────
|
|
# $UiLang controls the language of install prompts/menus ("zh" or "en").
|
|
# Chosen by the user at the first step; defaults to environment detection
|
|
# for management commands (start/stop/...).
|
|
$script:UiLang = ""
|
|
|
|
# Detect default UI language from the OS culture (best-effort). Checks the
|
|
# display/UI culture first (closest to the user's chosen Windows language),
|
|
# then the regional format culture as a fallback. Any zh-* signal -> "zh".
|
|
function Get-DefaultUiLang {
|
|
foreach ($getter in @({ (Get-UICulture).Name }, { (Get-Culture).Name })) {
|
|
try {
|
|
$name = & $getter
|
|
if ($name -match '^zh') { return "zh" }
|
|
} catch {}
|
|
}
|
|
return "en"
|
|
}
|
|
|
|
# Translation helper: T <zh> <en> -> string in the active UI language.
|
|
function T {
|
|
param([string]$Zh, [string]$En)
|
|
if ($script:UiLang -eq "en") { return $En } else { return $Zh }
|
|
}
|
|
|
|
# ── detect project directory ─────────────────────────────────────
|
|
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
|
|
$BaseDir = Split-Path $ScriptDir -Parent
|
|
|
|
$IsProjectDir = (Test-Path "$BaseDir\app.py") -and (Test-Path "$BaseDir\config-template.json")
|
|
if (-not $IsProjectDir) {
|
|
$BaseDir = $PWD.Path
|
|
$IsProjectDir = (Test-Path "$BaseDir\app.py") -and (Test-Path "$BaseDir\config-template.json")
|
|
}
|
|
|
|
# Initialize $UiLang for management commands: prefer cow_lang from an existing
|
|
# config.json, otherwise fall back to environment detection.
|
|
function Initialize-UiLang {
|
|
if ($script:UiLang) { return }
|
|
$cfgLang = ""
|
|
if (Test-Path "$BaseDir\config.json") {
|
|
try {
|
|
$cfg = Get-Content "$BaseDir\config.json" -Raw | ConvertFrom-Json
|
|
if ($cfg.cow_lang) { $cfgLang = "$($cfg.cow_lang)" }
|
|
} catch {}
|
|
}
|
|
switch ($cfgLang) {
|
|
"zh" { $script:UiLang = "zh" }
|
|
"en" { $script:UiLang = "en" }
|
|
default { $script:UiLang = Get-DefaultUiLang }
|
|
}
|
|
}
|
|
|
|
# Drain any buffered console keystrokes. During long steps (git clone, pip
|
|
# install) users often hit Enter to "wake up" a seemingly stuck console; those
|
|
# keys sit in the input buffer and would otherwise be consumed by the next
|
|
# ReadKey, auto-confirming a menu's default. Flush them before reading input.
|
|
function Clear-InputBuffer {
|
|
try {
|
|
if ([Console]::IsInputRedirected) { return }
|
|
while ([Console]::KeyAvailable) { [void][Console]::ReadKey($true) }
|
|
} catch {}
|
|
}
|
|
|
|
# ── arrow-key selectable menu with number fallback ───────────────
|
|
# Usage: $idx = Select-Menu -Title "..." -Options @("a","b") [-Default 1]
|
|
# Returns the selected 1-based index.
|
|
function Select-Menu {
|
|
param(
|
|
[string]$Title,
|
|
[string[]]$Options,
|
|
[int]$Default = 1
|
|
)
|
|
$count = $Options.Count
|
|
$cur = [Math]::Max(0, [Math]::Min($Default - 1, $count - 1))
|
|
|
|
# Fallback to numbered input when there is no interactive console
|
|
# (e.g. piped input, redirected host).
|
|
$interactive = $true
|
|
try {
|
|
if ([Console]::IsInputRedirected) { $interactive = $false }
|
|
} catch { $interactive = $false }
|
|
|
|
if (-not $interactive) {
|
|
Write-Info $Title
|
|
for ($i = 0; $i -lt $count; $i++) {
|
|
Write-Host (" {0}) {1}" -f ($i + 1), $Options[$i])
|
|
}
|
|
do {
|
|
$sel = Read-Host (T "请输入序号" "Enter number")
|
|
if (-not $sel) { $sel = "$($cur + 1)" }
|
|
} while ($sel -notmatch '^\d+$' -or [int]$sel -lt 1 -or [int]$sel -gt $count)
|
|
return [int]$sel
|
|
}
|
|
|
|
Write-Info $Title
|
|
Write-Host (T "↑/↓ 选择,Enter 确认" "Use ↑/↓ to move, Enter to select") -ForegroundColor Cyan
|
|
|
|
# Discard keystrokes buffered during the previous (possibly long-running)
|
|
# step so a leftover Enter doesn't instantly confirm the default option.
|
|
Clear-InputBuffer
|
|
|
|
[Console]::CursorVisible = $false
|
|
$firstDraw = $true
|
|
try {
|
|
while ($true) {
|
|
if (-not $firstDraw) {
|
|
# Move cursor up to the top of the option block to redraw it.
|
|
$top = [Console]::CursorTop - $count
|
|
if ($top -lt 0) { $top = 0 }
|
|
[Console]::SetCursorPosition(0, $top)
|
|
}
|
|
$firstDraw = $false
|
|
|
|
for ($i = 0; $i -lt $count; $i++) {
|
|
# Clear the line first to avoid leftover characters.
|
|
Write-Host (" " * ([Console]::WindowWidth - 1)) -NoNewline
|
|
[Console]::SetCursorPosition(0, [Console]::CursorTop)
|
|
if ($i -eq $cur) {
|
|
Write-Host (" > " + $Options[$i]) -ForegroundColor Green
|
|
} else {
|
|
Write-Host (" " + $Options[$i])
|
|
}
|
|
}
|
|
|
|
$key = [Console]::ReadKey($true)
|
|
switch ($key.Key) {
|
|
"UpArrow" { $cur = (($cur - 1 + $count) % $count) }
|
|
"DownArrow" { $cur = (($cur + 1) % $count) }
|
|
"Enter" { return ($cur + 1) }
|
|
default {
|
|
# Number shortcut (1-9) jumps to that option and confirms.
|
|
$ch = $key.KeyChar
|
|
if ($ch -match '^[1-9]$') {
|
|
$n = [int]"$ch"
|
|
if ($n -ge 1 -and $n -le $count) { return $n }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
[Console]::CursorVisible = $true
|
|
}
|
|
}
|
|
|
|
# ── language selection (first step of install) ───────────────────
|
|
function Select-Language {
|
|
# Order is fixed (English first, Chinese second). The default highlight
|
|
# follows detection, but conservatively: only a confident "zh" signal
|
|
# (a zh-* system culture) preselects Chinese; everything else defaults to
|
|
# English. The menu hint shows in the detected language for familiarity.
|
|
$detected = Get-DefaultUiLang
|
|
if ($detected -eq "zh") {
|
|
$default = 2
|
|
$script:UiLang = "zh"
|
|
} else {
|
|
$default = 1
|
|
$script:UiLang = "en"
|
|
}
|
|
|
|
$idx = Select-Menu -Title "Select Language / 选择语言" -Options @("English", "中文 (Chinese)") -Default $default
|
|
switch ($idx) {
|
|
1 { $script:UiLang = "en" }
|
|
2 { $script:UiLang = "zh" }
|
|
default { $script:UiLang = "en" }
|
|
}
|
|
$script:InstallLang = $script:UiLang
|
|
}
|
|
|
|
# ── Python detection ─────────────────────────────────────────────
|
|
function Find-Python {
|
|
# 3.13 compatibility is not great, so prefer 3.7-3.12 and only fall back to 3.13.
|
|
$fallback = $null
|
|
foreach ($cmd in @("python3", "python")) {
|
|
$bin = Get-Command $cmd -ErrorAction SilentlyContinue
|
|
if (-not $bin) { continue }
|
|
try {
|
|
$ver = & $bin.Source -c "import sys; v=sys.version_info; print(f'{v.major}.{v.minor}')" 2>$null
|
|
$parts = $ver -split '\.'
|
|
$major = [int]$parts[0]; $minor = [int]$parts[1]
|
|
if ($major -eq 3 -and $minor -ge 7 -and $minor -le 12) {
|
|
return $bin.Source
|
|
}
|
|
if ($major -eq 3 -and $minor -eq 13 -and -not $fallback) {
|
|
$fallback = $bin.Source
|
|
}
|
|
} catch {}
|
|
}
|
|
return $fallback
|
|
}
|
|
|
|
$PythonCmd = Find-Python
|
|
function Assert-Python {
|
|
if (-not $PythonCmd) {
|
|
Write-Err (T "未找到 Python 3.7-3.13,请从 https://www.python.org/downloads/ 安装" "Python 3.7-3.13 not found. Please install from https://www.python.org/downloads/")
|
|
Read-Host (T "按回车退出" "Press Enter to exit")
|
|
exit 1
|
|
}
|
|
Write-Cow ((T "找到 Python" "Found Python") + ": $PythonCmd")
|
|
}
|
|
|
|
# ── clone project ────────────────────────────────────────────────
|
|
function Install-Project {
|
|
if (Test-Path "CowAgent") {
|
|
# Auto-backup the existing directory without prompting.
|
|
$backup = "CowAgent_backup_$(Get-Date -Format 'yyyyMMddHHmmss')"
|
|
Rename-Item "CowAgent" $backup
|
|
Write-Warn ((T "已存在 CowAgent 目录,已自动备份为" "Existing 'CowAgent' directory backed up to") + " '$backup'")
|
|
}
|
|
|
|
$gitBin = Get-Command git -ErrorAction SilentlyContinue
|
|
if (-not $gitBin) {
|
|
Write-Err (T "未找到 Git,请从 https://git-scm.com/download/win 安装" "Git not found. Please install from https://git-scm.com/download/win")
|
|
Read-Host (T "按回车退出" "Press Enter to exit")
|
|
exit 1
|
|
}
|
|
|
|
Write-Cow (T "正在克隆 CowAgent 项目..." "Cloning CowAgent project...")
|
|
$cloneOk = $false
|
|
|
|
# Test GitHub connectivity before attempting clone
|
|
try {
|
|
$null = Invoke-WebRequest -Uri "https://github.com" -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
|
Write-Cow (T "GitHub 可达,正在从 GitHub 克隆..." "GitHub is reachable, cloning from GitHub...")
|
|
$prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
|
git clone --depth 10 --progress "https://github.com/zhayujie/CowAgent.git" 2>&1 | ForEach-Object { Write-Host $_ }
|
|
if ($LASTEXITCODE -eq 0) { $cloneOk = $true }
|
|
$ErrorActionPreference = $prevEAP
|
|
if (-not $cloneOk) {
|
|
if (Test-Path "CowAgent") { Remove-Item -Recurse -Force "CowAgent" }
|
|
}
|
|
} catch {}
|
|
|
|
if (-not $cloneOk) {
|
|
Write-Warn (T "GitHub 克隆失败或超时,切换到 Gitee 镜像..." "GitHub clone failed or timed out, switching to Gitee mirror...")
|
|
$prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
|
git clone --depth 10 --progress "https://gitee.com/zhayujie/CowAgent.git" 2>&1 | ForEach-Object { Write-Host $_ }
|
|
if ($LASTEXITCODE -eq 0) { $cloneOk = $true }
|
|
$ErrorActionPreference = $prevEAP
|
|
if (-not $cloneOk) {
|
|
if (Test-Path "CowAgent") { Remove-Item -Recurse -Force "CowAgent" }
|
|
}
|
|
}
|
|
|
|
if (-not $cloneOk) {
|
|
Write-Err (T "GitHub 和 Gitee 均克隆失败,请检查网络连接。" "Clone failed from both GitHub and Gitee. Please check your network connection.")
|
|
Write-Err (T "你也可以手动克隆: git clone https://gitee.com/zhayujie/CowAgent.git" "You can also manually clone: git clone https://gitee.com/zhayujie/CowAgent.git")
|
|
Read-Host (T "按回车退出" "Press Enter to exit")
|
|
exit 1
|
|
}
|
|
|
|
Set-Location "CowAgent"
|
|
$script:BaseDir = $PWD.Path
|
|
$script:IsProjectDir = $true
|
|
Write-Cow ((T "项目已克隆" "Project cloned") + ": $BaseDir")
|
|
}
|
|
|
|
# Test whether a URL is reachable within a short timeout. Uses a HEAD request
|
|
# and hides progress so it never blocks the UI for long. Any failure (DNS, TLS,
|
|
# timeout) just returns $false so the caller falls back gracefully.
|
|
function Test-UrlReachable {
|
|
param([string]$Url, [int]$TimeoutSec = 4)
|
|
$oldPP = $ProgressPreference; $ProgressPreference = "SilentlyContinue"
|
|
try {
|
|
$null = Invoke-WebRequest -Uri $Url -Method Head -UseBasicParsing -TimeoutSec $TimeoutSec -ErrorAction Stop
|
|
return $true
|
|
} catch {
|
|
return $false
|
|
} finally {
|
|
$ProgressPreference = $oldPP
|
|
}
|
|
}
|
|
|
|
# Pick the pip index by install language, with the other as fallback:
|
|
# - zh users: Tsinghua mirror first, official PyPI fallback
|
|
# - others : official PyPI first, Tsinghua mirror fallback
|
|
# Returns an args array to splat into pip (empty = pip default / official PyPI).
|
|
function Get-PipMirrorArgs {
|
|
$tuna = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
|
$pypi = "https://pypi.org/simple"
|
|
if ($script:UiLang -eq "zh") {
|
|
if (Test-UrlReachable "$tuna/") {
|
|
Write-Warn ((T "使用 pip 镜像" "Using pip mirror") + ": $tuna")
|
|
return @("-i", $tuna)
|
|
}
|
|
} else {
|
|
if ((-not (Test-UrlReachable "$pypi/")) -and (Test-UrlReachable "$tuna/")) {
|
|
Write-Warn ((T "使用 pip 镜像" "Using pip mirror") + ": $tuna")
|
|
return @("-i", $tuna)
|
|
}
|
|
}
|
|
return @()
|
|
}
|
|
|
|
# ── install dependencies ─────────────────────────────────────────
|
|
function Install-Dependencies {
|
|
Write-Cow (T "正在安装依赖..." "Installing dependencies...")
|
|
|
|
# Probe the mirror first (with progress hidden so the slow IWR call doesn't
|
|
# leave the screen blank for too long).
|
|
$oldPP = $ProgressPreference; $ProgressPreference = "SilentlyContinue"
|
|
$pipMirror = Get-PipMirrorArgs
|
|
$ProgressPreference = $oldPP
|
|
|
|
# Keep pip output VISIBLE (do not pipe to Out-Null): on slow networks the
|
|
# download can take minutes, and a silent step looks like a hang.
|
|
$prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
|
|
|
Write-Info (T "正在升级 pip 等基础工具..." "Upgrading pip and basic tools...")
|
|
& $PythonCmd -m pip install --upgrade pip setuptools wheel @pipMirror
|
|
|
|
Write-Info (T "正在安装项目依赖(可能需要几分钟)..." "Installing project dependencies (may take a few minutes)...")
|
|
& $PythonCmd -m pip install -r "$BaseDir\requirements.txt" @pipMirror
|
|
$pipExit = $LASTEXITCODE
|
|
$ErrorActionPreference = $prevEAP
|
|
if ($pipExit -ne 0) {
|
|
Write-Warn (T "部分依赖可能有问题,但继续安装..." "Some dependencies may have issues, but continuing...")
|
|
}
|
|
|
|
Write-Cow (T "正在注册 cow CLI..." "Registering cow CLI...")
|
|
$prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
|
& $PythonCmd -m pip install -e $BaseDir @pipMirror 2>&1 | Out-Null
|
|
$ErrorActionPreference = $prevEAP
|
|
|
|
# Add the Scripts dir (where pip puts cow.exe) to PATH, both for this
|
|
# session and persistently, so `cow` keeps working after a reboot.
|
|
$scriptsDir = & $PythonCmd -c "import sysconfig; print(sysconfig.get_path('scripts'))" 2>$null
|
|
if ($scriptsDir -and (Test-Path $scriptsDir)) {
|
|
Add-ScriptsDirToPath $scriptsDir
|
|
}
|
|
|
|
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
|
|
if ($cowBin) {
|
|
Write-Cow ((T "cow CLI 注册成功" "cow CLI registered") + ": $($cowBin.Source)")
|
|
} else {
|
|
Write-Warn ((T "cow CLI 不在 PATH 中,你可以使用" "cow CLI not in PATH. You can use") + ": $PythonCmd -m cli.cli")
|
|
}
|
|
}
|
|
|
|
# Add the Python Scripts dir to PATH for this session and persist it to the
|
|
# user PATH (User scope needs no admin rights), so `cow` survives a reboot.
|
|
function Add-ScriptsDirToPath {
|
|
param([string]$ScriptsDir)
|
|
|
|
if ($env:PATH -notlike "*$ScriptsDir*") {
|
|
$env:PATH = "$ScriptsDir;$env:PATH"
|
|
}
|
|
|
|
# Persist to the User PATH only if missing from BOTH User and Machine scopes
|
|
# (checking Machine avoids a duplicate when Python is installed for all
|
|
# users). Read raw scope values, not the already-merged $env:PATH.
|
|
try {
|
|
$target = $ScriptsDir.TrimEnd('\')
|
|
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
|
$machinePath = ""
|
|
try { $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") } catch {}
|
|
|
|
$allParts = @()
|
|
foreach ($p in @($userPath, $machinePath)) {
|
|
if ($p) { $allParts += ($p -split ';' | Where-Object { $_ -ne "" }) }
|
|
}
|
|
$already = $allParts | Where-Object { $_.TrimEnd('\') -ieq $target }
|
|
if (-not $already) {
|
|
$newPath = if ($userPath) { "$userPath;$ScriptsDir" } else { $ScriptsDir }
|
|
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")
|
|
Write-Cow ((T "已将 cow 命令目录加入用户 PATH(重启后仍可用)" "Added cow command directory to user PATH (persists after reboot)") + ": $ScriptsDir")
|
|
}
|
|
} catch {
|
|
Write-Warn (T "无法自动写入持久化 PATH,重启后可能需要重新配置。" "Could not persist PATH automatically; you may need to reconfigure after reboot.")
|
|
}
|
|
}
|
|
|
|
# ── model selection ──────────────────────────────────────────────
|
|
# Order mirrors run.sh: DeepSeek, Claude, Gemini, OpenAI, MiniMax, Zhipu,
|
|
# Qwen, Doubao, Kimi, LinkAI, then Skip (11th option).
|
|
# Each entry: Provider / default model name / config key field / optional base.
|
|
$ModelChoices = @{
|
|
1 = @{ Provider = "DeepSeek"; Default = "deepseek-v4-flash"; Field = "deepseek_api_key" }
|
|
2 = @{ Provider = "Claude"; Default = "claude-opus-4-8"; Field = "claude_api_key"; BaseField = "claude_api_base" }
|
|
3 = @{ Provider = "Gemini"; Default = "gemini-3.1-pro-preview"; Field = "gemini_api_key"; BaseField = "gemini_api_base" }
|
|
4 = @{ Provider = "OpenAI"; Default = "gpt-5.5"; Field = "open_ai_api_key"; BaseField = "open_ai_api_base" }
|
|
5 = @{ Provider = "MiniMax"; Default = "MiniMax-M3"; Field = "minimax_api_key" }
|
|
6 = @{ Provider = "GLM"; Default = "glm-5.2"; Field = "zhipu_ai_api_key" }
|
|
7 = @{ Provider = "Qwen (DashScope)"; Default = "qwen3.7-plus"; Field = "dashscope_api_key" }
|
|
8 = @{ Provider = "Doubao (Volcengine Ark)"; Default = "doubao-seed-2-1-pro-260628"; Field = "ark_api_key" }
|
|
9 = @{ Provider = "Kimi (Moonshot)"; Default = "kimi-k2.7-code"; Field = "moonshot_api_key" }
|
|
10 = @{ Provider = "MiMo"; Default = "mimo-v2.5-pro"; Field = "mimo_api_key" }
|
|
11 = @{ Provider = "LinkAI"; Default = "deepseek-v4-flash"; Field = "linkai_api_key"; Linkai = $true }
|
|
}
|
|
|
|
function Select-Model {
|
|
Write-Host ""
|
|
$title = T "选择 AI 模型" "Select AI Model"
|
|
$options = @(
|
|
"DeepSeek (deepseek-v4-flash, deepseek-v4-pro, etc.)",
|
|
"Claude (claude-opus-4-8, claude-fable-5, etc.)",
|
|
"Gemini (gemini-3.5-flash, gemini-3.1-pro-preview, etc.)",
|
|
"OpenAI (gpt-5.5, etc.)",
|
|
"MiniMax (MiniMax-M3, etc.)",
|
|
"GLM (glm-5.2, etc.)",
|
|
"Qwen (qwen3.7-plus, qwen3.7-max, etc.)",
|
|
"Doubao (doubao-seed-2.1, etc.)",
|
|
"Kimi (kimi-k2.7-code, etc.)",
|
|
"MiMo (mimo-v2.5-pro, etc.)",
|
|
("LinkAI (" + (T "一个 Key 接入所有模型" "access all models via one API") + ")"),
|
|
(T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)")
|
|
)
|
|
$script:ModelChoice = Select-Menu -Title $title -Options $options -Default 1
|
|
}
|
|
|
|
# Configure model. Only ask for the API key; model name and base default to
|
|
# sensible values and can be changed later in the web console.
|
|
function Configure-Model {
|
|
# Reset model-related state
|
|
$script:ModelName = ""
|
|
$script:ModelField = ""
|
|
$script:ApiKey = ""
|
|
$script:ApiBase = ""
|
|
$script:ApiBaseField = ""
|
|
$script:UseLinkai = $false
|
|
|
|
if ($script:ModelChoice -eq 12) {
|
|
# Skip: leave model unset, will be configured in the web console.
|
|
Write-Warn (T "已跳过模型配置,稍后可在 Web 控制台填写" "Model configuration skipped, you can set it later in the web console")
|
|
return
|
|
}
|
|
|
|
$m = $ModelChoices[$script:ModelChoice]
|
|
Write-Cow ((T "正在配置" "Configuring") + " $($m.Provider)...")
|
|
# Show where to obtain a LinkAI key.
|
|
if ($m.Linkai) {
|
|
Write-Info ((T "获取 LinkAI Key" "Get your LinkAI Key") + ": https://link-ai.tech/console/interface")
|
|
}
|
|
$hint = T "回车跳过,稍后在 Web 控制台填写" "press Enter to skip, set later in web console"
|
|
$script:ApiKey = Read-Host ((T "请输入" "Enter") + " $($m.Provider) API Key ($hint)")
|
|
$script:ModelName = $m.Default
|
|
$script:ModelField = $m.Field
|
|
if ($m.BaseField) { $script:ApiBaseField = $m.BaseField }
|
|
if ($m.Linkai) { $script:UseLinkai = $true }
|
|
}
|
|
|
|
# ── channel selection ────────────────────────────────────────────
|
|
# Channel label by stable key (independent of menu order).
|
|
function Get-ChannelLabel {
|
|
param([string]$Key)
|
|
switch ($Key) {
|
|
"web" { return (T "Web 网页控制台(推荐,开箱即用)" "Web Console (recommended, ready to use)") }
|
|
"weixin" { return (T "微信 Weixin" "Wechat") }
|
|
"feishu" { return (T "飞书 Feishu" "Feishu") }
|
|
"dingtalk" { return (T "钉钉 DingTalk" "DingTalk") }
|
|
"wecom_bot" { return (T "企微智能机器人 WeCom Bot" "WeCom Bot") }
|
|
"qq" { return "QQ" }
|
|
"wechatcom_app" { return (T "企微自建应用 WeCom App" "WeCom App") }
|
|
"telegram" { return "Telegram" }
|
|
"slack" { return "Slack" }
|
|
"discord" { return "Discord" }
|
|
"skip" { return (T "⏭ 跳过(稍后在 Web 控制台配置)" "⏭ Skip (configure later in the web console)") }
|
|
}
|
|
}
|
|
|
|
# Select channel. The display order depends on the install language:
|
|
# - English: Web first, then the global IM channels (Telegram/Discord/Slack),
|
|
# then the China-focused channels.
|
|
# - Chinese: Web first, then China-focused channels, then global ones.
|
|
# A stable key list decouples the menu order from the config logic.
|
|
function Select-Channel {
|
|
Write-Host ""
|
|
$title = T "选择接入渠道" "Select Communication Channel"
|
|
if ($script:UiLang -eq "en") {
|
|
$script:ChannelKeys = @("web", "telegram", "discord", "slack", "weixin", "feishu", "dingtalk", "wecom_bot", "qq", "wechatcom_app", "skip")
|
|
} else {
|
|
$script:ChannelKeys = @("web", "weixin", "feishu", "dingtalk", "wecom_bot", "qq", "wechatcom_app", "telegram", "slack", "discord", "skip")
|
|
}
|
|
$options = @($script:ChannelKeys | ForEach-Object { Get-ChannelLabel $_ })
|
|
$idx = Select-Menu -Title $title -Options $options -Default 1
|
|
# Map the 1-based menu position back to the stable channel key.
|
|
$script:ChannelChoice = $script:ChannelKeys[$idx - 1]
|
|
}
|
|
|
|
# Configure channel, dispatched by stable channel key (not menu position).
|
|
function Configure-Channel {
|
|
$script:ChannelExtra = @{}
|
|
$script:AccessInfo = ""
|
|
|
|
switch ($script:ChannelChoice) {
|
|
{ $_ -eq "web" -or $_ -eq "skip" } {
|
|
# Web (also the default when skipped). Default port, no prompt.
|
|
$script:ChannelType = "web"
|
|
$script:ChannelExtra["web_port"] = 9899
|
|
$script:AccessInfo = (T "Web 控制台地址" "Web console") + " : http://localhost:9899/chat"
|
|
}
|
|
"weixin" {
|
|
$script:ChannelType = "weixin"
|
|
$script:AccessInfo = T "微信渠道已配置,请在终端或 Web 控制台扫码登录" "Weixin channel configured. Scan QR code in terminal or web console to login."
|
|
}
|
|
"feishu" {
|
|
$script:ChannelType = "feishu"
|
|
Write-Cow (T "配置飞书(WebSocket 模式)..." "Configure Feishu (WebSocket mode)...")
|
|
$script:ChannelExtra["feishu_app_id"] = Read-Host (T "请输入飞书 App ID" "Enter Feishu App ID")
|
|
$script:ChannelExtra["feishu_app_secret"] = Read-Host (T "请输入飞书 App Secret" "Enter Feishu App Secret")
|
|
$script:ChannelExtra["feishu_event_mode"] = "websocket"
|
|
$script:AccessInfo = T "飞书渠道已配置(WebSocket 模式)" "Feishu channel configured (WebSocket mode)"
|
|
}
|
|
"dingtalk" {
|
|
$script:ChannelType = "dingtalk"
|
|
Write-Cow (T "配置钉钉..." "Configure DingTalk...")
|
|
$script:ChannelExtra["dingtalk_client_id"] = Read-Host (T "请输入钉钉 Client ID" "Enter DingTalk Client ID")
|
|
$script:ChannelExtra["dingtalk_client_secret"] = Read-Host (T "请输入钉钉 Client Secret" "Enter DingTalk Client Secret")
|
|
$script:AccessInfo = T "钉钉渠道已配置" "DingTalk channel configured"
|
|
}
|
|
"wecom_bot" {
|
|
$script:ChannelType = "wecom_bot"
|
|
Write-Cow (T "配置企微智能机器人..." "Configure WeCom Bot...")
|
|
$script:ChannelExtra["wecom_bot_id"] = Read-Host (T "请输入 WeCom Bot ID" "Enter WeCom Bot ID")
|
|
$script:ChannelExtra["wecom_bot_secret"] = Read-Host (T "请输入 WeCom Bot Secret" "Enter WeCom Bot Secret")
|
|
$script:AccessInfo = T "企微智能机器人渠道已配置" "WeCom Bot channel configured"
|
|
}
|
|
"qq" {
|
|
$script:ChannelType = "qq"
|
|
Write-Cow (T "配置 QQ 机器人..." "Configure QQ Bot...")
|
|
$script:ChannelExtra["qq_app_id"] = Read-Host (T "请输入 QQ App ID" "Enter QQ App ID")
|
|
$script:ChannelExtra["qq_app_secret"] = Read-Host (T "请输入 QQ App Secret" "Enter QQ App Secret")
|
|
$script:AccessInfo = T "QQ 机器人渠道已配置" "QQ Bot channel configured"
|
|
}
|
|
"wechatcom_app" {
|
|
$script:ChannelType = "wechatcom_app"
|
|
Write-Cow (T "配置企微自建应用..." "Configure WeCom App...")
|
|
$script:ChannelExtra["wechatcom_corp_id"] = Read-Host (T "请输入企业 Corp ID" "Enter WeChat Corp ID")
|
|
$script:ChannelExtra["wechatcomapp_token"] = Read-Host (T "请输入应用 Token" "Enter WeChat Com App Token")
|
|
$script:ChannelExtra["wechatcomapp_secret"] = Read-Host (T "请输入应用 Secret" "Enter WeChat Com App Secret")
|
|
$script:ChannelExtra["wechatcomapp_agent_id"] = Read-Host (T "请输入应用 Agent ID" "Enter WeChat Com App Agent ID")
|
|
$script:ChannelExtra["wechatcomapp_aes_key"] = Read-Host (T "请输入应用 AES Key" "Enter WeChat Com App AES Key")
|
|
$port = Read-Host ((T "请输入应用端口" "Enter port") + " [" + (T "默认" "default") + ": 9898]")
|
|
if (-not ($port -match '^\d+$')) { $port = "9898" }
|
|
$script:ChannelExtra["wechatcomapp_port"] = [int]$port
|
|
$script:AccessInfo = (T "企微自建应用渠道已配置,端口" "WeCom App channel configured on port") + " $port"
|
|
}
|
|
"telegram" {
|
|
$script:ChannelType = "telegram"
|
|
Write-Cow (T "配置 Telegram..." "Configure Telegram...")
|
|
$script:ChannelExtra["telegram_token"] = Read-Host (T "请输入 Telegram Bot Token" "Enter Telegram Bot Token")
|
|
$script:AccessInfo = T "Telegram 渠道已配置" "Telegram channel configured"
|
|
}
|
|
"slack" {
|
|
$script:ChannelType = "slack"
|
|
Write-Cow (T "配置 Slack..." "Configure Slack...")
|
|
$script:ChannelExtra["slack_bot_token"] = Read-Host ((T "请输入 Slack Bot Token" "Enter Slack Bot Token") + " (xoxb-...)")
|
|
$script:ChannelExtra["slack_app_token"] = Read-Host ((T "请输入 Slack App Token" "Enter Slack App Token") + " (xapp-...)")
|
|
$script:AccessInfo = T "Slack 渠道已配置" "Slack channel configured"
|
|
}
|
|
"discord" {
|
|
$script:ChannelType = "discord"
|
|
Write-Cow (T "配置 Discord..." "Configure Discord...")
|
|
$script:ChannelExtra["discord_token"] = Read-Host (T "请输入 Discord Bot Token" "Enter Discord Bot Token")
|
|
$script:AccessInfo = T "Discord 渠道已配置" "Discord channel configured"
|
|
}
|
|
}
|
|
}
|
|
|
|
# ── generate config.json ─────────────────────────────────────────
|
|
function New-ConfigFile {
|
|
Write-Cow (T "正在生成 config.json..." "Generating config.json...")
|
|
|
|
$config = [ordered]@{
|
|
channel_type = if ($script:ChannelType) { $script:ChannelType } else { "web" }
|
|
model = if ($script:ModelName) { $script:ModelName } else { "" }
|
|
cow_lang = if ($script:InstallLang) { $script:InstallLang } else { "auto" }
|
|
open_ai_api_key = ""
|
|
open_ai_api_base = "https://api.openai.com/v1"
|
|
claude_api_key = ""
|
|
claude_api_base = "https://api.anthropic.com/v1"
|
|
gemini_api_key = ""
|
|
gemini_api_base = "https://generativelanguage.googleapis.com"
|
|
zhipu_ai_api_key = ""
|
|
moonshot_api_key = ""
|
|
ark_api_key = ""
|
|
dashscope_api_key = ""
|
|
minimax_api_key = ""
|
|
mimo_api_key = ""
|
|
deepseek_api_key = ""
|
|
deepseek_api_base = "https://api.deepseek.com/v1"
|
|
# Leave ASR/TTS provider empty so the web console auto-suggests the
|
|
# vendor whose API key is actually configured (e.g. LinkAI), instead
|
|
# of always defaulting to OpenAI.
|
|
voice_to_text = ""
|
|
text_to_voice = ""
|
|
voice_reply_voice = $false
|
|
speech_recognition = $true
|
|
group_speech_recognition = $false
|
|
use_linkai = [bool]$script:UseLinkai
|
|
linkai_api_key = ""
|
|
linkai_app_code = ""
|
|
agent = $true
|
|
agent_max_context_tokens = 50000
|
|
agent_max_context_turns = 20
|
|
agent_max_steps = 20
|
|
# New installs opt into self-evolution (matches run.sh).
|
|
self_evolution_enabled = $true
|
|
}
|
|
|
|
# Set the API key into the right field (skipped models leave it empty).
|
|
if ($script:ModelField -and $config.Contains($script:ModelField)) {
|
|
$config[$script:ModelField] = $script:ApiKey
|
|
}
|
|
# Set API base if the model has a configurable base and the user changed it.
|
|
if ($script:ApiBase -and $script:ApiBaseField -and $config.Contains($script:ApiBaseField)) {
|
|
$config[$script:ApiBaseField] = $script:ApiBase
|
|
}
|
|
|
|
# Merge channel-specific fields
|
|
foreach ($k in $script:ChannelExtra.Keys) {
|
|
$config[$k] = $script:ChannelExtra[$k]
|
|
}
|
|
|
|
$jsonText = $config | ConvertTo-Json -Depth 5
|
|
[System.IO.File]::WriteAllText("$BaseDir\config.json", $jsonText, (New-Object System.Text.UTF8Encoding $false))
|
|
Write-Cow (T "配置文件创建成功。" "Configuration file created.")
|
|
}
|
|
|
|
# Resolve the `cow` command, self-healing PATH if needed. When `cow` is missing
|
|
# (e.g. an older install whose PATH was never persisted), locate the Scripts dir,
|
|
# re-add it to PATH (session + persistent), and retry. Returns $true if callable.
|
|
function Resolve-CowCommand {
|
|
if (Get-Command cow -ErrorAction SilentlyContinue) { return $true }
|
|
|
|
$py = if ($PythonCmd) { $PythonCmd } else { Find-Python }
|
|
if ($py) {
|
|
$scriptsDir = & $py -c "import sysconfig; print(sysconfig.get_path('scripts'))" 2>$null
|
|
if ($scriptsDir -and (Test-Path "$scriptsDir\cow.exe")) {
|
|
Add-ScriptsDirToPath $scriptsDir
|
|
if (Get-Command cow -ErrorAction SilentlyContinue) { return $true }
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
# ── start via cow CLI ─────────────────────────────────────────────
|
|
function Start-CowAgent {
|
|
Write-Cow (T "正在启动 CowAgent..." "Starting CowAgent...")
|
|
if (Resolve-CowCommand) {
|
|
& cow start
|
|
} else {
|
|
Write-Warn (T "未找到 cow CLI,直接启动..." "cow CLI not found, starting directly...")
|
|
& $PythonCmd "$BaseDir\app.py"
|
|
}
|
|
}
|
|
|
|
# ── delegate management commands to cow CLI ──────────────────────
|
|
function Invoke-CowCommand {
|
|
param([string]$Cmd)
|
|
if (Resolve-CowCommand) {
|
|
& cow $Cmd
|
|
} else {
|
|
# Fall back to the module entrypoint so management commands still work
|
|
# even when cow.exe isn't on PATH (e.g. right after a fresh reboot on
|
|
# an older install).
|
|
$py = if ($PythonCmd) { $PythonCmd } else { Find-Python }
|
|
if ($py -and (Test-Path "$BaseDir\app.py")) {
|
|
Write-Warn (T "未找到 cow 命令,使用 python -m cli.cli 兜底运行..." "cow command not found, falling back to python -m cli.cli...")
|
|
Push-Location $BaseDir
|
|
try { & $py -m cli.cli $Cmd } finally { Pop-Location }
|
|
} else {
|
|
Write-Err (T "未找到 cow CLI,请先不带参数运行本脚本进行安装。" "cow CLI not found. Run this script without arguments first to install.")
|
|
exit 1
|
|
}
|
|
}
|
|
}
|
|
|
|
# ── usage ─────────────────────────────────────────────────────────
|
|
function Show-Usage {
|
|
Write-Info "========================================="
|
|
Write-Info " CowAgent Management Script (Windows)"
|
|
Write-Info "========================================="
|
|
Write-Host ""
|
|
Write-Host (T "用法:" "Usage:")
|
|
Write-Host (" .\run.ps1 # " + (T "安装 / 配置" "Install / Configure"))
|
|
Write-Host (" .\run.ps1 <command> # " + (T "管理命令" "Management command"))
|
|
Write-Host ""
|
|
Write-Host (T "命令:" "Commands:")
|
|
Write-Host (" start " + (T "启动服务" "Start the service"))
|
|
Write-Host (" stop " + (T "停止服务" "Stop the service"))
|
|
Write-Host (" restart " + (T "重启服务" "Restart the service"))
|
|
Write-Host (" status " + (T "查看状态" "Check service status"))
|
|
Write-Host (" logs " + (T "查看日志" "View logs"))
|
|
Write-Host (" config " + (T "重新配置项目" "Reconfigure project"))
|
|
Write-Host (" update " + (T "更新并重启" "Update and restart"))
|
|
Write-Host (" help " + (T "显示本帮助" "Show this message"))
|
|
Write-Host ""
|
|
}
|
|
|
|
# ── install mode ──────────────────────────────────────────────────
|
|
function Install-Mode {
|
|
Clear-Host
|
|
Write-Info "========================================="
|
|
Write-Info " CowAgent Installation (Windows)"
|
|
Write-Info "========================================="
|
|
Write-Host ""
|
|
|
|
# Step 0: choose the install/UI language. Everything after this is localized.
|
|
Select-Language
|
|
Write-Host ""
|
|
|
|
if ($IsProjectDir) {
|
|
Write-Cow (T "检测到已有项目目录。" "Detected existing project directory.")
|
|
if (Test-Path "$BaseDir\config.json") {
|
|
Write-Cow (T "项目已配置。" "Project already configured.")
|
|
Write-Host ""
|
|
Show-Usage
|
|
return
|
|
}
|
|
Write-Warn (T "未找到 config.json,开始配置项目!" "No config.json found. Let's configure your project!")
|
|
Write-Host ""
|
|
Assert-Python
|
|
} else {
|
|
Assert-Python
|
|
Install-Project
|
|
}
|
|
|
|
Install-Dependencies
|
|
Select-Model
|
|
Configure-Model
|
|
Select-Channel
|
|
Configure-Channel
|
|
New-ConfigFile
|
|
|
|
# Auto-start after configuration for a true out-of-the-box experience.
|
|
Write-Host ""
|
|
if ($script:AccessInfo) { Write-Cow $script:AccessInfo }
|
|
Write-Warn (T "提示:需要让 Agent 浏览网页时,运行 cow install-browser 安装浏览器工具" "Tip: to let the Agent browse the web, run 'cow install-browser' to install the browser tool")
|
|
Start-CowAgent
|
|
}
|
|
|
|
# ── update ────────────────────────────────────────────────────────
|
|
function Update-Project {
|
|
Write-Cow (T "正在更新 CowAgent..." "Updating CowAgent...")
|
|
Set-Location $BaseDir
|
|
|
|
# Stop if running
|
|
$cowBin = Get-Command cow -ErrorAction SilentlyContinue
|
|
if ($cowBin) {
|
|
$prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
|
& cow stop 2>&1 | Out-Null
|
|
$ErrorActionPreference = $prevEAP
|
|
}
|
|
|
|
if (Test-Path "$BaseDir\.git") {
|
|
Write-Cow (T "正在拉取最新代码..." "Pulling latest code...")
|
|
$prevEAP = $ErrorActionPreference; $ErrorActionPreference = "Continue"
|
|
git pull 2>&1 | Out-Null
|
|
$pullExit = $LASTEXITCODE
|
|
$ErrorActionPreference = $prevEAP
|
|
if ($pullExit -ne 0) {
|
|
Write-Warn (T "GitHub 拉取失败,尝试 Gitee..." "GitHub failed, trying Gitee...")
|
|
$ErrorActionPreference = "Continue"
|
|
git remote set-url origin https://gitee.com/zhayujie/CowAgent.git 2>&1 | Out-Null
|
|
git pull 2>&1 | Out-Null
|
|
$ErrorActionPreference = $prevEAP
|
|
}
|
|
} else {
|
|
Write-Warn (T "非 git 仓库,跳过代码更新。" "Not a git repository, skipping code update.")
|
|
}
|
|
|
|
Assert-Python
|
|
Install-Dependencies
|
|
|
|
# Start via python -m cli.cli instead of cow.exe, because the exe may
|
|
# still be cached/locked from the previous installation on Windows.
|
|
Write-Cow (T "正在启动 CowAgent..." "Starting CowAgent...")
|
|
& $PythonCmd -m cli.cli start
|
|
}
|
|
|
|
# ── main ──────────────────────────────────────────────────────────
|
|
Initialize-UiLang
|
|
|
|
switch ($Command.ToLower()) {
|
|
"" { Install-Mode }
|
|
"start" { Invoke-CowCommand "start" }
|
|
"stop" { Invoke-CowCommand "stop" }
|
|
"restart" { Invoke-CowCommand "restart" }
|
|
"status" { Invoke-CowCommand "status" }
|
|
"logs" { Invoke-CowCommand "logs" }
|
|
"config" {
|
|
Assert-Python
|
|
Install-Dependencies
|
|
Select-Model
|
|
Configure-Model
|
|
Select-Channel
|
|
Configure-Channel
|
|
New-ConfigFile
|
|
$r = Read-Host (T "现在重启服务吗?[Y/n]" "Restart service now? [Y/n]")
|
|
if ($r -ne "n" -and $r -ne "N") { Invoke-CowCommand "restart" }
|
|
}
|
|
"update" { Update-Project }
|
|
"help" { Show-Usage }
|
|
default {
|
|
Write-Err ((T "未知命令" "Unknown command") + ": $Command")
|
|
Show-Usage
|
|
exit 1
|
|
}
|
|
}
|