Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ fn main() {
"mc_login",
"mc_password_login",
"mc_logout",
"mc_usage",
"mc_checkin",
"mc_models_sync",
"mc_models_revoke",
"mc_tasks",
Expand Down
13 changes: 13 additions & 0 deletions desktop/src/baizhi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,19 @@ pub async fn mc_logout(bz: State<'_, BaizhiState>) -> Result<Value, String> {
Ok(json!({ "ok": true }))
}

/// 账号权益(额度/会员/签到态/邀请一次取回;单路缺席按 null 降级,见 mc_usage)。
#[tauri::command]
pub async fn mc_usage(bz: State<'_, BaizhiState>) -> Result<Value, String> {
monkeycode::mc_usage(&bz.0).await.map_err(BzErr::msg)
}

/// 每日签到(壳内自动完成 PoW 验证码)。成功后 UI 重拉 mc_usage 刷新余额。
#[tauri::command]
pub async fn mc_checkin(bz: State<'_, BaizhiState>) -> Result<Value, String> {
monkeycode::mc_checkin(&bz.0).await.map_err(BzErr::msg)?;
Ok(json!({ "ok": true }))
}

// ==================== 会员模型本地同步 ====================

pub(crate) const OHMYAGENT_KEY_FILE: &str = "monkeycode-ohmyagent-key.json";
Expand Down
59 changes: 59 additions & 0 deletions desktop/src/baizhi/monkeycode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,65 @@ pub fn mc_host(svc: &Service) -> String {
.unwrap_or_else(|| svc.ep.monkeycode.clone())
}

/// 钱包(积分余额 + 每日免费模型 token 额度)。官方云才有这个端点,
/// 私有化部署会 404。
async fn mc_wallet(svc: &Service) -> BzResult<Value> {
mc_call(svc, reqwest::Method::GET, "/api/v1/users/wallet", None).await
}

/// 会员订阅(等级/到期/续费来源)。开源版后端固定返回基础状态。
async fn mc_subscription(svc: &Service) -> BzResult<Value> {
mc_call(svc, reqwest::Method::GET, "/api/v1/users/subscription", None).await
}

/// 当天是否已签到。
async fn mc_checkin_status(svc: &Service) -> BzResult<Value> {
mc_call(svc, reqwest::Method::GET, "/api/v1/users/wallet/checkin", None).await
}

/// 邀请记录({count, items})。头像地址可能是相对路径,由 UI 按 base_url 补全。
async fn mc_invitations(svc: &Service) -> BzResult<Value> {
mc_call(svc, reqwest::Method::GET, "/api/v1/users/invitations?page=1&size=50", None).await
}

/// 账号权益总览:额度、会员、签到态、邀请记录并发取回。单路失败按缺省
/// (null)降级——私有化部署只有订阅端点,其余都 404,此时仍要能看到会员
/// 等级。全部失败才报错(会话失效/网络不通这类真故障)。
/// base_url 一并回传:邀请链接和相对头像地址都要以它为解析基准,UI 自己
/// 按主机名拼 https:// 会在自建 http/带端口部署上拼错。
pub async fn mc_usage(svc: &Service) -> BzResult<Value> {
let (wallet, subscription, checkin, invitations) = tokio::join!(
mc_wallet(svc),
mc_subscription(svc),
mc_checkin_status(svc),
mc_invitations(svc)
);
if wallet.is_err() && subscription.is_err() && checkin.is_err() && invitations.is_err() {
return Err(wallet.unwrap_err());
}
Ok(json!({
"base_url": svc.ep.monkeycode,
"wallet": wallet.unwrap_or(Value::Null),
"subscription": subscription.unwrap_or(Value::Null),
// 取不到时给 null,与"确定没签到"(false)区分——否则会误催已签到的用户
"checked_in": checkin.ok().and_then(|v| v.get("checked_in").and_then(Value::as_bool)),
"invitations": invitations.unwrap_or(Value::Null),
}))
}

/// 每日签到(每天 1 次;与账密登录同一套 MonkeyCode 域 PoW 验证码)。
/// 重复签到等业务失败由服务端包壳原样透传。
pub async fn mc_checkin(svc: &Service) -> BzResult<Value> {
let captcha = svc.captcha_token_at(&svc.ep.monkeycode, &svc.mc, "MonkeyCode ").await?;
mc_call(
svc,
reqwest::Method::POST,
"/api/v1/users/wallet/checkin",
Some(&json!({ "captcha_token": captcha })),
)
.await
}

/// 云端任务列表({tasks, page_info} 原样透传 UI)。project_id / quick_start
/// 与 Web 侧栏筛选一致:项目内任务、未关联项目的快速任务分别查询。
pub async fn mc_tasks(
Expand Down
98 changes: 98 additions & 0 deletions desktop/src/baizhi/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,104 @@ async fn cloud_sidebar_and_task_actions_contract() {
assert!(requests.iter().any(|(method, path, _)| method == "DELETE" && path == "/api/v1/users/tasks/t1"));
}

/// 账号权益契约:钱包/订阅/签到态/邀请四路并发取回,单路缺席(私有化部署
/// 只有订阅端点,其余 404)按 null 降级而不牵连其余;全部失败才整体报错。
/// 签到态取不到时是 null 而非 false——否则会误催已签到的用户。
#[tokio::test(flavor = "multi_thread")]
async fn mc_usage_contract() {
let saas = Arc::new(AtomicBool::new(true)); // false = 只留订阅端点的自建部署
let sub_ok = Arc::new(AtomicBool::new(true));
let (o, s) = (saas.clone(), sub_ok.clone());
let (url, _stop) = serve(Arc::new(move |req: Req| match req.path.split('?').next().unwrap() {
"/api/v1/users/wallet" if o.load(Ordering::Relaxed) => Resp::json(
200,
json!({ "code": 0, "data": { "balance": 12_345_678, "daily_token_balance": 400_000, "daily_token_limit": 1_000_000 } }),
),
"/api/v1/users/wallet/checkin" if o.load(Ordering::Relaxed) => {
Resp::json(200, json!({ "code": 0, "data": { "checked_in": true } }))
}
"/api/v1/users/invitations" if o.load(Ordering::Relaxed) => Resp::json(
200,
json!({ "code": 0, "data": { "count": 2, "items": [{ "id": "u2", "name": "阿茂" }] } }),
),
"/api/v1/users/subscription" if s.load(Ordering::Relaxed) => {
Resp::json(200, json!({ "code": 0, "data": { "plan": "pro", "auto_renew": false } }))
}
_ => Resp::json(404, json!({ "code": 404, "message": "not found" })),
}));
let svc = Service::test_service(Endpoints {
account: url.clone(),
model_gateway: url.clone(),
mcp_gateway: url.clone(),
monkeycode: url.clone(),
});

let usage = super::monkeycode::mc_usage(&svc).await.map_err(|e| e.msg()).unwrap();
assert_eq!(usage.pointer("/wallet/balance").and_then(Value::as_i64), Some(12_345_678));
assert_eq!(usage.pointer("/subscription/plan").and_then(Value::as_str), Some("pro"));
assert_eq!(usage.get("checked_in").and_then(Value::as_bool), Some(true));
assert_eq!(usage.pointer("/invitations/count").and_then(Value::as_i64), Some(2));
// 邀请链接与相对头像地址的解析基准,必须是完整基址(含协议/端口)
assert_eq!(usage.get("base_url").and_then(Value::as_str), Some(url.as_str()));

// 私有化部署:只剩订阅端点,会员等级照常可见,其余按 null 降级
saas.store(false, Ordering::Relaxed);
let usage = super::monkeycode::mc_usage(&svc).await.map_err(|e| e.msg()).unwrap();
assert!(usage.get("wallet").unwrap().is_null());
assert!(usage.get("checked_in").unwrap().is_null(), "签到态取不到时不能退化成 false");
assert!(usage.get("invitations").unwrap().is_null());
assert_eq!(usage.pointer("/subscription/plan").and_then(Value::as_str), Some("pro"));

// 四路都不可用 = 真故障,如实报错
sub_ok.store(false, Ordering::Relaxed);
assert!(super::monkeycode::mc_usage(&svc).await.is_err());
}

/// 签到契约:壳内先取 MonkeyCode 域 PoW 验证码,再带 captcha_token POST
/// 签到端点(与账密登录同一套验证码,全程不碰百智罐)。PoW 求解本身由
/// monkeycode_password_login_contract 按协议校验,这里只盯签到这一跳。
#[tokio::test(flavor = "multi_thread")]
async fn mc_checkin_contract() {
let captured: Arc<Mutex<Vec<(String, String, Value)>>> = Arc::new(Mutex::new(Vec::new()));
let cap = captured.clone();
let (url, _stop) = serve(Arc::new(move |req: Req| {
let body = body_json(&req.body);
cap.lock().unwrap().push((req.method.clone(), req.path.clone(), body.clone()));
match (req.method.as_str(), req.path.split('?').next().unwrap()) {
// go-cap:201 + 裸结构(不套 {code,data} 包壳)
("POST", "/api/v1/public/captcha/challenge") => {
Resp::json(201, json!({ "challenge": { "c": 1, "s": 32, "d": 1 }, "token": "mc-chtok" }))
}
("POST", "/api/v1/public/captcha/redeem") => {
Resp::json(201, json!({ "success": true, "token": "mc-captoken" }))
}
("POST", "/api/v1/users/wallet/checkin") => {
if body.get("captcha_token").and_then(Value::as_str) != Some("mc-captoken") {
return Resp::json(403, json!({ "code": 403, "message": "禁止访问" }));
}
Resp::json(200, json!({ "code": 0, "data": { "credits": 100 } }))
}
_ => Resp::json(404, json!({ "code": 404, "message": "not found" })),
}
}));
let svc = Service::test_service(Endpoints {
account: url.clone(),
model_gateway: url.clone(),
mcp_gateway: url.clone(),
monkeycode: url,
});

super::monkeycode::mc_checkin(&svc).await.map_err(|e| e.msg()).unwrap();

let requests = captured.lock().unwrap();
assert!(requests.iter().any(|(m, p, _)| m == "POST" && p == "/api/v1/public/captcha/challenge"));
assert!(requests
.iter()
.any(|(m, p, b)| m == "POST" && p == "/api/v1/users/wallet/checkin" && b.get("captcha_token").is_some()));
// 百智罐不该被这条链路碰到(双罐隔离)
assert!(svc.store.is_empty());
}

// ==================== 会员模型本地同步 ====================

/// 会员模型同步/删除契约(对齐服务端 swagger【用户】OhMyAgent 分组):
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1207,6 +1207,8 @@ fn main() {
baizhi::mc_login,
baizhi::mc_password_login,
baizhi::mc_logout,
baizhi::mc_usage,
baizhi::mc_checkin,
baizhi::mc_models_sync,
baizhi::mc_models_revoke,
baizhi::mc_tasks,
Expand Down
2 changes: 2 additions & 0 deletions desktop/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@
"allow-mc-login",
"allow-mc-password-login",
"allow-mc-logout",
"allow-mc-usage",
"allow-mc-checkin",
"allow-mc-models-sync",
"allow-mc-models-revoke",
"allow-mc-tasks",
Expand Down
8 changes: 7 additions & 1 deletion desktop/ui/src/cloudapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { b64encode, frameData } from "./codec";
import type { McTaskOptions } from "./cloud";
import { invoke, listenAsync } from "./ipc";
import type { CloudAttachment, CloudProjectsResp, CloudTaskDetail, CloudTasksResp, Frame, McModelsSyncResult, McStatus, McUser, WsCloseInfo } from "./types";
import type { CloudAttachment, CloudProjectsResp, CloudTaskDetail, CloudTasksResp, Frame, McModelsSyncResult, McStatus, McUsage, McUser, WsCloseInfo } from "./types";

// ==================== 云端 REST(壳命令代理) ====================

Expand All @@ -21,6 +21,12 @@ export const mcPasswordLogin = (email: string, password: string) =>

export const mcLogout = () => invoke<{ ok: boolean }>("mc_logout");

/** 账号权益(额度/会员/签到态/邀请)。壳侧各路并发且各自容错,全失败才 reject。 */
export const mcUsage = () => invoke<McUsage>("mc_usage");

/** 每日签到(壳内自动完成 PoW 验证码)。成功后调用方重拉 mcUsage 刷新余额。 */
export const mcCheckin = () => invoke<{ ok: boolean }>("mc_checkin");

/** 同步会员内置模型为本地条目(不碰配置,返回值经设置表单落盘)。 */
export const mcModelsSync = () => invoke<McModelsSyncResult>("mc_models_sync");

Expand Down
37 changes: 37 additions & 0 deletions desktop/ui/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,3 +395,40 @@ export function IconBack({ size = 10, color = "var(--t1)", style }: IconProps) {
</svg>
);
}

// 以下三个与移动端 Icons(24 视框、1.7 描边)同路径,保证两端同一形状。

/** 皇冠(会员等级) */
export function IconCrown({ size = 13, color = "var(--accTx)", style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ ...base, ...style }}>
<path d="M12 6l4 6l5-4l-2 10h-14l-2-10l5 4z" stroke={color} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

/** 日历(每日签到) */
export function IconCalendar({ size = 13, color = "var(--t3)", style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ ...base, ...style }}>
<rect x="3.5" y="5" width="17" height="15.5" rx="2.5" stroke={color} strokeWidth="1.7" strokeLinejoin="round" />
<path d="M3.5 9.5h17M8 3v4M16 3v4" stroke={color} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

/** 复制(叠放的两个方框) */
export function IconCopy({ size = 12, color = "var(--t3)", style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ ...base, ...style }}>
<rect x="8.5" y="8.5" width="11" height="11" rx="2.2" stroke={color} strokeWidth="1.7" strokeLinejoin="round" />
<path
d="M5.5 15.5H5A1.5 1.5 0 0 1 3.5 14V5A1.5 1.5 0 0 1 5 3.5h9A1.5 1.5 0 0 1 15.5 5v.5"
stroke={color}
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
85 changes: 85 additions & 0 deletions desktop/ui/src/mcUsagePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";

import { McUsagePanel } from "./settings";
import type { McUsage } from "./types";

const usage = (u: Partial<McUsage> = {}): McUsage => ({
base_url: "https://monkeycode-ai.com",
wallet: null,
subscription: null,
checked_in: null,
invitations: null,
...u,
});
const noCheckin = async () => null;

/** 账号权益块:桌面端此前完全看不到自己的额度(只有移动端「我的」页有),
* 已关联的用户要能看到会员等级、今日 token 余量、积分、签到与邀请。 */
describe("McUsagePanel", () => {
it("渲染会员等级、今日额度与积分余额", () => {
const html = renderToStaticMarkup(
<McUsagePanel
usage={usage({
wallet: { balance: 12_345_678, daily_token_balance: 400_000, daily_token_limit: 1_000_000 },
subscription: { plan: "pro", expires_at: "2026-08-31T00:00:00Z" },
})}
onCheckin={noCheckin}
/>,
);

expect(html).toContain("专业会员");
expect(html).toContain("有效期至 2026-08-31");
expect(html).toContain("剩余 400,000 / 1.0M");
expect(html).toContain("12,345");
});

it("还没拉到数据时整块不占位(空进度条会被读成额度为 0)", () => {
expect(renderToStaticMarkup(<McUsagePanel usage={null} onCheckin={noCheckin} />)).toBe("");
expect(renderToStaticMarkup(<McUsagePanel usage={usage()} onCheckin={noCheckin} />)).toBe("");
});

it("私有化部署没有钱包端点时只显示会员等级,不出现额度/签到/邀请", () => {
const html = renderToStaticMarkup(<McUsagePanel usage={usage({ subscription: { plan: "basic" } })} onCheckin={noCheckin} />);

expect(html).toContain("基础会员");
expect(html).not.toContain("今日额度");
expect(html).not.toContain("积分余额");
expect(html).not.toContain("签到");
expect(html).not.toContain("已邀请");
});

it("未签到给可点的签到入口,已签到转低调态", () => {
const todo = renderToStaticMarkup(<McUsagePanel usage={usage({ checked_in: false })} onCheckin={noCheckin} />);
expect(todo).toContain("签到 +100");
expect(todo).not.toContain("今日已签到");

const done = renderToStaticMarkup(<McUsagePanel usage={usage({ checked_in: true })} onCheckin={noCheckin} />);
expect(done).toContain("今日已签到");
expect(done).not.toContain("签到 +100");
});

it("邀请行给出人数、奖励与复制入口", () => {
const html = renderToStaticMarkup(
<McUsagePanel
usage={usage({ invitations: { count: 3, items: [{ id: "u1", name: "阿茂" }] } })}
userId="me-1"
onCheckin={noCheckin}
/>,
);

expect(html).toContain("已邀请 3 人");
expect(html).toContain("每邀请一位 +5,000 积分");
expect(html).toContain("复制邀请链接");
expect(html).toContain("https://monkeycode-ai.com/?ic=me-1");
});

it("拿不到账号 id 时不给复制入口(链接拼不出来)", () => {
const html = renderToStaticMarkup(
<McUsagePanel usage={usage({ invitations: { count: 3, items: [] } })} onCheckin={noCheckin} />,
);

expect(html).toContain("已邀请 3 人");
expect(html).not.toContain("复制邀请链接");
});
});
2 changes: 2 additions & 0 deletions desktop/ui/src/mcaccount.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// MonkeyCode 账号探测只读取既有会话和任务,刻意不接收登录函数。
// 因此启动、聚焦和定时刷新都不可能隐式用百智云账号创建 MonkeyCode 会话。
// 账号权益(额度/签到/邀请)不在这里:它只在设置页可见,挂进这条 30 秒
// 轮询等于为一块看不见的面板长期空跑请求,改由设置页挂载时自取。
import { mcProjects, mcStatus, mcTasks } from "./cloudapi";
import type { CloudProject, CloudProjectsResp, CloudTask, CloudTasksResp, McStatus } from "./types";

Expand Down
Loading
Loading