mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
21 files changed
+577
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
Reference in new issue
Block a user