Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38f6a23306 | ||
|
|
12463ad593 | ||
|
|
618e505b8b | ||
|
|
0a3bf6094a | ||
|
|
957e2ecde7 | ||
|
|
9316fed3e3 | ||
|
|
d6fea0c6c2 | ||
|
|
d9a554b4b1 | ||
|
|
b20c10f820 | ||
|
|
49700bc7cf |
@@ -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、企业微信和钉钉,每个发送者使用独立远程会话。
|
||||
|
||||
@@ -298,6 +298,13 @@
|
||||
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
|
||||
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
|
||||
颜色或动效表达状态。
|
||||
- 左上角品牌区允许用户在“外观”设置中定制品牌名称、中英文副标题和单个 Logo。定制只
|
||||
影响该品牌区,不替换安装包名称、更新协议、Agent 标识或应用正文中的 GoodBuddy 产品
|
||||
身份。副标题按当前界面语言显示,留空时隐藏且不保留空行。
|
||||
- 品牌设置必须提供同布局预览、显式保存、默认 Logo 和整组恢复默认操作。自定义 Logo 仅
|
||||
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
|
||||
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||
单行截断,不能撑宽或挤压侧栏。
|
||||
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
|
||||
### 6.9 助手工作栏
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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('图片格式不受支持')
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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('图片格式不受支持')
|
||||
})
|
||||
})
|
||||
@@ -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('图片格式不受支持')
|
||||
}
|
||||
@@ -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}"'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user