Add OpenClaw skills, platform kit, and template docs
Made-with: Cursor
This commit is contained in:
11
llm-manager/.github/workflows/release_skill.yaml
vendored
Normal file
11
llm-manager/.github/workflows/release_skill.yaml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
name: 技能自动化发布
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main
|
||||
with:
|
||||
artifact_platform: windows
|
||||
pyarmor_platform: windows.x86_64
|
||||
119
llm-manager/SKILL.md
Normal file
119
llm-manager/SKILL.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: 大模型管理器
|
||||
description: 统一管理 AI 大模型的调用方式。优先通过账号网页版(免费)访问,无可用网页账号时自动降级到 API Key(付费)。支持豆包、DeepSeek、通义千问、Kimi、文心一言、腾讯元宝。
|
||||
version: 1.0.3
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
slug: llm-manager
|
||||
emoji: "🧠"
|
||||
category: "通用"
|
||||
dependencies:
|
||||
required:
|
||||
- account-manager
|
||||
optional:
|
||||
- openai # pip install openai(仅 API Key 模式需要)
|
||||
auto_install: false
|
||||
allowed-tools:
|
||||
- bash
|
||||
disable: false
|
||||
---
|
||||
|
||||
# 大模型管理器
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 生成内容(核心命令)
|
||||
|
||||
```bash
|
||||
# 指定平台名(自动选择模式:网页优先,API Key 备用)
|
||||
python3 {baseDir}/scripts/main.py generate kimi "帮我写一篇关于AI发展的文章"
|
||||
python3 {baseDir}/scripts/main.py generate deepseek "帮我写一篇关于AI发展的文章"
|
||||
python3 {baseDir}/scripts/main.py generate doubao "帮我写一篇关于AI发展的文章"
|
||||
python3 {baseDir}/scripts/main.py generate qianwen "帮我写一篇关于AI发展的文章"
|
||||
python3 {baseDir}/scripts/main.py generate yiyan "帮我写一篇关于AI发展的文章"
|
||||
python3 {baseDir}/scripts/main.py generate yuanbao "帮我写一篇关于AI发展的文章"
|
||||
|
||||
# 指定 account_id(强制使用该账号网页模式)
|
||||
python3 {baseDir}/scripts/main.py generate 3 "帮我写一篇关于AI发展的文章"
|
||||
```
|
||||
|
||||
### 管理 API Key(可选,付费模式)
|
||||
|
||||
```bash
|
||||
# 添加 API Key
|
||||
python3 {baseDir}/scripts/main.py key add deepseek "sk-xxx"
|
||||
python3 {baseDir}/scripts/main.py key add kimi "sk-xxx" --model moonshot-v1-8k
|
||||
python3 {baseDir}/scripts/main.py key add doubao "xxx" --model ep-xxx # 豆包须填 endpoint_id
|
||||
|
||||
# 查看
|
||||
python3 {baseDir}/scripts/main.py key list
|
||||
python3 {baseDir}/scripts/main.py key list deepseek
|
||||
|
||||
# 删除
|
||||
python3 {baseDir}/scripts/main.py key del <key_id>
|
||||
```
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
python3 {baseDir}/scripts/main.py health
|
||||
python3 {baseDir}/scripts/main.py version
|
||||
```
|
||||
|
||||
## 调用模式选择逻辑
|
||||
|
||||
```
|
||||
generate <target> "<prompt>"
|
||||
│
|
||||
├─ target 是数字(account_id)
|
||||
│ └─ 强制网页模式(需该账号已在 account-manager 登录)
|
||||
│
|
||||
└─ target 是平台名
|
||||
├─ 1. 查 account-manager:有 login_status=1 的账号? → 网页模式(免费)
|
||||
├─ 2. 查本地 llm_keys:有 is_active=1 的 Key? → API Key 模式(付费)
|
||||
└─ 3. 都没有 → 报错,给出两种凭据的配置指引
|
||||
```
|
||||
|
||||
## 使用网页模式的前提
|
||||
|
||||
网页模式依赖 **account-manager** 管理的账号和 Chrome Profile,使用前需完成:
|
||||
|
||||
```bash
|
||||
# 1. 在 account-manager 中添加对应平台账号
|
||||
python3 {accountManagerDir}/scripts/main.py add "Kimi" "手机号"
|
||||
|
||||
# 2. 登录(打开浏览器,手动完成登录后自动写入状态)
|
||||
python3 {accountManagerDir}/scripts/main.py login <id>
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|---|---|---|
|
||||
| `JIANGCHANG_DATA_ROOT` | 数据根目录(与 account-manager 一致) | Win: `D:\jiangchang-data` |
|
||||
| `JIANGCHANG_USER_ID` | 用户/工作区 ID | `_anon` |
|
||||
|
||||
API Key 存储路径:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/llm-manager/llm-manager.db`
|
||||
网页账号:通过 `account-manager` 子命令 `pick-logged-in` 查询,不直接读其数据库。
|
||||
|
||||
## 支持的平台
|
||||
|
||||
| 平台 | Slug | 中文别名 | 网页模式 | API 模式 |
|
||||
|---|---|---|---|---|
|
||||
| 豆包 | doubao | 豆包 | ✓ | ✓(火山方舟,需 ep-xxx) |
|
||||
| DeepSeek | deepseek | 深度求索 | ✓ | ✓ |
|
||||
| 通义千问 | qianwen | 通义、千问 | ✓ | ✓ |
|
||||
| Kimi | kimi | 月之暗面 | ✓ | ✓ |
|
||||
| 文心一言 | yiyan | 文心、一言 | ✓ | ✓ |
|
||||
| 腾讯元宝 | yuanbao | 元宝 | ✓ | ✗(暂无公开 API) |
|
||||
|
||||
## 输出格式
|
||||
|
||||
generate 命令输出固定格式,方便下游 Skill(如 sohu-publisher)精确提取:
|
||||
|
||||
```
|
||||
===LLM_START===
|
||||
(生成的内容)
|
||||
===LLM_END===
|
||||
```
|
||||
220
llm-manager/release.ps1
Normal file
220
llm-manager/release.ps1
Normal file
@@ -0,0 +1,220 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
One-command release script for skill repos.
|
||||
|
||||
.DESCRIPTION
|
||||
- Optional auto-commit
|
||||
- Push current branch
|
||||
- Auto-increment semantic tag (vX.Y.Z)
|
||||
- Create & push tag
|
||||
- Fail fast on unsafe states
|
||||
|
||||
.EXAMPLES
|
||||
# Safe mode (recommended): requires clean working tree
|
||||
.\release.ps1
|
||||
|
||||
# Auto commit tracked/untracked changes then release
|
||||
.\release.ps1 -AutoCommit -CommitMessage "chore: update skill config"
|
||||
|
||||
# Dry run (show what would happen)
|
||||
.\release.ps1 -DryRun
|
||||
|
||||
# Custom tag prefix
|
||||
.\release.ps1 -Prefix "v" -Message "正式发布"
|
||||
|
||||
.NOTES
|
||||
Requires: git, PowerShell 5+
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Prefix = "v",
|
||||
[string]$Message = "正式发布",
|
||||
[switch]$AutoCommit,
|
||||
[switch]$RequireClean,
|
||||
[string]$CommitMessage,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$sharedScript = Join-Path $scriptDir "..\jiangchang-platform-kit\tools\release.ps1"
|
||||
$sharedScript = [System.IO.Path]::GetFullPath($sharedScript)
|
||||
if (Test-Path $sharedScript) {
|
||||
& $sharedScript @PSBoundParameters
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
function Invoke-Git {
|
||||
param([Parameter(Mandatory = $true)][string]$Args)
|
||||
Write-Host ">> git $Args" -ForegroundColor DarkGray
|
||||
& cmd /c "git $Args"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git command failed: git $Args"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GitOutput {
|
||||
param([Parameter(Mandatory = $true)][string]$Args)
|
||||
$output = & cmd /c "git $Args" 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git command failed: git $Args"
|
||||
}
|
||||
return @($output)
|
||||
}
|
||||
|
||||
function Test-Repo {
|
||||
& git rev-parse --is-inside-work-tree *> $null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
|
||||
function Get-CurrentBranch {
|
||||
$b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim()
|
||||
return $b
|
||||
}
|
||||
|
||||
function Get-StatusPorcelain {
|
||||
$lines = @(Get-GitOutput "status --porcelain")
|
||||
return $lines
|
||||
}
|
||||
|
||||
function Parse-SemVerTag {
|
||||
param(
|
||||
[string]$Tag,
|
||||
[string]$TagPrefix
|
||||
)
|
||||
$escaped = [regex]::Escape($TagPrefix)
|
||||
$m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$")
|
||||
if (-not $m.Success) { return $null }
|
||||
|
||||
return [pscustomobject]@{
|
||||
Raw = $Tag
|
||||
Major = [int]$m.Groups[1].Value
|
||||
Minor = [int]$m.Groups[2].Value
|
||||
Patch = [int]$m.Groups[3].Value
|
||||
}
|
||||
}
|
||||
|
||||
function Get-NextTag {
|
||||
param([string]$TagPrefix)
|
||||
|
||||
$tags = Get-GitOutput "tag --list"
|
||||
$parsed = @()
|
||||
|
||||
foreach ($t in $tags) {
|
||||
$t = $t.Trim()
|
||||
if (-not $t) { continue }
|
||||
$obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix
|
||||
if ($null -ne $obj) { $parsed += $obj }
|
||||
}
|
||||
|
||||
if ($parsed.Count -eq 0) {
|
||||
return "${TagPrefix}1.0.1"
|
||||
}
|
||||
|
||||
$latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1
|
||||
return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)"
|
||||
}
|
||||
|
||||
function Ensure-CleanOrAutoCommit {
|
||||
param(
|
||||
[switch]$DoAutoCommit,
|
||||
[switch]$NeedClean,
|
||||
[switch]$IsDryRun,
|
||||
[string]$Msg
|
||||
)
|
||||
|
||||
$status = @(Get-StatusPorcelain)
|
||||
if ($status.Length -eq 0) { return }
|
||||
|
||||
if ($NeedClean) {
|
||||
Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow
|
||||
& git status --short
|
||||
throw "Abort: dirty working tree."
|
||||
}
|
||||
|
||||
# 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启
|
||||
$commitMsg = $Msg
|
||||
if ([string]::IsNullOrWhiteSpace($commitMsg)) {
|
||||
$commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))"
|
||||
}
|
||||
|
||||
if (-not $DoAutoCommit) {
|
||||
Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if ($IsDryRun) {
|
||||
Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow
|
||||
Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
Invoke-Git "add -A"
|
||||
Invoke-Git "commit -m `"$commitMsg`""
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "=== Release Script Start ===" -ForegroundColor Cyan
|
||||
|
||||
if (-not (Test-Repo)) {
|
||||
throw "Current directory is not a git repository."
|
||||
}
|
||||
|
||||
$branch = Get-CurrentBranch
|
||||
if ([string]::IsNullOrWhiteSpace($branch)) {
|
||||
throw "Unable to determine current branch."
|
||||
}
|
||||
|
||||
if ($branch -notin @("main", "master")) {
|
||||
throw "Current branch is '$branch'. Release is only allowed from main/master."
|
||||
}
|
||||
|
||||
Invoke-Git "fetch --tags --prune origin"
|
||||
|
||||
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
|
||||
|
||||
$upstream = (& git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null)
|
||||
$hasUpstream = ($LASTEXITCODE -eq 0)
|
||||
|
||||
if ($DryRun) {
|
||||
if ($hasUpstream) {
|
||||
Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
if ($hasUpstream) {
|
||||
Invoke-Git "push"
|
||||
} else {
|
||||
Invoke-Git "push -u origin $branch"
|
||||
}
|
||||
}
|
||||
|
||||
$nextTag = Get-NextTag -TagPrefix $Prefix
|
||||
Write-Host "Next tag: $nextTag" -ForegroundColor Green
|
||||
|
||||
$existing = @(Get-GitOutput "tag --list `"$nextTag`"")
|
||||
if ($existing.Length -gt 0) {
|
||||
throw "Tag already exists: $nextTag"
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow
|
||||
Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow
|
||||
Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan
|
||||
exit 0
|
||||
}
|
||||
|
||||
Invoke-Git "tag -a $nextTag -m `"$Message`""
|
||||
Invoke-Git "push origin $nextTag"
|
||||
|
||||
Write-Host "Release success: $nextTag" -ForegroundColor Green
|
||||
Write-Host "=== Release Script Done ===" -ForegroundColor Cyan
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
292
llm-manager/scripts/db.py
Normal file
292
llm-manager/scripts/db.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
llm-manager 本地数据库:
|
||||
- llm_keys: API Key 记录
|
||||
- llm_web_accounts: 网页模式账号关联记录(账号主数据仍由 account-manager 管理)
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from providers import get_data_root, get_user_id
|
||||
|
||||
SKILL_SLUG = "llm-manager"
|
||||
|
||||
# SQLite 无独立 DATETIME:时间统一存 INTEGER Unix 秒(UTC)。
|
||||
LLM_KEYS_TABLE_SQL = """
|
||||
CREATE TABLE llm_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 主键(自增)
|
||||
provider TEXT NOT NULL, -- 平台 slug:doubao/deepseek/qianwen/kimi/yiyan/yuanbao
|
||||
label TEXT NOT NULL DEFAULT '', -- 用户自定义备注,如「公司Key」
|
||||
api_key TEXT NOT NULL, -- API Key 原文
|
||||
default_model TEXT, -- 默认模型(doubao 须填 ep-xxx)
|
||||
is_active INTEGER NOT NULL DEFAULT 1, -- 是否启用:0 停用 1 启用
|
||||
last_used_at INTEGER, -- 最近调用时间,Unix 秒;从未用过为 NULL
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
LLM_WEB_ACCOUNTS_TABLE_SQL = """
|
||||
CREATE TABLE llm_web_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
account_name TEXT NOT NULL DEFAULT '',
|
||||
login_status INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE(provider, account_id)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _now_unix() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def get_skill_data_dir() -> str:
|
||||
path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_db_path() -> str:
|
||||
return os.path.join(get_skill_data_dir(), f"{SKILL_SLUG}.db")
|
||||
|
||||
|
||||
def get_conn():
|
||||
return sqlite3.connect(get_db_path())
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='llm_keys'")
|
||||
if not cur.fetchone():
|
||||
cur.executescript(LLM_KEYS_TABLE_SQL)
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='llm_web_accounts'")
|
||||
if not cur.fetchone():
|
||||
cur.executescript(LLM_WEB_ACCOUNTS_TABLE_SQL)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_key(provider: str, api_key: str, model: Optional[str] = None, label: str = "") -> int:
|
||||
init_db()
|
||||
now = _now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO llm_keys (provider, label, api_key, default_model, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||
""",
|
||||
(provider, label or "", api_key, model, now, now),
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
conn.commit()
|
||||
return new_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def upsert_web_account(provider: str, account_id: int, account_name: str = "", login_status: int = 1) -> int:
|
||||
init_db()
|
||||
now = _now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO llm_web_accounts (provider, account_id, account_name, login_status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider, account_id) DO UPDATE SET
|
||||
account_name = excluded.account_name,
|
||||
login_status = excluded.login_status,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(provider, int(account_id), account_name or "", int(login_status or 0), now, now),
|
||||
)
|
||||
conn.commit()
|
||||
cur.execute(
|
||||
"SELECT id FROM llm_web_accounts WHERE provider = ? AND account_id = ?",
|
||||
(provider, int(account_id)),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_keys(provider: Optional[str] = None, limit: int = 10) -> list:
|
||||
init_db()
|
||||
if not isinstance(limit, int) or limit <= 0:
|
||||
limit = 10
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
if provider:
|
||||
cur.execute(
|
||||
"SELECT id, provider, label, api_key, default_model, is_active, last_used_at, created_at "
|
||||
"FROM llm_keys WHERE provider = ? ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
(provider, limit),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id, provider, label, api_key, default_model, is_active, last_used_at, created_at "
|
||||
"FROM llm_keys ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
result.append({
|
||||
"id": row[0],
|
||||
"provider": row[1],
|
||||
"label": row[2] or "",
|
||||
"api_key": row[3],
|
||||
"default_model": row[4] or "",
|
||||
"is_active": row[5],
|
||||
"last_used_at": row[6],
|
||||
"created_at": row[7],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def list_web_accounts(provider: Optional[str] = None, limit: int = 10) -> list:
|
||||
init_db()
|
||||
if not isinstance(limit, int) or limit <= 0:
|
||||
limit = 10
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
if provider:
|
||||
cur.execute(
|
||||
"SELECT id, provider, account_id, account_name, login_status, created_at, updated_at "
|
||||
"FROM llm_web_accounts WHERE provider = ? ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
(provider, limit),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id, provider, account_id, account_name, login_status, created_at, updated_at "
|
||||
"FROM llm_web_accounts ORDER BY created_at DESC, id DESC LIMIT ?",
|
||||
(limit,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
result.append({
|
||||
"id": row[0],
|
||||
"provider": row[1],
|
||||
"account_id": row[2],
|
||||
"account_name": row[3] or "",
|
||||
"login_status": int(row[4] or 0),
|
||||
"created_at": row[5],
|
||||
"updated_at": row[6],
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def get_key_by_id(key_id: int) -> Optional[dict]:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, provider, label, api_key, default_model, is_active, last_used_at, created_at "
|
||||
"FROM llm_keys WHERE id = ?",
|
||||
(key_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"provider": row[1],
|
||||
"label": row[2] or "",
|
||||
"api_key": row[3],
|
||||
"default_model": row[4] or "",
|
||||
"is_active": row[5],
|
||||
"last_used_at": row[6],
|
||||
"created_at": row[7],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_key(key_id: int) -> bool:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id FROM llm_keys WHERE id = ?", (key_id,))
|
||||
if not cur.fetchone():
|
||||
return False
|
||||
cur.execute("DELETE FROM llm_keys WHERE id = ?", (key_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def find_active_key(provider: str) -> Optional[dict]:
|
||||
"""查找该平台第一个 is_active=1 的 key(按 id 升序)。"""
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, provider, label, api_key, default_model, is_active, last_used_at "
|
||||
"FROM llm_keys WHERE provider = ? AND is_active = 1 ORDER BY id LIMIT 1",
|
||||
(provider,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row[0],
|
||||
"provider": row[1],
|
||||
"label": row[2] or "",
|
||||
"api_key": row[3],
|
||||
"default_model": row[4] or "",
|
||||
"is_active": row[5],
|
||||
"last_used_at": row[6],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def mark_key_used(key_id: int):
|
||||
now = _now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE llm_keys SET last_used_at = ?, updated_at = ? WHERE id = ?",
|
||||
(now, now, key_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _mask_key(api_key: str) -> str:
|
||||
"""展示时打码:前4位 + ... + 后4位。"""
|
||||
k = api_key or ""
|
||||
if len(k) <= 8:
|
||||
return k[:2] + "****"
|
||||
return k[:4] + "..." + k[-4:]
|
||||
28
llm-manager/scripts/engines/api_engine.py
Normal file
28
llm-manager/scripts/engines/api_engine.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
OpenAI 兼容 API 引擎:适用于 DeepSeek、通义千问、Kimi、文心一言、豆包(火山方舟)。
|
||||
只要平台提供 OpenAI 兼容接口,均可用此引擎,无需 Playwright。
|
||||
"""
|
||||
|
||||
|
||||
class ApiEngine:
|
||||
def __init__(self, api_base: str, api_key: str, model: str):
|
||||
self.api_base = api_base
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
|
||||
def generate(self, prompt: str) -> str:
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError:
|
||||
return "ERROR:缺少依赖:pip install openai"
|
||||
|
||||
try:
|
||||
client = OpenAI(base_url=self.api_base, api_key=self.api_key)
|
||||
response = client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
return (content or "").strip()
|
||||
except Exception as e:
|
||||
return f"ERROR:API调用失败:{e}"
|
||||
15
llm-manager/scripts/engines/base.py
Normal file
15
llm-manager/scripts/engines/base.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from playwright.async_api import Page
|
||||
|
||||
|
||||
class BaseEngine(ABC):
|
||||
def __init__(self, page: Page):
|
||||
self.page = page
|
||||
|
||||
@abstractmethod
|
||||
async def generate(self, prompt: str) -> str:
|
||||
"""
|
||||
基于操作页面的具体大模型的生成逻辑。输入文本,抓取并返回文本。
|
||||
返回值:正常结果字符串,或以 "ERROR:" 开头的错误描述。
|
||||
"""
|
||||
pass
|
||||
70
llm-manager/scripts/engines/deepseek.py
Normal file
70
llm-manager/scripts/engines/deepseek.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
DeepSeek 网页版驱动引擎(chat.deepseek.com)。
|
||||
|
||||
选择器说明(如页面改版需更新):
|
||||
- 输入框:#chat-input(textarea)
|
||||
- 发送按钮:[aria-label="发送消息"] 或 div[class*="send"] > button
|
||||
- 停止生成按钮:[aria-label="停止生成"](生成中可见,生成结束消失)
|
||||
- 复制按钮:最后一条回复下的 [aria-label="复制"]
|
||||
"""
|
||||
import asyncio
|
||||
from .base import BaseEngine
|
||||
|
||||
|
||||
class DeepSeekEngine(BaseEngine):
|
||||
async def generate(self, prompt: str) -> str:
|
||||
await self.page.goto("https://chat.deepseek.com")
|
||||
await self.page.wait_for_load_state("networkidle")
|
||||
|
||||
# 登录检测:若能找到输入框则已登录
|
||||
try:
|
||||
editor = self.page.locator("textarea#chat-input").first
|
||||
await editor.wait_for(state="visible", timeout=10000)
|
||||
except Exception:
|
||||
return "ERROR:REQUIRE_LOGIN"
|
||||
|
||||
# 输入提示词
|
||||
await editor.click()
|
||||
await self.page.keyboard.insert_text(prompt)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 发送(优先点按钮,失败则按 Enter)
|
||||
sent = False
|
||||
for sel in ('[aria-label="发送消息"]', 'div[class*="send"] > button', 'button[type="submit"]'):
|
||||
try:
|
||||
btn = self.page.locator(sel).first
|
||||
if await btn.is_visible(timeout=1000):
|
||||
await btn.click()
|
||||
sent = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not sent:
|
||||
await self.page.keyboard.press("Enter")
|
||||
|
||||
print("💡 [llm-manager/deepseek] 已发送提示词,等待 DeepSeek 生成响应...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 等待生成完毕:停止按钮消失即为完成,超时 150 秒
|
||||
stop_sel = '[aria-label="停止生成"]'
|
||||
deadline = asyncio.get_event_loop().time() + 150
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
try:
|
||||
visible = await self.page.locator(stop_sel).first.is_visible(timeout=500)
|
||||
if not visible:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 通过最后一个复制按钮取结果
|
||||
try:
|
||||
copy_btn = self.page.locator('[aria-label="复制"]').last
|
||||
await copy_btn.click()
|
||||
await asyncio.sleep(0.5)
|
||||
result = await self.page.evaluate("navigator.clipboard.readText()")
|
||||
return result.strip()
|
||||
except Exception as e:
|
||||
return f"ERROR:抓取 DeepSeek 内容时异常:{e}"
|
||||
222
llm-manager/scripts/engines/doubao.py
Normal file
222
llm-manager/scripts/engines/doubao.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
豆包网页版驱动(doubao.com/chat/)。
|
||||
|
||||
生成后不依赖「喇叭瞬时出现/消失」这种不稳定信号。
|
||||
而是轮询判断两种输出是否已进入可复制状态:
|
||||
1) 右侧写作模式:出现「改用对话直接回答」入口并可点右侧复制按钮
|
||||
2) 左侧对话模式:出现可点的消息复制按钮(拿最后一条新增回复)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from .base import BaseEngine
|
||||
|
||||
_DOUBAO_SPEAKER_PATH_SNIPPET = "M19.8628 9.29346C20.3042"
|
||||
|
||||
|
||||
class DoubaoEngine(BaseEngine):
|
||||
async def generate(self, prompt: str) -> str:
|
||||
self.page.set_default_timeout(60_000)
|
||||
await self.page.goto(
|
||||
"https://www.doubao.com/chat/",
|
||||
wait_until="domcontentloaded",
|
||||
timeout=60_000,
|
||||
)
|
||||
|
||||
editor = self.page.locator('textarea[data-testid="chat_input_input"]').first
|
||||
try:
|
||||
await editor.wait_for(state="visible", timeout=30_000)
|
||||
except Exception:
|
||||
return "ERROR:REQUIRE_LOGIN"
|
||||
|
||||
text = (prompt or "").strip()
|
||||
if not text:
|
||||
return "ERROR:PROMPT_EMPTY"
|
||||
|
||||
await editor.click()
|
||||
await editor.fill(text)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
send = self.page.locator(
|
||||
'#flow-end-msg-send, [data-testid="chat_input_send_button"]'
|
||||
).first
|
||||
try:
|
||||
await send.wait_for(state="visible", timeout=15_000)
|
||||
await send.click(timeout=10_000)
|
||||
except Exception as e:
|
||||
return f"ERROR:DOUBAO_SEND_FAILED {e}"
|
||||
|
||||
print("💡 [llm-manager/doubao] 已发送,等待生成完成后再复制(最长 180s)...")
|
||||
|
||||
# 发送前记录:用于确认点的是新增复制按钮,且剪贴板确实变化了
|
||||
left_copy = self.page.locator('[data-testid="message_action_copy"]')
|
||||
right_copy = self.page.locator('[data-testid="container_inner_copy_btn"]')
|
||||
left_prev = await left_copy.count()
|
||||
right_prev = await right_copy.count()
|
||||
clipboard_before = await self._safe_read_clipboard()
|
||||
|
||||
# 稳定检测:避免生成中“喇叭短暂出现”误判(需要连续多次可见)
|
||||
speaker = self.page.locator(
|
||||
f'svg path[d*="{_DOUBAO_SPEAKER_PATH_SNIPPET}"]'
|
||||
).last
|
||||
stable_needed = 3
|
||||
stable = 0
|
||||
interval_sec = 0.5
|
||||
deadline = time.monotonic() + 180.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if await speaker.is_visible():
|
||||
stable += 1
|
||||
else:
|
||||
stable = 0
|
||||
except Exception:
|
||||
stable = 0
|
||||
if stable >= stable_needed:
|
||||
break
|
||||
await asyncio.sleep(interval_sec)
|
||||
else:
|
||||
return "ERROR:DOUBAO_WAIT_COMPLETE_TIMEOUT 180 秒内未检测到“喇叭稳定回显”。"
|
||||
|
||||
# 生成完成后:依据当前页面状态决定点右侧还是左侧复制
|
||||
write_mode = await self._is_write_mode()
|
||||
if write_mode:
|
||||
if await self._wait_count_gt(right_copy, right_prev, timeout_sec=15.0):
|
||||
copy_btn = right_copy.last
|
||||
elif await self._wait_count_gt(left_copy, left_prev, timeout_sec=15.0):
|
||||
copy_btn = left_copy.last
|
||||
else:
|
||||
return "ERROR:DOUBAO_COPY_BUTTON_NOT_READY 右侧/左侧复制按钮均未出现新增。"
|
||||
else:
|
||||
if await self._wait_count_gt(left_copy, left_prev, timeout_sec=15.0):
|
||||
copy_btn = left_copy.last
|
||||
elif await self._wait_count_gt(right_copy, right_prev, timeout_sec=5.0):
|
||||
copy_btn = right_copy.last
|
||||
else:
|
||||
return "ERROR:DOUBAO_COPY_BUTTON_NOT_READY 左侧复制按钮未出现新增。"
|
||||
|
||||
try:
|
||||
await copy_btn.scroll_into_view_if_needed()
|
||||
await copy_btn.click(timeout=15_000, force=True)
|
||||
except Exception as e:
|
||||
return f"ERROR:DOUBAO_COPY_CLICK_FAILED {e}"
|
||||
|
||||
out = await self._wait_clipboard_changed(clipboard_before, timeout_sec=20.0)
|
||||
if out is None:
|
||||
return "ERROR:DOUBAO_CLIPBOARD_NOT_UPDATED 剪贴板未在规定时间内更新。"
|
||||
if self._is_invalid_clipboard(out, clipboard_before=clipboard_before):
|
||||
return f"ERROR:DOUBAO_CLIPBOARD_INVALID {out[:200]}"
|
||||
|
||||
await asyncio.sleep(5)
|
||||
return out
|
||||
|
||||
async def _is_write_mode(self) -> bool:
|
||||
marker = self.page.locator('div.bottom-entry-GSWErB:has-text("改用对话直接回答")').first
|
||||
try:
|
||||
return await marker.is_visible()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _wait_count_gt(self, locator, prev_count: int, timeout_sec: float) -> bool:
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if await locator.count() > prev_count:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.3)
|
||||
return False
|
||||
|
||||
async def _wait_clipboard_changed(
|
||||
self, clipboard_before: str | None, timeout_sec: float
|
||||
) -> str | None:
|
||||
deadline = time.monotonic() + timeout_sec
|
||||
last = clipboard_before
|
||||
while time.monotonic() < deadline:
|
||||
cur = await self._safe_read_clipboard()
|
||||
if cur and cur != last and not self._is_invalid_clipboard(
|
||||
cur, clipboard_before=clipboard_before
|
||||
):
|
||||
return cur.strip()
|
||||
last = cur
|
||||
await asyncio.sleep(0.4)
|
||||
return None
|
||||
|
||||
async def _try_copy_right(self, right_copy) -> str | None:
|
||||
# 右侧复制按钮
|
||||
try:
|
||||
btn = right_copy.first
|
||||
await btn.wait_for(state="visible", timeout=2000)
|
||||
await btn.scroll_into_view_if_needed()
|
||||
await btn.click(timeout=5000, force=True)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
await asyncio.sleep(0.4)
|
||||
out = await self._safe_read_clipboard()
|
||||
if self._is_invalid_clipboard(out):
|
||||
return None
|
||||
if out == (await self._safe_read_clipboard()):
|
||||
pass
|
||||
return (out or "").strip()
|
||||
|
||||
async def _try_copy_left(self, left_copy, prev_count: int) -> str | None:
|
||||
n = await left_copy.count()
|
||||
if n <= prev_count:
|
||||
return None
|
||||
|
||||
# 从新增区间末尾往前找第一个可点击的复制按钮
|
||||
for idx in range(n - 1, prev_count - 1, -1):
|
||||
btn = left_copy.nth(idx)
|
||||
try:
|
||||
if not await btn.is_visible():
|
||||
continue
|
||||
await btn.scroll_into_view_if_needed()
|
||||
await btn.click(timeout=5000, force=True)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
await asyncio.sleep(0.4)
|
||||
out = await self._safe_read_clipboard()
|
||||
if self._is_invalid_clipboard(out):
|
||||
continue
|
||||
return (out or "").strip()
|
||||
return None
|
||||
|
||||
def _is_invalid_clipboard(
|
||||
self, text: str | None, clipboard_before: str | None = None
|
||||
) -> bool:
|
||||
if not text:
|
||||
return True
|
||||
t = str(text).strip()
|
||||
if not t:
|
||||
return True
|
||||
if clipboard_before is not None and t == str(clipboard_before).strip():
|
||||
return True
|
||||
if t.startswith("ERROR:"):
|
||||
return True
|
||||
# 防止误点拿到 UI 文案/占位文本
|
||||
banned_substrings = (
|
||||
"改用对话直接回答",
|
||||
"新对话",
|
||||
"正在生成",
|
||||
"复制",
|
||||
"下载",
|
||||
"快捷",
|
||||
"发消息",
|
||||
)
|
||||
if any(s in t for s in banned_substrings):
|
||||
return True
|
||||
if "data-testid" in t or "<button" in t:
|
||||
return True
|
||||
# 经验阈值:正常正文不会太短
|
||||
if len(t) < 30:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _safe_read_clipboard(self) -> str | None:
|
||||
try:
|
||||
return await self.page.evaluate("navigator.clipboard.readText()")
|
||||
except Exception:
|
||||
return None
|
||||
48
llm-manager/scripts/engines/kimi.py
Normal file
48
llm-manager/scripts/engines/kimi.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import asyncio
|
||||
from .base import BaseEngine
|
||||
|
||||
|
||||
class KimiEngine(BaseEngine):
|
||||
async def generate(self, prompt: str) -> str:
|
||||
await self.page.goto("https://kimi.moonshot.cn/")
|
||||
await self.page.wait_for_load_state("networkidle")
|
||||
|
||||
# 登录检测:等待输入框出现,超时则未登录
|
||||
try:
|
||||
editor = self.page.locator(".chat-input-editor").first
|
||||
await editor.wait_for(state="visible", timeout=10000)
|
||||
except Exception:
|
||||
return "ERROR:REQUIRE_LOGIN"
|
||||
|
||||
# 输入提示词(insert_text 避免换行符被误触发)
|
||||
await editor.click()
|
||||
await self.page.keyboard.insert_text(prompt)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 点击发送按钮
|
||||
await self.page.locator(".send-button-container").first.click()
|
||||
|
||||
print("💡 [llm-manager/kimi] 已发送提示词,等待 Kimi 生成响应...")
|
||||
|
||||
# 等待 2 秒让发送按钮进入"生成中"状态
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 等待 Send SVG 图标重新出现(表示生成完毕)
|
||||
try:
|
||||
send_icon = self.page.locator('.send-button-container svg[name="Send"]')
|
||||
await send_icon.wait_for(state="visible", timeout=150000)
|
||||
except Exception:
|
||||
return "ERROR:Kimi 生成超时(150秒未见完成标志),可能网络卡住或内容被拦截。"
|
||||
|
||||
# 稳妥起见多等 2 秒,让复制按钮完全渲染
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 点击最后一条回复的复制按钮,通过剪贴板取完整文本
|
||||
try:
|
||||
copy_btn = self.page.locator('svg[name="Copy"]').last
|
||||
await copy_btn.click()
|
||||
await asyncio.sleep(0.5)
|
||||
result = await self.page.evaluate("navigator.clipboard.readText()")
|
||||
return result.strip()
|
||||
except Exception as e:
|
||||
return f"ERROR:抓取 Kimi 内容时异常:{e}"
|
||||
91
llm-manager/scripts/engines/qianwen.py
Normal file
91
llm-manager/scripts/engines/qianwen.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
通义千问网页版驱动引擎(tongyi.aliyun.com/qianwen)。
|
||||
|
||||
选择器说明(如页面改版需更新):
|
||||
- 输入框:#search-input 或 textarea[class*="input"](textarea)
|
||||
- 发送按钮:button[class*="send"] 或 [aria-label="发送"]
|
||||
- 停止生成:button[class*="stop"] 或 [aria-label="停止"]
|
||||
- 复制按钮:最后一条回复下的 [class*="copy"] 或 [aria-label="复制"]
|
||||
"""
|
||||
import asyncio
|
||||
from .base import BaseEngine
|
||||
|
||||
|
||||
class QianwenEngine(BaseEngine):
|
||||
async def generate(self, prompt: str) -> str:
|
||||
await self.page.goto("https://tongyi.aliyun.com/qianwen/")
|
||||
await self.page.wait_for_load_state("networkidle")
|
||||
|
||||
# 登录检测
|
||||
input_selectors = [
|
||||
"#search-input",
|
||||
"textarea[class*='input']",
|
||||
"div[class*='input'][contenteditable='true']",
|
||||
]
|
||||
editor = None
|
||||
for sel in input_selectors:
|
||||
try:
|
||||
loc = self.page.locator(sel).first
|
||||
await loc.wait_for(state="visible", timeout=4000)
|
||||
editor = loc
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if editor is None:
|
||||
return "ERROR:REQUIRE_LOGIN"
|
||||
|
||||
# 输入提示词
|
||||
await editor.click()
|
||||
await self.page.keyboard.insert_text(prompt)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 发送
|
||||
sent = False
|
||||
for sel in (
|
||||
'button[class*="send"]',
|
||||
'[aria-label="发送"]',
|
||||
'button[type="submit"]',
|
||||
):
|
||||
try:
|
||||
btn = self.page.locator(sel).first
|
||||
if await btn.is_visible(timeout=1000):
|
||||
await btn.click()
|
||||
sent = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not sent:
|
||||
await self.page.keyboard.press("Enter")
|
||||
|
||||
print("💡 [llm-manager/qianwen] 已发送提示词,等待通义千问生成响应...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 等待生成完毕
|
||||
stop_selectors = ['button[class*="stop"]', '[aria-label="停止"]', '[aria-label="停止生成"]']
|
||||
deadline = asyncio.get_event_loop().time() + 150
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
stop_visible = False
|
||||
for sel in stop_selectors:
|
||||
try:
|
||||
if await self.page.locator(sel).first.is_visible(timeout=300):
|
||||
stop_visible = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not stop_visible:
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 取结果
|
||||
try:
|
||||
copy_btn = self.page.locator(
|
||||
'[aria-label="复制"], [class*="copy-btn"], button:has(svg[class*="copy"])'
|
||||
).last
|
||||
await copy_btn.click()
|
||||
await asyncio.sleep(0.5)
|
||||
result = await self.page.evaluate("navigator.clipboard.readText()")
|
||||
return result.strip()
|
||||
except Exception as e:
|
||||
return f"ERROR:抓取通义千问内容时异常:{e}"
|
||||
91
llm-manager/scripts/engines/yiyan.py
Normal file
91
llm-manager/scripts/engines/yiyan.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
文心一言网页版驱动引擎(yiyan.baidu.com)。
|
||||
|
||||
选择器说明(如页面改版需更新):
|
||||
- 输入框:[class*="editor"] 或 div[contenteditable="true"](富文本编辑器)
|
||||
- 发送按钮:[class*="send-btn"] 或 [aria-label="发送"]
|
||||
- 停止生成:[class*="stop"] 相关按钮
|
||||
- 复制按钮:最后一条回复下的复制按钮
|
||||
"""
|
||||
import asyncio
|
||||
from .base import BaseEngine
|
||||
|
||||
|
||||
class YiyanEngine(BaseEngine):
|
||||
async def generate(self, prompt: str) -> str:
|
||||
await self.page.goto("https://yiyan.baidu.com")
|
||||
await self.page.wait_for_load_state("networkidle")
|
||||
|
||||
# 登录检测
|
||||
input_selectors = [
|
||||
"div[class*='editor'][contenteditable='true']",
|
||||
"textarea[class*='input']",
|
||||
"[contenteditable='true']",
|
||||
]
|
||||
editor = None
|
||||
for sel in input_selectors:
|
||||
try:
|
||||
loc = self.page.locator(sel).first
|
||||
await loc.wait_for(state="visible", timeout=4000)
|
||||
editor = loc
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if editor is None:
|
||||
return "ERROR:REQUIRE_LOGIN"
|
||||
|
||||
# 输入提示词
|
||||
await editor.click()
|
||||
await self.page.keyboard.insert_text(prompt)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 发送
|
||||
sent = False
|
||||
for sel in (
|
||||
'[class*="send-btn"]',
|
||||
'[aria-label="发送"]',
|
||||
'button[class*="send"]',
|
||||
):
|
||||
try:
|
||||
btn = self.page.locator(sel).first
|
||||
if await btn.is_visible(timeout=1000):
|
||||
await btn.click()
|
||||
sent = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not sent:
|
||||
await self.page.keyboard.press("Enter")
|
||||
|
||||
print("💡 [llm-manager/yiyan] 已发送提示词,等待文心一言生成响应...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 等待生成完毕
|
||||
stop_selectors = ['[class*="stop"]', '[aria-label="停止"]', '[aria-label="停止生成"]']
|
||||
deadline = asyncio.get_event_loop().time() + 150
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
stop_visible = False
|
||||
for sel in stop_selectors:
|
||||
try:
|
||||
if await self.page.locator(sel).first.is_visible(timeout=300):
|
||||
stop_visible = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not stop_visible:
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 取结果
|
||||
try:
|
||||
copy_btn = self.page.locator(
|
||||
'[aria-label="复制"], [class*="copy"], button:has(svg[class*="copy"])'
|
||||
).last
|
||||
await copy_btn.click()
|
||||
await asyncio.sleep(0.5)
|
||||
result = await self.page.evaluate("navigator.clipboard.readText()")
|
||||
return result.strip()
|
||||
except Exception as e:
|
||||
return f"ERROR:抓取文心一言内容时异常:{e}"
|
||||
94
llm-manager/scripts/engines/yuanbao.py
Normal file
94
llm-manager/scripts/engines/yuanbao.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
腾讯元宝网页版驱动引擎(yuanbao.tencent.com)。
|
||||
元宝暂无公开 API,仅支持网页模式。
|
||||
|
||||
选择器说明(如页面改版需更新):
|
||||
- 输入框:[class*="input-area"] textarea 或 [contenteditable="true"]
|
||||
- 发送按钮:[class*="send-btn"] 或 [aria-label="发送"]
|
||||
- 停止生成:[class*="stop"] 相关按钮
|
||||
- 复制按钮:最后一条回复下的复制按钮
|
||||
"""
|
||||
import asyncio
|
||||
from .base import BaseEngine
|
||||
|
||||
|
||||
class YuanbaoEngine(BaseEngine):
|
||||
async def generate(self, prompt: str) -> str:
|
||||
await self.page.goto("https://yuanbao.tencent.com/chat")
|
||||
await self.page.wait_for_load_state("networkidle")
|
||||
|
||||
# 登录检测
|
||||
input_selectors = [
|
||||
"textarea[class*='input']",
|
||||
"div[class*='input-area'] textarea",
|
||||
"[contenteditable='true']",
|
||||
"textarea",
|
||||
]
|
||||
editor = None
|
||||
for sel in input_selectors:
|
||||
try:
|
||||
loc = self.page.locator(sel).first
|
||||
await loc.wait_for(state="visible", timeout=4000)
|
||||
editor = loc
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if editor is None:
|
||||
return "ERROR:REQUIRE_LOGIN"
|
||||
|
||||
# 输入提示词
|
||||
await editor.click()
|
||||
await self.page.keyboard.insert_text(prompt)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# 发送
|
||||
sent = False
|
||||
for sel in (
|
||||
'[class*="send-btn"]',
|
||||
'[aria-label="发送"]',
|
||||
'button[class*="send"]',
|
||||
'button[type="submit"]',
|
||||
):
|
||||
try:
|
||||
btn = self.page.locator(sel).first
|
||||
if await btn.is_visible(timeout=1000):
|
||||
await btn.click()
|
||||
sent = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not sent:
|
||||
await self.page.keyboard.press("Enter")
|
||||
|
||||
print("💡 [llm-manager/yuanbao] 已发送提示词,等待腾讯元宝生成响应...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 等待生成完毕
|
||||
stop_selectors = ['[class*="stop"]', '[aria-label="停止"]', '[aria-label="停止生成"]']
|
||||
deadline = asyncio.get_event_loop().time() + 150
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
stop_visible = False
|
||||
for sel in stop_selectors:
|
||||
try:
|
||||
if await self.page.locator(sel).first.is_visible(timeout=300):
|
||||
stop_visible = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not stop_visible:
|
||||
break
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 取结果
|
||||
try:
|
||||
copy_btn = self.page.locator(
|
||||
'[aria-label="复制"], [class*="copy"], button:has(svg[class*="copy"])'
|
||||
).last
|
||||
await copy_btn.click()
|
||||
await asyncio.sleep(0.5)
|
||||
result = await self.page.evaluate("navigator.clipboard.readText()")
|
||||
return result.strip()
|
||||
except Exception as e:
|
||||
return f"ERROR:抓取腾讯元宝内容时异常:{e}"
|
||||
575
llm-manager/scripts/main.py
Normal file
575
llm-manager/scripts/main.py
Normal file
@@ -0,0 +1,575 @@
|
||||
"""
|
||||
llm-manager 主入口 CLI。
|
||||
|
||||
子命令:
|
||||
health 快速离线健康检查
|
||||
version 输出版本 JSON
|
||||
add <platform> [api_key] 添加 API Key(不传 api_key 则走网页账号关联)
|
||||
key list [platform] 列出 Key(打码)
|
||||
key del <key_id> 删除 Key
|
||||
generate <platform_or_account_id> 生成内容(优先网页模式,备用 API Key 模式)
|
||||
"<prompt>"
|
||||
|
||||
generate 调度规则:
|
||||
1. 若 target 为纯数字 → 视为 account-manager 账号 ID → 强制网页模式
|
||||
2. 若 target 为平台名/别名:
|
||||
a. 先查 account-manager 有无该平台已登录账号 → 网页模式(免费)
|
||||
b. 再查 llm_keys 有无可用 Key → API Key 模式(付费)
|
||||
c. 两者均无 → 报错并给出操作指引
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
import asyncio
|
||||
import subprocess
|
||||
|
||||
# Windows GBK 编码兼容修复
|
||||
if sys.platform == "win32":
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OPENCLAW_DIR = os.path.dirname(BASE_DIR)
|
||||
|
||||
# 确保 scripts 目录在 sys.path,使 providers/db 可直接 import
|
||||
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if _scripts_dir not in sys.path:
|
||||
sys.path.insert(0, _scripts_dir)
|
||||
|
||||
from providers import (
|
||||
LLM_PROVIDERS,
|
||||
resolve_provider_key,
|
||||
provider_list_cn,
|
||||
find_logged_in_account,
|
||||
resolve_chromium_channel,
|
||||
)
|
||||
from db import (
|
||||
add_key,
|
||||
upsert_web_account,
|
||||
list_keys,
|
||||
list_web_accounts,
|
||||
get_key_by_id,
|
||||
delete_key,
|
||||
find_active_key,
|
||||
mark_key_used,
|
||||
_mask_key,
|
||||
)
|
||||
from engines.api_engine import ApiEngine
|
||||
from engines.kimi import KimiEngine
|
||||
from engines.doubao import DoubaoEngine
|
||||
from engines.deepseek import DeepSeekEngine
|
||||
from engines.qianwen import QianwenEngine
|
||||
from engines.yiyan import YiyanEngine
|
||||
from engines.yuanbao import YuanbaoEngine
|
||||
|
||||
# 平台 slug → 网页引擎类(全部 6 个平台)
|
||||
WEB_ENGINES = {
|
||||
"doubao": DoubaoEngine,
|
||||
"deepseek": DeepSeekEngine,
|
||||
"qianwen": QianwenEngine,
|
||||
"kimi": KimiEngine,
|
||||
"yiyan": YiyanEngine,
|
||||
"yuanbao": YuanbaoEngine,
|
||||
}
|
||||
|
||||
SKILL_VERSION = "1.0.3"
|
||||
|
||||
|
||||
def _engine_result_is_error(text: str) -> bool:
|
||||
"""网页/API 引擎约定:失败时返回以 ERROR: 开头的字符串,不得当作正文包进 LLM 标记块。"""
|
||||
return (text or "").lstrip().startswith("ERROR:")
|
||||
|
||||
|
||||
def _unix_to_iso(ts):
|
||||
if ts is None:
|
||||
return ""
|
||||
try:
|
||||
import datetime as _dt
|
||||
|
||||
return _dt.datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
return str(ts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# account-manager 跨 Skill 调用
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_account_from_manager(account_id) -> dict | None:
|
||||
"""通过 account-manager CLI 按 ID 取账号 JSON。"""
|
||||
script = os.path.join(OPENCLAW_DIR, "account-manager", "scripts", "main.py")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, script, "get", str(account_id)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
raw = result.stdout.strip()
|
||||
if raw.startswith("ERROR"):
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 网页模式
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _run_web_generate(account: dict, prompt: str) -> bool:
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
platform = account.get("platform", "")
|
||||
profile_dir = account.get("profile_dir", "").strip()
|
||||
|
||||
engine_cls = WEB_ENGINES.get(platform)
|
||||
if not engine_cls:
|
||||
print(f"ERROR:ENGINE_NOT_FOUND 平台 {platform} 暂无网页引擎实现。")
|
||||
return False
|
||||
|
||||
if not profile_dir or not os.path.isdir(profile_dir):
|
||||
print(f"ERROR:PROFILE_DIR_MISSING 账号 {account.get('id')} 的浏览器数据目录不存在:{profile_dir}")
|
||||
print("请先通过 account-manager 执行登录:python account-manager/scripts/main.py login <id>")
|
||||
return False
|
||||
|
||||
channel = resolve_chromium_channel()
|
||||
if not channel:
|
||||
print("ERROR:浏览器未找到。请安装 Google Chrome 或 Microsoft Edge 后重试。")
|
||||
return False
|
||||
|
||||
print(f"[llm-manager] 使用网页模式 | 平台: {LLM_PROVIDERS.get(platform, {}).get('label', platform)} | 账号: {account.get('name', account.get('id'))}")
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch_persistent_context(
|
||||
user_data_dir=profile_dir,
|
||||
headless=False,
|
||||
channel=channel,
|
||||
no_viewport=True,
|
||||
permissions=["clipboard-read", "clipboard-write"],
|
||||
args=["--start-maximized"],
|
||||
)
|
||||
try:
|
||||
page = browser.pages[0] if browser.pages else await browser.new_page()
|
||||
engine = engine_cls(page)
|
||||
try:
|
||||
result = await engine.generate(prompt)
|
||||
except Exception as e:
|
||||
print(f"ERROR:WEB_GENERATE_FAILED 网页模式生成失败:{e}")
|
||||
return False
|
||||
|
||||
result = (result or "").strip()
|
||||
if _engine_result_is_error(result):
|
||||
print(result)
|
||||
return False
|
||||
|
||||
print("===LLM_START===")
|
||||
print(result)
|
||||
print("===LLM_END===")
|
||||
return True
|
||||
finally:
|
||||
await asyncio.sleep(3)
|
||||
await browser.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API Key 模式
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run_api_generate(provider_key: str, key_record: dict, prompt: str) -> bool:
|
||||
provider = LLM_PROVIDERS[provider_key]
|
||||
api_base = provider.get("api_base")
|
||||
model = (key_record.get("default_model") or "").strip() or (provider.get("api_model") or "").strip()
|
||||
|
||||
if not api_base:
|
||||
print(f"ERROR:NO_API_BASE {provider['label']} 暂无 API 地址(不支持 API 模式)。")
|
||||
return False
|
||||
if not model:
|
||||
print(
|
||||
f"ERROR:API_MODEL_MISSING {provider['label']} 需要指定模型名称。\n"
|
||||
f"提示:{provider.get('api_note', '')}\n"
|
||||
f"请删除该 Key 并重新添加时带上 --model 参数:python main.py add {provider_key} \"Key\" --model \"模型名\""
|
||||
)
|
||||
return False
|
||||
|
||||
print(f"[llm-manager] 使用 API Key 模式 | 平台: {provider['label']} | 模型: {model}")
|
||||
|
||||
try:
|
||||
engine = ApiEngine(api_base=api_base, api_key=key_record["api_key"], model=model)
|
||||
result = engine.generate(prompt)
|
||||
except Exception as e:
|
||||
print(f"ERROR:API_GENERATE_FAILED API 模式调用失败:{e}")
|
||||
return False
|
||||
result = (result or "").strip()
|
||||
if _engine_result_is_error(result):
|
||||
print(result)
|
||||
return False
|
||||
mark_key_used(key_record["id"])
|
||||
|
||||
print("===LLM_START===")
|
||||
print(result)
|
||||
print("===LLM_END===")
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate 命令
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_generate(target: str, prompt: str) -> bool:
|
||||
"""
|
||||
target 可以是:
|
||||
- 纯数字 → account-manager 账号 ID(强制网页模式)
|
||||
- 平台名/别名 → 自动选择模式(网页优先,API Key 备用)
|
||||
成功返回 True,任一步失败返回 False(调用方应设进程退出码非 0)。
|
||||
"""
|
||||
target = (target or "").strip()
|
||||
prompt = (prompt or "").strip()
|
||||
if not prompt:
|
||||
print("ERROR:PROMPT_EMPTY 提示词不能为空。")
|
||||
return False
|
||||
|
||||
# ---- 情况 1:显式 account_id(纯数字)→ 强制网页模式 ----
|
||||
if target.isdigit():
|
||||
account = _get_account_from_manager(target)
|
||||
if not account:
|
||||
print(f"ERROR:ACCOUNT_NOT_FOUND 未在「模型管理」对应的账号列表中找到 id={target}。")
|
||||
print("请先在模型管理中添加该平台账号,或检查 id 是否抄错。")
|
||||
return False
|
||||
if account.get("login_status") != 1:
|
||||
print(
|
||||
f"ERROR:REQUIRE_LOGIN 账号「{account.get('name', target)}」尚未完成网页登录。\n"
|
||||
"请先在「模型管理」里确认已添加该账号,再执行下面命令完成登录:\n"
|
||||
f" python account-manager/scripts/main.py login {target}\n"
|
||||
"若使用网页模式:请先在对应平台官网注册好账号,再回到本工具添加并登录。"
|
||||
)
|
||||
return False
|
||||
return asyncio.run(_run_web_generate(account, prompt))
|
||||
|
||||
# ---- 情况 2:平台名 → 自动选模式 ----
|
||||
provider_key = resolve_provider_key(target)
|
||||
if not provider_key:
|
||||
print(f"ERROR:INVALID_PLATFORM 无法识别的平台「{target}」。")
|
||||
print("支持的平台:" + provider_list_cn())
|
||||
return False
|
||||
|
||||
provider = LLM_PROVIDERS[provider_key]
|
||||
label = provider["label"]
|
||||
web_url = (provider.get("web_url") or "").strip()
|
||||
|
||||
# 优先级 1:查 account-manager 已登录账号(网页模式,免费)
|
||||
account = find_logged_in_account(provider_key)
|
||||
if account:
|
||||
return asyncio.run(_run_web_generate(account, prompt))
|
||||
|
||||
# 优先级 2:查本地 API Key(付费)
|
||||
key_record = find_active_key(provider_key)
|
||||
if key_record:
|
||||
return _run_api_generate(provider_key, key_record, prompt)
|
||||
|
||||
# 两种凭据均无 → 明确说明「模型管理 + 网页模式需官网账号」
|
||||
print(f"ERROR:NO_CREDENTIAL 当前没有可用的「{label}」调用凭据(既没有已登录的网页账号,也没有可用的 API Key)。")
|
||||
print()
|
||||
print("【推荐 · 网页模式(免费)】按顺序做:")
|
||||
print(f" ① 打开「模型管理」(与 OpenClaw 里 account-manager 账号管理是同一套数据),先把「{label}」账号添加进去。")
|
||||
print(" 命令行示例(在 OpenClaw 根目录执行):")
|
||||
print(f" python account-manager/scripts/main.py add \"{label}\" \"你的手机号或登录名\"")
|
||||
print(" python account-manager/scripts/main.py login <上一步返回的账号 id>")
|
||||
print(" ② 若走网页模式:请先在对应平台官方网站注册并创建好账号(否则浏览器里登录会失败)。")
|
||||
if web_url:
|
||||
print(f" 「{label}」网页入口:{web_url}")
|
||||
print()
|
||||
if provider.get("has_api"):
|
||||
print("【备选 · API Key(付费)】若已在厂商控制台开通 API,可本地登记 Key 后走接口调用:")
|
||||
print(f" python llm-manager/scripts/main.py add {provider_key} \"你的API Key\"")
|
||||
if provider.get("api_note"):
|
||||
print(f" 说明:{provider['api_note']}")
|
||||
else:
|
||||
print(f"说明:「{label}」暂无公开 API,只能使用上面的网页模式。")
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# key 子命令
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_key_add(provider_input: str, api_key: str = "", model: str = None, label: str = ""):
|
||||
provider_key = resolve_provider_key(provider_input)
|
||||
if not provider_key:
|
||||
print(f"ERROR:INVALID_PLATFORM 无法识别的平台「{provider_input}」。")
|
||||
print("支持:" + provider_list_cn())
|
||||
return
|
||||
provider = LLM_PROVIDERS[provider_key]
|
||||
api_key = (api_key or "").strip()
|
||||
|
||||
# 不传 api_key:默认走网页版本,检查 account-manager 是否已有该平台已登录账号
|
||||
if not api_key:
|
||||
account = find_logged_in_account(provider_key)
|
||||
if account:
|
||||
web_row_id = upsert_web_account(
|
||||
provider=provider_key,
|
||||
account_id=int(account.get("id")),
|
||||
account_name=(account.get("name") or account.get("account_name") or ""),
|
||||
login_status=int(account.get("login_status") or 0),
|
||||
)
|
||||
print(
|
||||
f"OK:WEB_ACCOUNT_READY 已关联网页模式账号 | 平台: {provider['label']} "
|
||||
f"| account_id: {account.get('id')} | 账号: {account.get('name') or account.get('account_name') or ''}"
|
||||
)
|
||||
print(f"已写入 llm-manager 记录:WEB_ID {web_row_id}")
|
||||
print(f"后续可直接调用:python main.py generate {provider_key} \"<提示词>\"")
|
||||
return
|
||||
print(f"ERROR:WEB_ACCOUNT_NOT_FOUND 未找到已登录的「{provider['label']}」账号,暂时无法走网页模式。")
|
||||
print("请先在 account-manager 添加并登录该平台账号:")
|
||||
print(f" python account-manager/scripts/main.py add \"{provider['label']}\" \"你的登录名\"")
|
||||
print(" python account-manager/scripts/main.py login <账号id>")
|
||||
if provider.get("has_api"):
|
||||
print("或直接提供 API Key:")
|
||||
print(f" python main.py add {provider_key} \"<API_Key>\"")
|
||||
return
|
||||
|
||||
if not provider.get("has_api"):
|
||||
print(f"ERROR:{provider['label']} 暂无公开 API,不支持 API Key 模式。")
|
||||
print("请改用网页模式并先在 account-manager 完成登录。")
|
||||
return
|
||||
|
||||
new_id = add_key(provider=provider_key, api_key=api_key, model=model, label=label)
|
||||
print(f"✅ 已保存 API Key:ID {new_id} | {provider['label']} | 模型: {model or provider.get('api_model') or '(未指定)'} | {_mask_key(api_key)}")
|
||||
if not model and not provider.get("api_model"):
|
||||
print(f"⚠️ 注意:该平台需要指定模型名称,否则调用时会报错。")
|
||||
print(f" {provider.get('api_note', '')}")
|
||||
print(f" 可删除后重新添加:python main.py key del {new_id}")
|
||||
|
||||
|
||||
def cmd_key_list(provider_input: str = None, limit: int = 10):
|
||||
provider_key = None
|
||||
if provider_input:
|
||||
provider_key = resolve_provider_key(provider_input)
|
||||
if not provider_key:
|
||||
print(f"ERROR:INVALID_PLATFORM 无法识别的平台「{provider_input}」。")
|
||||
return
|
||||
|
||||
keys = list_keys(provider_key, limit=limit)
|
||||
web_accounts = list_web_accounts(provider_key, limit=limit)
|
||||
if not keys and not web_accounts:
|
||||
print("暂无记录(API Key / 网页账号关联 都为空)。")
|
||||
print("添加 API Key:python main.py add <platform> \"API Key\" [--model 模型名]")
|
||||
print("添加网页账号关联:python main.py add <platform>")
|
||||
return
|
||||
|
||||
sep_line = "_" * 39
|
||||
rows = []
|
||||
for k in keys:
|
||||
rows.append({
|
||||
"type": "api_key",
|
||||
"created_at": int(k.get("created_at") or 0),
|
||||
"payload": k,
|
||||
})
|
||||
for w in web_accounts:
|
||||
rows.append({
|
||||
"type": "web_account",
|
||||
"created_at": int(w.get("created_at") or 0),
|
||||
"payload": w,
|
||||
})
|
||||
rows.sort(key=lambda x: (x["created_at"], int(x["payload"].get("id") or 0)), reverse=True)
|
||||
|
||||
for idx, row in enumerate(rows):
|
||||
if row["type"] == "api_key":
|
||||
k = row["payload"]
|
||||
print("record_type:api_key")
|
||||
print(f"id:{k['id']}")
|
||||
print(f"platform:{k['provider']}")
|
||||
print(f"platform_cn:{LLM_PROVIDERS.get(k['provider'], {}).get('label', k['provider'])}")
|
||||
print(f"label:{k.get('label') or ''}")
|
||||
print(f"api_key:{_mask_key(k.get('api_key') or '')}")
|
||||
print(f"default_model:{k.get('default_model') or ''}")
|
||||
print(f"is_active:{int(k.get('is_active') or 0)}")
|
||||
print(f"last_used_at:{_unix_to_iso(k.get('last_used_at'))}")
|
||||
print(f"created_at:{_unix_to_iso(k.get('created_at'))}")
|
||||
else:
|
||||
w = row["payload"]
|
||||
print("record_type:web_account")
|
||||
print(f"id:{w['id']}")
|
||||
print(f"platform:{w['provider']}")
|
||||
print(f"platform_cn:{LLM_PROVIDERS.get(w['provider'], {}).get('label', w['provider'])}")
|
||||
print(f"account_id:{w.get('account_id')}")
|
||||
print(f"account_name:{w.get('account_name') or ''}")
|
||||
print(f"login_status:{int(w.get('login_status') or 0)}")
|
||||
print(f"created_at:{_unix_to_iso(w.get('created_at'))}")
|
||||
print(f"updated_at:{_unix_to_iso(w.get('updated_at'))}")
|
||||
if idx != len(rows) - 1:
|
||||
print(sep_line)
|
||||
print()
|
||||
|
||||
|
||||
def cmd_key_del(key_id_str: str):
|
||||
if not key_id_str.isdigit():
|
||||
print(f"ERROR:INVALID_ID key_id 须为正整数,收到「{key_id_str}」。")
|
||||
return
|
||||
key_id = int(key_id_str)
|
||||
key = get_key_by_id(key_id)
|
||||
if not key:
|
||||
print(f"ERROR:KEY_NOT_FOUND 未找到 ID={key_id} 的 API Key。")
|
||||
return
|
||||
delete_key(key_id)
|
||||
label_str = f"({key['label']})" if key.get("label") else ""
|
||||
print(f"✅ 已删除:ID {key_id} | {LLM_PROVIDERS.get(key['provider'], {}).get('label', key['provider'])}{label_str} | {_mask_key(key['api_key'])}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# health / version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_health():
|
||||
ok = sys.version_info >= (3, 10)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
def cmd_version():
|
||||
print(json.dumps({"version": SKILL_VERSION, "skill": "llm-manager"}, ensure_ascii=False))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI 解析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _print_usage():
|
||||
print("用法:")
|
||||
print(" python main.py health")
|
||||
print(" python main.py version")
|
||||
print(" python main.py generate <平台名或account_id> \"<提示词>\"")
|
||||
print(" python main.py add <平台> [API_Key] [--model 模型名] [--label 备注]")
|
||||
print(" python main.py list [平台] [--limit 条数]")
|
||||
print(" python main.py del <key_id>")
|
||||
print()
|
||||
print("支持的平台:" + provider_list_cn())
|
||||
print()
|
||||
print("generate 说明:")
|
||||
print(" · 传入 account_id(数字)→ 指定账号网页模式(需先用 account-manager 登录)")
|
||||
print(" · 传入平台名 → 自动选择:优先网页模式(免费),无可用账号时才用 API Key(付费)")
|
||||
print()
|
||||
print("兼容说明:key add/list/del 旧写法仍可用。")
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
args = argv if argv is not None else sys.argv[1:]
|
||||
if not args:
|
||||
_print_usage()
|
||||
return 1
|
||||
if args[0] in ("-h", "--help"):
|
||||
_print_usage()
|
||||
return 0
|
||||
|
||||
def _parse_list_args(rest_args):
|
||||
platform_filter = None
|
||||
limit = 10
|
||||
i = 0
|
||||
while i < len(rest_args):
|
||||
if rest_args[i] == "--limit" and i + 1 < len(rest_args):
|
||||
try:
|
||||
limit = int(rest_args[i + 1])
|
||||
except ValueError:
|
||||
print(f"ERROR:CLI_KEY_LIST_BAD_LIMIT limit 必须是整数,收到「{rest_args[i + 1]}」。")
|
||||
return None, None, 1
|
||||
i += 2
|
||||
else:
|
||||
if platform_filter is None:
|
||||
platform_filter = rest_args[i]
|
||||
i += 1
|
||||
return platform_filter, limit, 0
|
||||
|
||||
def _parse_add_args(rest_args):
|
||||
if len(rest_args) < 1:
|
||||
print("ERROR:CLI_ADD_MISSING_ARGS")
|
||||
print("用法:python main.py add <平台> [API_Key] [--model 模型] [--label 备注]")
|
||||
return None, None, None, None, 1
|
||||
platform_arg = rest_args[0]
|
||||
api_key_arg = ""
|
||||
model_arg = None
|
||||
label_arg = ""
|
||||
i = 1
|
||||
if i < len(rest_args) and not rest_args[i].startswith("--"):
|
||||
api_key_arg = rest_args[i]
|
||||
i += 1
|
||||
while i < len(rest_args):
|
||||
if rest_args[i] == "--model" and i + 1 < len(rest_args):
|
||||
model_arg = rest_args[i + 1]
|
||||
i += 2
|
||||
elif rest_args[i] == "--label" and i + 1 < len(rest_args):
|
||||
label_arg = rest_args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
return platform_arg, api_key_arg, model_arg, label_arg, 0
|
||||
|
||||
cmd = args[0]
|
||||
|
||||
if cmd == "health":
|
||||
cmd_health()
|
||||
|
||||
elif cmd == "version":
|
||||
cmd_version()
|
||||
|
||||
elif cmd == "generate":
|
||||
if len(args) < 3:
|
||||
print("ERROR:CLI_GENERATE_MISSING_ARGS")
|
||||
print("用法:python main.py generate <平台名或account_id> \"<提示词>\"")
|
||||
return 1
|
||||
if not cmd_generate(args[1], args[2]):
|
||||
return 1
|
||||
|
||||
elif cmd == "list":
|
||||
platform_filter, limit, err = _parse_list_args(args[1:])
|
||||
if err:
|
||||
return err
|
||||
cmd_key_list(platform_filter, limit=limit)
|
||||
|
||||
elif cmd == "add":
|
||||
platform_arg, api_key_arg, model_arg, label_arg, err = _parse_add_args(args[1:])
|
||||
if err:
|
||||
return err
|
||||
cmd_key_add(platform_arg, api_key_arg, model=model_arg, label=label_arg)
|
||||
|
||||
elif cmd == "del":
|
||||
if len(args) < 2:
|
||||
print("ERROR:CLI_DEL_MISSING_ARGS 用法:python main.py del <key_id>")
|
||||
return 1
|
||||
cmd_key_del(args[1])
|
||||
|
||||
elif cmd == "key":
|
||||
if len(args) < 2:
|
||||
print("ERROR:CLI_KEY_MISSING_SUBCOMMAND 请指定子命令:add / list / del")
|
||||
return 1
|
||||
sub = args[1]
|
||||
|
||||
if sub == "add":
|
||||
platform_arg, api_key_arg, model_arg, label_arg, err = _parse_add_args(args[2:])
|
||||
if err:
|
||||
return err
|
||||
cmd_key_add(platform_arg, api_key_arg, model=model_arg, label=label_arg)
|
||||
|
||||
elif sub == "list":
|
||||
platform_filter, limit, err = _parse_list_args(args[2:])
|
||||
if err:
|
||||
return err
|
||||
cmd_key_list(platform_filter, limit=limit)
|
||||
|
||||
elif sub == "del":
|
||||
if len(args) < 3:
|
||||
print("ERROR:CLI_KEY_DEL_MISSING_ARGS 用法:python main.py key del <key_id>")
|
||||
return 1
|
||||
cmd_key_del(args[2])
|
||||
|
||||
else:
|
||||
print(f"ERROR:CLI_UNKNOWN_KEY_SUB 未知 key 子命令「{sub}」,支持:add / list / del")
|
||||
return 1
|
||||
|
||||
else:
|
||||
print(f"ERROR:CLI_UNKNOWN_COMMAND 未知命令「{cmd}」。")
|
||||
_print_usage()
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
199
llm-manager/scripts/providers.py
Normal file
199
llm-manager/scripts/providers.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
平台配置中心:7 个 LLM 平台的静态配置、别名解析、以及通过 account-manager 暴露的 CLI 查询已登录网页账号。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 平台静态配置
|
||||
# ---------------------------------------------------------------------------
|
||||
LLM_PROVIDERS = {
|
||||
"doubao": {
|
||||
"label": "豆包",
|
||||
"aliases": ["豆包"],
|
||||
"web_url": "https://www.doubao.com/chat/",
|
||||
"api_base": "https://ark.volces.com/api/v3",
|
||||
"api_model": None, # 豆包需要用户在火山引擎控制台建推理接入点,model=ep-xxx
|
||||
"has_api": True,
|
||||
"api_note": "需先在火山引擎控制台创建推理接入点,--model 传 endpoint_id(格式 ep-xxx)",
|
||||
},
|
||||
"deepseek": {
|
||||
"label": "DeepSeek",
|
||||
"aliases": ["深度求索"],
|
||||
"web_url": "https://chat.deepseek.com",
|
||||
"api_base": "https://api.deepseek.com/v1",
|
||||
"api_model": "deepseek-chat",
|
||||
"has_api": True,
|
||||
"api_note": "模型可选:deepseek-chat / deepseek-reasoner",
|
||||
},
|
||||
"qianwen": {
|
||||
"label": "通义千问",
|
||||
"aliases": ["通义", "千问", "qwen", "tongyi"],
|
||||
"web_url": "https://tongyi.aliyun.com/qianwen/",
|
||||
"api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"api_model": "qwen-plus",
|
||||
"has_api": True,
|
||||
"api_note": "模型可选:qwen-turbo / qwen-plus / qwen-max",
|
||||
},
|
||||
"kimi": {
|
||||
"label": "Kimi",
|
||||
"aliases": ["月之暗面", "moonshot"],
|
||||
"web_url": "https://kimi.moonshot.cn",
|
||||
"api_base": "https://api.moonshot.cn/v1",
|
||||
"api_model": "moonshot-v1-8k",
|
||||
"has_api": True,
|
||||
"api_note": "模型可选:moonshot-v1-8k / moonshot-v1-32k / moonshot-v1-128k",
|
||||
},
|
||||
"yiyan": {
|
||||
"label": "文心一言",
|
||||
"aliases": ["文心", "一言", "ernie", "wenxin"],
|
||||
"web_url": "https://yiyan.baidu.com",
|
||||
"api_base": "https://qianfan.baidubce.com/v2",
|
||||
"api_model": "ernie-4.0-8k",
|
||||
"has_api": True,
|
||||
"api_note": "模型可选:ernie-4.0-8k / ernie-3.5-8k",
|
||||
},
|
||||
"yuanbao": {
|
||||
"label": "腾讯元宝",
|
||||
"aliases": ["元宝"],
|
||||
"web_url": "https://yuanbao.tencent.com/chat",
|
||||
"api_base": None,
|
||||
"api_model": None,
|
||||
"has_api": False,
|
||||
"api_note": "暂无公开 API,仅支持网页模式",
|
||||
},
|
||||
"minimax": {
|
||||
"label": "MiniMax",
|
||||
"aliases": ["minimax", "MiniMax", "海螺", "海螺AI"],
|
||||
"web_url": "https://chat.minimax.io/",
|
||||
"api_base": "https://api.minimax.chat/v1",
|
||||
"api_model": "MiniMax-Text-01",
|
||||
"has_api": True,
|
||||
"api_note": "模型可按 MiniMax 控制台可用模型调整,建议通过 --model 显式指定。",
|
||||
},
|
||||
}
|
||||
|
||||
# 构建别名查找表(含中文、英文键、aliases)
|
||||
_ALIAS_TO_KEY: dict = {}
|
||||
for _k, _spec in LLM_PROVIDERS.items():
|
||||
_ALIAS_TO_KEY[_k] = _k
|
||||
_ALIAS_TO_KEY[_k.lower()] = _k
|
||||
_ALIAS_TO_KEY[_spec["label"]] = _k
|
||||
for _a in (_spec.get("aliases") or []):
|
||||
_ALIAS_TO_KEY[_a] = _k
|
||||
_ALIAS_TO_KEY[_a.lower()] = _k
|
||||
|
||||
|
||||
def resolve_provider_key(name: str):
|
||||
"""将用户输入的平台名称/别名解析为内部 slug;无法识别返回 None。"""
|
||||
if not name:
|
||||
return None
|
||||
s = str(name).strip()
|
||||
return _ALIAS_TO_KEY.get(s) or _ALIAS_TO_KEY.get(s.lower())
|
||||
|
||||
|
||||
def provider_list_cn() -> str:
|
||||
return "、".join(s["label"] for s in LLM_PROVIDERS.values())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 路径帮助(与 account-manager/scripts/main.py 完全一致:仅 JIANGCHANG_*)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_OPENCLAW_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def get_data_root() -> str:
|
||||
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
if env:
|
||||
return env
|
||||
if sys.platform == "win32":
|
||||
return r"D:\jiangchang-data"
|
||||
return os.path.join(os.path.expanduser("~"), ".jiangchang-data")
|
||||
|
||||
|
||||
def get_user_id() -> str:
|
||||
uid = (os.getenv("JIANGCHANG_USER_ID") or "").strip()
|
||||
return uid or "_anon"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 跨技能:仅通过 account-manager 提供的 CLI 读取账号(不直接打开其数据库文件)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _account_manager_script_path() -> str:
|
||||
return os.path.join(_OPENCLAW_DIR, "account-manager", "scripts", "main.py")
|
||||
|
||||
|
||||
def find_logged_in_account(provider_key: str) -> dict | None:
|
||||
"""
|
||||
调用 account-manager:pick-logged-in <platform_key>,取该平台已登录(login_status=1)的优先账号。
|
||||
成功返回与 account.py get 一致的 dict;失败或不可用时返回 None。
|
||||
"""
|
||||
script = _account_manager_script_path()
|
||||
if not os.path.isfile(script):
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, script, "pick-logged-in", provider_key],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
raw = (proc.stdout or "").strip()
|
||||
if not raw or raw.startswith("ERROR:"):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw.splitlines()[0])
|
||||
except (json.JSONDecodeError, IndexError):
|
||||
return None
|
||||
if not isinstance(data, dict) or data.get("id") is None:
|
||||
return None
|
||||
plat = data.get("platform") or provider_key
|
||||
if not (data.get("url") or "").strip():
|
||||
data["url"] = LLM_PROVIDERS.get(provider_key, {}).get("web_url", "")
|
||||
data["platform"] = plat
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chrome/Edge 检测(与 account-manager 逻辑保持一致)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _win_find_exe(candidates):
|
||||
for p in candidates:
|
||||
if p and os.path.isfile(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def resolve_chromium_channel() -> str | None:
|
||||
"""返回 'chrome' | 'msedge' | None。"""
|
||||
if sys.platform != "win32":
|
||||
return "chrome"
|
||||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||
local = os.environ.get("LocalAppData", "")
|
||||
|
||||
chrome = _win_find_exe([
|
||||
os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
os.path.join(pfx86, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
os.path.join(local, "Google", "Chrome", "Application", "chrome.exe") if local else "",
|
||||
])
|
||||
if chrome:
|
||||
return "chrome"
|
||||
|
||||
edge = _win_find_exe([
|
||||
os.path.join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
os.path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
os.path.join(local, "Microsoft", "Edge", "Application", "msedge.exe") if local else "",
|
||||
])
|
||||
if edge:
|
||||
return "msedge"
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user