Compare commits

...
10 Commits
Author SHA1 Message Date
mesalogo 38f6a23306 chore: prepare GoodBuddy 0.11.6 release notes
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Add the approved Simplified Chinese and English release notes for sidebar brand customization, independent Anthropic output limits, and restored composer and brand guidance.
2026-08-27 12:56:51 +08:00
mesalogo 12463ad593 feat: add sidebar brand customization
The primary sidebar brand was fixed to the built-in name, subtitle, and logo. Appearance settings now let users preview and save a custom brand name, localized Chinese and English subtitles, and a validated local PNG, JPEG, or WebP logo, with default-logo and full-reset actions.

Customization remains device-local and limited to the top-left brand area; installer, update, Agent, and protocol identities remain GoodBuddy. The desktop package version is now 0.11.6.

Release note: 新增“外观 → 品牌定制”,可修改左上角品牌名称、中英文副标题和本地 Logo,并支持实时预览、恢复默认及本机持久化。
2026-08-27 12:42:26 +08:00
mesalogo 618e505b8b fix: restore the workspace brand subtitle
The primary sidebar showed only the GoodBuddy name after its supporting brand line was removed. It now displays the localized “智能工作台” / “Intelligent Workspace” subtitle beneath the product name again.

Release note: 恢复左上角 GoodBuddy 标题下方的“智能工作台”副标题,使品牌区域的信息层级更加完整。
2026-08-27 10:30:15 +08:00
mesalogo 0a3bf6094a fix: restore composer guidance in input hint
The composer placeholder had been reduced to only the message prompt, hiding send, newline, and paste guidance unless users hovered the field. The full localized keyboard guidance now appears directly in the empty text area again for both chat and image generation.

Release note: 恢复聊天输入框中的发送、换行和粘贴快捷键提示,空输入框会再次直接显示完整操作说明。
2026-08-27 10:14:39 +08:00
mesalogo 957e2ecde7 fix: align homepage feature checklist
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Wrapped feature rows were centered independently, which shifted the lower-right item away from the column above it. The Chinese homepage now uses a shared two-column grid and collapses to one aligned column on narrow screens.

Release note: 官网首页特性列表现在按列对齐,桌面与移动端布局更整齐。
2026-08-27 08:04:36 +08:00
mesalogo 9316fed3e3 fix: streamline homepage introduction
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
The homepage hero paragraph and page description now focus on GoodBuddy's desktop assistant and Agent Runtime capabilities. Download options and architecture support remain unchanged.

Release note: 官网首页产品介绍现更聚焦桌面助手与 Agent Runtime 能力。
2026-08-27 07:54:04 +08:00
mesalogo d6fea0c6c2 fix: simplify LoongArch download copy
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
The Linux download card now presents only the preview version, DEB format, size, and checksum. Preview index validation and fail-closed download behavior are unchanged.

Release note: 官网龙芯下载信息现仅展示版本、包格式、大小与校验值。
2026-08-27 07:45:03 +08:00
mesalogo d9a554b4b1 feat: add LoongArch preview download to the website
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
The Chinese Linux download card now includes a LoongArch processor option and fixes its package type to DEB. It reads a separate strictly validated OSS preview index, while the formal six-target release index and English GitHub downloads remain unchanged.

The page keeps three symmetric platform cards, disables only the LoongArch selection when its index is unavailable, and labels the package as unverified on real LoongArch hardware.

Release note: 官网 Linux 下载新增龙芯 LoongArch 处理器选项,选择后提供独立 DEB 预览包;正式下载与更新索引保持不变。
2026-08-26 23:23:27 +08:00
mesalogo b20c10f820 fix: separate Anthropic output and context limits
Direct Anthropic connections reused the configured context window as max_tokens, which could impose an unintended output value or send a provider-invalid request. Anthropic connections now expose an independent per-response maximum output setting, defaulting to 32K for the protocol-required field, while OpenAI direct requests remain without an added output token cap and direct tool rounds remain unmetered.

Release note: Anthropic 直连模型现在可单独配置单次最大输出,不再复用上下文窗口;OpenAI 直连请求和工具调用轮次不增加额外配额。
2026-08-26 22:53:49 +08:00
mesalogo 49700bc7cf fix: make LoongArch preview checksum portable
Generate SHA256SUMS from inside the timestamped artifact directory so it records only the DEB filename and remains verifiable after copying the preview output elsewhere.
2026-08-26 17:12:20 +08:00
42 changed files with 1923 additions and 210 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ GoodBuddy是一个开箱即用的 AI 生产平台,安全、跨平台、本地
- **安全执行**:`Ask` 保持只读;`Execute` 仅运行已启用且受边界约束的工具,并保留活动记录。
- **本地优先**:会话、任务、成果、记忆、知识库和图谱保存在本地 SQLite;API Key 由系统安全存储加密。
- **多 Runtime**:支持直连模型、OpenCode、Continue 和预览版 DeepSeek Harness,统一处理取消、超时、输出限制和进程退出;原生能力清单将 Tools 与 Commands、LSP、Formatters 分开,并显示来源及 Ask/Execute 可用性。内置 OpenCode 提供 Agent、Tool、Command 与原生 Compact,Continue 提供 Rules、Prompt 预设、结构化提问与 GoodBuddy 手动摘要压缩,并明确标记当前版本无法静态发现原生 Tools。
- **开放连接**:支持 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images、Embeddings、跨 Runtime Skills 与自定义 MCP,以及默认关闭、由用户显式开启的 DeepSeek Harness npm 插件市场。MCP 测试可读取有界 Prompt/Resource 元数据,但不会读取 Resource 内容。
- **开放连接**:支持 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images、Embeddings、跨 Runtime Skills 与自定义 MCP,以及默认关闭、由用户显式开启的 DeepSeek Harness npm 插件市场。Anthropic 连接可独立配置协议必填的单次最大输出,不再复用上下文窗口;OpenAI 直连请求不额外设置输出 Token 上限。MCP 测试可读取有界 Prompt/Resource 元数据,但不会读取 Resource 内容。
- **知识工作区**:支持文件、目录和网页导入,以及全文、词组、向量和图谱混合检索。
- **工作管理**:集中管理 Projects、对话、任务、活动、成果、记忆、魔法笔记和智能心跳;可从稳定的本地会话复制当前聊天内容并创建互不影响的会话分支。
- **远程通道**:支持微信 ClawBot、企业微信和钉钉,每个发送者使用独立远程会话。
+7
View File
@@ -298,6 +298,13 @@
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
颜色或动效表达状态。
- 左上角品牌区允许用户在“外观”设置中定制品牌名称、中英文副标题和单个 Logo。定制只
影响该品牌区,不替换安装包名称、更新协议、Agent 标识或应用正文中的 GoodBuddy 产品
身份。副标题按当前界面语言显示,留空时隐藏且不保留空行。
- 品牌设置必须提供同布局预览、显式保存、默认 Logo 和整组恢复默认操作。自定义 Logo 仅
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
单行截断,不能撑宽或挤压侧栏。
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
### 6.9 助手工作栏
+4 -1
View File
@@ -300,7 +300,10 @@ assert_loongarch_elf \
assert_loongarch_elf \
"${verify_root}/opt/${application_directory}/resources/app/${koffi_binding#${app_root}/}"
sha256sum "${artifact}" > "${artifact_root}/SHA256SUMS"
(
cd "${artifact_root}"
sha256sum "${artifact_name}" > SHA256SUMS
)
cat > "${artifact_root}/preview-manifest.json" <<EOF
{
"formatVersion": 1,
@@ -28,8 +28,8 @@ Linux x64 和 Linux arm64。
导航及重定向仍经过 DNS 解析和目标地址固定。
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
空闲回收和应用退出清理。
- 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和
无进展保护。
- 直连模型工具循环不设置工具次数或模型轮次配额;取消、请求超时、工具结果大小和
累计输出边界继续防止失控执行。
### MCP
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.11.5",
"version": "0.11.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.11.5",
"version": "0.11.6",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.11.5",
"version": "0.11.6",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+38
View File
@@ -1,6 +1,44 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.11.6",
"releasedAt": "2026-08-27",
"notes": {
"zh-CN": {
"highlights": [
"GoodBuddy 0.11.6 新增侧栏品牌定制,并修复 Anthropic 输出上限与关键界面提示。"
],
"features": [
"**侧栏品牌定制。** “设置 → 外观 → 品牌定制”现在可修改左上角品牌名称、中英文副标题和本地 Logo,并提供实时预览、默认 Logo、恢复默认及本机持久化。"
],
"fixes": [
"**Anthropic 输出上限。** Anthropic 直连模型现在可单独配置单次最大输出,不再错误复用上下文窗口;OpenAI 直连请求和工具调用轮次不增加额外配额。",
"**输入框与品牌提示。** 恢复聊天和生图输入框中的发送、换行与粘贴快捷键说明,并恢复左上角品牌副标题。"
],
"notices": [
"品牌定制仅保存在当前设备并只影响左上角品牌区;安装包、更新、Agent 和协议身份仍为 GoodBuddy。",
"Anthropic 单次最大输出未填写时默认使用 32K Token;实际可用上限仍取决于所选模型和服务商。"
]
},
"en-US": {
"highlights": [
"GoodBuddy 0.11.6 adds sidebar brand customization while fixing Anthropic output limits and essential interface guidance."
],
"features": [
"**Sidebar brand customization.** Settings → Appearance → Brand customization now lets you change the top-left brand name, Chinese and English subtitles, and a local logo, with live preview, default-logo, full-reset, and on-device persistence."
],
"fixes": [
"**Anthropic output limits.** Direct Anthropic connections now have an independent per-response maximum output setting instead of incorrectly reusing the context window. OpenAI direct requests and tool rounds receive no additional quota.",
"**Composer and brand guidance.** Restored send, newline, and paste shortcuts in chat and image-generation input hints, along with the subtitle beneath the top-left brand name."
],
"notices": [
"Brand customization is stored only on the current device and affects only the top-left brand area. Installer, update, Agent, and protocol identities remain GoodBuddy.",
"Anthropic per-response maximum output defaults to 32K tokens when left unset. The selected model and provider may enforce a lower limit."
]
}
}
},
{
"version": "0.11.5",
"releasedAt": "2026-08-26",
+59 -1
View File
@@ -4,7 +4,7 @@
正式站点地址:<https://mesalogo.github.io/goodbuddy/>
首页将 GoodBuddy 定位为“首个支持龙芯的开源桌面 AI 助理产品”和
首页将 GoodBuddy 定位为“首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品”和
“免注册、支持信创软硬件的一站式 AI 助手”,优先展示三大桌面系统、正式
x64 / arm64 下载入口、独立编译的龙芯 loong64 预览版、统一 Agent Runtime,
以及知识库、魔法笔记、智能心跳、桌面上下文和远程消息通道等桌面助手能力。
@@ -89,6 +89,64 @@ https://github.com/mesalogo/goodbuddy/releases/latest
`GoodBuddy-${version}-mac-${arch}.dmg|zip`;Linux x64 的 AppImage、DEB 与
RPM 分别使用 electron-builder 的 `x86_64`、`amd64` 与 `x86_64` 架构名。
Linux arm64 的 AppImage/DEB 使用 `arm64`,RPM 使用 `aarch64`。
Linux 处理器选择器另外提供龙芯 `loong64` 实验预览;选中后安装包类型固定为
DEB,并改用下述独立预览索引,不会把 `loong64` 注入正式六目标索引。
### 龙芯实验预览通道
龙芯包不加入上述正式索引,而使用独立 OSS 前缀:
```text
releases/
└── loongarch-preview/
├── latest.json
└── v<GoodBuddy 版本>/
├── GoodBuddy-<版本>-linux-loong64-preview.deb
├── preview-manifest.json
└── SHA256SUMS
```
版本目录不可变。先上传并公开验证三个版本对象,最后才覆盖
`releases/loongarch-preview/latest.json`;不得把预览包放入 `releases/v*`、
`releases/latest.json`、标准 GitHub Release 资产或桌面自动更新索引。
版本对象建议使用 OSS Standard 存储、继承 Bucket 的公开读取策略,并设置长期
immutable 缓存;`latest.json` 应使用 `application/json` 和短缓存或
`no-cache`。`SHA256SUMS` 必须只记录 DEB 文件名,不得记录构建容器绝对路径。
官网只读取以下严格结构,且仅接受北京 OSS
`releases/loongarch-preview/v<goodBuddyVersion>/` 下的精确 HTTPS 地址:
```json
{
"formatVersion": 1,
"product": "GoodBuddy LoongArch Preview",
"goodBuddyVersion": "0.11.5",
"previewVersion": "0.11.5-loong64-preview.1",
"architecture": "loong64",
"format": "deb",
"artifact": {
"name": "GoodBuddy-0.11.5-linux-loong64-preview.deb",
"size": 186853872,
"sha256": "672321e314fbdb91c6d7b2b549a838e556bd7e597746a3e6d346b936f2ff8369",
"url": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/GoodBuddy-0.11.5-linux-loong64-preview.deb"
},
"manifestUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/preview-manifest.json",
"checksumUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/SHA256SUMS"
}
```
上传仍应使用 GitHub OIDC 获取的短期 STS 凭据和固定 `ossutil 2.3.0`,Bucket
为 `goodbuddy`,Endpoint 为 `https://oss-cn-beijing.aliyuncs.com`,Region
为 `cn-beijing`。上传版本对象前先读取目标对象元数据:不存在时才上传,已存在
且大小和 SHA-256 相同时跳过,任一字节不同时必须停止;`--update` 只用于传输
优化,不能代替不可变性检查。也可为版本前缀启用 OSS 保留策略/WORM。指针仅在
所有公开 GET、大小、SHA-256、manifest 和 DEB 元数据检查通过后使用
`--force` 更新。手工上传时也应遵循同一顺序,不使用长期 AccessKey。
若独立索引不存在、超时、过大、字段多缺、版本不稳定、摘要格式错误或任一
URL 不匹配受信任前缀,中文官网选择龙芯处理器时会保持下载按钮禁用;Linux
x64 / arm64 及另外两个正式平台入口不受影响。英文页面继续只展示正式
GitHub Release。
## 字体与可访问性
+199 -69
View File
@@ -17,6 +17,8 @@
const isEnglish = root.lang.toLowerCase().startsWith("en");
const releaseManifestUrl =
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json";
const loongArchPreviewManifestUrl =
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json";
const releaseFallbackUrl =
"https://github.com/mesalogo/goodbuddy/releases/latest";
const releaseRequestTimeoutMs = 10_000;
@@ -24,6 +26,8 @@
const downloadCards = [
...document.querySelectorAll("[data-download-card]"),
];
let verifiedRelease;
let verifiedLoongArchPreview;
const interfaceCopy = isEnglish
? {
themeDark: "Switch to dark theme",
@@ -65,8 +69,7 @@
}
};
const readBoundedJson = async (response) => {
const maximumBytes = releaseIndexApi?.maximumIndexBytes;
const readBoundedJson = async (response, maximumBytes) => {
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
throw new Error("发布索引大小上限无效");
}
@@ -111,30 +114,163 @@
return JSON.parse(new TextDecoder().decode(bytes));
};
const setReleaseLink = (link, url, visibleText) => {
const setLinkText = (link, visibleText) => {
const newWindowNotice = link.querySelector(".sr-only");
link.href = url;
link.replaceChildren(document.createTextNode(visibleText));
if (newWindowNotice) {
link.append(newWindowNotice);
}
};
const setFallbackDownloads = () => {
const setReleaseLink = (link, url, visibleText) => {
link.href = url;
link.target = "_blank";
link.rel = "noreferrer";
link.removeAttribute("aria-disabled");
link.removeAttribute("tabindex");
link.classList.remove("is-disabled");
setLinkText(link, visibleText);
};
const getDownloadCardControls = (card) => {
const platform = card.dataset.downloadCard;
const archSelect = card.querySelector("[data-download-arch]");
const formatSelect = card.querySelector("[data-download-format]");
const container = card.closest(".download-card");
const link = container?.querySelector("[data-release-link]");
const meta = container?.querySelector("[data-download-meta]");
if (
!platform ||
!(archSelect instanceof HTMLSelectElement) ||
!(formatSelect instanceof HTMLSelectElement) ||
!(link instanceof HTMLAnchorElement) ||
!(meta instanceof HTMLElement)
) {
throw new Error("下载卡片结构无效");
}
return { platform, archSelect, formatSelect, link, meta };
};
const setDownloadUnavailable = (link, meta) => {
link.removeAttribute("href");
link.removeAttribute("target");
link.removeAttribute("rel");
link.setAttribute("aria-disabled", "true");
link.tabIndex = -1;
link.classList.add("is-disabled");
setLinkText(link, "龙芯预览包暂不可用");
meta.textContent = "实验预览 · 仅 DEB · 索引尚未发布或校验失败。";
};
const setLoongArchFormat = (formatSelect, selected) => {
for (const option of formatSelect.options) {
const unavailable = selected && option.value !== "deb";
option.hidden = unavailable;
option.disabled = unavailable;
}
if (selected) {
formatSelect.value = "deb";
}
formatSelect.disabled = selected;
};
const updateDownloadCard = (card) => {
const {
platform,
archSelect,
formatSelect,
link,
meta,
} = getDownloadCardControls(card);
const loongArchSelected =
platform === "linux" && archSelect.value === "loong64";
setLoongArchFormat(formatSelect, loongArchSelected);
if (loongArchSelected) {
if (!verifiedLoongArchPreview) {
setDownloadUnavailable(link, meta);
return;
}
setReleaseLink(
link,
verifiedLoongArchPreview.artifact.url,
"下载 Linux 龙芯 LoongArch DEB 预览版 →",
);
meta.textContent =
`GoodBuddy ${verifiedLoongArchPreview.goodBuddyVersion} · ` +
`${formatFileSize(verifiedLoongArchPreview.artifact.size)} · ` +
`实验预览 · SHA-256 ` +
`${verifiedLoongArchPreview.artifact.sha256.slice(0, 12)}…`;
return;
}
if (!verifiedRelease) {
setReleaseLink(
link,
releaseFallbackUrl,
`前往 GitHub 下载 ${platformNames[platform] ?? platform} →`,
);
meta.textContent = "请在 GitHub Release 中选择对应的安装文件。";
return;
}
const target =
verifiedRelease.targets[`${platform}-${archSelect.value}`];
const file = target?.files?.[formatSelect.value];
if (!file) {
throw new Error("下载选项不在已校验的发布索引中");
}
const platformName = platformNames[platform] ?? platform;
const archName =
platform === "macos" && archSelect.value === "arm64"
? "Apple 芯片"
: archSelect.value === "arm64"
? "ARM64"
: "x64";
const formatName = formatNames[formatSelect.value] ?? formatSelect.value;
setReleaseLink(
link,
file.url,
`下载 ${platformName} ${archName} ${formatName} →`,
);
meta.textContent =
`GoodBuddy ${verifiedRelease.version} · ` +
`${formatFileSize(file.size)} · ` +
`${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`;
};
const updateAllDownloadCards = () => {
for (const card of downloadCards) {
const platform = card.dataset.downloadCard;
const link = card.closest(".download-card")?.querySelector("[data-release-link]");
const meta = card.closest(".download-card")?.querySelector("[data-download-meta]");
if (link instanceof HTMLAnchorElement) {
setReleaseLink(
link,
releaseFallbackUrl,
`前往 GitHub 下载 ${platformNames[platform] ?? platform ?? ""} →`,
);
}
if (meta instanceof HTMLElement) {
meta.textContent = "请在 GitHub Release 中选择对应的安装文件。";
}
updateDownloadCard(card);
}
};
const setFallbackDownloads = () => {
verifiedRelease = undefined;
updateAllDownloadCards();
};
const setLoongArchPreviewUnavailable = () => {
verifiedLoongArchPreview = undefined;
const linuxCard = downloadCards.find(
(card) => card.dataset.downloadCard === "linux",
);
if (linuxCard) {
updateDownloadCard(linuxCard);
}
};
const configureLoongArchPreview = (payload) => {
if (!releaseIndexApi?.validateLoongArchPreviewIndex) {
throw new Error("龙芯预览索引校验器不可用");
}
verifiedLoongArchPreview =
releaseIndexApi.validateLoongArchPreviewIndex(payload);
const linuxCard = downloadCards.find(
(card) => card.dataset.downloadCard === "linux",
);
if (linuxCard) {
updateDownloadCard(linuxCard);
}
};
@@ -142,71 +278,37 @@
if (!releaseIndexApi?.validateReleaseIndex) {
throw new Error("发布索引校验器不可用");
}
const release = releaseIndexApi.validateReleaseIndex(payload);
const updateCard = (card) => {
const platform = card.dataset.downloadCard;
const archSelect = card.querySelector("[data-download-arch]");
const formatSelect = card.querySelector("[data-download-format]");
const link = card.closest(".download-card")?.querySelector("[data-release-link]");
const meta = card.closest(".download-card")?.querySelector("[data-download-meta]");
if (
!platform ||
!(archSelect instanceof HTMLSelectElement) ||
!(formatSelect instanceof HTMLSelectElement) ||
!(link instanceof HTMLAnchorElement) ||
!(meta instanceof HTMLElement)
) {
throw new Error("下载卡片结构无效");
}
const target = release.targets[`${platform}-${archSelect.value}`];
const file = target?.files?.[formatSelect.value];
if (!file) {
throw new Error("下载选项不在已校验的发布索引中");
}
const platformName = platformNames[platform] ?? platform;
const archName =
platform === "macos" && archSelect.value === "arm64"
? "Apple 芯片"
: archSelect.value === "arm64"
? "ARM64"
: "x64";
const formatName = formatNames[formatSelect.value] ?? formatSelect.value;
setReleaseLink(
link,
file.url,
`下载 ${platformName} ${archName} ${formatName} →`,
);
meta.textContent =
`GoodBuddy ${release.version} · ${formatFileSize(file.size)} · ` +
`${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`;
};
verifiedRelease = releaseIndexApi.validateReleaseIndex(payload);
updateAllDownloadCards();
};
const initializeDownloadCards = () => {
for (const card of downloadCards) {
const selects = card.querySelectorAll("select");
for (const select of selects) {
for (const select of card.querySelectorAll("select")) {
select.addEventListener("change", () => {
try {
updateCard(card);
updateDownloadCard(card);
} catch {
setFallbackDownloads();
}
});
}
updateCard(card);
updateDownloadCard(card);
}
};
const loadRelease = async () => {
const fetchBoundedIndex = async (
url,
maximumBytes,
errorLabel,
) => {
const controller = new AbortController();
const timeout = window.setTimeout(
() => controller.abort(),
releaseRequestTimeoutMs,
);
try {
const response = await fetch(releaseManifestUrl, {
const response = await fetch(url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
@@ -214,16 +316,42 @@
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`发布索引请求失败:${response.status}`);
throw new Error(`${errorLabel}请求失败:${response.status}`);
}
configureDownloads(await readBoundedJson(response));
} catch {
setFallbackDownloads();
return await readBoundedJson(response, maximumBytes);
} finally {
window.clearTimeout(timeout);
}
};
const loadRelease = async () => {
try {
configureDownloads(
await fetchBoundedIndex(
releaseManifestUrl,
releaseIndexApi?.maximumIndexBytes,
"发布索引",
),
);
} catch {
setFallbackDownloads();
}
};
const loadLoongArchPreview = async () => {
try {
configureLoongArchPreview(
await fetchBoundedIndex(
loongArchPreviewManifestUrl,
releaseIndexApi?.maximumPreviewIndexBytes,
"龙芯预览索引",
),
);
} catch {
setLoongArchPreviewUnavailable();
}
};
const getSavedTheme = () => {
try {
const savedTheme = localStorage.getItem("goodbuddy-site-theme");
@@ -311,8 +439,10 @@
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
setHeaderState();
initializeDownloadCards();
if (!isEnglish) {
void loadRelease();
void loadLoongArchPreview();
}
themeToggle?.addEventListener("click", () => {
+8 -8
View File
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="GoodBuddy 是首个支持龙芯的开源桌面 AI 助理产品,也是免注册、支持信创软硬件的一站式 AI 助手;龙芯 LoongArch 当前提供独立编译预览版。"
content="GoodBuddy 是首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品,也是免注册、支持信创软硬件的一站式 AI 助手。"
/>
<link rel="canonical" href="https://mesalogo.github.io/goodbuddy/" />
<link rel="alternate" hreflang="zh-CN" href="https://mesalogo.github.io/goodbuddy/" />
<link rel="alternate" hreflang="en" href="https://mesalogo.github.io/goodbuddy/en.html" />
<link rel="alternate" hreflang="x-default" href="https://mesalogo.github.io/goodbuddy/en.html" />
<meta name="theme-color" content="#f6f8fb" />
<title>GoodBuddy|首个支持龙芯的开源桌面 AI 助理产品</title>
<title>GoodBuddy|首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</title>
<link
rel="icon"
href="./assets/goodbuddy-light.png"
@@ -124,9 +124,8 @@
<span>一站式 AI 助手。</span>
</h1>
<p class="hero-lead">
GoodBuddy 是首个支持龙芯的开源桌面 AI 助理产品,龙芯 LoongArch
当前以独立编译预览版提供。作为桌面助手与 AI 编程工具台,GoodBuddy
管理对话、知识、笔记与任务,
GoodBuddy 是首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品。作为桌面助手与
AI 编程工具台,GoodBuddy 管理对话、知识、笔记与任务,
也通过独创的统一 Agent Runtime 接入直连模型、OpenCode、Continue 和 DeepSeek Harness。
无需反复配置命令行,选择工具和项目即可开始。
</p>
@@ -134,7 +133,7 @@
<a class="button button--primary" href="#download">立即下载</a>
<a class="button button--secondary" href="#features">了解 Agent Runtime</a>
</div>
<ul class="hero-facts" aria-label="产品特性概览">
<ul class="hero-facts hero-facts--aligned" aria-label="产品特性概览">
<li>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
3 大桌面系统,x64 / arm64
@@ -327,6 +326,7 @@
<select data-download-arch aria-label="Linux 处理器架构">
<option value="x64">x64(Intel / AMD)</option>
<option value="arm64">ARM64</option>
<option value="loong64">龙芯 LoongArch(实验预览)</option>
</select>
</label>
<label>
@@ -353,7 +353,7 @@
<aside class="domestic-support" aria-labelledby="domestic-support-title">
<div>
<p class="kicker">信创软硬件支持</p>
<h3 id="domestic-support-title">首个支持龙芯的开源桌面 AI 助理产品</h3>
<h3 id="domestic-support-title">首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</h3>
<p>
正式下载覆盖国产 x64 与 ARM64;龙芯 LoongArch 使用独立编译的
loong64 预览版。
@@ -525,7 +525,7 @@
</span>
<span>GoodBuddy</span>
</a>
<p>首个支持龙芯的开源桌面 AI 助理产品</p>
<p>首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</p>
<div class="footer-links">
<a href="#download">下载</a>
<a href="#features">Agent Runtime</a>
+88
View File
@@ -3,9 +3,12 @@
const mirrorIndexUrl =
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json";
const loongArchPreviewIndexUrl =
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json";
const fallbackUrl =
"https://github.com/mesalogo/goodbuddy/releases/latest";
const maximumIndexBytes = 512 * 1024;
const maximumPreviewIndexBytes = 16 * 1024;
const semVerPattern =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+((?:[0-9a-zA-Z-]+)(?:\.[0-9a-zA-Z-]+)*))?$/u;
const sha256Pattern = /^[a-f0-9]{64}$/u;
@@ -201,8 +204,93 @@
return index;
};
const validateLoongArchPreviewIndex = (index) => {
assert(
hasExactKeys(index, [
"formatVersion",
"product",
"goodBuddyVersion",
"previewVersion",
"architecture",
"format",
"artifact",
"manifestUrl",
"checksumUrl",
]),
"龙芯预览索引结构无效",
);
assert(index.formatVersion === 1, "龙芯预览索引版本无效");
assert(
index.product === "GoodBuddy LoongArch Preview",
"龙芯预览产品名称无效",
);
assert(
typeof index.goodBuddyVersion === "string" &&
index.goodBuddyVersion.length <= 256,
"龙芯预览 GoodBuddy 版本无效",
);
const parsedVersion = semVerPattern.exec(index.goodBuddyVersion);
assert(
parsedVersion && !parsedVersion[4],
"龙芯预览必须基于稳定 GoodBuddy 版本",
);
const previewPrefix = `${index.goodBuddyVersion}-loong64-preview.`;
assert(
typeof index.previewVersion === "string" &&
index.previewVersion.startsWith(previewPrefix) &&
/^[1-9]\d*$/u.test(index.previewVersion.slice(previewPrefix.length)),
"龙芯预览版本无效",
);
assert(index.architecture === "loong64", "龙芯预览架构无效");
assert(index.format === "deb", "龙芯预览格式无效");
assert(
hasExactKeys(index.artifact, ["name", "size", "sha256", "url"]),
"龙芯预览文件结构无效",
);
const expectedName =
`GoodBuddy-${index.goodBuddyVersion}-linux-loong64-preview.deb`;
assert(
index.artifact.name === expectedName &&
safeFileNamePattern.test(index.artifact.name),
"龙芯预览文件名无效",
);
assert(
Number.isSafeInteger(index.artifact.size) && index.artifact.size > 0,
"龙芯预览文件大小无效",
);
assert(
typeof index.artifact.sha256 === "string" &&
sha256Pattern.test(index.artifact.sha256),
"龙芯预览 SHA-256 无效",
);
const previewBase = new URL(
`v${index.goodBuddyVersion}/`,
loongArchPreviewIndexUrl,
);
assertExactUrl(
index.artifact.url,
new URL(encodeURIComponent(expectedName), previewBase).href,
"龙芯预览文件地址",
);
assertExactUrl(
index.manifestUrl,
new URL("preview-manifest.json", previewBase).href,
"龙芯预览 manifest 地址",
);
assertExactUrl(
index.checksumUrl,
new URL("SHA256SUMS", previewBase).href,
"龙芯预览校验清单地址",
);
return index;
};
window.GoodBuddyReleaseIndex = Object.freeze({
maximumIndexBytes,
maximumPreviewIndexBytes,
validateReleaseIndex,
validateLoongArchPreviewIndex,
});
})();
+165
View File
@@ -86,6 +86,99 @@ const renderApp = (legacy = false) => {
return { dom, queries, window };
};
const renderLoongArchPreview = (payload) => {
const dom = new JSDOM(
`<!doctype html>
<html lang="zh-CN">
<head><meta name="theme-color" content="#f6f8fb"></head>
<body>
<header data-site-header>
<button type="button" data-menu-toggle>菜单</button>
<nav data-navigation></nav>
<button type="button" data-theme-toggle>主题</button>
</header>
<div data-menu-backdrop></div>
<main>
<article class="download-card">
<div data-download-card="linux">
<select data-download-arch>
<option value="x64">x64</option>
<option value="arm64">ARM64</option>
<option value="loong64">龙芯 LoongArch(实验预览)</option>
</select>
<select data-download-format>
<option value="AppImage">AppImage</option>
<option value="deb">DEB</option>
<option value="rpm">RPM</option>
</select>
</div>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
target="_blank"
rel="noreferrer"
data-release-link
>下载 Linux 版<span class="sr-only">(在新窗口打开)</span></a>
<p data-download-meta></p>
</article>
</main>
<span data-current-year></span>
</body>
</html>`,
{
runScripts: "outside-only",
url: "https://example.test/",
},
);
const { window } = dom;
createMediaQueries(window, false);
window.TextDecoder = TextDecoder;
window.GoodBuddyReleaseIndex = {
maximumIndexBytes: 512 * 1024,
maximumPreviewIndexBytes: 16 * 1024,
validateReleaseIndex() {
throw new Error("正式发布索引在此测试中不可用");
},
validateLoongArchPreviewIndex(value) {
return value;
},
};
window.fetch = async (url) => {
if (!String(url).includes("/loongarch-preview/")) {
throw new Error("正式发布索引在此测试中不可用");
}
if (!payload) {
throw new Error("龙芯预览索引不可用");
}
const bytes = new TextEncoder().encode(JSON.stringify(payload));
let delivered = false;
return {
ok: true,
status: 200,
headers: {
get(name) {
return name === "content-length" ? String(bytes.length) : null;
},
},
body: {
getReader() {
return {
async read() {
if (delivered) {
return { done: true, value: undefined };
}
delivered = true;
return { done: false, value: bytes };
},
};
},
},
};
};
window.eval(source);
return { dom, window };
};
for (const legacy of [false, true]) {
test(
`mobile menu isolates content and restores focus with ${
@@ -149,3 +242,75 @@ for (const legacy of [false, true]) {
},
);
}
test("enables only a validated LoongArch preview download", async () => {
const artifactUrl =
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/GoodBuddy-0.11.5-linux-loong64-preview.deb";
const { dom, window } = renderLoongArchPreview({
goodBuddyVersion: "0.11.5",
artifact: {
size: 186_853_872,
sha256: "a".repeat(64),
url: artifactUrl,
},
});
await new Promise((resolve) => window.setTimeout(resolve, 0));
const arch = window.document.querySelector("[data-download-arch]");
const format = window.document.querySelector(
"[data-download-format]",
);
const link = window.document.querySelector("[data-release-link]");
const meta = window.document.querySelector("[data-download-meta]");
arch.value = "loong64";
arch.dispatchEvent(new window.Event("change"));
assert.equal(format.value, "deb");
assert.equal(format.disabled, true);
assert.equal(format.options[0].hidden, true);
assert.equal(format.options[1].hidden, false);
assert.equal(format.options[2].hidden, true);
assert.equal(link.getAttribute("href"), artifactUrl);
assert.equal(link.getAttribute("target"), "_blank");
assert.equal(link.getAttribute("rel"), "noreferrer");
assert.equal(link.hasAttribute("aria-disabled"), false);
assert.equal(link.classList.contains("is-disabled"), false);
assert.equal(
meta.textContent,
"GoodBuddy 0.11.5 · 178 MB · 实验预览 · SHA-256 aaaaaaaaaaaa…",
);
dom.window.close();
});
test("keeps the LoongArch preview disabled when its index is unavailable", async () => {
const { dom, window } = renderLoongArchPreview(null);
await new Promise((resolve) => window.setTimeout(resolve, 0));
const arch = window.document.querySelector("[data-download-arch]");
const format = window.document.querySelector(
"[data-download-format]",
);
const link = window.document.querySelector("[data-release-link]");
const meta = window.document.querySelector("[data-download-meta]");
arch.value = "loong64";
arch.dispatchEvent(new window.Event("change"));
assert.equal(format.value, "deb");
assert.equal(format.disabled, true);
assert.equal(link.hasAttribute("href"), false);
assert.equal(link.getAttribute("aria-disabled"), "true");
assert.equal(link.tabIndex, -1);
assert.equal(link.classList.contains("is-disabled"), true);
assert.equal(
meta.textContent,
"实验预览 · 仅 DEB · 索引尚未发布或校验失败。",
);
arch.value = "x64";
arch.dispatchEvent(new window.Event("change"));
assert.equal(format.disabled, false);
assert.equal(format.options[0].hidden, false);
assert.equal(link.getAttribute("href"), "https://github.com/mesalogo/goodbuddy/releases/latest");
assert.equal(link.hasAttribute("aria-disabled"), false);
dom.window.close();
});
+80 -1
View File
@@ -9,7 +9,10 @@ const { test } = process.env.VITEST
const source = await readFile(path.resolve("sites/release-index.js"), "utf8");
const context = vm.createContext({ URL, window: {} });
vm.runInContext(source, context, { filename: "release-index.js" });
const { validateReleaseIndex } = context.window.GoodBuddyReleaseIndex;
const {
validateReleaseIndex,
validateLoongArchPreviewIndex,
} = context.window.GoodBuddyReleaseIndex;
const definitions = [
["windows", "x64", ["nsis", "portable"]],
@@ -90,6 +93,36 @@ const expectRejected = (mutate) => {
assert.throws(() => validateReleaseIndex(index));
};
const validLoongArchPreviewIndex = () => {
const goodBuddyVersion = "1.2.3";
const previewBase =
`https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v${goodBuddyVersion}/`;
const name =
`GoodBuddy-${goodBuddyVersion}-linux-loong64-preview.deb`;
return {
formatVersion: 1,
product: "GoodBuddy LoongArch Preview",
goodBuddyVersion,
previewVersion: `${goodBuddyVersion}-loong64-preview.1`,
architecture: "loong64",
format: "deb",
artifact: {
name,
size: 186_000_000,
sha256: "a".repeat(64),
url: new URL(encodeURIComponent(name), previewBase).href,
},
manifestUrl: new URL("preview-manifest.json", previewBase).href,
checksumUrl: new URL("SHA256SUMS", previewBase).href,
};
};
const expectLoongArchPreviewRejected = (mutate) => {
const index = validLoongArchPreviewIndex();
mutate(index);
assert.throws(() => validateLoongArchPreviewIndex(index));
};
test("accepts the canonical stable six-target release index", () => {
const index = validIndex();
assert.equal(validateReleaseIndex(index), index);
@@ -204,3 +237,49 @@ test("requires exact checksum and GitHub fallback URLs", () => {
index.fallbackUrl = "https://github.com/mesalogo/goodbuddy/releases";
});
});
test("accepts the isolated immutable LoongArch preview index", () => {
const index = validLoongArchPreviewIndex();
assert.equal(validateLoongArchPreviewIndex(index), index);
});
test("binds the LoongArch preview to its stable version and exact OSS prefix", () => {
for (const version of ["v1.2.3", "1.2.3-rc.1", "1.2"]) {
expectLoongArchPreviewRejected((index) => {
index.goodBuddyVersion = version;
});
}
expectLoongArchPreviewRejected((index) => {
index.previewVersion = "1.2.3-loong64-preview.0";
});
expectLoongArchPreviewRejected((index) => {
index.artifact.url = index.artifact.url.replace(
"/releases/loongarch-preview/v1.2.3/",
"/releases/v1.2.3/",
);
});
expectLoongArchPreviewRejected((index) => {
index.manifestUrl += "?download=1";
});
expectLoongArchPreviewRejected((index) => {
index.unexpected = true;
});
});
test("requires one canonical LoongArch DEB with bounded metadata", () => {
expectLoongArchPreviewRejected((index) => {
index.architecture = "arm64";
});
expectLoongArchPreviewRejected((index) => {
index.format = "rpm";
});
expectLoongArchPreviewRejected((index) => {
index.artifact.name = "GoodBuddy-1.2.3-linux-loong64.deb";
});
expectLoongArchPreviewRejected((index) => {
index.artifact.size = 0;
});
expectLoongArchPreviewRejected((index) => {
index.artifact.sha256 = "A".repeat(64);
});
});
+36 -2
View File
@@ -250,8 +250,9 @@ for (const breakpoint of ["1199px", "959px", "719px"]) {
const requiredCopy = [
"免注册",
"支持信创软硬件的一站式 AI 助手",
"首个支持龙芯的开源桌面 AI 助理产品",
"首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品",
"龙芯 LoongArch 独立编译预览版",
"龙芯 LoongArch(实验预览)",
"桌面助手",
"AI 编程工具台",
"Windows、macOS、Linux",
@@ -328,6 +329,18 @@ report(
(html.match(/data-download-format/g) ?? []).length === 3,
"每个平台必须提供安装包类型选择器",
);
report(
/<option\s+value="loong64">龙芯 LoongArch(实验预览)<\/option>/.test(
html,
),
"Linux 处理器选项必须包含龙芯实验预览",
);
report(
/const setLoongArchFormat =[\s\S]*option\.value !== "deb"[\s\S]*formatSelect\.value = "deb"[\s\S]*formatSelect\.disabled = selected/.test(
appJs,
),
"龙芯处理器必须只允许选择 DEB",
);
report(
!/data-release-status|download-release-status/.test(`${html}\n${englishHtml}\n${css}\n${appJs}`),
"官网不得显示下载源状态提示",
@@ -362,6 +375,27 @@ report(
),
"官网必须从 GoodBuddy OSS 加载最新发布索引",
);
report(
appJs.includes(
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json",
),
"官网必须从独立 OSS 索引加载龙芯预览包",
);
report(
/validateLoongArchPreviewIndex\(payload\)/.test(appJs),
"龙芯预览下载链接必须先通过独立索引校验",
);
report(
/catch\s*\{[\s\S]*setLoongArchPreviewUnavailable\(\)/.test(appJs),
"龙芯预览索引失败时必须保持下载入口禁用",
);
report(
releaseIndexJs.includes(
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json",
) &&
/const validateLoongArchPreviewIndex =/.test(releaseIndexJs),
"龙芯预览索引必须使用独立受信任前缀和校验器",
);
report(
appJs.includes("https://github.com/mesalogo/goodbuddy/releases/latest"),
"官网必须保留 GitHub Release 回退地址",
@@ -502,7 +536,7 @@ const expectedDownloadOptions = {
formats: ["dmg", "zip"],
},
linux: {
arches: ["x64", "arm64"],
arches: ["x64", "arm64", "loong64"],
formats: ["AppImage", "deb", "rpm"],
},
};
+17 -1
View File
@@ -586,6 +586,11 @@ p {
list-style: none;
}
.hero-facts--aligned {
display: grid;
grid-template-columns: repeat(2, minmax(0, max-content));
}
.hero-facts li {
display: flex;
align-items: center;
@@ -1274,7 +1279,7 @@ p {
.download-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-4);
}
@@ -1385,6 +1390,13 @@ p {
transform: translateX(2px);
}
.button--download.is-disabled {
background: transparent;
color: var(--text-muted);
cursor: not-allowed;
text-decoration: none;
}
.download-meta {
grid-column: 1 / -1;
min-height: 18px;
@@ -1832,6 +1844,10 @@ p {
gap: var(--space-2);
}
.hero-facts--aligned {
grid-template-columns: minmax(0, max-content);
}
.product-stage {
min-height: 365px;
margin-top: var(--space-4);
+4 -1
View File
@@ -96,7 +96,8 @@ function resolveContextCompression(
'openai-images-generations'
>,
authentication: summaryProfile.authentication,
contextWindowTokens: summaryProfile.contextWindowTokens
contextWindowTokens: summaryProfile.contextWindowTokens,
maximumOutputTokens: summaryProfile.maximumOutputTokens
}
}
: {})
@@ -120,6 +121,7 @@ export function createDefaultModelRuntime(
protocol: settings.modelProtocol,
authentication: settings.modelAuthentication,
supportsImageInput: settings.supportsImageInput,
maximumOutputTokens: currentProfile?.maximumOutputTokens,
defaultWorkspace: settings.workspacePath || defaultWorkspace,
contextCompression: resolveContextCompression(
settings,
@@ -141,6 +143,7 @@ export function createModelProfileRuntime(
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality:
profile.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
+3
View File
@@ -476,6 +476,7 @@ describe('ModelAgentRuntime', () => {
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
maximumOutputTokens: 48_000,
skillInstructions: '# 文档写作',
fetcher
})
@@ -500,11 +501,13 @@ describe('ModelAgentRuntime', () => {
const body = JSON.parse(init?.body as string) as {
model: string
max_tokens: number
stream: boolean
system: string
}
expect(body).toMatchObject({
model: 'sonnet-5',
max_tokens: 48_000,
stream: true
})
expect(body.system).toMatch(
+8 -5
View File
@@ -8,6 +8,7 @@ import type {
ModelProtocol,
RuntimeConversationCompactInput
} from '../../shared/contracts'
import { defaultAnthropicMaximumOutputTokens } from '../../shared/contracts'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BrowserToolService } from '../browser/browser-model-tools'
import type { WorkspaceAccess } from '../workspace'
@@ -233,8 +234,10 @@ export type ModelRuntimeOptions = {
protocol: Exclude<ModelProtocol, 'openai-images-generations'>
authentication: ModelAuthentication
contextWindowTokens?: number
maximumOutputTokens?: number
}
}
maximumOutputTokens?: number
}
function getErrorMessage(value: unknown): string | undefined {
@@ -1794,10 +1797,9 @@ export class ModelAgentRuntime implements AgentRuntime {
}
private get anthropicProtocolMaximumTokens(): number {
return Math.max(
this.options.contextCompression?.contextWindowTokens ??
minimumModelContextWindowTokens,
minimumModelContextWindowTokens
return (
this.options.maximumOutputTokens ??
defaultAnthropicMaximumOutputTokens
)
}
@@ -1877,7 +1879,8 @@ export class ModelAgentRuntime implements AgentRuntime {
ModelProtocol,
'openai-images-generations'
>,
authentication: this.options.authentication
authentication: this.options.authentication,
maximumOutputTokens: this.options.maximumOutputTokens
}
const summaryRuntime = new ModelAgentRuntime({
...summaryModel,
+1
View File
@@ -386,6 +386,7 @@ export function createResolvedModelProfileDigest(
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput === true,
contextWindowTokens: profile.contextWindowTokens ?? null,
maximumOutputTokens: profile.maximumOutputTokens ?? null,
imageGenerationQuality: profile.imageGenerationQuality ?? null
})
)
+1 -30
View File
@@ -1,4 +1,5 @@
import { extname } from 'node:path'
export { detectSupportedImage } from '../shared/image-media-type'
const mimeTypes: Readonly<Record<string, string>> = {
'.c': 'text/x-c',
@@ -40,33 +41,3 @@ export function mimeTypeFromFileName(
): string {
return mimeTypes[extname(name).toLocaleLowerCase()] ?? fallback
}
export function detectSupportedImage(data: Buffer): {
extension: 'jpg' | 'png' | 'webp'
mimeType: 'image/jpeg' | 'image/png' | 'image/webp'
} {
if (
data.byteLength >= 3 &&
data[0] === 0xff &&
data[1] === 0xd8 &&
data[2] === 0xff
) {
return { extension: 'jpg', mimeType: 'image/jpeg' }
}
if (
data.byteLength >= 8 &&
data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
return { extension: 'png', mimeType: 'image/png' }
}
if (
data.byteLength >= 12 &&
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
data.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return { extension: 'webp', mimeType: 'image/webp' }
}
throw new Error('图片格式不受支持')
}
+5 -2
View File
@@ -283,6 +283,7 @@ describe('RuntimeSettingsStore', () => {
authentication: 'none',
supportsImageInput: false,
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000,
imageGenerationQuality: 'auto',
apiKey: { action: 'keep' }
}
@@ -302,7 +303,8 @@ describe('RuntimeSettingsStore', () => {
modelProfiles: [
{
id: profileId,
contextWindowTokens: 256_000
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000
}
],
contextCompression: {
@@ -316,7 +318,8 @@ describe('RuntimeSettingsStore', () => {
modelProfiles: [
{
id: profileId,
contextWindowTokens: 256_000
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000
}
],
contextCompression: {
+16
View File
@@ -170,6 +170,12 @@ const currentStoredModelProfileSchema = storedModelProfileSchema.extend({
.int()
.min(8_000)
.max(10_000_000)
.optional(),
maximumOutputTokens: z
.number()
.int()
.min(1)
.max(10_000_000)
.optional()
})
@@ -434,6 +440,7 @@ export type ResolvedModelProfile = {
authentication: RuntimeSettings['modelAuthentication']
supportsImageInput?: boolean
contextWindowTokens?: number
maximumOutputTokens?: number
imageGenerationQuality?: RuntimeSettings['imageGenerationQuality']
apiKey?: string
}
@@ -1468,6 +1475,7 @@ export class RuntimeSettingsStore {
authentication: RuntimeSettings['modelAuthentication']
supportsImageInput: boolean
contextWindowTokens?: number
maximumOutputTokens?: number
imageGenerationQuality: RuntimeSettings['imageGenerationQuality']
credentialSource: RuntimeSettings['credentialSource']
} {
@@ -1529,6 +1537,7 @@ export class RuntimeSettingsStore {
: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
credentialSource: credential.source
}
@@ -1552,6 +1561,7 @@ export class RuntimeSettingsStore {
authentication: effective.authentication,
supportsImageInput: effective.supportsImageInput,
contextWindowTokens: effective.contextWindowTokens,
maximumOutputTokens: effective.maximumOutputTokens,
imageGenerationQuality: effective.imageGenerationQuality,
apiKey: effective.apiKey
}
@@ -1569,6 +1579,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: credential.activeApiKey
}
@@ -1651,6 +1662,7 @@ export class RuntimeSettingsStore {
authentication: resolved.authentication,
supportsImageInput: resolved.supportsImageInput,
contextWindowTokens: resolved.contextWindowTokens,
maximumOutputTokens: resolved.maximumOutputTokens,
imageGenerationQuality:
resolved.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
@@ -1672,6 +1684,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality:
profile.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
@@ -1966,6 +1979,7 @@ export class RuntimeSettingsStore {
authentication: input.modelAuthentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: input.imageGenerationQuality,
apiKey: input.apiKey
}
@@ -1978,6 +1992,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: { action: 'keep' as const }
}
@@ -2029,6 +2044,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput ?? false,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality
}
if (
+70 -4
View File
@@ -18,6 +18,10 @@ import type {
} from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import {
loadBrandingPreferences,
saveBrandingPreferences
} from './branding'
import type {
AssistantProject,
AssistantSchedule,
@@ -1163,7 +1167,7 @@ describe('App', () => {
screen.getByLabelText('Message GoodBuddy')
).toHaveAttribute(
'placeholder',
'Message GoodBuddy…'
'Message GoodBuddy…\nEnter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text'
)
expect(
screen.getByLabelText('Message GoodBuddy')
@@ -1336,13 +1340,40 @@ describe('App', () => {
/你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件/u
)
).toBeInTheDocument()
expect(screen.queryByText('桌面工作区')).not.toBeInTheDocument()
expect(screen.getByText('智能工作台')).toBeInTheDocument()
expect(screen.queryByText('GOODBUDDY 工作台')).not.toBeInTheDocument()
expect(
document.querySelector('.composer__runtime-toolbar')
).not.toBeInTheDocument()
})
it('loads customized branding into the primary sidebar', async () => {
expect(
saveBrandingPreferences({
name: '研发伙伴',
subtitleZhCN: '团队工作台',
subtitleEnUS: 'Team Workspace',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
).toBe(true)
render(<App />)
await screen.findByRole('heading', {
name: '今天想一起完成什么?'
})
const brand = document.querySelector('.brand')
expect(brand).not.toBeNull()
expect(within(brand as HTMLElement).getByText('研发伙伴'))
.toBeInTheDocument()
expect(within(brand as HTMLElement).getByText('团队工作台'))
.toBeInTheDocument()
expect(brand?.querySelector('img')).toHaveAttribute(
'src',
'data:image/png;base64,iVBORw0KGgo='
)
})
it('renders the unified user and Scheduled Task queue above the Composer', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000951'
@@ -1751,6 +1782,41 @@ describe('App', () => {
})
})
it('saves branding from Appearance and updates the sidebar immediately', async () => {
render(<App />)
fireEvent.click(
await screen.findByRole('button', {
name: /本地工作区/u
})
)
fireEvent.click(
await screen.findByRole('tab', { name: '外观' })
)
fireEvent.change(screen.getByLabelText('品牌名称'), {
target: { value: '研发伙伴' }
})
fireEvent.change(screen.getByLabelText('中文副标题'), {
target: { value: '团队工作台' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存品牌设置' })
)
expect(await screen.findByText('品牌设置已保存'))
.toBeInTheDocument()
const brand = document.querySelector('.brand')
expect(brand).not.toBeNull()
expect(within(brand as HTMLElement).getByText('研发伙伴'))
.toBeInTheDocument()
expect(within(brand as HTMLElement).getByText('团队工作台'))
.toBeInTheDocument()
expect(loadBrandingPreferences()).toMatchObject({
name: '研发伙伴',
subtitleZhCN: '团队工作台'
})
})
it('preloads a heavy route once across repeated pointer and focus intent', async () => {
render(<App />)
const activity = await screen.findByRole('button', {
@@ -5581,7 +5647,7 @@ describe('App', () => {
).toHaveTextContent(/^Ask$/u)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'给 GoodBuddy 发消息…'
'给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本'
)
expect(
within(conversationSettings).getByRole('button', {
@@ -8710,7 +8776,7 @@ describe('App', () => {
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'描述你想生成的图片…'
'描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本'
)
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenCalled()
+45 -18
View File
@@ -180,6 +180,11 @@ import type { SettingsLeaveRequester } from './SettingsPanel'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
import {
loadBrandingPreferences,
saveBrandingPreferences
} from './branding'
import { BrandLockup } from './BrandLockup'
import {
applyAppearanceTheme,
loadAppearanceTheme,
@@ -2084,6 +2089,9 @@ function App(): React.JSX.Element {
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
const [appearanceTheme, setAppearanceTheme] =
useState<AppearanceTheme>(loadAppearanceTheme)
const [brandingPreferences, setBrandingPreferences] = useState(
loadBrandingPreferences
)
const [systemPrefersDark, setSystemPrefersDark] = useState(
() =>
typeof window.matchMedia === 'function' &&
@@ -2093,6 +2101,10 @@ function App(): React.JSX.Element {
appearanceTheme,
systemPrefersDark
)
const brandingSubtitle =
locale === 'en-US'
? brandingPreferences.subtitleEnUS
: brandingPreferences.subtitleZhCN
const toggleAppearanceTheme = useCallback((): void => {
setAppearanceTheme(
resolvedAppearanceTheme === 'dark' ? 'light' : 'dark'
@@ -7902,22 +7914,19 @@ function App(): React.JSX.Element {
ref={sidebarRef}
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
>
<div className="brand">
<div className="brand__mark">
<img
alt=""
aria-hidden="true"
src={
resolvedAppearanceTheme === 'dark'
? goodbuddyDarkIcon
: goodbuddyLightIcon
}
/>
</div>
<div className="brand__copy">
<strong>GoodBuddy</strong>
</div>
</div>
<BrandLockup
className="brand"
copyClassName="brand__copy"
logo={
brandingPreferences.logoDataUrl ??
(resolvedAppearanceTheme === 'dark'
? goodbuddyDarkIcon
: goodbuddyLightIcon)
}
markClassName="brand__mark"
name={brandingPreferences.name}
subtitle={brandingSubtitle || undefined}
/>
<ProjectSwitcher
activeProjectId={activeProjectId}
@@ -8908,11 +8917,11 @@ function App(): React.JSX.Element {
<div className="composer__input">
<textarea
aria-label={t('composer.inputLabel')}
placeholder={
placeholder={`${
runtime?.capability === 'image-generation'
? t('composer.imagePlaceholder')
: t('composer.placeholder')
}
}\n${t('composer.keyboardHint')}`}
ref={inputRef}
rows={3}
title={t('composer.keyboardHint')}
@@ -10125,11 +10134,29 @@ function App(): React.JSX.Element {
>
<SettingsPanel
appearanceTheme={appearanceTheme}
brandingFallbackLogo={
resolvedAppearanceTheme === 'dark'
? goodbuddyDarkIcon
: goodbuddyLightIcon
}
brandingPreferences={brandingPreferences}
initialCategory={settingsInitialCategory}
initialChannel={settingsInitialChannel}
magicNotesEnabled={magicNotesEnabled}
remoteProjectsEnabled={remoteProjectsEnabled}
onAppearanceThemeChange={setAppearanceTheme}
onBrandingPreferencesChange={(preferences) => {
if (!saveBrandingPreferences(preferences)) {
return false
}
setBrandingPreferences(preferences)
notify({
tone: 'success',
message: t('notices.brandingSaved'),
dedupeKey: 'branding-saved'
})
return true
}}
onClearLocalData={clearLocalData}
onClose={() => {
setSettingsInitialCategory(undefined)
+33
View File
@@ -0,0 +1,33 @@
type BrandLockupProps = {
ariaLabel?: string
className: string
copyClassName: string
logo?: string
markClassName: string
name: string
role?: 'img'
subtitle?: string
}
export function BrandLockup({
ariaLabel,
className,
copyClassName,
logo,
markClassName,
name,
role,
subtitle
}: BrandLockupProps): React.JSX.Element {
return (
<div aria-label={ariaLabel} className={className} role={role}>
<div className={markClassName}>
{logo && <img alt="" aria-hidden="true" src={logo} />}
</div>
<div className={copyClassName}>
<strong>{name}</strong>
{subtitle && <span>{subtitle}</span>}
</div>
</div>
)
}
@@ -0,0 +1,268 @@
import { ImagePlus, RotateCcw, Save, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
brandingLogoMaximumBytes,
brandingNameMaximumLength,
brandingPreferencesEqual,
brandingSubtitleMaximumLength,
defaultBrandingPreferences,
isValidBrandingLogoDataUrl,
normalizeBrandingPreferences,
type BrandingPreferences
} from './branding'
import { BrandLockup } from './BrandLockup'
import {
loadImageDimensions,
readFileAsDataUrl
} from './file-data-url'
type BrandingSettingsSectionProps = {
preferences: BrandingPreferences
fallbackLogo?: string
onChange: (preferences: BrandingPreferences) => boolean
onDirtyChange?: (dirty: boolean) => void
}
export function BrandingSettingsSection({
preferences,
fallbackLogo,
onChange,
onDirtyChange
}: BrandingSettingsSectionProps): React.JSX.Element {
const { i18n, t } = useTranslation('settings')
const [draft, setDraft] = useState(preferences)
const [error, setError] = useState<string>()
const fileInputRef = useRef<HTMLInputElement>(null)
const logoReadRef = useRef<AbortController>(null)
const dirty = !brandingPreferencesEqual(draft, preferences)
useEffect(() => {
onDirtyChange?.(dirty)
}, [dirty, onDirtyChange])
useEffect(
() => () => {
logoReadRef.current?.abort()
},
[]
)
const save = (): void => {
const normalized = normalizeBrandingPreferences(draft)
if (!onChange(normalized)) {
setError(t('appearance.branding.errors.saveFailed'))
return
}
setDraft(normalized)
setError(undefined)
}
const selectLogo = async (file: File | undefined): Promise<void> => {
logoReadRef.current?.abort()
if (!file) {
return
}
if (
!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)
) {
setError(t('appearance.branding.errors.unsupportedLogo'))
return
}
if (file.size === 0 || file.size > brandingLogoMaximumBytes) {
setError(t('appearance.branding.errors.logoTooLarge'))
return
}
const controller = new AbortController()
logoReadRef.current = controller
try {
const dataUrl = await readFileAsDataUrl(
file,
file.type,
controller.signal
)
if (!isValidBrandingLogoDataUrl(dataUrl)) {
setError(t('appearance.branding.errors.invalidLogo'))
return
}
const { width, height } = await loadImageDimensions(
dataUrl,
controller.signal
)
if (
width <= 0 ||
height <= 0 ||
width > 4_096 ||
height > 4_096 ||
width * height > 16_000_000
) {
setError(t('appearance.branding.errors.invalidLogo'))
return
}
if (logoReadRef.current !== controller) {
return
}
setDraft((current) => ({
...current,
logoDataUrl: dataUrl
}))
setError(undefined)
} catch {
if (!controller.signal.aborted) {
setError(t('appearance.branding.errors.readLogoFailed'))
}
} finally {
if (logoReadRef.current === controller) {
logoReadRef.current = null
}
}
}
const previewSubtitle =
(i18n.resolvedLanguage === 'en-US'
? draft.subtitleEnUS
: draft.subtitleZhCN) || undefined
const previewLogo = draft.logoDataUrl ?? fallbackLogo
return (
<div className="settings-section appearance-settings branding-settings">
<div className="settings-section__title">
<ImagePlus aria-hidden="true" size={17} />
<div>
<strong>{t('appearance.branding.title')}</strong>
<small>{t('appearance.branding.description')}</small>
</div>
</div>
<BrandLockup
ariaLabel={t('appearance.branding.previewLabel')}
className="branding-settings__preview"
copyClassName="branding-settings__preview-copy"
logo={previewLogo}
markClassName="branding-settings__preview-mark"
name={draft.name || defaultBrandingPreferences.name}
role="img"
subtitle={previewSubtitle}
/>
<div className="branding-settings__fields">
<label className="field">
<span>{t('appearance.branding.fields.name')}</span>
<input
maxLength={brandingNameMaximumLength}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value
}))
}
value={draft.name}
/>
</label>
<label className="field">
<span>{t('appearance.branding.fields.subtitleZhCN')}</span>
<input
lang="zh-CN"
maxLength={brandingSubtitleMaximumLength}
onChange={(event) =>
setDraft((current) => ({
...current,
subtitleZhCN: event.target.value
}))
}
value={draft.subtitleZhCN}
/>
</label>
<label className="field">
<span>{t('appearance.branding.fields.subtitleEnUS')}</span>
<input
lang="en-US"
maxLength={brandingSubtitleMaximumLength}
onChange={(event) =>
setDraft((current) => ({
...current,
subtitleEnUS: event.target.value
}))
}
value={draft.subtitleEnUS}
/>
</label>
</div>
<div className="branding-settings__logo">
<input
accept="image/png,image/jpeg,image/webp"
aria-label={t('appearance.branding.logo.select')}
hidden
onChange={(event) => {
void selectLogo(event.target.files?.[0])
event.target.value = ''
}}
ref={fileInputRef}
type="file"
/>
<button
className="secondary-button"
onClick={() => fileInputRef.current?.click()}
type="button"
>
<ImagePlus aria-hidden="true" size={13} />
{t('appearance.branding.logo.select')}
</button>
{draft.logoDataUrl && (
<button
className="secondary-button"
onClick={() => {
logoReadRef.current?.abort()
setDraft((current) => ({
name: current.name,
subtitleZhCN: current.subtitleZhCN,
subtitleEnUS: current.subtitleEnUS
}))
setError(undefined)
}}
type="button"
>
<Trash2 aria-hidden="true" size={13} />
{t('appearance.branding.logo.useDefault')}
</button>
)}
<small>{t('appearance.branding.logo.help')}</small>
</div>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
<div className="update-settings__actions">
<button
className="secondary-button"
disabled={brandingPreferencesEqual(
draft,
defaultBrandingPreferences
)}
onClick={() => {
logoReadRef.current?.abort()
setDraft({ ...defaultBrandingPreferences })
setError(undefined)
}}
type="button"
>
<RotateCcw aria-hidden="true" size={13} />
{t('appearance.branding.actions.restore')}
</button>
<button
className="primary-button"
disabled={!dirty}
onClick={save}
type="button"
>
<Save aria-hidden="true" size={13} />
{t('appearance.branding.actions.save')}
</button>
</div>
</div>
)
}
+2 -16
View File
@@ -24,6 +24,7 @@ import {
type FeedbackSubmissionErrorCode
} from '../../shared/feedback-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { loadImageDimensions } from './file-data-url'
type FeedbackDialogProps = {
appInfo: AppInfo
@@ -49,21 +50,6 @@ function formatScreenshotBytes(bytes: number): string {
: `${Math.max(1, Math.ceil(bytes / 1_024))} KB`
}
function imageDimensions(
objectUrl: string
): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () =>
resolve({
width: image.naturalWidth,
height: image.naturalHeight
})
image.onerror = () => reject(new Error('Image could not be decoded'))
image.src = objectUrl
})
}
export function FeedbackDialog({
appInfo,
onClose
@@ -174,7 +160,7 @@ export function FeedbackDialog({
}
objectUrl = URL.createObjectURL(file)
pendingScreenshotUrlsRef.current.add(objectUrl)
const { width, height } = await imageDimensions(objectUrl)
const { width, height } = await loadImageDimensions(objectUrl)
if (selection !== screenshotSelectionRef.current) {
pendingScreenshotUrlsRef.current.delete(objectUrl)
URL.revokeObjectURL(objectUrl)
+4 -40
View File
@@ -22,6 +22,7 @@ import {
magicNoteDataBytes,
type MagicNoteRichContent
} from '../../shared/magic-notes-contracts'
import { readFileAsDataUrl } from './file-data-url'
const supportedImageTypes = new Set([
'image/jpeg',
@@ -41,32 +42,6 @@ export type MagicNoteEditorProps = {
onParagraphCommit?: (content: MagicNoteRichContent) => void
}
function readFileAsDataUrl(
file: File,
mimeType: string,
readFailedMessage: string
): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error(readFailedMessage))
return
}
const separatorIndex = reader.result.indexOf(',')
if (separatorIndex < 0) {
reject(new Error(readFailedMessage))
return
}
resolve(
`data:${mimeType};base64,${reader.result.slice(separatorIndex + 1)}`
)
}
reader.onerror = () => reject(new Error(readFailedMessage))
reader.readAsDataURL(file)
})
}
function safeEmbeddedFileName(file: File): string {
const fallback = file.type.startsWith('video/')
? 'video'
@@ -273,9 +248,6 @@ export function MagicNoteEditor({
return
}
try {
const readFailedMessage = translateRef.current(
'editor.fileReadFailed'
)
const embeds = await Promise.all(
classified.map(async ({ file, kind, mimeType }) => ({
kind,
@@ -283,11 +255,7 @@ export function MagicNoteEditor({
name: safeEmbeddedFileName(file),
mimeType,
size: file.size,
dataUrl: await readFileAsDataUrl(
file,
mimeType,
readFailedMessage
)
dataUrl: await readFileAsDataUrl(file, mimeType)
}
}))
)
@@ -302,12 +270,8 @@ export function MagicNoteEditor({
index += 2
}
quill.setSelection(index, 0, 'silent')
} catch (error) {
onErrorRef.current(
error instanceof Error
? error.message
: translateRef.current('editor.fileReadFailed')
)
} catch {
onErrorRef.current(translateRef.current('editor.fileReadFailed'))
}
}
+116 -1
View File
@@ -48,8 +48,21 @@ import {
} from './RuntimeCustomizationSection'
import { changeUiLocale } from './i18n'
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
import type { BrandingPreferences } from './branding'
const modelProfileId = '00000000-0000-4000-8000-000000000001'
const nativeImage = globalThis.Image
class DecodableBrandImage {
naturalWidth = 64
naturalHeight = 64
onload: (() => void) | null = null
onerror: (() => void) | null = null
set src(_value: string) {
queueMicrotask(() => this.onload?.())
}
}
const browserProfileId = '00000000-0000-4000-8000-000000000201'
const runtimeSettings: RuntimeSettings = {
provider: 'auto',
@@ -934,6 +947,10 @@ describe('SettingsPanel runtime files', () => {
afterEach(() => {
cleanup()
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: nativeImage
})
})
it('offers system, light, and dark appearance modes', async () => {
@@ -963,6 +980,99 @@ describe('SettingsPanel runtime files', () => {
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
})
it('customizes and restores the sidebar brand from Appearance', async () => {
const onBrandingPreferencesChange = vi.fn<
(preferences: BrandingPreferences) => boolean
>(() => true)
function BrandingHarness(): React.JSX.Element {
const [preferences, setPreferences] = useState({
name: 'GoodBuddy',
subtitleZhCN: '智能工作台',
subtitleEnUS: 'Intelligent Workspace'
})
return (
<SettingsPanel
{...heartbeatSettingsProps}
brandingFallbackLogo="default-logo.png"
brandingPreferences={preferences}
onBrandingPreferencesChange={(next) => {
onBrandingPreferencesChange(next)
setPreferences(next)
return true
}}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
}
render(<BrandingHarness />)
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(screen.getByRole('img', { name: '品牌效果预览' }))
.toHaveTextContent('GoodBuddy')
expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy')
fireEvent.change(screen.getByLabelText('品牌名称'), {
target: { value: '研发伙伴' }
})
fireEvent.change(screen.getByLabelText('中文副标题'), {
target: { value: '团队工作台' }
})
fireEvent.change(screen.getByLabelText('英文副标题'), {
target: { value: 'Team Workspace' }
})
expect(screen.getByRole('img', { name: '品牌效果预览' }))
.toHaveTextContent('研发伙伴')
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: DecodableBrandImage
})
fireEvent.change(screen.getByLabelText('选择 Logo'), {
target: {
files: [
new File(
[
Uint8Array.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a
])
],
'brand.png',
{ type: 'image/png' }
)
]
}
})
await screen.findByRole('button', { name: '使用默认 Logo' })
fireEvent.click(
screen.getByRole('button', { name: '保存品牌设置' })
)
expect(onBrandingPreferencesChange).toHaveBeenCalledWith({
name: '研发伙伴',
subtitleZhCN: '团队工作台',
subtitleEnUS: 'Team Workspace',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
fireEvent.click(screen.getByRole('button', { name: '恢复默认' }))
expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy')
expect(screen.getByLabelText('中文副标题')).toHaveValue(
'智能工作台'
)
})
it('configures direct model context compression with explicit token budgets', async () => {
render(
<SettingsPanel
@@ -1112,6 +1222,10 @@ describe('SettingsPanel runtime files', () => {
expect(contextWindow).toHaveValue(null)
expect(contextWindow).toHaveAttribute('min', '32')
fireEvent.change(contextWindow, { target: { value: '256' } })
const maximumOutput = screen.getByLabelText('Anthropic 最大输出')
expect(maximumOutput).toHaveValue(32)
expect(maximumOutput).toHaveAttribute('min', '1')
fireEvent.change(maximumOutput, { target: { value: '48' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
@@ -1120,7 +1234,8 @@ describe('SettingsPanel runtime files', () => {
modelProfiles: expect.arrayContaining([
expect.objectContaining({
id: modelProfileId,
contextWindowTokens: 256_000
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000
})
])
})
+59
View File
@@ -34,6 +34,7 @@ import type {
} from '../../shared/contracts'
import {
defaultContextCompressionSettings,
defaultAnthropicMaximumOutputTokens,
builtinEmbeddingConnectionId,
defaultModelProfileId as builtInDefaultModelProfileId,
defaultRuntimeSettings,
@@ -75,6 +76,11 @@ import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { useUiLocale } from './i18n/UiLocaleProvider'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
import { BrandingSettingsSection } from './BrandingSettingsSection'
import {
defaultBrandingPreferences,
type BrandingPreferences
} from './branding'
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
type AgentRuntimeType =
@@ -217,6 +223,11 @@ type SettingsPanelProps = {
onClearLocalData: () => Promise<void>
appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
brandingPreferences?: BrandingPreferences
brandingFallbackLogo?: string
onBrandingPreferencesChange?: (
preferences: BrandingPreferences
) => boolean
magicNotesEnabled?: boolean
remoteProjectsEnabled?: boolean
onMagicNotesEnabledChange?: (enabled: boolean) => void
@@ -257,6 +268,9 @@ function toModelProfileDrafts(
return settings.modelProfiles.map((profile) => ({
...profile,
supportsImageInput: profile.supportsImageInput ?? false,
maximumOutputTokens:
profile.maximumOutputTokens ??
defaultAnthropicMaximumOutputTokens,
apiKey: '',
clearApiKey: false
}))
@@ -580,6 +594,9 @@ export function SettingsPanel({
onExpertsChanged = () => {},
appearanceTheme = 'system',
onAppearanceThemeChange = () => {},
brandingPreferences = defaultBrandingPreferences,
brandingFallbackLogo,
onBrandingPreferencesChange = () => false,
magicNotesEnabled = false,
remoteProjectsEnabled = false,
onMagicNotesEnabledChange = () => {},
@@ -594,6 +611,7 @@ export function SettingsPanel({
setPreference: setUiLocalePreference
} = useUiLocale()
const [settings, setSettings] = useState<RuntimeSettings>()
const [brandingDirty, setBrandingDirty] = useState(false)
const [provider, setProvider] =
useState<RuntimeSettingsInput['provider']>(
defaultRuntimeSettings.provider
@@ -923,11 +941,13 @@ export function SettingsPanel({
runtimeDraftDirty ||
speechModelSelectionDirty ||
runtimeCustomizationDirty ||
brandingDirty ||
platformFeaturesDirty ||
documentParsingDirty ||
sshHostsDirty
const navigationWouldLoseDraft =
runtimeCustomizationDirty ||
brandingDirty ||
platformFeaturesDirty ||
documentParsingDirty ||
sshHostsDirty
@@ -935,6 +955,7 @@ export function SettingsPanel({
runtimeCustomizationDirty &&
!runtimeDraftDirty &&
!speechModelSelectionDirty &&
!brandingDirty &&
!platformFeaturesDirty &&
!documentParsingDirty &&
!sshHostsDirty
@@ -1184,6 +1205,7 @@ export function SettingsPanel({
setSpeechModelDraftId(undefined)
setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false)
setBrandingDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
@@ -1209,6 +1231,7 @@ export function SettingsPanel({
setSpeechModelSelectionDirty(false)
runtimeCustomizationRef.current?.discard()
setRuntimeCustomizationDirty(false)
setBrandingDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
@@ -1285,6 +1308,7 @@ export function SettingsPanel({
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: profile.clearApiKey
? ({ action: 'clear' } as const)
@@ -1670,6 +1694,7 @@ export function SettingsPanel({
protocol: 'openai-chat-completions',
authentication: defaultRuntimeSettings.modelAuthentication,
supportsImageInput: defaultRuntimeSettings.supportsImageInput,
maximumOutputTokens: defaultAnthropicMaximumOutputTokens,
imageGenerationQuality:
defaultRuntimeSettings.imageGenerationQuality,
apiKeyConfigured: false,
@@ -2104,6 +2129,12 @@ export function SettingsPanel({
))}
</div>
</div>
<BrandingSettingsSection
fallbackLogo={brandingFallbackLogo}
onChange={onBrandingPreferencesChange}
onDirtyChange={setBrandingDirty}
preferences={brandingPreferences}
/>
</>
)}
{activeTab === 'platform-features' && (
@@ -3136,6 +3167,34 @@ export function SettingsPanel({
{t('model.profile.contextWindowDescription')}
</small>
</label>
{profile.protocol === 'anthropic-messages' && (
<label className="field">
<span>{t('model.profile.maximumOutput')}</span>
<input
aria-label={t('model.profile.maximumOutput')}
inputMode="numeric"
max={10_000}
min={1}
onChange={(event) => {
const value = event.target.valueAsNumber
updateModelProfile(profile.id, {
maximumOutputTokens: Number.isFinite(value)
? Math.round(value * 1_000)
: defaultAnthropicMaximumOutputTokens
})
}}
placeholder="32"
type="number"
value={
(profile.maximumOutputTokens ??
defaultAnthropicMaximumOutputTokens) / 1_000
}
/>
<small>
{t('model.profile.maximumOutputDescription')}
</small>
</label>
)}
</>
)}
{profile.protocol ===
+97
View File
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
brandingLogoMaximumBytes,
brandingNameMaximumLength,
defaultBrandingPreferences,
loadBrandingPreferences,
normalizeBrandingPreferences,
saveBrandingPreferences
} from './branding'
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
})
describe('branding preferences', () => {
it('uses defaults when no valid preference is stored', () => {
expect(loadBrandingPreferences()).toEqual(defaultBrandingPreferences)
localStorage.setItem('goodbuddy.branding.v1', '{"name":true}')
expect(loadBrandingPreferences()).toEqual(defaultBrandingPreferences)
})
it('normalizes text and accepts only bounded inline image data', () => {
expect(
normalizeBrandingPreferences({
name: ` ${'B'.repeat(brandingNameMaximumLength + 4)} `,
subtitleZhCN: ' 中文品牌 ',
subtitleEnUS: ' English brand ',
logoDataUrl: 'https://example.com/logo.png'
})
).toEqual({
name: 'B'.repeat(brandingNameMaximumLength),
subtitleZhCN: '中文品牌',
subtitleEnUS: 'English brand'
})
expect(
normalizeBrandingPreferences({
name: ' ',
subtitleZhCN: '',
subtitleEnUS: '',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
).toEqual({
name: 'GoodBuddy',
subtitleZhCN: '',
subtitleEnUS: '',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
})
it('persists and reloads valid preferences', () => {
const preferences = {
name: '团队助手',
subtitleZhCN: '研发工作台',
subtitleEnUS: 'Engineering Workspace',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
}
expect(saveBrandingPreferences(preferences)).toBe(true)
expect(loadBrandingPreferences()).toEqual(preferences)
})
it('rejects a logo whose bytes do not match its declared type', () => {
expect(
normalizeBrandingPreferences({
...defaultBrandingPreferences,
logoDataUrl: 'data:image/jpeg;base64,iVBORw0KGgo='
})
).toEqual(defaultBrandingPreferences)
})
it('rejects oversized encoded logos before decoding them', () => {
const decode = vi.spyOn(globalThis, 'atob')
expect(
normalizeBrandingPreferences({
...defaultBrandingPreferences,
logoDataUrl: `data:image/png;base64,${'A'.repeat(
Math.ceil((brandingLogoMaximumBytes * 4) / 3) + 8
)}`
})
).toEqual(defaultBrandingPreferences)
expect(decode).not.toHaveBeenCalled()
})
it('reports unavailable local storage without throwing', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked')
})
expect(
saveBrandingPreferences(defaultBrandingPreferences)
).toBe(false)
})
})
+140
View File
@@ -0,0 +1,140 @@
import { detectSupportedImage } from '../../shared/image-media-type'
export type BrandingPreferences = {
name: string
subtitleZhCN: string
subtitleEnUS: string
logoDataUrl?: string
}
export const defaultBrandingPreferences: BrandingPreferences = {
name: 'GoodBuddy',
subtitleZhCN: '智能工作台',
subtitleEnUS: 'Intelligent Workspace'
}
export const brandingNameMaximumLength = 32
export const brandingSubtitleMaximumLength = 48
export const brandingLogoMaximumBytes = 512 * 1024
const storageKey = 'goodbuddy.branding.v1'
const supportedLogoDataUrlPattern =
/^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/]+={0,2})$/iu
export function isValidBrandingLogoDataUrl(value: string): boolean {
const match = supportedLogoDataUrlPattern.exec(value)
if (!match) {
return false
}
try {
const encoded = match[2]!
if (
encoded.length >
Math.ceil((brandingLogoMaximumBytes * 4) / 3) + 4
) {
return false
}
const decoded = atob(encoded)
if (
decoded.length === 0 ||
decoded.length > brandingLogoMaximumBytes
) {
return false
}
const header = Uint8Array.from(
decoded
.slice(0, 12)
.split('')
.map((character) => character.charCodeAt(0))
)
return (
detectSupportedImage(header).mimeType === match[1]!.toLowerCase()
)
} catch {
return false
}
}
export function normalizeBrandingPreferences(
value: BrandingPreferences
): BrandingPreferences {
const name = value.name.trim()
const subtitleZhCN = value.subtitleZhCN.trim()
const subtitleEnUS = value.subtitleEnUS.trim()
return {
name:
name.length > 0
? name.slice(0, brandingNameMaximumLength)
: defaultBrandingPreferences.name,
subtitleZhCN: subtitleZhCN.slice(
0,
brandingSubtitleMaximumLength
),
subtitleEnUS: subtitleEnUS.slice(
0,
brandingSubtitleMaximumLength
),
...(value.logoDataUrl &&
isValidBrandingLogoDataUrl(value.logoDataUrl)
? { logoDataUrl: value.logoDataUrl }
: {})
}
}
export function loadBrandingPreferences(): BrandingPreferences {
try {
const value = localStorage.getItem(storageKey)
if (!value) {
return { ...defaultBrandingPreferences }
}
const parsed = JSON.parse(value) as unknown
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof (parsed as Record<string, unknown>).name !== 'string' ||
typeof (parsed as Record<string, unknown>).subtitleZhCN !==
'string' ||
typeof (parsed as Record<string, unknown>).subtitleEnUS !==
'string'
) {
return { ...defaultBrandingPreferences }
}
const candidate = parsed as BrandingPreferences
return normalizeBrandingPreferences({
name: candidate.name,
subtitleZhCN: candidate.subtitleZhCN,
subtitleEnUS: candidate.subtitleEnUS,
...(typeof candidate.logoDataUrl === 'string'
? { logoDataUrl: candidate.logoDataUrl }
: {})
})
} catch {
return { ...defaultBrandingPreferences }
}
}
export function saveBrandingPreferences(
value: BrandingPreferences
): boolean {
try {
localStorage.setItem(
storageKey,
JSON.stringify(normalizeBrandingPreferences(value))
)
return true
} catch {
return false
}
}
export function brandingPreferencesEqual(
left: BrandingPreferences,
right: BrandingPreferences
): boolean {
return (
left.name === right.name &&
left.subtitleZhCN === right.subtitleZhCN &&
left.subtitleEnUS === right.subtitleEnUS &&
left.logoDataUrl === right.logoDataUrl
)
}
+82
View File
@@ -0,0 +1,82 @@
export function readFileAsDataUrl(
file: Blob,
mimeType: string,
signal?: AbortSignal
): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
const cleanup = (): void => {
signal?.removeEventListener('abort', abort)
reader.onload = null
reader.onerror = null
reader.onabort = null
}
const fail = (): void => {
cleanup()
reject(new Error('File could not be read'))
}
const abort = (): void => {
reader.abort()
}
if (signal?.aborted) {
fail()
return
}
signal?.addEventListener('abort', abort, { once: true })
reader.onload = () => {
if (typeof reader.result !== 'string') {
fail()
return
}
const separatorIndex = reader.result.indexOf(',')
if (separatorIndex < 0) {
fail()
return
}
const dataUrl =
`data:${mimeType};base64,${reader.result.slice(separatorIndex + 1)}`
cleanup()
resolve(dataUrl)
}
reader.onerror = fail
reader.onabort = fail
reader.readAsDataURL(file)
})
}
export function loadImageDimensions(
source: string,
signal?: AbortSignal
): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const image = new Image()
const cleanup = (): void => {
signal?.removeEventListener('abort', abort)
image.onload = null
image.onerror = null
}
const abort = (): void => {
cleanup()
image.src = ''
reject(new Error('Image loading was cancelled'))
}
if (signal?.aborted) {
abort()
return
}
signal?.addEventListener('abort', abort, { once: true })
image.onload = () => {
cleanup()
resolve({
width: image.naturalWidth,
height: image.naturalHeight
})
}
image.onerror = () => {
cleanup()
reject(new Error('Image could not be decoded'))
}
image.src = source
})
}
@@ -564,6 +564,7 @@ export const app = {
}
},
notices: {
brandingSaved: 'Branding saved',
updateAvailable:
'GoodBuddy {{version}} is available. View it in About & Updates.',
remoteProjectActivated:
@@ -16,8 +16,8 @@ export const settings = {
categories: {
appearance: {
label: 'Appearance',
navigationDescription: 'Theme and interface language',
description: 'Theme and interface language'
navigationDescription: 'Theme, language, and branding',
description: 'Theme, language, and brand customization'
},
platformFeatures: {
label: 'Platform features',
@@ -721,6 +721,9 @@ export const settings = {
contextWindow: 'Context window (optional)',
contextWindowDescription:
'Enter 32K–10000K tokens. Leave blank when unknown. This value is used only for GoodBuddy local budget calculations.',
maximumOutput: 'Anthropic maximum output',
maximumOutputDescription:
'The Anthropic Messages protocol requires this value on every request. Enter 1K–10000K tokens. It limits only one model response, not tool rounds or cumulative usage.',
imageQuality: 'Image quality',
imageQualityAriaLabel: 'Image quality for {{name}}',
quality: {
@@ -876,6 +879,36 @@ export const settings = {
description: 'Use the English interface'
}
}
},
branding: {
title: 'Brand customization',
description:
'Customize the brand name, subtitle, and logo in the top-left corner',
previewLabel: 'Brand preview',
fields: {
name: 'Brand name',
subtitleZhCN: 'Chinese subtitle',
subtitleEnUS: 'English subtitle'
},
logo: {
select: 'Choose logo',
useDefault: 'Use default logo',
help:
'Supports PNG, JPEG, or WebP up to 4096 × 4096 and 512 KB'
},
actions: {
restore: 'Restore defaults',
save: 'Save branding'
},
errors: {
unsupportedLogo: 'Choose a PNG, JPEG, or WebP image',
logoTooLarge: 'The logo file cannot exceed 512 KB',
invalidLogo:
'The logo is invalid, has the wrong format, or is too large',
readLogoFailed: 'The logo could not be read. Choose it again.',
saveFailed:
'Branding could not be saved. Check local storage and try again.'
}
}
}
} satisfies TranslationShape<typeof chineseSettings>
@@ -530,6 +530,7 @@ export const app = {
}
},
notices: {
brandingSaved: '品牌设置已保存',
updateAvailable: '发现 GoodBuddy {{version}},可在“关于与更新”中查看',
remoteProjectActivated: '远程项目 Agent 和 Runtime 已就绪',
remoteProjectActivationFailed:
@@ -12,8 +12,8 @@ export const settings = {
categories: {
appearance: {
label: '外观',
navigationDescription: '主题与界面语言',
description: '主题与界面语言'
navigationDescription: '主题、语言与品牌',
description: '主题、语言与品牌定制'
},
platformFeatures: {
label: '平台功能',
@@ -662,6 +662,9 @@ export const settings = {
contextWindow: '上下文上限(可选)',
contextWindowDescription:
'以 K tokens 填写,范围为 32K–10000K。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。',
maximumOutput: 'Anthropic 最大输出',
maximumOutputDescription:
'Anthropic Messages 协议要求每次请求提供该值。填写 1K–10000K tokens;它只限制单次模型响应,不限制工具调用轮次或累计用量。',
imageQuality: '图片质量',
imageQualityAriaLabel: '图片质量 {{name}}',
quality: {
@@ -806,6 +809,33 @@ export const settings = {
description: 'Use the English interface'
}
}
},
branding: {
title: '品牌定制',
description: '定制左上角品牌名称、副标题与 Logo',
previewLabel: '品牌效果预览',
fields: {
name: '品牌名称',
subtitleZhCN: '中文副标题',
subtitleEnUS: '英文副标题'
},
logo: {
select: '选择 Logo',
useDefault: '使用默认 Logo',
help:
'支持 PNG、JPEG 或 WebP,尺寸不超过 4096 × 4096,文件不能超过 512 KB'
},
actions: {
restore: '恢复默认',
save: '保存品牌设置'
},
errors: {
unsupportedLogo: '请选择 PNG、JPEG 或 WebP 图片',
logoTooLarge: 'Logo 文件不能超过 512 KB',
invalidLogo: 'Logo 图片无效、格式不符或尺寸过大',
readLogoFailed: '读取 Logo 失败,请重新选择',
saveFailed: '保存品牌设置失败,请检查本地存储后重试'
}
}
}
} as const
+90
View File
@@ -2359,10 +2359,18 @@ textarea:focus-visible {
.brand__copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.brand__copy strong,
.brand__copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand__copy strong {
font-size: 15px;
font-weight: 600;
@@ -13736,6 +13744,84 @@ details.settings-section > :not(summary) + :not(summary) {
gap: 14px;
}
.branding-settings__preview {
display: flex;
align-items: center;
min-height: 76px;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
gap: var(--space-3);
}
.branding-settings__preview-mark {
display: grid;
width: 44px;
height: 44px;
flex: 0 0 44px;
overflow: hidden;
border: 1px solid var(--accent-selected);
border-radius: 12px;
background: var(--accent-subtle);
place-items: center;
}
.branding-settings__preview-mark img {
width: 100%;
height: 100%;
object-fit: cover;
}
.branding-settings__preview-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-1);
}
.branding-settings__preview-copy strong,
.branding-settings__preview-copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.branding-settings__preview-copy strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.branding-settings__preview-copy span {
color: var(--text-muted);
font-size: var(--font-caption);
}
.branding-settings__fields {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.branding-settings__logo {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.branding-settings__logo button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.branding-settings__logo small {
flex-basis: 100%;
color: var(--text-muted);
font-size: var(--font-caption);
}
.appearance-options {
display: grid;
gap: 10px;
@@ -14495,6 +14581,10 @@ details.settings-section > :not(summary) + :not(summary) {
grid-template-columns: 1fr;
}
.branding-settings__fields {
grid-template-columns: 1fr;
}
.workspace-picker {
flex-wrap: wrap;
}
+10
View File
@@ -568,6 +568,9 @@ export {
minimumModelContextWindowTokens
} from './context-window'
export const defaultAnthropicMaximumOutputTokens = 32_000
export const maximumModelOutputTokens = 10_000_000
const modelProfileInputSchema = z
.object({
id: modelProfileIdSchema,
@@ -588,6 +591,12 @@ const modelProfileInputSchema = z
.min(minimumModelContextWindowTokens)
.max(maximumModelContextWindowTokens)
.optional(),
maximumOutputTokens: z
.number()
.int()
.min(1)
.max(maximumModelOutputTokens)
.optional(),
imageGenerationQuality: imageGenerationQualitySchema,
apiKey: modelApiKeyUpdateSchema
})
@@ -968,6 +977,7 @@ export type ModelConnectionSettings = {
authentication: ModelAuthentication
supportsImageInput?: boolean
contextWindowTokens?: number
maximumOutputTokens?: number
imageGenerationQuality: ImageGenerationQuality
apiKeyConfigured: boolean
credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { detectSupportedImage } from './image-media-type'
describe('supported image detection', () => {
it.each([
{
bytes: [0xff, 0xd8, 0xff],
extension: 'jpg',
mimeType: 'image/jpeg'
},
{
bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
extension: 'png',
mimeType: 'image/png'
},
{
bytes: [
0x52,
0x49,
0x46,
0x46,
0,
0,
0,
0,
0x57,
0x45,
0x42,
0x50
],
extension: 'webp',
mimeType: 'image/webp'
}
])('detects $mimeType bytes', ({ bytes, extension, mimeType }) => {
expect(detectSupportedImage(Uint8Array.from(bytes))).toEqual({
extension,
mimeType
})
})
it('rejects unsupported bytes', () => {
expect(() =>
detectSupportedImage(Uint8Array.from([1, 2, 3]))
).toThrow('图片格式不受支持')
})
})
+44
View File
@@ -0,0 +1,44 @@
export type SupportedImageFormat = {
extension: 'jpg' | 'png' | 'webp'
mimeType: 'image/jpeg' | 'image/png' | 'image/webp'
}
function matchesAscii(
data: Uint8Array,
offset: number,
value: string
): boolean {
return [...value].every(
(character, index) =>
data[offset + index] === character.charCodeAt(0)
)
}
export function detectSupportedImage(
data: Uint8Array
): SupportedImageFormat {
if (
data.byteLength >= 3 &&
data[0] === 0xff &&
data[1] === 0xd8 &&
data[2] === 0xff
) {
return { extension: 'jpg', mimeType: 'image/jpeg' }
}
if (
data.byteLength >= 8 &&
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every(
(value, index) => data[index] === value
)
) {
return { extension: 'png', mimeType: 'image/png' }
}
if (
data.byteLength >= 12 &&
matchesAscii(data, 0, 'RIFF') &&
matchesAscii(data, 8, 'WEBP')
) {
return { extension: 'webp', mimeType: 'image/webp' }
}
throw new Error('图片格式不受支持')
}
+3
View File
@@ -97,6 +97,9 @@ describe('LoongArch preview package contract', () => {
expect(previewScript).not.toContain(
'node_pty_version="1.1.0"'
)
expect(previewScript).toContain(
'sha256sum "${artifact_name}" > SHA256SUMS'
)
expect(previewScript).toContain(
'assert_loongarch_elf "${koffi_binding}"'
)