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
5 changes: 3 additions & 2 deletions .github/RELEASE_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
- 设置页新增「自动更新」和「测试计划」:默认自动检查正式版,开启测试计划后可接收 RC,关闭自动更新时仍可手动检查正式版。
- 按已保存的通道选择已发布 Release,并核对版本和更新资产;正式版通道不会提示 RC。
- 下载和安装仍需在版本提示中由用户点击「确认更新」,检查时只获取版本清单。
- 已安装旧版使用固定的正式版地址,需从 Releases 手动安装本版一次,之后才能选择测试计划。
- RC 发布列表扫描设置总超时;模型添加、测试、预览、应用与恢复经过隔离验收。
- `rc.2` 及更早版本使用固定的正式版地址,需从 Releases 手动安装支持测试计划的版本;已开启测试计划的 `rc.3` 可在应用内升级到本版。

### 下载选择

Expand All @@ -28,7 +29,7 @@
- 当前安装包未使用开发者证书签名或 Apple 公证,系统可能提示未知发布者或阻止首次打开。请先确认下载来源并核对校验值。
- Windows 需要 WebView2 Runtime;MSI 可引导安装,ZIP 需要预先安装。ZIP 仍将数据保存在当前用户应用目录,不能视为数据随身携带的便携版,也不保证注册 `power-switch://` 协议。
- Linux AppImage 需赋予执行权限;部分发行版还需要 FUSE。DEB/RPM 会声明所需的系统依赖。
- 此 RC 预发布不会进入正式版 Latest 更新通道。支持「测试计划」的客户端启用该选项后可收到 RC 提示;旧版客户端仍需从 Releases 手动安装一次。
- 此 RC 预发布不会进入正式版 Latest 更新通道。支持「测试计划」的客户端启用该选项后可收到 RC 提示;`rc.2` 及更早版本需从 Releases 手动安装一次。
- 检查更新只读取版本清单,下载安装需要在应用内再次确认。
- 云端构建与测试通过不代表所有平台均完成真实机器安装验收。

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ WorkBuddy 写入成功后可以打开新建任务页,请在 WorkBuddy 中手

恢复前会再次备份当前配置,避免误操作后无法返回。

## 设置配置文件位置
## 设置配置位置

在“设置”中可以修改各 Agent 的配置文件位置。默认位置如下:
在“设置”中可以修改 WorkBuddy、Claude Code 的配置文件位置和 Codex 的配置目录。默认位置如下:

| Agent | 默认位置 |
| ----------- | ------------------------------------- |
Expand Down
2 changes: 1 addition & 1 deletion docs/new-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ power-switch 内置 New API 接入模块,默认地址为 `https://new-api.banm

1. 可先点击应用顶栏的 **新手指引**,通过三步动画了解申请密钥、模型入库和应用到 Agent 的流程。随后在模型库点击橙色 **从 New API 添加**。实例地址应为 HTTPS 根地址,不包含 `/v1`,且与 New API 的 `ServerAddress` 一致。
2. 点击 **钉钉 / Keycloak 登录**,在独立窗口完成授权。应用不会读取或保存钉钉密码。
3. 选择目前客户端、平台名称和模型。接口固定使用 `default` 分组,无需在页面选择。列表只显示该分组可用的模型,并按服务端元数据筛选;对 Codex,`openai` 模型也会作为 Responses 候选,是否真正兼容由保存前的调用确认。默认优先选中 `auto`;没有兼容的 `auto` 时选择第一个可用模型。平台名称默认为 `winwin`,可自行修改,切换模型时保留。
3. 选择目标客户端、平台名称和模型。接口固定使用 `default` 分组,无需在页面选择。列表只显示该分组可用的模型,并按服务端元数据筛选;对 Codex,`openai` 模型也会作为 Responses 候选,是否真正兼容由保存前的调用确认。默认优先选中 `auto`;没有兼容的 `auto` 时选择第一个可用模型。平台名称默认为 `winwin`,可自行修改,切换模型时保留。
4. 按上游实际能力设置图像、工具调用和推理档位,不能从模型别名推断。
5. 点击 **创建并添加**。同一实例、同一账号创建或复用一把 power-switch 专属密钥,不设置模型限制;首次创建使用 `default` 分组决定密钥的实际访问范围。应用先检查所选模型是否可访问,再发送一次模型调用;只有收到所选协议的有效文本回复才保存到模型库。调用可能消耗账户额度;失败时密钥可能已创建,重试会复用。
6. 可主动显示或复制密钥。保存后按需点击 **测试连接** 可再次调用模型;测试只确认基本文本调用及响应格式,不证明工具调用、图像、流式或上下文能力。应用到 Agent 时只预览并写入配置,不重复调用模型。
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "power-switch",
"private": true,
"version": "0.1.6-rc.3",
"version": "0.1.6-rc.4",
"type": "module",
"packageManager": "pnpm@10.18.3",
"pnpm": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "power-switch"
version = "0.1.6-rc.3"
version = "0.1.6-rc.4"
description = "Local model configuration switcher for coding agents"
edition = "2021"
rust-version = "1.92"
Expand Down
44 changes: 24 additions & 20 deletions src-tauri/src/update_desktop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,32 +106,36 @@ fn manifest_url(release: &GithubRelease) -> AppResult<Url> {
Url::parse(&manifest.browser_download_url).map_err(|_| "更新清单地址无效".into())
}

/// List published releases through the official API so RC builds can be found without changing the stable feed.
/// Bound the complete GitHub release scan so pagination cannot leave update checks hanging.
async fn github_releases() -> AppResult<Vec<GithubRelease>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(15))
.user_agent("power-switch-updater")
.build()
.map_err(|error| format!("无法初始化更新检查:{error}"))?;
let mut releases = Vec::new();
for page in 1..=10 {
let batch: Vec<GithubRelease> = client
.get(format!("{RELEASES_API}?per_page=100&page={page}"))
.header("Accept", "application/vnd.github+json")
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|error| format!("无法获取 GitHub 发布列表:{error}"))?
.json()
.await
.map_err(|error| format!("无法解析 GitHub 发布列表:{error}"))?;
let count = batch.len();
releases.extend(batch);
if count < 100 {
return Ok(releases);
tokio::time::timeout(Duration::from_secs(30), async {
let mut releases = Vec::new();
for page in 1..=10 {
let batch: Vec<GithubRelease> = client
.get(format!("{RELEASES_API}?per_page=100&page={page}"))
.header("Accept", "application/vnd.github+json")
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|error| format!("无法获取 GitHub 发布列表:{error}"))?
.json()
.await
.map_err(|error| format!("无法解析 GitHub 发布列表:{error}"))?;
let count = batch.len();
releases.extend(batch);
if count < 100 {
return Ok(releases);
}
}
}
Err("发布列表过长,无法安全判断最新版本".into())
Err("发布列表过长,无法安全判断最新版本".into())
})
.await
.map_err(|_| "获取 GitHub 发布列表超时,请稍后重试".to_string())?
}

/// Let Tauri parse the signed manifest and compare it with this installed app version.
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "power-switch",
"version": "0.1.6-rc.3",
"version": "0.1.6-rc.4",
"identifier": "com.powerswitch.desktop",
"build": {
"beforeDevCommand": "pnpm dev:web",
Expand Down Expand Up @@ -30,7 +30,7 @@
"targets": "all",
"windows": {
"wix": {
"version": "0.1.6.3"
"version": "0.1.6.4"
}
},
"shortDescription": "模型随心切换",
Expand Down
40 changes: 20 additions & 20 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -562,26 +562,6 @@ export default function App() {
</div>
)}
</header>
{notice && (
<div
role={notice.kind === "error" ? "alert" : "status"}
className={`notification ${notice.kind}`}
>
{notice.kind === "success" ? (
<CheckCircle2 size={19} />
) : (
<CircleHelp size={19} />
)}
<span>{notice.text}</span>
<button
className="icon-button"
aria-label="关闭提示"
onClick={() => setNotice(null)}
>
<X size={16} />
</button>
</div>
)}
{!data ? (
<div className="empty-state">
<LoaderCircle className="spin" />
Expand Down Expand Up @@ -1037,6 +1017,26 @@ export default function App() {
</footer>
</div>
</main>
{notice && (
<div
role={notice.kind === "error" ? "alert" : "status"}
className={`notification ${notice.kind}`}
>
{notice.kind === "success" ? (
<CheckCircle2 size={19} />
) : (
<CircleHelp size={19} />
)}
<span>{notice.text}</span>
<button
className="icon-button"
aria-label="关闭提示"
onClick={() => setNotice(null)}
>
<X size={16} />
</button>
</div>
)}
{modal?.kind === "guide" && (
<NewcomerGuide
onClose={closeModal}
Expand Down
4 changes: 2 additions & 2 deletions src/NewApiDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ export function NewApiDialog({
</div>
<div className="new-api-fields">
<label className="field">
目前客户端
目标客户端
<select
value={agent}
disabled={Boolean(busy)}
Expand Down Expand Up @@ -499,7 +499,7 @@ export function NewApiDialog({
</select>
<span className="field-hint">
{agent === "codex"
? "显示 default 分组中的 OpenAI 模型;保存前会调用 Responses 接口验证。"
? "显示 default 分组中的 OpenAI 模型候选;创建密钥后会调用 Responses 接口,通过测试才保存。"
: "仅显示 default 分组中支持当前客户端的模型。"}
</span>
</label>
Expand Down
2 changes: 1 addition & 1 deletion src/NewcomerGuide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export function NewcomerGuide({
<KeyRound size={20} /> 从 New API 添加
</div>
<div className="newcomer-demo-row">
<span>目前客户端</span>
<span>目标客户端</span>
<strong>WorkBuddy</strong>
</div>
<div className="newcomer-demo-row">
Expand Down
Binary file added src/assets/agents/codex.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 5 additions & 2 deletions src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "react";
import { api } from "./api";
import claudeAgentIcon from "./assets/agents/claude.png";
import codexAgentIcon from "./assets/agents/codex.png";
import workbuddyAgentIcon from "./assets/agents/workbuddy.png";
import {
ChevronDown,
Expand Down Expand Up @@ -88,7 +89,7 @@ export function Modal({
);
}

/** Show the platform name's first character, uppercasing English initials and preserving Chinese characters. */
/** Show the native Agent icon for each supported protocol, falling back to the model initial. */
export function ProtocolMark({
protocol,
name,
Expand All @@ -104,7 +105,9 @@ export function ProtocolMark({
? workbuddyAgentIcon
: protocol === "anthropic-messages"
? claudeAgentIcon
: null;
: protocol === "openai-responses"
? codexAgentIcon
: null;
return (
<span
className={`protocol-mark ${protocol} ${small ? "small" : ""} ${agentIcon ? "brand-icon" : ""}`}
Expand Down
28 changes: 23 additions & 5 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,6 @@ h1 {
.openai-responses.protocol-tag {
color: var(--green);
background: var(--green-soft);
opacity: 0.48;
}
.anthropic-messages.protocol-mark,
.anthropic-messages.protocol-tag {
Expand Down Expand Up @@ -866,17 +865,25 @@ h1 {
letter-spacing: 1.5px;
}
.notification {
position: fixed;
top: 18px;
left: calc((100vw + 228px) / 2);
transform: translateX(-50%);
z-index: 60;
width: min(420px, calc(100vw - 228px - 48px));
max-height: min(280px, calc(100vh - 106px));
padding: 13px 15px;
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
align-items: center;
align-items: flex-start;
gap: 9px;
margin-bottom: 18px;
font-size: 12px;
background: var(--surface);
line-height: 1.7;
overflow-wrap: anywhere;
overflow-y: auto;
box-shadow: 0 10px 30px #0000001c;
}
.notification > span {
flex: 1;
Expand All @@ -888,8 +895,6 @@ h1 {
.notification.error {
color: #bc6857;
background: var(--orange-soft);
position: relative;
z-index: 60;
}
.empty-state {
padding: 62px 24px;
Expand Down Expand Up @@ -1604,6 +1609,10 @@ textarea {
margin-left: 198px;
width: calc(100% - 198px);
}
.notification {
left: calc((100vw + 198px) / 2);
width: min(420px, calc(100vw - 198px - 48px));
}
.page-content {
padding: 30px 25px 22px;
}
Expand Down Expand Up @@ -1676,6 +1685,10 @@ textarea {
margin-left: 76px;
width: calc(100% - 76px);
}
.notification {
left: calc((100vw + 76px) / 2);
width: min(420px, calc(100vw - 76px - 48px));
}
.page-content {
padding: 26px 22px;
}
Expand Down Expand Up @@ -1713,6 +1726,11 @@ textarea {
}
}
@media (max-width: 580px) {
.notification {
top: 61px;
width: calc(100vw - 76px - 30px);
max-height: min(280px, calc(100vh - 76px));
}
.page-content {
padding: 25px 15px;
}
Expand Down
49 changes: 48 additions & 1 deletion src/test/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "../App";
import { AgentPicker, ApplyReview, ModelForm } from "../components";
import {
AgentPicker,
ApplyReview,
ModelForm,
ProtocolMark,
} from "../components";
import {
newModel,
type AppData,
Expand Down Expand Up @@ -90,6 +95,21 @@ beforeEach(() => {
});
afterEach(() => vi.useRealTimers());

describe("Agent icons", () => {
it("shows the Codex icon for OpenAI Responses models", () => {
const { container } = render(
<ProtocolMark protocol="openai-responses" name="专注代码" />,
);
const mark = container.querySelector(".protocol-mark.openai-responses");
expect(mark).toHaveClass("brand-icon");
expect(mark?.querySelector("img")).toHaveAttribute(
"src",
expect.stringContaining("codex.png"),
);
expect(mark).not.toHaveTextContent("专");
});
});

describe("model editing", () => {
it("offers Responses for Codex while retaining the provider model-list requirement", async () => {
render(
Expand Down Expand Up @@ -532,6 +552,33 @@ describe("temporary model test feedback", () => {
});

describe("settings and backup controls", () => {
it("shows model deletion in the fixed global tip", async () => {
vi.mocked(api.delete).mockResolvedValue();
render(<App />);
await screen.findByText("我的模型");
await userEvent.click(
screen.getByRole("button", { name: "删除 我的模型" }),
);
await userEvent.click(screen.getByRole("button", { name: "确认删除" }));
const notice = await screen.findByRole("status");
expect(notice).toHaveTextContent("模型已从模型库删除。");
expect(notice.parentElement).toHaveClass("app-shell");
expect(notice.closest(".page-content")).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "关闭提示" }));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});

it("keeps the SKILL sidebar entry unavailable", async () => {
render(<App />);
await screen.findByText("我的模型");
const skills = screen.getByRole("button", { name: /SKILL 技能/ });
expect(skills).toBeDisabled();
expect(skills).toHaveTextContent("待开放");
expect(
screen.getByRole("heading", { name: /^模型库/ }),
).toBeInTheDocument();
});

it("keeps native selections in the draft until saving and preserves them on cancel", async () => {
vi.mocked(api.data).mockResolvedValue({
...data,
Expand Down
Loading
Loading