Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38f6a23306 | ||
|
|
12463ad593 | ||
|
|
618e505b8b | ||
|
|
0a3bf6094a | ||
|
|
957e2ecde7 | ||
|
|
9316fed3e3 | ||
|
|
d6fea0c6c2 | ||
|
|
d9a554b4b1 | ||
|
|
b20c10f820 | ||
|
|
49700bc7cf |
@@ -20,7 +20,7 @@ GoodBuddy是一个开箱即用的 AI 生产平台,安全、跨平台、本地
|
|||||||
- **安全执行**:`Ask` 保持只读;`Execute` 仅运行已启用且受边界约束的工具,并保留活动记录。
|
- **安全执行**:`Ask` 保持只读;`Execute` 仅运行已启用且受边界约束的工具,并保留活动记录。
|
||||||
- **本地优先**:会话、任务、成果、记忆、知识库和图谱保存在本地 SQLite;API Key 由系统安全存储加密。
|
- **本地优先**:会话、任务、成果、记忆、知识库和图谱保存在本地 SQLite;API Key 由系统安全存储加密。
|
||||||
- **多 Runtime**:支持直连模型、OpenCode、Continue 和预览版 DeepSeek Harness,统一处理取消、超时、输出限制和进程退出;原生能力清单将 Tools 与 Commands、LSP、Formatters 分开,并显示来源及 Ask/Execute 可用性。内置 OpenCode 提供 Agent、Tool、Command 与原生 Compact,Continue 提供 Rules、Prompt 预设、结构化提问与 GoodBuddy 手动摘要压缩,并明确标记当前版本无法静态发现原生 Tools。
|
- **多 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、对话、任务、活动、成果、记忆、魔法笔记和智能心跳;可从稳定的本地会话复制当前聊天内容并创建互不影响的会话分支。
|
- **工作管理**:集中管理 Projects、对话、任务、活动、成果、记忆、魔法笔记和智能心跳;可从稳定的本地会话复制当前聊天内容并创建互不影响的会话分支。
|
||||||
- **远程通道**:支持微信 ClawBot、企业微信和钉钉,每个发送者使用独立远程会话。
|
- **远程通道**:支持微信 ClawBot、企业微信和钉钉,每个发送者使用独立远程会话。
|
||||||
|
|||||||
@@ -298,6 +298,13 @@
|
|||||||
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
|
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
|
||||||
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
|
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
|
||||||
颜色或动效表达状态。
|
颜色或动效表达状态。
|
||||||
|
- 左上角品牌区允许用户在“外观”设置中定制品牌名称、中英文副标题和单个 Logo。定制只
|
||||||
|
影响该品牌区,不替换安装包名称、更新协议、Agent 标识或应用正文中的 GoodBuddy 产品
|
||||||
|
身份。副标题按当前界面语言显示,留空时隐藏且不保留空行。
|
||||||
|
- 品牌设置必须提供同布局预览、显式保存、默认 Logo 和整组恢复默认操作。自定义 Logo 仅
|
||||||
|
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
|
||||||
|
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||||
|
单行截断,不能撑宽或挤压侧栏。
|
||||||
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||||
|
|
||||||
### 6.9 助手工作栏
|
### 6.9 助手工作栏
|
||||||
|
|||||||
@@ -300,7 +300,10 @@ assert_loongarch_elf \
|
|||||||
assert_loongarch_elf \
|
assert_loongarch_elf \
|
||||||
"${verify_root}/opt/${application_directory}/resources/app/${koffi_binding#${app_root}/}"
|
"${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
|
cat > "${artifact_root}/preview-manifest.json" <<EOF
|
||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ Linux x64 和 Linux arm64。
|
|||||||
导航及重定向仍经过 DNS 解析和目标地址固定。
|
导航及重定向仍经过 DNS 解析和目标地址固定。
|
||||||
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
|
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
|
||||||
空闲回收和应用退出清理。
|
空闲回收和应用退出清理。
|
||||||
- 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和
|
- 直连模型工具循环不设置工具次数或模型轮次配额;取消、请求超时、工具结果大小和
|
||||||
无进展保护。
|
累计输出边界继续防止失控执行。
|
||||||
|
|
||||||
### MCP
|
### MCP
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.11.5",
|
"version": "0.11.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.11.5",
|
"version": "0.11.6",
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.25.1",
|
"@agentclientprotocol/sdk": "0.25.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.11.5",
|
"version": "0.11.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||||
"desktopName": "GoodBuddy",
|
"desktopName": "GoodBuddy",
|
||||||
|
|||||||
@@ -1,6 +1,44 @@
|
|||||||
{
|
{
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"releases": [
|
"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",
|
"version": "0.11.5",
|
||||||
"releasedAt": "2026-08-26",
|
"releasedAt": "2026-08-26",
|
||||||
|
|||||||
+59
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
正式站点地址:<https://mesalogo.github.io/goodbuddy/>
|
正式站点地址:<https://mesalogo.github.io/goodbuddy/>
|
||||||
|
|
||||||
首页将 GoodBuddy 定位为“首个支持龙芯的开源桌面 AI 助理产品”和
|
首页将 GoodBuddy 定位为“首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品”和
|
||||||
“免注册、支持信创软硬件的一站式 AI 助手”,优先展示三大桌面系统、正式
|
“免注册、支持信创软硬件的一站式 AI 助手”,优先展示三大桌面系统、正式
|
||||||
x64 / arm64 下载入口、独立编译的龙芯 loong64 预览版、统一 Agent Runtime,
|
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 与
|
`GoodBuddy-${version}-mac-${arch}.dmg|zip`;Linux x64 的 AppImage、DEB 与
|
||||||
RPM 分别使用 electron-builder 的 `x86_64`、`amd64` 与 `x86_64` 架构名。
|
RPM 分别使用 electron-builder 的 `x86_64`、`amd64` 与 `x86_64` 架构名。
|
||||||
Linux arm64 的 AppImage/DEB 使用 `arm64`,RPM 使用 `aarch64`。
|
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 isEnglish = root.lang.toLowerCase().startsWith("en");
|
||||||
const releaseManifestUrl =
|
const releaseManifestUrl =
|
||||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json";
|
"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 =
|
const releaseFallbackUrl =
|
||||||
"https://github.com/mesalogo/goodbuddy/releases/latest";
|
"https://github.com/mesalogo/goodbuddy/releases/latest";
|
||||||
const releaseRequestTimeoutMs = 10_000;
|
const releaseRequestTimeoutMs = 10_000;
|
||||||
@@ -24,6 +26,8 @@
|
|||||||
const downloadCards = [
|
const downloadCards = [
|
||||||
...document.querySelectorAll("[data-download-card]"),
|
...document.querySelectorAll("[data-download-card]"),
|
||||||
];
|
];
|
||||||
|
let verifiedRelease;
|
||||||
|
let verifiedLoongArchPreview;
|
||||||
const interfaceCopy = isEnglish
|
const interfaceCopy = isEnglish
|
||||||
? {
|
? {
|
||||||
themeDark: "Switch to dark theme",
|
themeDark: "Switch to dark theme",
|
||||||
@@ -65,8 +69,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const readBoundedJson = async (response) => {
|
const readBoundedJson = async (response, maximumBytes) => {
|
||||||
const maximumBytes = releaseIndexApi?.maximumIndexBytes;
|
|
||||||
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
|
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
|
||||||
throw new Error("发布索引大小上限无效");
|
throw new Error("发布索引大小上限无效");
|
||||||
}
|
}
|
||||||
@@ -111,30 +114,163 @@
|
|||||||
return JSON.parse(new TextDecoder().decode(bytes));
|
return JSON.parse(new TextDecoder().decode(bytes));
|
||||||
};
|
};
|
||||||
|
|
||||||
const setReleaseLink = (link, url, visibleText) => {
|
const setLinkText = (link, visibleText) => {
|
||||||
const newWindowNotice = link.querySelector(".sr-only");
|
const newWindowNotice = link.querySelector(".sr-only");
|
||||||
link.href = url;
|
|
||||||
link.replaceChildren(document.createTextNode(visibleText));
|
link.replaceChildren(document.createTextNode(visibleText));
|
||||||
if (newWindowNotice) {
|
if (newWindowNotice) {
|
||||||
link.append(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) {
|
for (const card of downloadCards) {
|
||||||
const platform = card.dataset.downloadCard;
|
updateDownloadCard(card);
|
||||||
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(
|
const setFallbackDownloads = () => {
|
||||||
link,
|
verifiedRelease = undefined;
|
||||||
releaseFallbackUrl,
|
updateAllDownloadCards();
|
||||||
`前往 GitHub 下载 ${platformNames[platform] ?? platform ?? ""} →`,
|
};
|
||||||
);
|
|
||||||
}
|
const setLoongArchPreviewUnavailable = () => {
|
||||||
if (meta instanceof HTMLElement) {
|
verifiedLoongArchPreview = undefined;
|
||||||
meta.textContent = "请在 GitHub Release 中选择对应的安装文件。";
|
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) {
|
if (!releaseIndexApi?.validateReleaseIndex) {
|
||||||
throw new Error("发布索引校验器不可用");
|
throw new Error("发布索引校验器不可用");
|
||||||
}
|
}
|
||||||
const release = releaseIndexApi.validateReleaseIndex(payload);
|
verifiedRelease = releaseIndexApi.validateReleaseIndex(payload);
|
||||||
|
updateAllDownloadCards();
|
||||||
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}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const initializeDownloadCards = () => {
|
||||||
for (const card of downloadCards) {
|
for (const card of downloadCards) {
|
||||||
const selects = card.querySelectorAll("select");
|
for (const select of card.querySelectorAll("select")) {
|
||||||
for (const select of selects) {
|
|
||||||
select.addEventListener("change", () => {
|
select.addEventListener("change", () => {
|
||||||
try {
|
try {
|
||||||
updateCard(card);
|
updateDownloadCard(card);
|
||||||
} catch {
|
} catch {
|
||||||
setFallbackDownloads();
|
setFallbackDownloads();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
updateCard(card);
|
updateDownloadCard(card);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadRelease = async () => {
|
const fetchBoundedIndex = async (
|
||||||
|
url,
|
||||||
|
maximumBytes,
|
||||||
|
errorLabel,
|
||||||
|
) => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = window.setTimeout(
|
const timeout = window.setTimeout(
|
||||||
() => controller.abort(),
|
() => controller.abort(),
|
||||||
releaseRequestTimeoutMs,
|
releaseRequestTimeoutMs,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(releaseManifestUrl, {
|
const response = await fetch(url, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
credentials: "omit",
|
credentials: "omit",
|
||||||
redirect: "error",
|
redirect: "error",
|
||||||
@@ -214,16 +316,42 @@
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`发布索引请求失败:${response.status}`);
|
throw new Error(`${errorLabel}请求失败:${response.status}`);
|
||||||
}
|
}
|
||||||
configureDownloads(await readBoundedJson(response));
|
return await readBoundedJson(response, maximumBytes);
|
||||||
} catch {
|
|
||||||
setFallbackDownloads();
|
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeout);
|
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 = () => {
|
const getSavedTheme = () => {
|
||||||
try {
|
try {
|
||||||
const savedTheme = localStorage.getItem("goodbuddy-site-theme");
|
const savedTheme = localStorage.getItem("goodbuddy-site-theme");
|
||||||
@@ -311,8 +439,10 @@
|
|||||||
|
|
||||||
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
|
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
|
||||||
setHeaderState();
|
setHeaderState();
|
||||||
|
initializeDownloadCards();
|
||||||
if (!isEnglish) {
|
if (!isEnglish) {
|
||||||
void loadRelease();
|
void loadRelease();
|
||||||
|
void loadLoongArchPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
themeToggle?.addEventListener("click", () => {
|
themeToggle?.addEventListener("click", () => {
|
||||||
|
|||||||
+8
-8
@@ -5,14 +5,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="GoodBuddy 是首个支持龙芯的开源桌面 AI 助理产品,也是免注册、支持信创软硬件的一站式 AI 助手;龙芯 LoongArch 当前提供独立编译预览版。"
|
content="GoodBuddy 是首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品,也是免注册、支持信创软硬件的一站式 AI 助手。"
|
||||||
/>
|
/>
|
||||||
<link rel="canonical" href="https://mesalogo.github.io/goodbuddy/" />
|
<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="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="en" href="https://mesalogo.github.io/goodbuddy/en.html" />
|
||||||
<link rel="alternate" hreflang="x-default" 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" />
|
<meta name="theme-color" content="#f6f8fb" />
|
||||||
<title>GoodBuddy|首个支持龙芯的开源桌面 AI 助理产品</title>
|
<title>GoodBuddy|首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</title>
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
href="./assets/goodbuddy-light.png"
|
href="./assets/goodbuddy-light.png"
|
||||||
@@ -124,9 +124,8 @@
|
|||||||
<span>一站式 AI 助手。</span>
|
<span>一站式 AI 助手。</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="hero-lead">
|
<p class="hero-lead">
|
||||||
GoodBuddy 是首个支持龙芯的开源桌面 AI 助理产品,龙芯 LoongArch
|
GoodBuddy 是首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品。作为桌面助手与
|
||||||
当前以独立编译预览版提供。作为桌面助手与 AI 编程工具台,GoodBuddy
|
AI 编程工具台,GoodBuddy 管理对话、知识、笔记与任务,
|
||||||
管理对话、知识、笔记与任务,
|
|
||||||
也通过独创的统一 Agent Runtime 接入直连模型、OpenCode、Continue 和 DeepSeek Harness。
|
也通过独创的统一 Agent Runtime 接入直连模型、OpenCode、Continue 和 DeepSeek Harness。
|
||||||
无需反复配置命令行,选择工具和项目即可开始。
|
无需反复配置命令行,选择工具和项目即可开始。
|
||||||
</p>
|
</p>
|
||||||
@@ -134,7 +133,7 @@
|
|||||||
<a class="button button--primary" href="#download">立即下载</a>
|
<a class="button button--primary" href="#download">立即下载</a>
|
||||||
<a class="button button--secondary" href="#features">了解 Agent Runtime</a>
|
<a class="button button--secondary" href="#features">了解 Agent Runtime</a>
|
||||||
</div>
|
</div>
|
||||||
<ul class="hero-facts" aria-label="产品特性概览">
|
<ul class="hero-facts hero-facts--aligned" aria-label="产品特性概览">
|
||||||
<li>
|
<li>
|
||||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
|
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
|
||||||
3 大桌面系统,x64 / arm64
|
3 大桌面系统,x64 / arm64
|
||||||
@@ -327,6 +326,7 @@
|
|||||||
<select data-download-arch aria-label="Linux 处理器架构">
|
<select data-download-arch aria-label="Linux 处理器架构">
|
||||||
<option value="x64">x64(Intel / AMD)</option>
|
<option value="x64">x64(Intel / AMD)</option>
|
||||||
<option value="arm64">ARM64</option>
|
<option value="arm64">ARM64</option>
|
||||||
|
<option value="loong64">龙芯 LoongArch(实验预览)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
<aside class="domestic-support" aria-labelledby="domestic-support-title">
|
<aside class="domestic-support" aria-labelledby="domestic-support-title">
|
||||||
<div>
|
<div>
|
||||||
<p class="kicker">信创软硬件支持</p>
|
<p class="kicker">信创软硬件支持</p>
|
||||||
<h3 id="domestic-support-title">首个支持龙芯的开源桌面 AI 助理产品</h3>
|
<h3 id="domestic-support-title">首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</h3>
|
||||||
<p>
|
<p>
|
||||||
正式下载覆盖国产 x64 与 ARM64;龙芯 LoongArch 使用独立编译的
|
正式下载覆盖国产 x64 与 ARM64;龙芯 LoongArch 使用独立编译的
|
||||||
loong64 预览版。
|
loong64 预览版。
|
||||||
@@ -525,7 +525,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span>GoodBuddy</span>
|
<span>GoodBuddy</span>
|
||||||
</a>
|
</a>
|
||||||
<p>首个支持龙芯的开源桌面 AI 助理产品</p>
|
<p>首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</p>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="#download">下载</a>
|
<a href="#download">下载</a>
|
||||||
<a href="#features">Agent Runtime</a>
|
<a href="#features">Agent Runtime</a>
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
|
|
||||||
const mirrorIndexUrl =
|
const mirrorIndexUrl =
|
||||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json";
|
"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 =
|
const fallbackUrl =
|
||||||
"https://github.com/mesalogo/goodbuddy/releases/latest";
|
"https://github.com/mesalogo/goodbuddy/releases/latest";
|
||||||
const maximumIndexBytes = 512 * 1024;
|
const maximumIndexBytes = 512 * 1024;
|
||||||
|
const maximumPreviewIndexBytes = 16 * 1024;
|
||||||
const semVerPattern =
|
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;
|
/^(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;
|
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||||
@@ -201,8 +204,93 @@
|
|||||||
return index;
|
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({
|
window.GoodBuddyReleaseIndex = Object.freeze({
|
||||||
maximumIndexBytes,
|
maximumIndexBytes,
|
||||||
|
maximumPreviewIndexBytes,
|
||||||
validateReleaseIndex,
|
validateReleaseIndex,
|
||||||
|
validateLoongArchPreviewIndex,
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -86,6 +86,99 @@ const renderApp = (legacy = false) => {
|
|||||||
return { dom, queries, window };
|
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]) {
|
for (const legacy of [false, true]) {
|
||||||
test(
|
test(
|
||||||
`mobile menu isolates content and restores focus with ${
|
`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 source = await readFile(path.resolve("sites/release-index.js"), "utf8");
|
||||||
const context = vm.createContext({ URL, window: {} });
|
const context = vm.createContext({ URL, window: {} });
|
||||||
vm.runInContext(source, context, { filename: "release-index.js" });
|
vm.runInContext(source, context, { filename: "release-index.js" });
|
||||||
const { validateReleaseIndex } = context.window.GoodBuddyReleaseIndex;
|
const {
|
||||||
|
validateReleaseIndex,
|
||||||
|
validateLoongArchPreviewIndex,
|
||||||
|
} = context.window.GoodBuddyReleaseIndex;
|
||||||
|
|
||||||
const definitions = [
|
const definitions = [
|
||||||
["windows", "x64", ["nsis", "portable"]],
|
["windows", "x64", ["nsis", "portable"]],
|
||||||
@@ -90,6 +93,36 @@ const expectRejected = (mutate) => {
|
|||||||
assert.throws(() => validateReleaseIndex(index));
|
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", () => {
|
test("accepts the canonical stable six-target release index", () => {
|
||||||
const index = validIndex();
|
const index = validIndex();
|
||||||
assert.equal(validateReleaseIndex(index), index);
|
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";
|
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 = [
|
const requiredCopy = [
|
||||||
"免注册",
|
"免注册",
|
||||||
"支持信创软硬件的一站式 AI 助手",
|
"支持信创软硬件的一站式 AI 助手",
|
||||||
"首个支持龙芯的开源桌面 AI 助理产品",
|
"首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品",
|
||||||
"龙芯 LoongArch 独立编译预览版",
|
"龙芯 LoongArch 独立编译预览版",
|
||||||
|
"龙芯 LoongArch(实验预览)",
|
||||||
"桌面助手",
|
"桌面助手",
|
||||||
"AI 编程工具台",
|
"AI 编程工具台",
|
||||||
"Windows、macOS、Linux",
|
"Windows、macOS、Linux",
|
||||||
@@ -328,6 +329,18 @@ report(
|
|||||||
(html.match(/data-download-format/g) ?? []).length === 3,
|
(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(
|
report(
|
||||||
!/data-release-status|download-release-status/.test(`${html}\n${englishHtml}\n${css}\n${appJs}`),
|
!/data-release-status|download-release-status/.test(`${html}\n${englishHtml}\n${css}\n${appJs}`),
|
||||||
"官网不得显示下载源状态提示",
|
"官网不得显示下载源状态提示",
|
||||||
@@ -362,6 +375,27 @@ report(
|
|||||||
),
|
),
|
||||||
"官网必须从 GoodBuddy OSS 加载最新发布索引",
|
"官网必须从 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(
|
report(
|
||||||
appJs.includes("https://github.com/mesalogo/goodbuddy/releases/latest"),
|
appJs.includes("https://github.com/mesalogo/goodbuddy/releases/latest"),
|
||||||
"官网必须保留 GitHub Release 回退地址",
|
"官网必须保留 GitHub Release 回退地址",
|
||||||
@@ -502,7 +536,7 @@ const expectedDownloadOptions = {
|
|||||||
formats: ["dmg", "zip"],
|
formats: ["dmg", "zip"],
|
||||||
},
|
},
|
||||||
linux: {
|
linux: {
|
||||||
arches: ["x64", "arm64"],
|
arches: ["x64", "arm64", "loong64"],
|
||||||
formats: ["AppImage", "deb", "rpm"],
|
formats: ["AppImage", "deb", "rpm"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+17
-1
@@ -586,6 +586,11 @@ p {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-facts--aligned {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, max-content));
|
||||||
|
}
|
||||||
|
|
||||||
.hero-facts li {
|
.hero-facts li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1274,7 +1279,7 @@ p {
|
|||||||
|
|
||||||
.download-grid {
|
.download-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1385,6 +1390,13 @@ p {
|
|||||||
transform: translateX(2px);
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button--download.is-disabled {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.download-meta {
|
.download-meta {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
@@ -1832,6 +1844,10 @@ p {
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-facts--aligned {
|
||||||
|
grid-template-columns: minmax(0, max-content);
|
||||||
|
}
|
||||||
|
|
||||||
.product-stage {
|
.product-stage {
|
||||||
min-height: 365px;
|
min-height: 365px;
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ function resolveContextCompression(
|
|||||||
'openai-images-generations'
|
'openai-images-generations'
|
||||||
>,
|
>,
|
||||||
authentication: summaryProfile.authentication,
|
authentication: summaryProfile.authentication,
|
||||||
contextWindowTokens: summaryProfile.contextWindowTokens
|
contextWindowTokens: summaryProfile.contextWindowTokens,
|
||||||
|
maximumOutputTokens: summaryProfile.maximumOutputTokens
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: {})
|
: {})
|
||||||
@@ -120,6 +121,7 @@ export function createDefaultModelRuntime(
|
|||||||
protocol: settings.modelProtocol,
|
protocol: settings.modelProtocol,
|
||||||
authentication: settings.modelAuthentication,
|
authentication: settings.modelAuthentication,
|
||||||
supportsImageInput: settings.supportsImageInput,
|
supportsImageInput: settings.supportsImageInput,
|
||||||
|
maximumOutputTokens: currentProfile?.maximumOutputTokens,
|
||||||
defaultWorkspace: settings.workspacePath || defaultWorkspace,
|
defaultWorkspace: settings.workspacePath || defaultWorkspace,
|
||||||
contextCompression: resolveContextCompression(
|
contextCompression: resolveContextCompression(
|
||||||
settings,
|
settings,
|
||||||
@@ -141,6 +143,7 @@ export function createModelProfileRuntime(
|
|||||||
protocol: profile.protocol,
|
protocol: profile.protocol,
|
||||||
authentication: profile.authentication,
|
authentication: profile.authentication,
|
||||||
supportsImageInput: profile.supportsImageInput,
|
supportsImageInput: profile.supportsImageInput,
|
||||||
|
maximumOutputTokens: profile.maximumOutputTokens,
|
||||||
imageGenerationQuality:
|
imageGenerationQuality:
|
||||||
profile.imageGenerationQuality ??
|
profile.imageGenerationQuality ??
|
||||||
defaultRuntimeSettings.imageGenerationQuality,
|
defaultRuntimeSettings.imageGenerationQuality,
|
||||||
|
|||||||
@@ -476,6 +476,7 @@ describe('ModelAgentRuntime', () => {
|
|||||||
model: 'sonnet-5',
|
model: 'sonnet-5',
|
||||||
protocol: 'anthropic-messages',
|
protocol: 'anthropic-messages',
|
||||||
authentication: 'api-key',
|
authentication: 'api-key',
|
||||||
|
maximumOutputTokens: 48_000,
|
||||||
skillInstructions: '# 文档写作',
|
skillInstructions: '# 文档写作',
|
||||||
fetcher
|
fetcher
|
||||||
})
|
})
|
||||||
@@ -500,11 +501,13 @@ describe('ModelAgentRuntime', () => {
|
|||||||
|
|
||||||
const body = JSON.parse(init?.body as string) as {
|
const body = JSON.parse(init?.body as string) as {
|
||||||
model: string
|
model: string
|
||||||
|
max_tokens: number
|
||||||
stream: boolean
|
stream: boolean
|
||||||
system: string
|
system: string
|
||||||
}
|
}
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
model: 'sonnet-5',
|
model: 'sonnet-5',
|
||||||
|
max_tokens: 48_000,
|
||||||
stream: true
|
stream: true
|
||||||
})
|
})
|
||||||
expect(body.system).toMatch(
|
expect(body.system).toMatch(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
ModelProtocol,
|
ModelProtocol,
|
||||||
RuntimeConversationCompactInput
|
RuntimeConversationCompactInput
|
||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
|
import { defaultAnthropicMaximumOutputTokens } from '../../shared/contracts'
|
||||||
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
import type { ResolvedMcpServer } from '../capabilities/capability-service'
|
||||||
import type { BrowserToolService } from '../browser/browser-model-tools'
|
import type { BrowserToolService } from '../browser/browser-model-tools'
|
||||||
import type { WorkspaceAccess } from '../workspace'
|
import type { WorkspaceAccess } from '../workspace'
|
||||||
@@ -233,8 +234,10 @@ export type ModelRuntimeOptions = {
|
|||||||
protocol: Exclude<ModelProtocol, 'openai-images-generations'>
|
protocol: Exclude<ModelProtocol, 'openai-images-generations'>
|
||||||
authentication: ModelAuthentication
|
authentication: ModelAuthentication
|
||||||
contextWindowTokens?: number
|
contextWindowTokens?: number
|
||||||
|
maximumOutputTokens?: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
maximumOutputTokens?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function getErrorMessage(value: unknown): string | undefined {
|
function getErrorMessage(value: unknown): string | undefined {
|
||||||
@@ -1794,10 +1797,9 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get anthropicProtocolMaximumTokens(): number {
|
private get anthropicProtocolMaximumTokens(): number {
|
||||||
return Math.max(
|
return (
|
||||||
this.options.contextCompression?.contextWindowTokens ??
|
this.options.maximumOutputTokens ??
|
||||||
minimumModelContextWindowTokens,
|
defaultAnthropicMaximumOutputTokens
|
||||||
minimumModelContextWindowTokens
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1877,7 +1879,8 @@ export class ModelAgentRuntime implements AgentRuntime {
|
|||||||
ModelProtocol,
|
ModelProtocol,
|
||||||
'openai-images-generations'
|
'openai-images-generations'
|
||||||
>,
|
>,
|
||||||
authentication: this.options.authentication
|
authentication: this.options.authentication,
|
||||||
|
maximumOutputTokens: this.options.maximumOutputTokens
|
||||||
}
|
}
|
||||||
const summaryRuntime = new ModelAgentRuntime({
|
const summaryRuntime = new ModelAgentRuntime({
|
||||||
...summaryModel,
|
...summaryModel,
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ export function createResolvedModelProfileDigest(
|
|||||||
authentication: profile.authentication,
|
authentication: profile.authentication,
|
||||||
supportsImageInput: profile.supportsImageInput === true,
|
supportsImageInput: profile.supportsImageInput === true,
|
||||||
contextWindowTokens: profile.contextWindowTokens ?? null,
|
contextWindowTokens: profile.contextWindowTokens ?? null,
|
||||||
|
maximumOutputTokens: profile.maximumOutputTokens ?? null,
|
||||||
imageGenerationQuality: profile.imageGenerationQuality ?? null
|
imageGenerationQuality: profile.imageGenerationQuality ?? null
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { extname } from 'node:path'
|
import { extname } from 'node:path'
|
||||||
|
export { detectSupportedImage } from '../shared/image-media-type'
|
||||||
|
|
||||||
const mimeTypes: Readonly<Record<string, string>> = {
|
const mimeTypes: Readonly<Record<string, string>> = {
|
||||||
'.c': 'text/x-c',
|
'.c': 'text/x-c',
|
||||||
@@ -40,33 +41,3 @@ export function mimeTypeFromFileName(
|
|||||||
): string {
|
): string {
|
||||||
return mimeTypes[extname(name).toLocaleLowerCase()] ?? fallback
|
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',
|
authentication: 'none',
|
||||||
supportsImageInput: false,
|
supportsImageInput: false,
|
||||||
contextWindowTokens: 256_000,
|
contextWindowTokens: 256_000,
|
||||||
|
maximumOutputTokens: 48_000,
|
||||||
imageGenerationQuality: 'auto',
|
imageGenerationQuality: 'auto',
|
||||||
apiKey: { action: 'keep' }
|
apiKey: { action: 'keep' }
|
||||||
}
|
}
|
||||||
@@ -302,7 +303,8 @@ describe('RuntimeSettingsStore', () => {
|
|||||||
modelProfiles: [
|
modelProfiles: [
|
||||||
{
|
{
|
||||||
id: profileId,
|
id: profileId,
|
||||||
contextWindowTokens: 256_000
|
contextWindowTokens: 256_000,
|
||||||
|
maximumOutputTokens: 48_000
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
contextCompression: {
|
contextCompression: {
|
||||||
@@ -316,7 +318,8 @@ describe('RuntimeSettingsStore', () => {
|
|||||||
modelProfiles: [
|
modelProfiles: [
|
||||||
{
|
{
|
||||||
id: profileId,
|
id: profileId,
|
||||||
contextWindowTokens: 256_000
|
contextWindowTokens: 256_000,
|
||||||
|
maximumOutputTokens: 48_000
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
contextCompression: {
|
contextCompression: {
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ const currentStoredModelProfileSchema = storedModelProfileSchema.extend({
|
|||||||
.int()
|
.int()
|
||||||
.min(8_000)
|
.min(8_000)
|
||||||
.max(10_000_000)
|
.max(10_000_000)
|
||||||
|
.optional(),
|
||||||
|
maximumOutputTokens: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(10_000_000)
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -434,6 +440,7 @@ export type ResolvedModelProfile = {
|
|||||||
authentication: RuntimeSettings['modelAuthentication']
|
authentication: RuntimeSettings['modelAuthentication']
|
||||||
supportsImageInput?: boolean
|
supportsImageInput?: boolean
|
||||||
contextWindowTokens?: number
|
contextWindowTokens?: number
|
||||||
|
maximumOutputTokens?: number
|
||||||
imageGenerationQuality?: RuntimeSettings['imageGenerationQuality']
|
imageGenerationQuality?: RuntimeSettings['imageGenerationQuality']
|
||||||
apiKey?: string
|
apiKey?: string
|
||||||
}
|
}
|
||||||
@@ -1468,6 +1475,7 @@ export class RuntimeSettingsStore {
|
|||||||
authentication: RuntimeSettings['modelAuthentication']
|
authentication: RuntimeSettings['modelAuthentication']
|
||||||
supportsImageInput: boolean
|
supportsImageInput: boolean
|
||||||
contextWindowTokens?: number
|
contextWindowTokens?: number
|
||||||
|
maximumOutputTokens?: number
|
||||||
imageGenerationQuality: RuntimeSettings['imageGenerationQuality']
|
imageGenerationQuality: RuntimeSettings['imageGenerationQuality']
|
||||||
credentialSource: RuntimeSettings['credentialSource']
|
credentialSource: RuntimeSettings['credentialSource']
|
||||||
} {
|
} {
|
||||||
@@ -1529,6 +1537,7 @@ export class RuntimeSettingsStore {
|
|||||||
: profile.authentication,
|
: profile.authentication,
|
||||||
supportsImageInput: profile.supportsImageInput,
|
supportsImageInput: profile.supportsImageInput,
|
||||||
contextWindowTokens: profile.contextWindowTokens,
|
contextWindowTokens: profile.contextWindowTokens,
|
||||||
|
maximumOutputTokens: profile.maximumOutputTokens,
|
||||||
imageGenerationQuality: profile.imageGenerationQuality,
|
imageGenerationQuality: profile.imageGenerationQuality,
|
||||||
credentialSource: credential.source
|
credentialSource: credential.source
|
||||||
}
|
}
|
||||||
@@ -1552,6 +1561,7 @@ export class RuntimeSettingsStore {
|
|||||||
authentication: effective.authentication,
|
authentication: effective.authentication,
|
||||||
supportsImageInput: effective.supportsImageInput,
|
supportsImageInput: effective.supportsImageInput,
|
||||||
contextWindowTokens: effective.contextWindowTokens,
|
contextWindowTokens: effective.contextWindowTokens,
|
||||||
|
maximumOutputTokens: effective.maximumOutputTokens,
|
||||||
imageGenerationQuality: effective.imageGenerationQuality,
|
imageGenerationQuality: effective.imageGenerationQuality,
|
||||||
apiKey: effective.apiKey
|
apiKey: effective.apiKey
|
||||||
}
|
}
|
||||||
@@ -1569,6 +1579,7 @@ export class RuntimeSettingsStore {
|
|||||||
authentication: profile.authentication,
|
authentication: profile.authentication,
|
||||||
supportsImageInput: profile.supportsImageInput,
|
supportsImageInput: profile.supportsImageInput,
|
||||||
contextWindowTokens: profile.contextWindowTokens,
|
contextWindowTokens: profile.contextWindowTokens,
|
||||||
|
maximumOutputTokens: profile.maximumOutputTokens,
|
||||||
imageGenerationQuality: profile.imageGenerationQuality,
|
imageGenerationQuality: profile.imageGenerationQuality,
|
||||||
apiKey: credential.activeApiKey
|
apiKey: credential.activeApiKey
|
||||||
}
|
}
|
||||||
@@ -1651,6 +1662,7 @@ export class RuntimeSettingsStore {
|
|||||||
authentication: resolved.authentication,
|
authentication: resolved.authentication,
|
||||||
supportsImageInput: resolved.supportsImageInput,
|
supportsImageInput: resolved.supportsImageInput,
|
||||||
contextWindowTokens: resolved.contextWindowTokens,
|
contextWindowTokens: resolved.contextWindowTokens,
|
||||||
|
maximumOutputTokens: resolved.maximumOutputTokens,
|
||||||
imageGenerationQuality:
|
imageGenerationQuality:
|
||||||
resolved.imageGenerationQuality ??
|
resolved.imageGenerationQuality ??
|
||||||
defaultRuntimeSettings.imageGenerationQuality,
|
defaultRuntimeSettings.imageGenerationQuality,
|
||||||
@@ -1672,6 +1684,7 @@ export class RuntimeSettingsStore {
|
|||||||
authentication: profile.authentication,
|
authentication: profile.authentication,
|
||||||
supportsImageInput: profile.supportsImageInput,
|
supportsImageInput: profile.supportsImageInput,
|
||||||
contextWindowTokens: profile.contextWindowTokens,
|
contextWindowTokens: profile.contextWindowTokens,
|
||||||
|
maximumOutputTokens: profile.maximumOutputTokens,
|
||||||
imageGenerationQuality:
|
imageGenerationQuality:
|
||||||
profile.imageGenerationQuality ??
|
profile.imageGenerationQuality ??
|
||||||
defaultRuntimeSettings.imageGenerationQuality,
|
defaultRuntimeSettings.imageGenerationQuality,
|
||||||
@@ -1966,6 +1979,7 @@ export class RuntimeSettingsStore {
|
|||||||
authentication: input.modelAuthentication,
|
authentication: input.modelAuthentication,
|
||||||
supportsImageInput: profile.supportsImageInput,
|
supportsImageInput: profile.supportsImageInput,
|
||||||
contextWindowTokens: profile.contextWindowTokens,
|
contextWindowTokens: profile.contextWindowTokens,
|
||||||
|
maximumOutputTokens: profile.maximumOutputTokens,
|
||||||
imageGenerationQuality: input.imageGenerationQuality,
|
imageGenerationQuality: input.imageGenerationQuality,
|
||||||
apiKey: input.apiKey
|
apiKey: input.apiKey
|
||||||
}
|
}
|
||||||
@@ -1978,6 +1992,7 @@ export class RuntimeSettingsStore {
|
|||||||
authentication: profile.authentication,
|
authentication: profile.authentication,
|
||||||
supportsImageInput: profile.supportsImageInput,
|
supportsImageInput: profile.supportsImageInput,
|
||||||
contextWindowTokens: profile.contextWindowTokens,
|
contextWindowTokens: profile.contextWindowTokens,
|
||||||
|
maximumOutputTokens: profile.maximumOutputTokens,
|
||||||
imageGenerationQuality: profile.imageGenerationQuality,
|
imageGenerationQuality: profile.imageGenerationQuality,
|
||||||
apiKey: { action: 'keep' as const }
|
apiKey: { action: 'keep' as const }
|
||||||
}
|
}
|
||||||
@@ -2029,6 +2044,7 @@ export class RuntimeSettingsStore {
|
|||||||
authentication: profile.authentication,
|
authentication: profile.authentication,
|
||||||
supportsImageInput: profile.supportsImageInput ?? false,
|
supportsImageInput: profile.supportsImageInput ?? false,
|
||||||
contextWindowTokens: profile.contextWindowTokens,
|
contextWindowTokens: profile.contextWindowTokens,
|
||||||
|
maximumOutputTokens: profile.maximumOutputTokens,
|
||||||
imageGenerationQuality: profile.imageGenerationQuality
|
imageGenerationQuality: profile.imageGenerationQuality
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import type {
|
|||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||||
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
||||||
|
import {
|
||||||
|
loadBrandingPreferences,
|
||||||
|
saveBrandingPreferences
|
||||||
|
} from './branding'
|
||||||
import type {
|
import type {
|
||||||
AssistantProject,
|
AssistantProject,
|
||||||
AssistantSchedule,
|
AssistantSchedule,
|
||||||
@@ -1163,7 +1167,7 @@ describe('App', () => {
|
|||||||
screen.getByLabelText('Message GoodBuddy')
|
screen.getByLabelText('Message GoodBuddy')
|
||||||
).toHaveAttribute(
|
).toHaveAttribute(
|
||||||
'placeholder',
|
'placeholder',
|
||||||
'Message GoodBuddy…'
|
'Message GoodBuddy…\nEnter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text'
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
screen.getByLabelText('Message GoodBuddy')
|
screen.getByLabelText('Message GoodBuddy')
|
||||||
@@ -1336,13 +1340,40 @@ describe('App', () => {
|
|||||||
/你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件/u
|
/你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件/u
|
||||||
)
|
)
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(screen.queryByText('桌面工作区')).not.toBeInTheDocument()
|
expect(screen.getByText('智能工作台')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('GOODBUDDY 工作台')).not.toBeInTheDocument()
|
expect(screen.queryByText('GOODBUDDY 工作台')).not.toBeInTheDocument()
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('.composer__runtime-toolbar')
|
document.querySelector('.composer__runtime-toolbar')
|
||||||
).not.toBeInTheDocument()
|
).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 () => {
|
it('renders the unified user and Scheduled Task queue above the Composer', async () => {
|
||||||
const conversationId =
|
const conversationId =
|
||||||
'00000000-0000-4000-8000-000000000951'
|
'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 () => {
|
it('preloads a heavy route once across repeated pointer and focus intent', async () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
const activity = await screen.findByRole('button', {
|
const activity = await screen.findByRole('button', {
|
||||||
@@ -5581,7 +5647,7 @@ describe('App', () => {
|
|||||||
).toHaveTextContent(/^Ask$/u)
|
).toHaveTextContent(/^Ask$/u)
|
||||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
|
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
|
||||||
'placeholder',
|
'placeholder',
|
||||||
'给 GoodBuddy 发消息…'
|
'给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本'
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
within(conversationSettings).getByRole('button', {
|
within(conversationSettings).getByRole('button', {
|
||||||
@@ -8710,7 +8776,7 @@ describe('App', () => {
|
|||||||
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
|
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
|
||||||
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
|
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
|
||||||
'placeholder',
|
'placeholder',
|
||||||
'描述你想生成的图片…'
|
'描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本'
|
||||||
)
|
)
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.artifacts.list).toHaveBeenCalled()
|
expect(api.artifacts.list).toHaveBeenCalled()
|
||||||
|
|||||||
+45
-18
@@ -180,6 +180,11 @@ import type { SettingsLeaveRequester } from './SettingsPanel'
|
|||||||
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
||||||
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
|
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
|
||||||
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
|
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
|
||||||
|
import {
|
||||||
|
loadBrandingPreferences,
|
||||||
|
saveBrandingPreferences
|
||||||
|
} from './branding'
|
||||||
|
import { BrandLockup } from './BrandLockup'
|
||||||
import {
|
import {
|
||||||
applyAppearanceTheme,
|
applyAppearanceTheme,
|
||||||
loadAppearanceTheme,
|
loadAppearanceTheme,
|
||||||
@@ -2084,6 +2089,9 @@ function App(): React.JSX.Element {
|
|||||||
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
|
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
|
||||||
const [appearanceTheme, setAppearanceTheme] =
|
const [appearanceTheme, setAppearanceTheme] =
|
||||||
useState<AppearanceTheme>(loadAppearanceTheme)
|
useState<AppearanceTheme>(loadAppearanceTheme)
|
||||||
|
const [brandingPreferences, setBrandingPreferences] = useState(
|
||||||
|
loadBrandingPreferences
|
||||||
|
)
|
||||||
const [systemPrefersDark, setSystemPrefersDark] = useState(
|
const [systemPrefersDark, setSystemPrefersDark] = useState(
|
||||||
() =>
|
() =>
|
||||||
typeof window.matchMedia === 'function' &&
|
typeof window.matchMedia === 'function' &&
|
||||||
@@ -2093,6 +2101,10 @@ function App(): React.JSX.Element {
|
|||||||
appearanceTheme,
|
appearanceTheme,
|
||||||
systemPrefersDark
|
systemPrefersDark
|
||||||
)
|
)
|
||||||
|
const brandingSubtitle =
|
||||||
|
locale === 'en-US'
|
||||||
|
? brandingPreferences.subtitleEnUS
|
||||||
|
: brandingPreferences.subtitleZhCN
|
||||||
const toggleAppearanceTheme = useCallback((): void => {
|
const toggleAppearanceTheme = useCallback((): void => {
|
||||||
setAppearanceTheme(
|
setAppearanceTheme(
|
||||||
resolvedAppearanceTheme === 'dark' ? 'light' : 'dark'
|
resolvedAppearanceTheme === 'dark' ? 'light' : 'dark'
|
||||||
@@ -7902,22 +7914,19 @@ function App(): React.JSX.Element {
|
|||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
|
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
|
||||||
>
|
>
|
||||||
<div className="brand">
|
<BrandLockup
|
||||||
<div className="brand__mark">
|
className="brand"
|
||||||
<img
|
copyClassName="brand__copy"
|
||||||
alt=""
|
logo={
|
||||||
aria-hidden="true"
|
brandingPreferences.logoDataUrl ??
|
||||||
src={
|
(resolvedAppearanceTheme === 'dark'
|
||||||
resolvedAppearanceTheme === 'dark'
|
? goodbuddyDarkIcon
|
||||||
? goodbuddyDarkIcon
|
: goodbuddyLightIcon)
|
||||||
: goodbuddyLightIcon
|
}
|
||||||
}
|
markClassName="brand__mark"
|
||||||
/>
|
name={brandingPreferences.name}
|
||||||
</div>
|
subtitle={brandingSubtitle || undefined}
|
||||||
<div className="brand__copy">
|
/>
|
||||||
<strong>GoodBuddy</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProjectSwitcher
|
<ProjectSwitcher
|
||||||
activeProjectId={activeProjectId}
|
activeProjectId={activeProjectId}
|
||||||
@@ -8908,11 +8917,11 @@ function App(): React.JSX.Element {
|
|||||||
<div className="composer__input">
|
<div className="composer__input">
|
||||||
<textarea
|
<textarea
|
||||||
aria-label={t('composer.inputLabel')}
|
aria-label={t('composer.inputLabel')}
|
||||||
placeholder={
|
placeholder={`${
|
||||||
runtime?.capability === 'image-generation'
|
runtime?.capability === 'image-generation'
|
||||||
? t('composer.imagePlaceholder')
|
? t('composer.imagePlaceholder')
|
||||||
: t('composer.placeholder')
|
: t('composer.placeholder')
|
||||||
}
|
}\n${t('composer.keyboardHint')}`}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
rows={3}
|
rows={3}
|
||||||
title={t('composer.keyboardHint')}
|
title={t('composer.keyboardHint')}
|
||||||
@@ -10125,11 +10134,29 @@ function App(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
appearanceTheme={appearanceTheme}
|
appearanceTheme={appearanceTheme}
|
||||||
|
brandingFallbackLogo={
|
||||||
|
resolvedAppearanceTheme === 'dark'
|
||||||
|
? goodbuddyDarkIcon
|
||||||
|
: goodbuddyLightIcon
|
||||||
|
}
|
||||||
|
brandingPreferences={brandingPreferences}
|
||||||
initialCategory={settingsInitialCategory}
|
initialCategory={settingsInitialCategory}
|
||||||
initialChannel={settingsInitialChannel}
|
initialChannel={settingsInitialChannel}
|
||||||
magicNotesEnabled={magicNotesEnabled}
|
magicNotesEnabled={magicNotesEnabled}
|
||||||
remoteProjectsEnabled={remoteProjectsEnabled}
|
remoteProjectsEnabled={remoteProjectsEnabled}
|
||||||
onAppearanceThemeChange={setAppearanceTheme}
|
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}
|
onClearLocalData={clearLocalData}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSettingsInitialCategory(undefined)
|
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
|
type FeedbackSubmissionErrorCode
|
||||||
} from '../../shared/feedback-contracts'
|
} from '../../shared/feedback-contracts'
|
||||||
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
||||||
|
import { loadImageDimensions } from './file-data-url'
|
||||||
|
|
||||||
type FeedbackDialogProps = {
|
type FeedbackDialogProps = {
|
||||||
appInfo: AppInfo
|
appInfo: AppInfo
|
||||||
@@ -49,21 +50,6 @@ function formatScreenshotBytes(bytes: number): string {
|
|||||||
: `${Math.max(1, Math.ceil(bytes / 1_024))} KB`
|
: `${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({
|
export function FeedbackDialog({
|
||||||
appInfo,
|
appInfo,
|
||||||
onClose
|
onClose
|
||||||
@@ -174,7 +160,7 @@ export function FeedbackDialog({
|
|||||||
}
|
}
|
||||||
objectUrl = URL.createObjectURL(file)
|
objectUrl = URL.createObjectURL(file)
|
||||||
pendingScreenshotUrlsRef.current.add(objectUrl)
|
pendingScreenshotUrlsRef.current.add(objectUrl)
|
||||||
const { width, height } = await imageDimensions(objectUrl)
|
const { width, height } = await loadImageDimensions(objectUrl)
|
||||||
if (selection !== screenshotSelectionRef.current) {
|
if (selection !== screenshotSelectionRef.current) {
|
||||||
pendingScreenshotUrlsRef.current.delete(objectUrl)
|
pendingScreenshotUrlsRef.current.delete(objectUrl)
|
||||||
URL.revokeObjectURL(objectUrl)
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
magicNoteDataBytes,
|
magicNoteDataBytes,
|
||||||
type MagicNoteRichContent
|
type MagicNoteRichContent
|
||||||
} from '../../shared/magic-notes-contracts'
|
} from '../../shared/magic-notes-contracts'
|
||||||
|
import { readFileAsDataUrl } from './file-data-url'
|
||||||
|
|
||||||
const supportedImageTypes = new Set([
|
const supportedImageTypes = new Set([
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
@@ -41,32 +42,6 @@ export type MagicNoteEditorProps = {
|
|||||||
onParagraphCommit?: (content: MagicNoteRichContent) => void
|
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 {
|
function safeEmbeddedFileName(file: File): string {
|
||||||
const fallback = file.type.startsWith('video/')
|
const fallback = file.type.startsWith('video/')
|
||||||
? 'video'
|
? 'video'
|
||||||
@@ -273,9 +248,6 @@ export function MagicNoteEditor({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const readFailedMessage = translateRef.current(
|
|
||||||
'editor.fileReadFailed'
|
|
||||||
)
|
|
||||||
const embeds = await Promise.all(
|
const embeds = await Promise.all(
|
||||||
classified.map(async ({ file, kind, mimeType }) => ({
|
classified.map(async ({ file, kind, mimeType }) => ({
|
||||||
kind,
|
kind,
|
||||||
@@ -283,11 +255,7 @@ export function MagicNoteEditor({
|
|||||||
name: safeEmbeddedFileName(file),
|
name: safeEmbeddedFileName(file),
|
||||||
mimeType,
|
mimeType,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
dataUrl: await readFileAsDataUrl(
|
dataUrl: await readFileAsDataUrl(file, mimeType)
|
||||||
file,
|
|
||||||
mimeType,
|
|
||||||
readFailedMessage
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
@@ -302,12 +270,8 @@ export function MagicNoteEditor({
|
|||||||
index += 2
|
index += 2
|
||||||
}
|
}
|
||||||
quill.setSelection(index, 0, 'silent')
|
quill.setSelection(index, 0, 'silent')
|
||||||
} catch (error) {
|
} catch {
|
||||||
onErrorRef.current(
|
onErrorRef.current(translateRef.current('editor.fileReadFailed'))
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: translateRef.current('editor.fileReadFailed')
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,21 @@ import {
|
|||||||
} from './RuntimeCustomizationSection'
|
} from './RuntimeCustomizationSection'
|
||||||
import { changeUiLocale } from './i18n'
|
import { changeUiLocale } from './i18n'
|
||||||
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
|
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
|
||||||
|
import type { BrandingPreferences } from './branding'
|
||||||
|
|
||||||
const modelProfileId = '00000000-0000-4000-8000-000000000001'
|
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 browserProfileId = '00000000-0000-4000-8000-000000000201'
|
||||||
const runtimeSettings: RuntimeSettings = {
|
const runtimeSettings: RuntimeSettings = {
|
||||||
provider: 'auto',
|
provider: 'auto',
|
||||||
@@ -934,6 +947,10 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
|
Object.defineProperty(globalThis, 'Image', {
|
||||||
|
configurable: true,
|
||||||
|
value: nativeImage
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('offers system, light, and dark appearance modes', async () => {
|
it('offers system, light, and dark appearance modes', async () => {
|
||||||
@@ -963,6 +980,99 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
|
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 () => {
|
it('configures direct model context compression with explicit token budgets', async () => {
|
||||||
render(
|
render(
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
@@ -1112,6 +1222,10 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
expect(contextWindow).toHaveValue(null)
|
expect(contextWindow).toHaveValue(null)
|
||||||
expect(contextWindow).toHaveAttribute('min', '32')
|
expect(contextWindow).toHaveAttribute('min', '32')
|
||||||
fireEvent.change(contextWindow, { target: { value: '256' } })
|
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: '保存设置' }))
|
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -1120,7 +1234,8 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
modelProfiles: expect.arrayContaining([
|
modelProfiles: expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: modelProfileId,
|
id: modelProfileId,
|
||||||
contextWindowTokens: 256_000
|
contextWindowTokens: 256_000,
|
||||||
|
maximumOutputTokens: 48_000
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type {
|
|||||||
} from '../../shared/contracts'
|
} from '../../shared/contracts'
|
||||||
import {
|
import {
|
||||||
defaultContextCompressionSettings,
|
defaultContextCompressionSettings,
|
||||||
|
defaultAnthropicMaximumOutputTokens,
|
||||||
builtinEmbeddingConnectionId,
|
builtinEmbeddingConnectionId,
|
||||||
defaultModelProfileId as builtInDefaultModelProfileId,
|
defaultModelProfileId as builtInDefaultModelProfileId,
|
||||||
defaultRuntimeSettings,
|
defaultRuntimeSettings,
|
||||||
@@ -75,6 +76,11 @@ import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
|
|||||||
import { useUiLocale } from './i18n/UiLocaleProvider'
|
import { useUiLocale } from './i18n/UiLocaleProvider'
|
||||||
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
||||||
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
|
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
|
||||||
|
import { BrandingSettingsSection } from './BrandingSettingsSection'
|
||||||
|
import {
|
||||||
|
defaultBrandingPreferences,
|
||||||
|
type BrandingPreferences
|
||||||
|
} from './branding'
|
||||||
|
|
||||||
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
|
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
|
||||||
type AgentRuntimeType =
|
type AgentRuntimeType =
|
||||||
@@ -217,6 +223,11 @@ type SettingsPanelProps = {
|
|||||||
onClearLocalData: () => Promise<void>
|
onClearLocalData: () => Promise<void>
|
||||||
appearanceTheme?: AppearanceTheme
|
appearanceTheme?: AppearanceTheme
|
||||||
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
|
||||||
|
brandingPreferences?: BrandingPreferences
|
||||||
|
brandingFallbackLogo?: string
|
||||||
|
onBrandingPreferencesChange?: (
|
||||||
|
preferences: BrandingPreferences
|
||||||
|
) => boolean
|
||||||
magicNotesEnabled?: boolean
|
magicNotesEnabled?: boolean
|
||||||
remoteProjectsEnabled?: boolean
|
remoteProjectsEnabled?: boolean
|
||||||
onMagicNotesEnabledChange?: (enabled: boolean) => void
|
onMagicNotesEnabledChange?: (enabled: boolean) => void
|
||||||
@@ -257,6 +268,9 @@ function toModelProfileDrafts(
|
|||||||
return settings.modelProfiles.map((profile) => ({
|
return settings.modelProfiles.map((profile) => ({
|
||||||
...profile,
|
...profile,
|
||||||
supportsImageInput: profile.supportsImageInput ?? false,
|
supportsImageInput: profile.supportsImageInput ?? false,
|
||||||
|
maximumOutputTokens:
|
||||||
|
profile.maximumOutputTokens ??
|
||||||
|
defaultAnthropicMaximumOutputTokens,
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
clearApiKey: false
|
clearApiKey: false
|
||||||
}))
|
}))
|
||||||
@@ -580,6 +594,9 @@ export function SettingsPanel({
|
|||||||
onExpertsChanged = () => {},
|
onExpertsChanged = () => {},
|
||||||
appearanceTheme = 'system',
|
appearanceTheme = 'system',
|
||||||
onAppearanceThemeChange = () => {},
|
onAppearanceThemeChange = () => {},
|
||||||
|
brandingPreferences = defaultBrandingPreferences,
|
||||||
|
brandingFallbackLogo,
|
||||||
|
onBrandingPreferencesChange = () => false,
|
||||||
magicNotesEnabled = false,
|
magicNotesEnabled = false,
|
||||||
remoteProjectsEnabled = false,
|
remoteProjectsEnabled = false,
|
||||||
onMagicNotesEnabledChange = () => {},
|
onMagicNotesEnabledChange = () => {},
|
||||||
@@ -594,6 +611,7 @@ export function SettingsPanel({
|
|||||||
setPreference: setUiLocalePreference
|
setPreference: setUiLocalePreference
|
||||||
} = useUiLocale()
|
} = useUiLocale()
|
||||||
const [settings, setSettings] = useState<RuntimeSettings>()
|
const [settings, setSettings] = useState<RuntimeSettings>()
|
||||||
|
const [brandingDirty, setBrandingDirty] = useState(false)
|
||||||
const [provider, setProvider] =
|
const [provider, setProvider] =
|
||||||
useState<RuntimeSettingsInput['provider']>(
|
useState<RuntimeSettingsInput['provider']>(
|
||||||
defaultRuntimeSettings.provider
|
defaultRuntimeSettings.provider
|
||||||
@@ -923,11 +941,13 @@ export function SettingsPanel({
|
|||||||
runtimeDraftDirty ||
|
runtimeDraftDirty ||
|
||||||
speechModelSelectionDirty ||
|
speechModelSelectionDirty ||
|
||||||
runtimeCustomizationDirty ||
|
runtimeCustomizationDirty ||
|
||||||
|
brandingDirty ||
|
||||||
platformFeaturesDirty ||
|
platformFeaturesDirty ||
|
||||||
documentParsingDirty ||
|
documentParsingDirty ||
|
||||||
sshHostsDirty
|
sshHostsDirty
|
||||||
const navigationWouldLoseDraft =
|
const navigationWouldLoseDraft =
|
||||||
runtimeCustomizationDirty ||
|
runtimeCustomizationDirty ||
|
||||||
|
brandingDirty ||
|
||||||
platformFeaturesDirty ||
|
platformFeaturesDirty ||
|
||||||
documentParsingDirty ||
|
documentParsingDirty ||
|
||||||
sshHostsDirty
|
sshHostsDirty
|
||||||
@@ -935,6 +955,7 @@ export function SettingsPanel({
|
|||||||
runtimeCustomizationDirty &&
|
runtimeCustomizationDirty &&
|
||||||
!runtimeDraftDirty &&
|
!runtimeDraftDirty &&
|
||||||
!speechModelSelectionDirty &&
|
!speechModelSelectionDirty &&
|
||||||
|
!brandingDirty &&
|
||||||
!platformFeaturesDirty &&
|
!platformFeaturesDirty &&
|
||||||
!documentParsingDirty &&
|
!documentParsingDirty &&
|
||||||
!sshHostsDirty
|
!sshHostsDirty
|
||||||
@@ -1184,6 +1205,7 @@ export function SettingsPanel({
|
|||||||
setSpeechModelDraftId(undefined)
|
setSpeechModelDraftId(undefined)
|
||||||
setPersistedSpeechModelId(undefined)
|
setPersistedSpeechModelId(undefined)
|
||||||
setSpeechModelSelectionDirty(false)
|
setSpeechModelSelectionDirty(false)
|
||||||
|
setBrandingDirty(false)
|
||||||
setPlatformFeaturesDirty(false)
|
setPlatformFeaturesDirty(false)
|
||||||
setDocumentParsingDirty(false)
|
setDocumentParsingDirty(false)
|
||||||
setSshHostsDirty(false)
|
setSshHostsDirty(false)
|
||||||
@@ -1209,6 +1231,7 @@ export function SettingsPanel({
|
|||||||
setSpeechModelSelectionDirty(false)
|
setSpeechModelSelectionDirty(false)
|
||||||
runtimeCustomizationRef.current?.discard()
|
runtimeCustomizationRef.current?.discard()
|
||||||
setRuntimeCustomizationDirty(false)
|
setRuntimeCustomizationDirty(false)
|
||||||
|
setBrandingDirty(false)
|
||||||
setPlatformFeaturesDirty(false)
|
setPlatformFeaturesDirty(false)
|
||||||
setDocumentParsingDirty(false)
|
setDocumentParsingDirty(false)
|
||||||
setSshHostsDirty(false)
|
setSshHostsDirty(false)
|
||||||
@@ -1285,6 +1308,7 @@ export function SettingsPanel({
|
|||||||
authentication: profile.authentication,
|
authentication: profile.authentication,
|
||||||
supportsImageInput: profile.supportsImageInput,
|
supportsImageInput: profile.supportsImageInput,
|
||||||
contextWindowTokens: profile.contextWindowTokens,
|
contextWindowTokens: profile.contextWindowTokens,
|
||||||
|
maximumOutputTokens: profile.maximumOutputTokens,
|
||||||
imageGenerationQuality: profile.imageGenerationQuality,
|
imageGenerationQuality: profile.imageGenerationQuality,
|
||||||
apiKey: profile.clearApiKey
|
apiKey: profile.clearApiKey
|
||||||
? ({ action: 'clear' } as const)
|
? ({ action: 'clear' } as const)
|
||||||
@@ -1670,6 +1694,7 @@ export function SettingsPanel({
|
|||||||
protocol: 'openai-chat-completions',
|
protocol: 'openai-chat-completions',
|
||||||
authentication: defaultRuntimeSettings.modelAuthentication,
|
authentication: defaultRuntimeSettings.modelAuthentication,
|
||||||
supportsImageInput: defaultRuntimeSettings.supportsImageInput,
|
supportsImageInput: defaultRuntimeSettings.supportsImageInput,
|
||||||
|
maximumOutputTokens: defaultAnthropicMaximumOutputTokens,
|
||||||
imageGenerationQuality:
|
imageGenerationQuality:
|
||||||
defaultRuntimeSettings.imageGenerationQuality,
|
defaultRuntimeSettings.imageGenerationQuality,
|
||||||
apiKeyConfigured: false,
|
apiKeyConfigured: false,
|
||||||
@@ -2104,6 +2129,12 @@ export function SettingsPanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<BrandingSettingsSection
|
||||||
|
fallbackLogo={brandingFallbackLogo}
|
||||||
|
onChange={onBrandingPreferencesChange}
|
||||||
|
onDirtyChange={setBrandingDirty}
|
||||||
|
preferences={brandingPreferences}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'platform-features' && (
|
{activeTab === 'platform-features' && (
|
||||||
@@ -3136,6 +3167,34 @@ export function SettingsPanel({
|
|||||||
{t('model.profile.contextWindowDescription')}
|
{t('model.profile.contextWindowDescription')}
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</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 ===
|
{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: {
|
notices: {
|
||||||
|
brandingSaved: 'Branding saved',
|
||||||
updateAvailable:
|
updateAvailable:
|
||||||
'GoodBuddy {{version}} is available. View it in About & Updates.',
|
'GoodBuddy {{version}} is available. View it in About & Updates.',
|
||||||
remoteProjectActivated:
|
remoteProjectActivated:
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export const settings = {
|
|||||||
categories: {
|
categories: {
|
||||||
appearance: {
|
appearance: {
|
||||||
label: 'Appearance',
|
label: 'Appearance',
|
||||||
navigationDescription: 'Theme and interface language',
|
navigationDescription: 'Theme, language, and branding',
|
||||||
description: 'Theme and interface language'
|
description: 'Theme, language, and brand customization'
|
||||||
},
|
},
|
||||||
platformFeatures: {
|
platformFeatures: {
|
||||||
label: 'Platform features',
|
label: 'Platform features',
|
||||||
@@ -721,6 +721,9 @@ export const settings = {
|
|||||||
contextWindow: 'Context window (optional)',
|
contextWindow: 'Context window (optional)',
|
||||||
contextWindowDescription:
|
contextWindowDescription:
|
||||||
'Enter 32K–10000K tokens. Leave blank when unknown. This value is used only for GoodBuddy local budget calculations.',
|
'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',
|
imageQuality: 'Image quality',
|
||||||
imageQualityAriaLabel: 'Image quality for {{name}}',
|
imageQualityAriaLabel: 'Image quality for {{name}}',
|
||||||
quality: {
|
quality: {
|
||||||
@@ -876,6 +879,36 @@ export const settings = {
|
|||||||
description: 'Use the English interface'
|
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>
|
} satisfies TranslationShape<typeof chineseSettings>
|
||||||
|
|||||||
@@ -530,6 +530,7 @@ export const app = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
notices: {
|
notices: {
|
||||||
|
brandingSaved: '品牌设置已保存',
|
||||||
updateAvailable: '发现 GoodBuddy {{version}},可在“关于与更新”中查看',
|
updateAvailable: '发现 GoodBuddy {{version}},可在“关于与更新”中查看',
|
||||||
remoteProjectActivated: '远程项目 Agent 和 Runtime 已就绪',
|
remoteProjectActivated: '远程项目 Agent 和 Runtime 已就绪',
|
||||||
remoteProjectActivationFailed:
|
remoteProjectActivationFailed:
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export const settings = {
|
|||||||
categories: {
|
categories: {
|
||||||
appearance: {
|
appearance: {
|
||||||
label: '外观',
|
label: '外观',
|
||||||
navigationDescription: '主题与界面语言',
|
navigationDescription: '主题、语言与品牌',
|
||||||
description: '主题与界面语言'
|
description: '主题、语言与品牌定制'
|
||||||
},
|
},
|
||||||
platformFeatures: {
|
platformFeatures: {
|
||||||
label: '平台功能',
|
label: '平台功能',
|
||||||
@@ -662,6 +662,9 @@ export const settings = {
|
|||||||
contextWindow: '上下文上限(可选)',
|
contextWindow: '上下文上限(可选)',
|
||||||
contextWindowDescription:
|
contextWindowDescription:
|
||||||
'以 K tokens 填写,范围为 32K–10000K。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。',
|
'以 K tokens 填写,范围为 32K–10000K。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。',
|
||||||
|
maximumOutput: 'Anthropic 最大输出',
|
||||||
|
maximumOutputDescription:
|
||||||
|
'Anthropic Messages 协议要求每次请求提供该值。填写 1K–10000K tokens;它只限制单次模型响应,不限制工具调用轮次或累计用量。',
|
||||||
imageQuality: '图片质量',
|
imageQuality: '图片质量',
|
||||||
imageQualityAriaLabel: '图片质量 {{name}}',
|
imageQualityAriaLabel: '图片质量 {{name}}',
|
||||||
quality: {
|
quality: {
|
||||||
@@ -806,6 +809,33 @@ export const settings = {
|
|||||||
description: 'Use the English interface'
|
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
|
} as const
|
||||||
|
|||||||
@@ -2359,10 +2359,18 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.brand__copy {
|
.brand__copy {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand__copy strong,
|
||||||
|
.brand__copy span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.brand__copy strong {
|
.brand__copy strong {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -13736,6 +13744,84 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
gap: 14px;
|
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 {
|
.appearance-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -14495,6 +14581,10 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.branding-settings__fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-picker {
|
.workspace-picker {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -568,6 +568,9 @@ export {
|
|||||||
minimumModelContextWindowTokens
|
minimumModelContextWindowTokens
|
||||||
} from './context-window'
|
} from './context-window'
|
||||||
|
|
||||||
|
export const defaultAnthropicMaximumOutputTokens = 32_000
|
||||||
|
export const maximumModelOutputTokens = 10_000_000
|
||||||
|
|
||||||
const modelProfileInputSchema = z
|
const modelProfileInputSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: modelProfileIdSchema,
|
id: modelProfileIdSchema,
|
||||||
@@ -588,6 +591,12 @@ const modelProfileInputSchema = z
|
|||||||
.min(minimumModelContextWindowTokens)
|
.min(minimumModelContextWindowTokens)
|
||||||
.max(maximumModelContextWindowTokens)
|
.max(maximumModelContextWindowTokens)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
maximumOutputTokens: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(maximumModelOutputTokens)
|
||||||
|
.optional(),
|
||||||
imageGenerationQuality: imageGenerationQualitySchema,
|
imageGenerationQuality: imageGenerationQualitySchema,
|
||||||
apiKey: modelApiKeyUpdateSchema
|
apiKey: modelApiKeyUpdateSchema
|
||||||
})
|
})
|
||||||
@@ -968,6 +977,7 @@ export type ModelConnectionSettings = {
|
|||||||
authentication: ModelAuthentication
|
authentication: ModelAuthentication
|
||||||
supportsImageInput?: boolean
|
supportsImageInput?: boolean
|
||||||
contextWindowTokens?: number
|
contextWindowTokens?: number
|
||||||
|
maximumOutputTokens?: number
|
||||||
imageGenerationQuality: ImageGenerationQuality
|
imageGenerationQuality: ImageGenerationQuality
|
||||||
apiKeyConfigured: boolean
|
apiKeyConfigured: boolean
|
||||||
credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
|
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(
|
expect(previewScript).not.toContain(
|
||||||
'node_pty_version="1.1.0"'
|
'node_pty_version="1.1.0"'
|
||||||
)
|
)
|
||||||
|
expect(previewScript).toContain(
|
||||||
|
'sha256sum "${artifact_name}" > SHA256SUMS'
|
||||||
|
)
|
||||||
expect(previewScript).toContain(
|
expect(previewScript).toContain(
|
||||||
'assert_loongarch_elf "${koffi_binding}"'
|
'assert_loongarch_elf "${koffi_binding}"'
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user