Merge pull request #11660 from esengine/feat/studio-community

feat(studio): add the community group, Discord and contributors to the app
This commit is contained in:
YHH authored and GitHub committed 2026-10-02 04:00:17 -07:00
commit 77990ed9eb
21 files changed
+577 -10

No files matched your search

+1 -1
View File
@@ -80,7 +80,7 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
+8
View File
@@ -155,6 +155,14 @@ export const ACTIONS: UIAction[] = [
{ id: "account.reload", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "account.sign-in", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "account.sign-out", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "community.join", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.discord", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.issues", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.contributors", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.profile", kind: "shell-native", target: "entity", proof: "interaction" },
{ id: "community.douyin-qr", kind: "view", target: "none", proof: "interaction" },
{ id: "community.author", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.more", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="296" height="296" class="segno"><g transform="scale(8)"><path fill="#fff" d="M0 0h37v37h-37z"/><path class="qrline" stroke="#000" d="M4 4.5h7m2 0h1m1 0h1m2 0h1m2 0h1m1 0h2m1 0h7m-29 1h1m5 0h1m5 0h1m3 0h2m2 0h1m1 0h1m5 0h1m-29 1h1m1 0h3m1 0h1m2 0h4m1 0h2m1 0h1m2 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m6 0h1m2 0h1m1 0h3m1 0h1m1 0h3m1 0h1m-29 1h1m1 0h3m1 0h1m11 0h1m1 0h1m1 0h1m1 0h3m1 0h1m-29 1h1m5 0h1m1 0h5m2 0h3m1 0h1m2 0h1m5 0h1m-29 1h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7m-20 1h1m1 0h3m1 0h3m-18 1h1m2 0h1m1 0h2m1 0h1m1 0h2m2 0h1m1 0h1m1 0h2m1 0h1m1 0h1m-21 1h1m1 0h1m1 0h2m1 0h1m2 0h1m1 0h4m1 0h1m1 0h1m2 0h1m2 0h1m-29 1h4m1 0h4m1 0h2m1 0h3m2 0h2m3 0h5m-28 1h4m4 0h1m3 0h2m2 0h1m9 0h2m-28 1h2m2 0h1m1 0h1m1 0h5m1 0h2m1 0h1m1 0h2m1 0h2m1 0h1m1 0h2m-28 1h1m1 0h2m3 0h1m2 0h1m1 0h4m1 0h2m1 0h1m-22 1h2m1 0h2m1 0h1m1 0h1m1 0h1m1 0h3m3 0h1m1 0h2m1 0h1m1 0h4m-28 1h2m1 0h2m2 0h1m1 0h1m4 0h1m1 0h1m1 0h1m1 0h2m1 0h2m1 0h1m-28 1h4m2 0h2m1 0h3m1 0h1m3 0h3m1 0h1m5 0h1m-27 1h2m2 0h1m1 0h1m2 0h1m1 0h5m1 0h1m2 0h2m2 0h1m2 0h1m-29 1h1m1 0h2m1 0h6m3 0h5m3 0h3m2 0h2m-27 1h2m4 0h1m1 0h2m2 0h1m2 0h1m1 0h2m3 0h1m2 0h2m-29 1h1m2 0h1m2 0h1m2 0h1m5 0h3m1 0h6m1 0h1m-19 1h2m1 0h2m1 0h2m1 0h4m3 0h1m1 0h3m-29 1h7m2 0h1m2 0h1m4 0h2m1 0h1m1 0h1m1 0h1m2 0h1m-28 1h1m5 0h1m1 0h2m1 0h1m2 0h2m3 0h2m3 0h5m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m3 0h3m1 0h5m2 0h1m-28 1h1m1 0h3m1 0h1m1 0h2m2 0h2m1 0h2m1 0h2m2 0h5m1 0h1m-29 1h1m1 0h3m1 0h1m2 0h2m1 0h1m1 0h1m2 0h4m3 0h3m1 0h1m-29 1h1m5 0h1m2 0h1m2 0h1m1 0h1m2 0h1m1 0h2m1 0h1m4 0h1m-28 1h7m1 0h1m3 0h2m2 0h2m2 0h3m1 0h1m2 0h1"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,141 @@
[
"SivanCola",
"ttmouse",
"KHG420",
"lifu963",
"HUQIANTAO",
"GTC2080",
"mchenziyi",
"Li-Charles-One",
"light-front-theory",
"eghrhegpe",
"Bernardxu123",
"HaoyueQin",
"BuGlessRB",
"chuiwenwei",
"CVEngineer66",
"JesonChou",
"lanshi17",
"SuMuxi66",
"paradoxSCH",
"CnsMaple",
"cyq1017",
"Guan0923",
"ipanta",
"XTLine",
"YoungDan-hero",
"erphenheimer",
"SauronSkywalker",
"ashishexee",
"lizhengwu",
"par73e",
"yizeyi18",
"Nath-Vikky",
"PMKang",
"qiaone",
"ferstar",
"Harbor404",
"liaoyl830",
"Lxiny-zy",
"zhangsanouo",
"AtmoOmen",
"codex",
"Diarica",
"ffff2004",
"ikelvingo",
"kekexunxun",
"lightfront",
"maoxin1234",
"nostalgia296",
"onionviolet",
"PaulDing98",
"scrappylabsai",
"skyzhao1223",
"Tatlatat",
"yekern",
"assoyuan",
"billycat",
"boscocp",
"closss",
"drafish",
"HBLADEH",
"heshuimu",
"iysun",
"jackie-cqz",
"LVGG",
"nanami7777777",
"qiukeren",
"sudoviber",
"warvyvr",
"whale-fall-ouo",
"wqshan-cn",
"xianyu-sheng",
"Xio-Shark",
"yhemeo613",
"zdjmrq",
"Abysstsuki",
"ahmedalbusaidy",
"Akashmdshetty",
"alxbnct",
"c020627",
"chengoak",
"CooperSheroy",
"dcs02280653-collab",
"dengbushi",
"EMEEEEMMMM",
"ERroooooR",
"gcoder1991",
"h4rk8s",
"HHHEEEWWW",
"hobostay",
"HorusJiang",
"i1j",
"innocarpe",
"j2bap",
"jackijianxa",
"jbts6",
"ji-tz",
"JimmyDaddy",
"kabaka9527",
"kate1128",
"LegGasai",
"leslie3747",
"LouF666",
"MagnumGoYB",
"MaitreyeeDeshmukh",
"myipanta",
"nanami-0713",
"Ninot1Quyi",
"NkAntony777",
"no-name-touse",
"Nontee22",
"onenewcode",
"Oxygen56",
"PorunC",
"Q1hangL",
"QingLong-C",
"Ray-lyy",
"rixzkiye",
"Robertoarce",
"ryukeilee",
"seankhliao",
"ShiroKSH",
"siwenyu666-beep",
"SprayHank",
"Starry0214",
"Surfire",
"taibai233",
"themarchrain",
"tic-top",
"weikengchen",
"wudiandy",
"xiaoshuai1024",
"xiaotaoya2022-cell",
"xinrenH",
"xxoingr",
"ying-hua",
"yuanchenglu",
"zhuguang-ZFG",
"Zhuocheng-Lang",
"zlwu"
]
+2
View File
@@ -17,11 +17,13 @@ import { EN_USAGE } from "./en_usage";
import { EN_COMPOSER } from "./en_composer";
import { EN_BACKUP } from "./en_backup";
import { EN_FEEDBACK } from "./en_feedback";
import { EN_COMMUNITY } from "./en_community";
export const EN: Record<string, string> = {
"工作台": "Workbench",
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
...EN_SETTINGS,
...EN_COMMUNITY,
...EN_METRICS,
...EN_STORAGE,
...EN_USAGE,
@@ -0,0 +1,20 @@
export const EN_COMMUNITY: Record<string, string> = {
"社区与贡献者": "Community and contributors",
"遇到问题、想交流用法或想参与进来,可以加入社区;下面是为 Reasonix 提交过代码的人。": "Join the community for help, tips and ideas. Below are the people who have contributed code to Reasonix.",
"QQ 群二维码": "QQ group QR code",
"群号": "Group number",
"复制群号": "Copy group number",
"用 QQ 扫描二维码,或点击下方按钮加入。": "Scan the QR code with QQ, or use the button below to join.",
"加入 QQ 群": "Join the QQ group",
"贡献者": "Contributors",
"按提交数排序,名单随版本更新": "Sorted by commits; the list is refreshed with each release",
"查看全部贡献者": "View all contributors",
"显示全部 {n} 位": "Show all {n}",
"抖音": "Douyin",
"抖音号": "Douyin ID",
"复制抖音号": "Copy Douyin ID",
"显示二维码": "Show QR code",
"收起二维码": "Hide QR code",
"抖音二维码": "Douyin QR code",
"作者与维护者": "Author and maintainer",
};
+1
View File
@@ -7,6 +7,7 @@ import "./styles/tokens.css";
import "./styles/app.css";
import "./styles/studio.css";
import "./styles/feedback.css";
import "./styles/community.css";
import { App } from "./ui/App";
import { SseHub } from "./port/hub";
import { HttpError } from "./port/http_error";
@@ -803,6 +803,14 @@ what exists.
- Remove is the one irreversible action in a row, so it does not look like the
other buttons and appears only on hover.
## Community
- The QR code sits on a white plate in both themes: a scanner needs dark modules
on a light field with a quiet zone, and an inverted code in the dark theme
does not scan. The plate is the image's own quiet zone, not a card behind it.
- The image is drawn at 5 CSS pixels per module (185px for 37 modules) so the
squares land on whole pixels; `image-rendering: pixelated` keeps them hard.
## Version list
- The list is one grid and each row a subgrid of it, so version, description,
@@ -0,0 +1,35 @@
.comm { display: flex; flex-direction: column; gap: 22px; }
.comm-join { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 20px; }
.comm-qr {
flex: none; width: 185px; height: 185px; max-width: 100%; box-sizing: border-box;
background: #fff; border: 1px solid var(--border); border-radius: var(--r-md);
image-rendering: pixelated;
}
.comm-qq { flex: 1 1 220px; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
.comm-name { font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); color: var(--text); overflow-wrap: anywhere; }
.comm-num { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.comm-num code { font: var(--w-med) var(--fs-lead) var(--mono); color: var(--text); letter-spacing: .02em; user-select: all; }
.comm-k { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint); }
.comm-how { font: var(--w-reg) var(--fs-ui)/1.6 var(--ui); color: var(--muted); }
.comm-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.comm-fail { margin: 0; font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); color: var(--err-ink); overflow-wrap: anywhere; }
.comm-who { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding-top: 16px; border-top: 1px solid var(--hair); }
.comm-who-hd { align-self: stretch; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
.comm-who-hd h4 { margin: 0; font: var(--w-semi) var(--fs-ui) var(--ui); color: var(--text); }
.comm-who-hd .btn { margin-left: auto; }
.comm-list { align-self: stretch; margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 6px; }
.comm-list li { min-width: 0; }
.comm-list a {
display: block; max-width: 100%; padding: 2px 9px; border: 1px solid var(--hair); border-radius: var(--r-pill);
font: var(--w-reg) var(--fs-meta)/1.7 var(--mono); color: var(--muted); text-decoration: none;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.comm-list a:hover { color: var(--text); border-color: var(--faint); }
.comm-list a:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
.comm-dy { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--muted); }
.comm-dy code { font: var(--w-reg) var(--fs-meta) var(--mono); color: var(--muted); user-select: all; }
.comm-dy-qr { align-self: flex-start; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--r-md); }
.comm-author { display: flex; align-items: baseline; gap: 10px; padding: 0 0 14px; }
.comm-author a { font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text); text-decoration: none; }
.comm-author a:hover { text-decoration: underline; }
.comm-author a:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
+26
View File
@@ -0,0 +1,26 @@
import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import { AuthorLine } from "./AuthorLine";
import { Community } from "./Community";
import { Group } from "./Group";
import { Versions } from "./Versions";
export function About({ port }: { port: AgentPort }) {
return (
<>
<Group id="versions"
title={t("版本")}
hint={t("当前安装的版本、可用更新,以及出现问题时如何回退。更新下载好后由你决定何时重启;回退后会固定在所选版本,不再提示新版本。")}
>
<Versions port={port} />
</Group>
<AuthorLine port={port} />
<Group id="community"
title={t("社区与贡献者")}
hint={t("遇到问题、想交流用法或想参与进来,可以加入社区;下面是为 Reasonix 提交过代码的人。")}
>
<Community port={port} />
</Group>
</>
);
}
@@ -0,0 +1,12 @@
import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import { COMMUNITY } from "./communityLinks";
export function AuthorLine({ port }: { port: Pick<AgentPort, "openExternal"> }) {
return (
<div className="comm-author">
<span className="comm-k">{t("作者与维护者")}</span>
<a href={COMMUNITY.authorUrl} data-action="community.author" onClick={(e) => { e.preventDefault(); void port.openExternal(COMMUNITY.authorUrl).catch(() => {}); }}>{COMMUNITY.author}</a>
</div>
);
}
@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Community } from "./Community";
import { AuthorLine } from "./AuthorLine";
import { profileUrl } from "./communityLinks";
import contributors from "../data/contributors.json";
import type { AgentPort } from "../port/port";
const QR = import.meta.glob("../assets/qq-group-qr.svg", { query: "?raw", import: "default", eager: true }) as Record<string, string>;
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const portWith = (openExternal = vi.fn(async () => {})) => ({ openExternal }) as unknown as Pick<AgentPort, "openExternal">;
describe("community and contributors", () => {
it("names the group and shows its number", () => {
render(<Community port={portWith()} logins={["a"]} />);
expect(screen.getByText("DeepSeek-Reasonix官方群")).toBeTruthy();
expect(screen.getByText("1093562660")).toBeTruthy();
});
it("opens the join link, Discord and issues through the host", async () => {
const openExternal = vi.fn(async () => {});
render(<Community port={portWith(openExternal)} logins={["a"]} />);
await userEvent.click(screen.getByRole("button", { name: "加入 QQ 群" }));
expect(openExternal).toHaveBeenLastCalledWith("https://qm.qq.com/q/i59b0z2R8s");
await userEvent.click(screen.getByRole("button", { name: "Discord" }));
expect(openExternal).toHaveBeenLastCalledWith("https://discord.gg/XF78rEME2D");
await userEvent.click(screen.getByRole("button", { name: "GitHub issues" }));
expect(openExternal).toHaveBeenLastCalledWith("https://github.com/esengine/DeepSeek-Reasonix/issues");
});
it("says so, with the address, when the host cannot open it", async () => {
const openExternal = vi.fn(async () => { throw new Error("no browser"); });
render(<Community port={portWith(openExternal)} logins={[]} />);
await userEvent.click(screen.getByRole("button", { name: "Discord" }));
expect((await screen.findByRole("alert")).textContent).toContain("https://discord.gg/XF78rEME2D");
});
it("copies the group number", async () => {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
render(<Community port={portWith()} logins={[]} />);
await userEvent.click(screen.getByRole("button", { name: "复制群号" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith("1093562660"));
});
it("references a QR image that exists", () => {
render(<Community port={portWith()} logins={[]} />);
const img = screen.getByRole("img", { name: "QQ 群二维码" }) as HTMLImageElement;
expect(img.getAttribute("src")).toBeTruthy();
expect(Object.values(QR)[0]).toMatch(/^<svg /);
});
it("shows the Douyin ID, copies it, and keeps the QR hidden until asked", async () => {
const writeText = vi.fn(async () => {});
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
render(<Community port={portWith()} logins={[]} />);
expect(screen.getByText("做游戏的小鱼")).toBeTruthy();
expect(screen.getByText("22703872788")).toBeTruthy();
expect(screen.queryByRole("img", { name: "抖音二维码" })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: "复制抖音号" }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith("22703872788"));
const toggle = screen.getByRole("button", { name: "显示二维码" });
await userEvent.click(toggle);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect((screen.getByRole("img", { name: "抖音二维码" }) as HTMLImageElement).getAttribute("src")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "收起二维码" }));
expect(screen.queryByRole("img", { name: "抖音二维码" })).toBeNull();
});
it("has no Douyin link, only the ID", () => {
const { container } = render(<Community port={portWith()} logins={[]} />);
expect(container.innerHTML).not.toMatch(/douyin\.com/);
});
it("links the author to their GitHub profile through the host", async () => {
const openExternal = vi.fn(async () => {});
render(<AuthorLine port={portWith(openExternal)} />);
expect(screen.getByText("作者与维护者")).toBeTruthy();
const link = screen.getByRole("link", { name: "esengine" });
expect(link.getAttribute("href")).toBe("https://github.com/esengine");
await userEvent.click(link);
expect(openExternal).toHaveBeenCalledWith("https://github.com/esengine");
});
it("lists contributors as links to their exact profiles", async () => {
const openExternal = vi.fn(async () => {});
render(<Community port={portWith(openExternal)} logins={["SivanCola", "o-n-e"]} />);
const link = screen.getByRole("link", { name: "SivanCola" });
expect(link.getAttribute("href")).toBe("https://github.com/SivanCola");
await userEvent.click(link);
expect(openExternal).toHaveBeenLastCalledWith("https://github.com/SivanCola");
await userEvent.click(screen.getByRole("button", { name: "查看全部贡献者" }));
expect(openExternal).toHaveBeenLastCalledWith("https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors");
});
it("never turns a hostile login into a URL", () => {
const hostile = ["a/b", 'x"y', "../../evil", "-lead", "trail-", "a--b", "", "x".repeat(40), "https://evil.test"];
for (const login of hostile) expect(profileUrl(login)).toBeNull();
expect(profileUrl("A-b1")).toBe("https://github.com/A-b1");
render(<Community port={portWith()} logins={[...hostile, "good"]} />);
const people = screen.getByRole("list", { name: "贡献者" });
expect(within(people).getAllByRole("link").map((a) => a.getAttribute("href"))).toEqual(["https://github.com/good"]);
});
it("collapses a long list and expands it on request", async () => {
const logins = Array.from({ length: 60 }, (_, i) => `user${i}`);
render(<Community port={portWith()} logins={logins} />);
const people = screen.getByRole("list", { name: "贡献者" });
expect(within(people).getAllByRole("link")).toHaveLength(24);
await userEvent.click(screen.getByRole("button", { name: "显示全部 60 位" }));
expect(within(people).getAllByRole("link")).toHaveLength(60);
await userEvent.click(screen.getByRole("button", { name: "收起" }));
expect(within(people).getAllByRole("link")).toHaveLength(24);
});
it("offers no toggle for a short list", () => {
render(<Community port={portWith()} logins={["a", "b"]} />);
expect(screen.queryByRole("button", { name: /显示全部/ })).toBeNull();
});
it("ships a contributors file whose every entry is a valid login", () => {
expect(contributors.length).toBeGreaterThan(0);
for (const login of contributors) expect(profileUrl(login)).not.toBeNull();
expect(contributors.map((l) => l.toLowerCase())).not.toContain("esengine");
});
});
@@ -0,0 +1,83 @@
import { useMemo, useState } from "react";
import { t } from "../i18n";
import type { AgentPort } from "../port/port";
import contributors from "../data/contributors.json";
import qrSrc from "../assets/qq-group-qr.svg";
import douyinSrc from "../assets/douyin-qr.jpg";
import { CopyButton } from "./CopyButton";
import { COMMUNITY, profileUrl } from "./communityLinks";
const COLLAPSED = 24;
export function Community({ port, logins = contributors }: { port: Pick<AgentPort, "openExternal">; logins?: string[] }) {
const [all, setAll] = useState(false);
const [failed, setFailed] = useState("");
const [douyinQr, setDouyinQr] = useState(false);
const open = (url: string) =>
void port.openExternal(url).then(() => setFailed(""), () => setFailed(t("无法打开浏览器,请手动访问 {at}", { at: url })));
const people = useMemo(
() => logins.flatMap((login) => { const url = profileUrl(login); return url ? [{ login, url }] : []; }),
[logins],
);
const shown = all ? people : people.slice(0, COLLAPSED);
return (
<div className="comm">
<div className="comm-join">
<img className="comm-qr" src={qrSrc} alt={t("QQ 群二维码")} width={185} height={185} />
<div className="comm-qq">
<span className="comm-name">{COMMUNITY.qqName}</span>
<span className="comm-num">
<span className="comm-k">{t("群号")}</span>
<code>{COMMUNITY.qqNumber}</code>
<CopyButton text={COMMUNITY.qqNumber} iconOnly showFeedback label={t("复制群号")} />
</span>
<span className="comm-how">{t("用 QQ 扫描二维码,或点击下方按钮加入。")}</span>
<span className="comm-acts">
<button type="button" className="btn" data-primary data-action="community.join" onClick={() => open(COMMUNITY.qqJoin)}>{t("加入 QQ 群")}</button>
<button type="button" className="btn" data-action="community.discord" onClick={() => open(COMMUNITY.discord)}>Discord</button>
<button type="button" className="btn" data-action="community.issues" onClick={() => open(COMMUNITY.issues)}>GitHub issues</button>
</span>
</div>
</div>
<div className="comm-dy">
<span className="comm-k">{t("抖音")}</span>
<span className="comm-dy-name">{COMMUNITY.douyinName}</span>
<span className="comm-k">{t("抖音号")}</span>
<code>{COMMUNITY.douyinId}</code>
<CopyButton text={COMMUNITY.douyinId} iconOnly showFeedback label={t("复制抖音号")} />
<button type="button" className="btn sm" data-action="community.douyin-qr" aria-expanded={douyinQr} aria-controls="comm-dy-qr" onClick={() => setDouyinQr((v) => !v)}>
{douyinQr ? t("收起二维码") : t("显示二维码")}
</button>
</div>
{douyinQr && (
<img id="comm-dy-qr" className="comm-dy-qr" src={douyinSrc} alt={t("抖音二维码")} width={180} height={269} />
)}
{failed && <p className="comm-fail" role="alert">{failed}</p>}
<div className="comm-who">
<div className="comm-who-hd">
<h4>{t("贡献者")}</h4>
<span className="comm-k">{t("按提交数排序,名单随版本更新")}</span>
<button type="button" className="btn sm" data-action="community.contributors" onClick={() => open(COMMUNITY.contributors)}>{t("查看全部贡献者")}</button>
</div>
<ul className="comm-list" aria-label={t("贡献者")}>
{shown.map(({ login, url }) => (
<li key={login}>
<a href={url} data-action="community.profile" data-target={login} onClick={(e) => { e.preventDefault(); open(url); }}>{login}</a>
</li>
))}
</ul>
{people.length > COLLAPSED && (
<button type="button" className="btn sm comm-more" data-action="community.more" aria-expanded={all} onClick={() => setAll((v) => !v)}>
{all ? t("收起") : t("显示全部 {n} 位", { n: people.length })}
</button>
)}
</div>
</div>
);
}
+2 -9
View File
@@ -38,7 +38,7 @@ import { KIND_LABEL } from "./vendors";
import { planProtocolSwitch } from "./protocolswitch";
import { Boundary } from "./Boundary";
import { Path } from "./Path";
import { Versions } from "./Versions";
import { About } from "./About";
import { Memory } from "./Memory";
import { DEFAULT_DAYS, Usage } from "./Usage";
import { Storage } from "./Storage";
@@ -745,14 +745,7 @@ export function Settings({ hub, onError, port, networkPort, networkHost, status,
</Group>
)}
{at === "versions" && (
<Group id="versions"
title={t("版本")}
hint={t("当前安装的版本、可用更新,以及出现问题时如何回退。更新下载好后由你决定何时重启;回退后会固定在所选版本,不再提示新版本。")}
>
<Versions port={port} />
</Group>
)}
{at === "versions" && <About port={port} />}
{at === "memory" && (
<Group id="memory"
+2
View File
@@ -137,6 +137,8 @@ export function Sidebar({
run: () => onFeedback("send") },
{ id: "feedback-mine", label: t("我的反馈"), icon: "feedback", keywords: "feedback receipt status my reply 反馈 回执 进展 我的 回复",
status: feedbackUnread > 0 ? t("{n} 项待查看", { n: feedbackUnread }) : undefined, run: () => onFeedback("mine") },
{ id: "community", label: t("社区与贡献者"), icon: "globe", keywords: "community discord qq group contributors 社区 交流群 加群 贡献者",
run: () => onSettings("versions:community") },
{ id: "settings", label: t("设置"), icon: "settings", keywords: "settings preferences 设置 偏好",
run: () => onSettings() },
],
@@ -0,0 +1,21 @@
const REPO = "https://github.com/esengine/DeepSeek-Reasonix";
export const COMMUNITY = {
qqName: "DeepSeek-Reasonix官方群",
qqNumber: "1093562660",
qqJoin: "https://qm.qq.com/q/i59b0z2R8s",
douyinName: "做游戏的小鱼",
douyinId: "22703872788",
author: "esengine",
authorUrl: "https://github.com/esengine",
discord: "https://discord.gg/XF78rEME2D",
issues: `${REPO}/issues`,
contributors: `${REPO}/graphs/contributors`,
} as const;
// GitHub's own rule: alphanumerics and single hyphens, no hyphen at either end, 39 at most.
const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
export function profileUrl(login: string): string | null {
return LOGIN.test(login) ? `https://github.com/${login}` : null;
}
@@ -241,6 +241,7 @@ export const SETTINGS: SettingEntry[] = [
// Shown only while signed in; restoring writes through the ordinary config paths and reloads the runtime.
{ section: "account", anchor: "backup", title: "云备份", scope: "account", apply: "immediate", keywords: ["备份", "恢复", "换机", "同步", "导出"] },
{ section: "versions", anchor: "versions", title: "版本", scope: "machine", apply: "immediate", keywords: ["更新", "升级"] },
{ section: "versions", anchor: "community", title: "社区与贡献者", scope: "machine", apply: "none", keywords: ["社区", "交流群", "QQ", "加群", "二维码", "discord", "贡献者", "contributors", "community"] },
{ section: "memory", anchor: "memory", title: "记忆", scope: "chosen", apply: "immediate", keywords: ["记住", "忘记", "事实"] },
{ section: "usage", anchor: "usage", title: "用量与成本", scope: "machine", apply: "none", keywords: ["token", "花费", "缓存命中"] },
{ section: "storage", anchor: "storage", title: "存储", scope: "machine", apply: "restart", keywords: ["搬家", "迁移", "磁盘", "位置"] },
@@ -0,0 +1,8 @@
import segno
URL = "https://qm.qq.com/q/i59b0z2R8s"
OUT = "src/assets/qq-group-qr.svg"
qr = segno.make(URL, error="m", boost_error=False, micro=False)
qr.save(OUT, scale=8, border=4, dark="#000000", light="#ffffff", xmldecl=False, nl=False)
print(qr.designator)
+41
View File
@@ -0,0 +1,41 @@
// Refreshes the contributor list the desktop app shows, as logins only: no
// avatars, so the app never fetches from GitHub to draw it. The repository owner
// is left out. Run at release time.
import { execFileSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { defaultRepository, isBotAccount } from "./release-credits.mjs";
export const outputPath = fileURLToPath(new URL("../desktop/frontend-next/src/data/contributors.json", import.meta.url));
export function ownerOf(repository) {
return repository.split("/")[0];
}
export function loginsFrom(rows, owner = "") {
const skip = owner.toLowerCase();
return rows
.filter((row) => row && typeof row.login === "string" && !isBotAccount(row.login, row.type) && row.login.toLowerCase() !== skip)
.sort((a, b) => (b.contributions ?? 0) - (a.contributions ?? 0) || a.login.localeCompare(b.login))
.map((row) => row.login);
}
export function render(logins) {
return `${JSON.stringify(logins, null, 2)}\n`;
}
function fetchRows(repository) {
const out = execFileSync("gh", ["api", `repos/${repository}/contributors?per_page=100`, "--paginate", "--slurp"], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
return JSON.parse(out).flat();
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const repository = process.argv[2] ?? defaultRepository;
const logins = loginsFrom(fetchRows(repository), ownerOf(repository));
writeFileSync(outputPath, render(logins));
console.log(`wrote ${logins.length} contributors to ${outputPath}`);
}
+30
View File
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { loginsFrom, ownerOf, render } from "./update-contributors.mjs";
test("bots are dropped and the rest sorted by contributions, ties by login", () => {
const rows = [
{ login: "b", type: "User", contributions: 3 },
{ login: "dependabot[bot]", type: "Bot", contributions: 99 },
{ login: "a", type: "User", contributions: 3 },
{ login: "top", type: "User", contributions: 10 },
{ login: "github-actions", type: "User", contributions: 50 },
];
assert.deepEqual(loginsFrom(rows), ["top", "a", "b"]);
});
test("the file is a stable JSON array ending in a newline", () => {
assert.equal(render(["a", "b"]), '[\n "a",\n "b"\n]\n');
});
test("the repository owner is left out, whatever the case, and nobody else moves", () => {
const rows = [
{ login: "Owner", type: "User", contributions: 100 },
{ login: "b", type: "User", contributions: 3 },
{ login: "a", type: "User", contributions: 3 },
];
assert.equal(ownerOf("esengine/DeepSeek-Reasonix"), "esengine");
assert.deepEqual(loginsFrom(rows, "owner"), ["a", "b"]);
assert.deepEqual(loginsFrom(rows, "owner"), loginsFrom([...rows].reverse(), "owner"));
assert.deepEqual(loginsFrom(rows), ["Owner", "a", "b"]);
});