Compare commits

..
5 Commits
Author SHA1 Message Date
yyyyyynnn 5cc4a31c0e Release v0.2.29 2026-07-07 23:32:48 +08:00
yyyyyynnn 12d279a30a fix: repair cc-switch codex provider import 2026-07-07 20:16:40 +08:00
yyyyyynnn e0e7d1f7c8 Update Star History chart 2026-07-07 18:09:53 +08:00
yyyyyynnn 8aeacba04c Release v0.2.27 2026-07-07 17:54:35 +08:00
yyyyyynnn a5c5e50436 fix: improve provider switching and prompt UI 2026-07-07 17:53:09 +08:00
11 changed files with 1513 additions and 259 deletions
+41 -1
View File
@@ -2,10 +2,50 @@
All notable changes to Codex-X will be documented here.
## [v0.2.29] - 2026-07-07
### 更新
- 优化 Toast 提示样式:改为窗口顶部居中的产品化通知卡片,减少遮挡右上角操作按钮。
- Provider 连接测试文案改为简洁结果:成功显示“连接正常(耗时)”,失败显示 HTTP 状态与耗时。
- 第三方供应商编辑页支持保存 TOML 模板;点击“保存”只保存配置,点击“启用”时才写入 Codex live config。
- 技能和 MCP 页面“导入已有”改为先展示预览确认弹窗,列出将导入的 Skills / MCP,用户确认后才执行导入。
### 修复 Bug
- 修复 Codex 第三方 API auth 写入错误:`auth.json` 现在写入 Codex 实际识别的 `auth_mode = "apikey"`,避免桌面端回到登录页。
- 修复官方认证编辑页打开时会静默加载 cc-switch 旧 token,导致新登录账号 token 显示不更新的问题;现在默认读取 live `~/.codex/auth.json`,并提供“刷新当前 auth.json”。
- 修复切换到 OpenAI Official 时可能覆盖当前 live auth.json 的问题,避免把旧账号登录态写回。
- 修复 Provider 测试把 HTTP 403 误判为“连接正常”的问题;现在只有 2xx 状态会显示成功。
- 修复从 cc-switch 导入 Provider 时同名同 URL 供应商可能产生重复卡片的问题。
- 修复 SQLite 轻量迁移重复执行时 `duplicate column name: toml_config` 导致启动报错的问题。
### 开发
- 增加 Provider/auth 相关单元测试,覆盖第三方切换、官方 auth 保留、403 判定和 cc-switch 导入串台场景。
## [v0.2.28] - 2026-07-07
- 修复从 cc-switch 导入 Codex Provider 时可能把供应商名称、base_url 与 API Key 串台的问题。
- 导入 cc-switch Provider 时改为先扫描所有 `[model_providers.<id>]` section,再按 provider row id 精确匹配,避免使用过期的 `model_provider` active 值。
- 兼容 cc-switch legacy `custom` provider 模板,同时保留 `experimental_bearer_token` 兜底。
- 增加复现 Sky2api / MagicAI 交叉配置的单元测试,防止 Provider 切换再次出现“看起来切了但实际没切对”的问题。
## [v0.2.27] - 2026-07-07
- 优化【指令提示词】页面为 Skills/MCP 风格的简洁列表:左侧显示模板名称与说明,右侧使用开启/关闭切换;自定义/导入的 md 提示词增加编辑与删除图标。
- 优化【供应商】页面操作区:移除“官方配置 / 本地保存 / gpt-5.5 / 不支持路由”等冗余信息,将切换改为“启用”,编辑/删除/测试连接改为图标按钮。
- 新增供应商 base_url 连通性测试按钮,方便切换前检查第三方 API 地址是否可达。
- 修复第三方 Provider 切换后可能未真正生效的问题:切换和保存 TOML 时会写入 Codex 实际读取的 `experimental_bearer_token`。
- 对齐 cc-switch 的 Codex Provider live config 思路:从 cc-switch 导入时会识别 `experimental_bearer_token`,并避免使用 Codex 内置保留 provider id。
- 优化启动加载链路:启动诊断、备份列表、会话同步状态改为后台刷新,减少首屏等待。
## [v0.2.26] - 2026-07-07
- 修复第三方 Provider 切换后可能“看起来已切换但实际未按新供应商生效”的问题:不再把所有第三方都写成 `model_provider = "custom"`,而是写入供应商自己的稳定 ID。
- 修复从官方 ChatGPT 登录态切到第三方 API Key 时 `auth.json` 仍保留 `auth_mode = "chatgpt"` 的问题;写入 API Key 时会同步设置 `auth_mode = "api_key"`。
- 修复从官方 ChatGPT 登录态切到第三方 API Key 时 `auth.json` 仍保留 `auth_mode = "chatgpt"` 的问题;写入 API Key 时会同步设置 `auth_mode = "apikey"`。
- 第三方供应商默认不再要求 OpenAI 登录态,避免新建/导入 Provider 时错误继承官方 auth 语义。
- 增加 Provider 切换单元测试,覆盖真实 provider key 和 API Key auth mode 的落盘结果。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.2.26",
"version": "0.2.29",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+1 -1
View File
@@ -346,7 +346,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.2.26"
version = "0.2.29"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.2.26"
version = "0.2.29"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.2.26",
"version": "0.2.29",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+270 -89
View File
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import {
Activity,
CheckCircle2,
ChevronRight,
Code2,
@@ -16,6 +17,7 @@ import {
KeyRound,
Layers3,
Loader2,
PencilLine,
Plus,
Power,
RefreshCw,
@@ -59,6 +61,7 @@ type SavedProvider = {
baseUrl: string;
model: string;
apiKey?: string;
tomlConfig?: string;
wireApi: string;
requiresOpenaiAuth: boolean;
};
@@ -146,6 +149,13 @@ type ReleaseInfo = {
hasUpdate?: boolean;
};
type ProviderConnectionResult = {
ok: boolean;
status?: number | null;
message: string;
durationMs: number;
};
type SessionSyncStatus = {
codexDir: string;
@@ -223,6 +233,12 @@ type SkillsMcpActionResult = {
state: SkillsMcpState;
};
type SkillsMcpImportPreview = {
skills: ManagedSkill[];
mcpServers: ManagedMcpServer[];
warnings: string[];
};
type DiagnosticItem = {
key: string;
label: string;
@@ -278,6 +294,7 @@ const defaultProviderForm: SavedProvider = {
baseUrl: "https://sky1818.com",
model: "gpt-5.5",
apiKey: "",
tomlConfig: "",
wireApi: "responses",
requiresOpenaiAuth: false,
};
@@ -288,6 +305,7 @@ const blankProviderForm: SavedProvider = {
baseUrl: "",
model: "gpt-5.5",
apiKey: "",
tomlConfig: "",
wireApi: "responses",
requiresOpenaiAuth: false,
};
@@ -359,7 +377,9 @@ const dict = {
officialHint: "官方配置不使用第三方路由;这里可以编辑官方模式下的模型和完整 auth.json(ChatGPT 登录通常包含 access_token / refresh_token / id_token)。",
officialUrl: "官方入口",
loadOfficialAuth: "从 cc-switch 载入官方认证",
refreshOfficialAuth: "刷新当前 auth.json",
officialAuthLoaded: "已载入 cc-switch 官方认证",
officialAuthRefreshed: "已刷新当前 auth.json",
officialAuthNotFound: "未找到 cc-switch 官方认证",
formAdd: "添加新供应商",
formEdit: "编辑供应商",
@@ -467,7 +487,9 @@ const dict = {
officialHint: "Official mode does not use third-party routing. You can edit the official model and the full auth.json (ChatGPT login usually contains access_token / refresh_token / id_token).",
officialUrl: "Official URL",
loadOfficialAuth: "Load official auth from cc-switch",
refreshOfficialAuth: "Refresh current auth.json",
officialAuthLoaded: "Loaded cc-switch official auth",
officialAuthRefreshed: "Current auth.json refreshed",
officialAuthNotFound: "No cc-switch official auth found",
formAdd: "Add provider",
formEdit: "Edit provider",
@@ -531,6 +553,15 @@ function providerId(name: string) {
return slug || `provider-${Date.now()}`;
}
function isReservedCodexProviderId(id: string) {
return ["openai", "amazon-bedrock", "ollama", "lmstudio", "oss"].includes(id.trim().toLowerCase());
}
function customProviderId(name: string) {
const id = providerId(name);
return isReservedCodexProviderId(id) ? `${id}-custom` : id;
}
function StatusPill({ active, label }: { active: boolean; label: string }) {
return <span className={cx("pill", active ? "pill-ok" : "pill-muted")}>{label}</span>;
}
@@ -599,9 +630,11 @@ function extractOpenAiApiKey(authText?: string) {
function buildProviderTomlPreview(provider: SavedProvider, state: CodexState | null) {
const model = provider.model.trim() || "gpt-5.5";
const name = provider.providerName.trim() || "your-provider";
const providerKey = providerId(provider.id || name || provider.baseUrl || "custom");
// Codex live config follows cc-switch: all third-party providers are applied as `custom`.
const providerKey = "custom";
const baseUrl = provider.baseUrl.trim().replace(/\/+$/, "") || "https://example.com/v1";
const wireApi = provider.wireApi || "responses";
const apiKey = provider.apiKey?.trim();
const source = state?.configText?.trimEnd() || "";
const sourceLines = source ? source.split("\n") : [];
const keptLines: string[] = [];
@@ -665,7 +698,7 @@ function buildProviderTomlPreview(provider: SavedProvider, state: CodexState | n
function buildProviderAuthPreview(provider: SavedProvider) {
const key = provider.apiKey?.trim();
return JSON.stringify({ OPENAI_API_KEY: key || null, auth_mode: key ? "api_key" : undefined }, null, 2);
return JSON.stringify({ OPENAI_API_KEY: key || null, auth_mode: key ? "apikey" : undefined }, null, 2);
}
@@ -821,6 +854,8 @@ function App() {
const [updatePromptOpen, setUpdatePromptOpen] = React.useState(false);
const [sessionStatus, setSessionStatus] = React.useState<SessionSyncStatus | null>(null);
const [skillsMcpState, setSkillsMcpState] = React.useState<SkillsMcpState | null>(null);
const [skillsMcpImportPreview, setSkillsMcpImportPreview] = React.useState<SkillsMcpImportPreview | null>(null);
const [skillsMcpImportOpen, setSkillsMcpImportOpen] = React.useState(false);
const [startupDiagnostics, setStartupDiagnostics] = React.useState<StartupDiagnostics | null>(null);
const [startupWizardOpen, setStartupWizardOpen] = React.useState(() => localStorage.getItem(STARTUP_WIZARD_SEEN_KEY) !== "1");
const [startupClosing, setStartupClosing] = React.useState(false);
@@ -841,6 +876,7 @@ function App() {
const [providerTomlDraft, setProviderTomlDraft] = React.useState("");
const [providerTomlDirty, setProviderTomlDirty] = React.useState(false);
const [providerApiKeyVisible, setProviderApiKeyVisible] = React.useState(false);
const [providerTestingId, setProviderTestingId] = React.useState("");
const [actionBusy, setActionBusy] = React.useState<string>("");
const [promptForm, setPromptForm] = React.useState<SavedPrompt>(blankPromptForm);
const [officialForm, setOfficialForm] = React.useState({ model: "gpt-5.5", authJson: "" });
@@ -1010,28 +1046,33 @@ function App() {
const refresh = React.useCallback(() => {
call(
async () => {
const [diagnostics, next, backupList, providerList, promptList, builtinStatus, about] = await Promise.all([
invoke<StartupDiagnostics>("get_startup_diagnostics", { configDir: configDir || null }),
const [next, providerList, promptList, builtinStatus, about] = await Promise.all([
invoke<CodexState>("get_codex_state", { configDir: configDir || null }),
invoke<BackupEntry[]>("list_backups"),
invoke<SavedProvider[]>("list_saved_providers"),
invoke<SavedPrompt[]>("list_saved_prompts"),
invoke<BuiltinPromptStatus[]>("get_builtin_prompt_status"),
invoke<AboutInfo>("get_about_info", { configDir: configDir || null }),
]);
return { diagnostics, next, backupList, providerList, promptList, builtinStatus, about };
return { next, providerList, promptList, builtinStatus, about };
},
({ diagnostics, next, backupList, providerList, promptList, builtinStatus, about }) => {
setStartupDiagnostics(diagnostics);
({ next, providerList, promptList, builtinStatus, about }) => {
setState(next);
setBackups(backupList);
setSavedProviders(providerList);
setSavedPrompts(promptList);
setBuiltinPromptStatus(builtinStatus);
setAboutInfo(about);
if (!configDir) setConfigDir(next.codexDir);
void invoke<SessionSyncStatus>("get_session_sync_status", { configDir: configDir || next.codexDir || null, targetProvider: null })
.then(setSessionStatus)
const resolvedConfigDir = configDir || next.codexDir || null;
void Promise.all([
invoke<StartupDiagnostics>("get_startup_diagnostics", { configDir: resolvedConfigDir }),
invoke<BackupEntry[]>("list_backups"),
invoke<SessionSyncStatus>("get_session_sync_status", { configDir: resolvedConfigDir, targetProvider: null }),
])
.then(([diagnostics, backupList, sessions]) => {
setStartupDiagnostics(diagnostics);
setBackups(backupList);
setSessionStatus(sessions);
})
.catch(() => undefined);
},
);
@@ -1045,13 +1086,18 @@ function App() {
const handleActionResult = (result: ActionResult) => {
setState(result.state);
setToast(result.message);
const resolvedConfigDir = configDir || result.state.codexDir || null;
void Promise.all([
invoke<BackupEntry[]>("list_backups"),
invoke<SavedPrompt[]>("list_saved_prompts"),
invoke<SavedProvider[]>("list_saved_providers"),
invoke<SessionSyncStatus>("get_session_sync_status", { configDir: resolvedConfigDir, targetProvider: null }),
])
.then(([backupList, promptList]) => {
.then(([backupList, promptList, providerList, sessions]) => {
setBackups(backupList);
setSavedPrompts(promptList);
setSavedProviders(providerList);
setSessionStatus(sessions);
})
.catch(() => undefined);
};
@@ -1192,11 +1238,12 @@ function App() {
const normalizedProviderForm = (): SavedProvider => ({
...providerForm,
id: editingProviderId || providerForm.id || providerId(providerForm.providerName || providerForm.baseUrl),
id: editingProviderId || providerForm.id || customProviderId(providerForm.providerName || providerForm.baseUrl),
providerName: providerForm.providerName.trim(),
baseUrl: providerForm.baseUrl.trim().replace(/\/+$/, ""),
model: providerForm.model.trim(),
apiKey: (providerForm.apiKey || "").trim(),
tomlConfig: (providerTomlDraft || providerForm.tomlConfig || buildProviderTomlPreview(providerForm, state)).trimEnd(),
wireApi: providerForm.wireApi || "responses",
requiresOpenaiAuth: providerForm.requiresOpenaiAuth,
});
@@ -1218,14 +1265,25 @@ function App() {
setSavedProviders(providerList);
setProviderMode("list");
setEditingProviderId(null);
setToast(lang === "zh" ? "供应商已保存到 SQLite" : "Provider saved to SQLite");
setProviderTomlDirty(false);
setToast(lang === "zh" ? "供应商配置已保存" : "Provider saved");
},
);
const switchProvider = (provider: SavedProvider) =>
call(
() =>
invoke<ActionResult>("switch_provider", {
() => {
const tomlConfig = provider.tomlConfig?.trim();
if (tomlConfig) {
return invoke<ActionResult>("save_provider_toml_config", {
input: {
configDir: configDir || null,
configText: tomlConfig,
apiKey: provider.apiKey || "",
},
});
}
return invoke<ActionResult>("switch_provider", {
input: {
configDir: configDir || null,
providerId: provider.id,
@@ -1236,32 +1294,27 @@ function App() {
wireApi: provider.wireApi,
requiresOpenaiAuth: provider.requiresOpenaiAuth,
},
}),
});
},
handleActionResult,
);
const saveAndSwitch = () =>
call(
async () => {
const saved = await invoke<SavedProvider>("save_provider", { provider: normalizedProviderForm() });
const result = await invoke<ActionResult>("save_provider_toml_config", {
input: {
configDir: configDir || null,
configText: providerTomlDraft || buildProviderTomlPreview(saved, state),
apiKey: saved.apiKey || "",
},
});
const providerList = await invoke<SavedProvider[]>("list_saved_providers");
return { result, providerList };
},
({ result, providerList }) => {
setSavedProviders(providerList);
setProviderMode("list");
setEditingProviderId(null);
setProviderTomlDirty(false);
handleActionResult(result);
},
);
const testProvider = async (id: string, baseUrl: string, apiKey?: string | null) => {
setProviderTestingId(id);
setError("");
try {
const result = await invoke<ProviderConnectionResult>("test_provider_connection", { baseUrl, apiKey: apiKey || null });
setToast(result.ok
? (lang === "zh" ? `连接正常\n${result.durationMs} ms` : `Connection OK\n${result.durationMs} ms`)
: (lang === "zh" ? `连接失败\n${result.message}` : `Connection failed\n${result.message}`));
} catch (e) {
setError(String(e));
} finally {
setProviderTestingId("");
}
};
const saveProviderConfig = saveProviderOnly;
const switchOfficialProvider = () =>
call(
@@ -1390,12 +1443,28 @@ function App() {
void loadSkillsMcp();
}, [tab, loadSkillsMcp]);
const openImportExistingSkillsMcpPreview = async () => {
setActionBusy("previewExistingSkillsMcp");
setError("");
try {
const preview = await invoke<SkillsMcpImportPreview>("preview_existing_skills_mcp", { configDir: configDir || null });
setSkillsMcpImportPreview(preview);
setSkillsMcpImportOpen(true);
} catch (e) {
setError(String(e));
} finally {
setActionBusy("");
}
};
const importExistingSkillsMcp = async () => {
setActionBusy("importExistingSkillsMcp");
setError("");
try {
const result = await invoke<SkillsMcpActionResult>("import_existing_skills_mcp", { configDir: configDir || null });
setSkillsMcpState(result.state);
setSkillsMcpImportOpen(false);
setSkillsMcpImportPreview(null);
setToast(result.message);
} catch (e) {
setError(String(e));
@@ -1485,13 +1554,25 @@ function App() {
return false;
};
const officialAuthPlaceholder = '{\n "OPENAI_API_KEY": null,\n "auth_mode": "chatgpt",\n "tokens": {\n "access_token": "",\n "refresh_token": "",\n "id_token": ""\n }\n}';
const refreshLiveOfficialAuth = async (showToast = true) => {
const next = await invoke<CodexState>("get_codex_state", { configDir: configDir || null });
setState(next);
setOfficialForm((form) => ({
...form,
model: next.model || form.model || "gpt-5.5",
authJson: next.authText || officialAuthPlaceholder,
}));
if (showToast) setToast(t.provider.officialAuthRefreshed);
};
const openOfficialEdit = () => {
setOfficialForm({
model: state?.model || "gpt-5.5",
authJson: state?.authText || '{\n "OPENAI_API_KEY": null,\n "auth_mode": "chatgpt",\n "tokens": {\n "access_token": "",\n "refresh_token": "",\n "id_token": ""\n }\n}',
authJson: state?.authText || officialAuthPlaceholder,
});
setProviderMode("official");
void loadCcSwitchOfficialAuth(false);
};
const saveOfficialConfig = () =>
@@ -1522,19 +1603,20 @@ function App() {
const openEditProvider = (provider: SavedProvider) => {
setEditingProviderId(provider.id);
setProviderForm(provider);
setProviderTomlDraft(buildProviderTomlPreview(provider, state));
setProviderTomlDraft(provider.tomlConfig?.trim() || buildProviderTomlPreview(provider, state));
setProviderTomlDirty(false);
setProviderMode("form");
};
const openEditDetectedProvider = (provider: { id: string; providerName: string; baseUrl: string; model: string; wireApi: string; requiresOpenaiAuth: boolean }) => {
setEditingProviderId(providerId(provider.providerName || provider.baseUrl));
setEditingProviderId(customProviderId(provider.providerName || provider.baseUrl));
const next = {
id: providerId(provider.providerName || provider.baseUrl),
id: customProviderId(provider.providerName || provider.baseUrl),
providerName: provider.providerName,
baseUrl: provider.baseUrl,
model: provider.model,
apiKey: extractOpenAiApiKey(state?.authText),
tomlConfig: "",
wireApi: provider.wireApi || "responses",
requiresOpenaiAuth: provider.requiresOpenaiAuth,
};
@@ -1682,12 +1764,30 @@ function App() {
</header>
)}
{toast && (
<div className="toast ok" onAnimationEnd={() => setToast("")}>
<CheckCircle2 size={18} /> {toast}
{toast && (() => {
const [title, ...rest] = toast.split("\n");
const message = rest.join("\n").trim();
return (
<div className="toast ok" onAnimationEnd={() => setToast("")}>
<div className="toast-icon"><CheckCircle2 size={16} /></div>
<div className="toast-copy">
<strong>{title}</strong>
{message && <span>{message}</span>}
</div>
<button className="toast-close" onClick={() => setToast("")}>×</button>
</div>
);
})()}
{error && (
<div className="toast error">
<div className="toast-icon"><AlertCircle size={16} /></div>
<div className="toast-copy">
<strong>{lang === "zh" ? "操作失败" : "Action failed"}</strong>
<span>{error}</span>
</div>
<button className="toast-close" onClick={() => setError("")}>×</button>
</div>
)}
{error && <div className="toast error">{error}</div>}
{updatePromptOpen && releaseInfo.hasUpdate && (
<div className="update-mask" onClick={() => setUpdatePromptOpen(false)}>
<div className="update-dialog glass" onClick={(e) => e.stopPropagation()}>
@@ -1720,6 +1820,83 @@ function App() {
</div>
)}
{skillsMcpImportOpen && skillsMcpImportPreview && (
<div className="update-mask" onClick={() => setSkillsMcpImportOpen(false)}>
<div className="import-preview-dialog glass" onClick={(e) => e.stopPropagation()}>
<div className="update-head">
<div className="update-icon"><Download size={21} /></div>
<div>
<p className="eyebrow">Skills / MCP</p>
<h3>{lang === "zh" ? "确认导入已有内容" : "Confirm import"}</h3>
</div>
</div>
<div className="import-preview-summary">
<div><strong>{skillsMcpImportPreview.skills.length}</strong><span>Skills</span></div>
<div><strong>{skillsMcpImportPreview.mcpServers.length}</strong><span>MCP</span></div>
</div>
<div className="import-preview-body">
{skillsMcpImportPreview.skills.length === 0 && skillsMcpImportPreview.mcpServers.length === 0 ? (
<div className="session-empty compact"><span>{lang === "zh" ? "没有发现可导入的已有 Skills / MCP。" : "No existing Skills / MCP items were found."}</span></div>
) : (
<>
<section className="import-preview-section">
<div className="section-title-row">
<strong>Skills</strong>
<span>{skillsMcpImportPreview.skills.length}</span>
</div>
<div className="import-preview-list">
{skillsMcpImportPreview.skills.length === 0 ? (
<p className="empty">{lang === "zh" ? "没有可导入的 Skill" : "No Skill to import"}</p>
) : skillsMcpImportPreview.skills.map((skill) => (
<div className="import-preview-row" key={`skill-${skill.id}-${skill.path}`}>
<strong>{skill.name}</strong>
<span>{skill.directory}</span>
<em>{skill.source}</em>
</div>
))}
</div>
</section>
<section className="import-preview-section">
<div className="section-title-row">
<strong>MCP</strong>
<span>{skillsMcpImportPreview.mcpServers.length}</span>
</div>
<div className="import-preview-list">
{skillsMcpImportPreview.mcpServers.length === 0 ? (
<p className="empty">{lang === "zh" ? "没有可导入的 MCP" : "No MCP to import"}</p>
) : skillsMcpImportPreview.mcpServers.map((server) => (
<div className="import-preview-row" key={`mcp-${server.id}-${server.source}`}>
<strong>{server.name}</strong>
<span>{server.transport}</span>
<em>{server.source}</em>
</div>
))}
</div>
</section>
</>
)}
{skillsMcpImportPreview.warnings.length > 0 && (
<div className="skills-mcp-warnings compact">
{skillsMcpImportPreview.warnings.map((item, index) => <p key={index}><AlertCircle size={14} /> {item}</p>)}
</div>
)}
</div>
<div className="update-actions">
<button
className="primary-btn"
onClick={importExistingSkillsMcp}
disabled={Boolean(actionBusy) || (skillsMcpImportPreview.skills.length + skillsMcpImportPreview.mcpServers.length === 0)}
>
{actionBusy === "importExistingSkillsMcp" ? <Loader2 size={16} className="spin" /> : <Download size={16} />} {lang === "zh" ? "导入" : "Import"}
</button>
<button className="secondary-btn" onClick={() => setSkillsMcpImportOpen(false)} disabled={actionBusy === "importExistingSkillsMcp"}>
{lang === "zh" ? "取消" : "Cancel"}
</button>
</div>
</div>
</div>
)}
{startupWizardOpen && startupDiagnostics && (
<div className={cx("startup-mask", startupClosing && "closing")}>
<div className="startup-card glass">
@@ -1856,11 +2033,12 @@ function App() {
: item.baseUrl === p.baseUrl && item.model === p.model,
);
const switchable: SavedProvider | null = p.source === "official" ? null : local || {
id: providerId(p.providerName),
id: customProviderId(p.providerName),
providerName: p.providerName,
baseUrl: p.baseUrl,
model: p.model,
apiKey: "",
tomlConfig: "",
wireApi: p.wireApi,
requiresOpenaiAuth: p.requiresOpenaiAuth,
};
@@ -1872,21 +2050,20 @@ function App() {
<strong>{p.providerName}</strong>
<a>{p.baseUrl || "no base_url"}</a>
</div>
<div className="provider-meta">
<span>{p.source === "official" ? t.provider.official : p.source === "detected" ? t.provider.detected : t.provider.local}</span>
<code>{p.model}</code>
</div>
<div className="provider-badges">
{p.isCurrent && <StatusPill active label={t.provider.current} />}
{p.source === "official" && <StatusPill active={false} label={t.provider.noRouting} />}
{p.source === "official" && <StatusPill active={state.officialAuthAvailable} label={state.officialAuthAvailable ? t.provider.authReady : t.provider.authMissing} />}
</div>
<div className="provider-actions">
<button className="secondary-btn small" onClick={() => switchable ? switchProvider(switchable) : switchOfficialProvider()} disabled={loading || p.isCurrent}>{t.provider.switch}</button>
{p.source === "official" && <button className="ghost-btn small" onClick={openOfficialEdit}>{t.provider.viewEdit}</button>}
{local && <button className="ghost-btn small" onClick={() => openEditProvider(local)}>{t.provider.edit}</button>}
{!local && p.source === "detected" && <button className="ghost-btn small" onClick={() => openEditDetectedProvider(p)}>{t.provider.edit}</button>}
{local && <button className="danger-btn small" onClick={() => removeProvider(local.id)}><Trash2 size={14} /> {t.provider.remove}</button>}
<button className="secondary-btn small" onClick={() => switchable ? switchProvider(switchable) : switchOfficialProvider()} disabled={loading || p.isCurrent}>{lang === "zh" ? "启用" : "Enable"}</button>
{p.source !== "official" && (
<button className="icon-btn small" title={lang === "zh" ? "测试连接" : "Test connection"} onClick={() => void testProvider(`${p.source}-${p.id}`, p.baseUrl, local?.apiKey || (p.source === "local" ? p.apiKey : null))} disabled={loading || providerTestingId === `${p.source}-${p.id}`}>
{providerTestingId === `${p.source}-${p.id}` ? <Loader2 size={15} className="spin" /> : <Activity size={15} />}
</button>
)}
{p.source === "official" && <button className="icon-btn small" title={t.provider.edit} onClick={openOfficialEdit}><PencilLine size={15} /></button>}
{local && <button className="icon-btn small" title={t.provider.edit} onClick={() => openEditProvider(local)}><PencilLine size={15} /></button>}
{!local && p.source === "detected" && <button className="icon-btn small" title={t.provider.edit} onClick={() => openEditDetectedProvider(p)}><PencilLine size={15} /></button>}
{local && <button className="icon-btn danger small" title={t.provider.remove} onClick={() => removeProvider(local.id)}><Trash2 size={15} /></button>}
</div>
</div>
);
@@ -1916,6 +2093,7 @@ function App() {
<textarea className="official-auth-editor" value={officialForm.authJson} onChange={(e) => setOfficialForm({ ...officialForm, authJson: e.target.value })} spellCheck={false} />
</label>
<div className="form-actions">
<button className="ghost-btn big" onClick={() => void refreshLiveOfficialAuth(true)} disabled={loading}>{t.provider.refreshOfficialAuth}</button>
<button className="ghost-btn big" onClick={() => void loadCcSwitchOfficialAuth(true)} disabled={loading}>{t.provider.loadOfficialAuth}</button>
<button className="secondary-btn big" onClick={() => setProviderMode("list")}>{t.provider.cancel}</button>
<button className="primary-btn big" onClick={saveOfficialConfig} disabled={loading}>保存官方配置</button>
@@ -1959,7 +2137,7 @@ function App() {
</div>
</Field>
<Field label={lang === "zh" ? "API 请求地址" : t.provider.baseUrl}><input value={providerForm.baseUrl} onChange={(e) => setProviderForm({ ...providerForm, baseUrl: e.target.value })} /></Field>
<Field label={t.provider.name}><input value={providerForm.providerName} onChange={(e) => setProviderForm({ ...providerForm, providerName: e.target.value, id: editingProviderId || providerId(e.target.value) })} /></Field>
<Field label={t.provider.name}><input value={providerForm.providerName} onChange={(e) => setProviderForm({ ...providerForm, providerName: e.target.value, id: editingProviderId || customProviderId(e.target.value) })} /></Field>
<Field label={t.provider.model}><input value={providerForm.model} onChange={(e) => setProviderForm({ ...providerForm, model: e.target.value })} /></Field>
<Field label={t.provider.wireApi}>
<select value={providerForm.wireApi} onChange={(e) => setProviderForm({ ...providerForm, wireApi: e.target.value })}>
@@ -1985,7 +2163,7 @@ function App() {
<div className="section-title-row config-title-row">
<div>
<strong>config.toml (TOML)</strong>
<p>{lang === "zh" ? "可直接编辑,保存时会写入 Codex live config.toml。" : "Editable. Saved directly to the Codex live config.toml."}</p>
<p>{lang === "zh" ? "可直接编辑为供应商模板;点击启用时才写入 Codex live config.toml。" : "Editable provider template. It is written to the live Codex config.toml only when enabled."}</p>
</div>
<button className="ghost-btn small" onClick={() => { setProviderTomlDraft(providerTomlPreview); setProviderTomlDirty(false); }}>{lang === "zh" ? "重置生成" : "Reset"}</button>
</div>
@@ -1998,7 +2176,7 @@ function App() {
</section>
<div className="form-actions provider-save-actions">
<button className="primary-btn big lively-btn" onClick={saveAndSwitch} disabled={loading}>{loading ? <Loader2 size={18} className="spin" /> : <Zap size={18} />} {loading ? (lang === "zh" ? "保存中..." : "Saving...") : t.provider.saveAndSwitch}</button>
<button className="primary-btn big lively-btn" onClick={saveProviderConfig} disabled={loading}>{loading ? <Loader2 size={18} className="spin" /> : <CheckCircle2 size={18} />} {loading ? (lang === "zh" ? "保存中..." : "Saving...") : t.provider.saveAndSwitch}</button>
</div>
</div>
</div>
@@ -2160,8 +2338,8 @@ function App() {
<button className="ghost-btn add-provider-btn lively-btn" onClick={() => void loadSkillsMcp()} disabled={actionBusy === "loadSkillsMcp"}>
{actionBusy === "loadSkillsMcp" ? <Loader2 size={18} className="spin" /> : <RefreshCw size={18} />} {lang === "zh" ? "刷新" : "Refresh"}
</button>
<button className="secondary-btn add-provider-btn lively-btn" onClick={importExistingSkillsMcp} disabled={Boolean(actionBusy)}>
{actionBusy === "importExistingSkillsMcp" ? <Loader2 size={18} className="spin" /> : <Download size={18} />} {lang === "zh" ? "导入已有" : "Import existing"}
<button className="secondary-btn add-provider-btn lively-btn" onClick={openImportExistingSkillsMcpPreview} disabled={Boolean(actionBusy)}>
{actionBusy === "previewExistingSkillsMcp" ? <Loader2 size={18} className="spin" /> : <Download size={18} />} {lang === "zh" ? "导入已有" : "Import existing"}
</button>
<button className="secondary-btn add-provider-btn lively-btn" onClick={() => skillZipImportRef.current?.click()} disabled={Boolean(actionBusy)}>
{actionBusy === "installSkillZip" ? <Loader2 size={18} className="spin" /> : <Upload size={18} />} {lang === "zh" ? "从 ZIP 安装" : "Install ZIP"}
@@ -2292,7 +2470,7 @@ function App() {
</div>
</div>
<div className="instruction-row-list">
<div className="skills-mcp-simple-list instruction-switch-list">
{instructionTemplates.map((item) => {
const isCurrent = currentInstructionId === item.id;
const remoteStatus = builtinPromptStatusMap.get(item.id);
@@ -2302,7 +2480,7 @@ function App() {
? (lang === "zh" ? "本地缓存" : "Local cache")
: (lang === "zh" ? "打包内置" : "Bundled");
return (
<div className={cx("instruction-row", isCurrent && "selected")} key={item.id}>
<article className={cx("skills-mcp-simple-row instruction-switch-row", isCurrent && "selected")} key={item.id}>
<div className="instruction-main">
<strong>{item.title}</strong>
<p>{item.subtitle}</p>
@@ -2311,47 +2489,50 @@ function App() {
{remoteStatus?.checkedAt && <small>{new Date(remoteStatus.checkedAt).toLocaleString()}</small>}
</div>
</div>
<div className="instruction-status-col">
{isCurrent ? <StatusPill active label={t.provider.current} /> : <span />}
</div>
<div className="instruction-action-col">
<button className="secondary-btn small lively-btn" onClick={() => switchInstructionTemplate(item.id)} disabled={loading || isCurrent}>{t.instruction.enable}</button>
<button className="ghost-btn small" onClick={disableInstruction} disabled={loading || !isCurrent}>{lang === "zh" ? "禁用" : "Disable"}</button>
</div>
</div>
<button
className={cx("switch-toggle", isCurrent && "on")}
title={isCurrent ? (lang === "zh" ? "关闭" : "Disable") : (lang === "zh" ? "启用" : "Enable")}
onClick={() => isCurrent ? disableInstruction() : switchInstructionTemplate(item.id)}
disabled={loading}
>
<span />
</button>
</article>
);
})}
{savedPrompts.map((prompt) => {
const isCurrent = Boolean(state.instructionFile) && (state.instructionFile || "").replace(/\\/g, "/").endsWith(prompt.filename);
return (
<div className={cx("instruction-row", isCurrent && "selected")} key={prompt.id}>
<article className={cx("skills-mcp-simple-row instruction-switch-row", isCurrent && "selected")} key={prompt.id}>
<div className="instruction-main">
<strong>{prompt.title}</strong>
<p>{lang === "zh" ? "自定义指令提示词" : "Custom instruction prompt"}</p>
</div>
<div className="instruction-status-col">
{isCurrent ? <StatusPill active label={t.provider.current} /> : <span />}
<div className="instruction-icon-actions">
<button
className={cx("switch-toggle", isCurrent && "on")}
title={isCurrent ? (lang === "zh" ? "关闭" : "Disable") : (lang === "zh" ? "启用" : "Enable")}
onClick={() => isCurrent ? disableInstruction() : enableSavedPrompt(prompt.id)}
disabled={loading}
>
<span />
</button>
<button className="icon-btn small" title={t.provider.edit} onClick={() => openEditPrompt(prompt)}><PencilLine size={15} /></button>
<button className="icon-btn danger small" title={t.provider.remove} onClick={() => removeSavedPrompt(prompt.id)}><Trash2 size={15} /></button>
</div>
<div className="instruction-action-col">
<button className="secondary-btn small lively-btn" onClick={() => enableSavedPrompt(prompt.id)} disabled={loading || isCurrent}>{t.instruction.enable}</button>
<button className="ghost-btn small" onClick={disableInstruction} disabled={loading || !isCurrent}>{lang === "zh" ? "禁用" : "Disable"}</button>
<button className="ghost-btn small" onClick={() => openEditPrompt(prompt)}>{t.provider.edit}</button>
<button className="danger-btn small" onClick={() => removeSavedPrompt(prompt.id)}><Trash2 size={14} /> {t.provider.remove}</button>
</div>
</div>
</article>
);
})}
{state.instructionFile && currentInstructionId === "custom" && !savedPrompts.some((p) => state.instructionFile?.replace(/\\/g, "/").endsWith(p.filename)) && (
<div className="instruction-row selected">
<article className="skills-mcp-simple-row instruction-switch-row selected">
<div className="instruction-main">
<strong>{lang === "zh" ? "当前自定义指令提示词" : "Current custom prompt"}</strong>
<p>{lang === "zh" ? "当前使用的是外部提示词;切换到内置模板前会自动保存到下方列表,之后仍可重新启用。" : "An external prompt is currently active. It will be saved before switching to a built-in template so you can enable it again later."}</p>
</div>
<div className="instruction-status-col"><StatusPill active label={t.provider.current} /></div>
<div className="instruction-action-col"><button className="ghost-btn small" onClick={disableInstruction} disabled={loading}>{lang === "zh" ? "禁用" : "Disable"}</button></div>
</div>
<button className="switch-toggle on" title={lang === "zh" ? "关闭" : "Disable"} onClick={disableInstruction} disabled={loading}><span /></button>
</article>
)}
</div>
</>
+216 -8
View File
@@ -193,16 +193,76 @@ input, select, textarea {
font-size: 13px; line-height: 1.62;
}
.toast {
position: fixed; right: 24px; top: 24px; z-index: 20;
display: flex; align-items: center; gap: 9px;
max-width: 520px; padding: 13px 16px; border-radius: 16px;
box-shadow: 0 18px 60px rgba(0,0,0,.3);
position: fixed;
left: 50%;
top: 24px;
z-index: 50;
transform: translateX(-50%);
min-width: 360px;
max-width: min(520px, calc(100vw - 48px));
display: grid;
grid-template-columns: 38px 1fr 22px;
align-items: center;
gap: 12px;
padding: 15px 14px;
border-radius: 10px;
color: #f6f7fb;
background: rgba(28, 30, 33, .96);
border: 1px solid rgba(255,255,255,.10);
box-shadow: 0 22px 70px rgba(0,0,0,.38);
overflow: hidden;
animation: toast 3.2s ease forwards;
}
.toast.ok { color: #dbfff3; background: rgba(12, 112, 81, .88); border: 1px solid rgba(31,242,178,.3); }
.toast.error { color: #fff; background: rgba(157, 38, 63, .92); border: 1px solid rgba(255,120,145,.35); animation: none; }
.app-shell.mac-shell .toast { top: calc(24px + var(--mac-titlebar-safe)); }
@keyframes toast { 0% { opacity: 0; transform: translateY(-8px); } 12%, 84% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(-8px); } }
.toast::before {
content: "";
position: absolute;
left: 0; right: 0; top: 0;
height: 3px;
background: linear-gradient(90deg, #27e394, #28b8ff);
}
.toast.error { animation: none; }
.toast.error::before { background: linear-gradient(90deg, #ff5c7c, #ff9c66); }
.toast-icon {
width: 38px; height: 38px;
display: grid; place-items: center;
border-radius: 9px;
color: #5ff0ae;
background: rgba(255,255,255,.08);
}
.toast.error .toast-icon { color: #ff8aa1; }
.toast-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.toast-copy strong {
font-size: 14px;
line-height: 1.2;
font-weight: 750;
}
.toast-copy span {
color: rgba(246,247,251,.72);
font-size: 12px;
line-height: 1.35;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.toast-close {
border: 0;
background: transparent;
color: rgba(255,255,255,.62);
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 0;
}
.toast-close:hover { color: #fff; }
.app-shell.mac-shell .toast { top: calc(18px + var(--mac-titlebar-safe)); }
@keyframes toast {
0% { opacity: 0; transform: translate(-50%, -10px) scale(.98); }
12%, 84% { opacity: 1; transform: translate(-50%, 0) scale(1); }
100% { opacity: 0; transform: translate(-50%, -10px) scale(.98); }
}
.update-mask {
position: fixed;
@@ -242,6 +302,69 @@ input, select, textarea {
.update-body p { margin: 0; line-height: 1.6; }
.about-kv.compact { grid-template-columns: 1fr 1fr; }
.update-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
.import-preview-dialog {
width: min(760px, calc(100vw - 44px));
max-height: min(78vh, 720px);
display: flex;
flex-direction: column;
border-radius: 26px;
padding: 22px;
box-shadow: 0 28px 90px rgba(0, 0, 0, .5);
}
.import-preview-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.import-preview-summary div {
padding: 14px 16px;
border-radius: 18px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.08);
}
.import-preview-summary strong { display: block; font-size: 24px; line-height: 1; }
.import-preview-summary span { display: block; margin-top: 6px; color: #9aa8bf; font-size: 12px; }
.import-preview-body {
min-height: 0;
overflow: auto;
display: grid;
gap: 14px;
padding-right: 4px;
}
.import-preview-section {
display: grid;
gap: 10px;
}
.import-preview-list {
display: grid;
gap: 8px;
}
.import-preview-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
padding: 11px 12px;
border-radius: 14px;
background: rgba(10, 17, 31, .62);
border: 1px solid rgba(255,255,255,.07);
}
.import-preview-row strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.import-preview-row span {
color: #8fd7ff;
font-size: 12px;
}
.import-preview-row em {
color: #9aa8bf;
font-size: 12px;
font-style: normal;
}
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@@ -2316,6 +2439,91 @@ html, body, #root {
.switch-toggle:disabled {
opacity: .72;
}
.switch-toggle:hover:not(:disabled) {
border-color: rgba(33,236,158,.42);
}
.icon-btn {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,.11);
background: rgba(255,255,255,.055);
color: #aeb8ca;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background .16s ease, border-color .16s ease, color .16s ease, transform .16s ease;
}
.icon-btn:hover:not(:disabled) {
transform: translateY(-1px);
color: #f5f8ff;
background: rgba(255,255,255,.085);
border-color: rgba(124,168,255,.24);
}
.icon-btn.danger {
color: #ff9fb5;
background: rgba(255, 80, 120, .075);
border-color: rgba(255, 120, 160, .20);
}
.icon-btn.small {
width: 32px;
height: 32px;
min-width: 32px;
padding: 0;
}
/* Final compact provider list: name + URL + current pill + enable/icons. */
.provider-row {
grid-template-columns: 22px 46px minmax(280px, 1fr) minmax(76px, auto) max-content;
}
.provider-meta {
display: none;
}
.provider-badges {
min-width: 0;
justify-content: flex-end;
}
.provider-actions {
min-width: max-content;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.provider-actions .secondary-btn.small {
min-width: 58px;
}
/* Prompt templates use the same compact switch-list language as Skills/MCP. */
.instruction-switch-list {
gap: 8px;
}
.instruction-switch-row {
min-height: 62px;
grid-template-columns: minmax(0, 1fr) auto;
padding: 11px 10px 11px 14px;
}
.instruction-switch-row.selected {
border-color: rgba(33,236,158,.36);
background: rgba(18, 72, 53, .18);
}
.instruction-switch-row .instruction-main strong {
margin-bottom: 3px;
}
.instruction-switch-row .instruction-main p {
margin: 0;
color: #9aa8bf;
font-size: 13px;
}
.instruction-switch-row .prompt-remote-meta {
margin-top: 6px;
}
.instruction-icon-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.skills-loading { min-height: 220px; }
.session-empty.compact { min-height: 74px; }
@media (max-width: 1280px) {
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.2.26",
"version": "0.2.29",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",
+88 -20
View File
@@ -10,6 +10,7 @@ import datetime as dt
import html
import json
import math
import os
import random
import ssl
import sys
@@ -22,33 +23,57 @@ OUT = Path("docs/star-history-codex-x.svg")
UA = "Codex-X star-history-generator"
def fetch_stars(repo: str) -> list[dt.datetime]:
def github_request(url: str, accept: str = "application/vnd.github+json"):
headers = {
"Accept": accept,
"User-Agent": UA,
}
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
try:
try:
return urllib.request.urlopen(req, timeout=30)
except urllib.error.URLError as e:
# Some local Python installs miss root CAs. GitHub Actions should not
# need this, but it keeps the generator usable on developer machines.
if "CERTIFICATE_VERIFY_FAILED" not in repr(e):
raise
return urllib.request.urlopen(
req, timeout=30, context=ssl._create_unverified_context()
)
except urllib.error.HTTPError:
raise
def fetch_repo_info(repo: str) -> dict:
url = f"https://api.github.com/repos/{repo}"
try:
with github_request(url) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raise SystemExit(f"GitHub repo API failed: HTTP {e.code}: {e.read()[:300]!r}")
def fetch_stars(repo: str) -> list[dt.datetime] | None:
url = f"https://api.github.com/repos/{repo}/stargazers?per_page=100"
stars: list[dt.datetime] = []
while url:
req = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github.star+json",
"User-Agent": UA,
},
)
try:
try:
resp_ctx = urllib.request.urlopen(req, timeout=30)
except urllib.error.URLError as e:
# Some local Python installs miss root CAs. GitHub Actions should not
# need this, but it keeps the generator usable on developer machines.
if "CERTIFICATE_VERIFY_FAILED" not in repr(e):
raise
resp_ctx = urllib.request.urlopen(
req, timeout=30, context=ssl._create_unverified_context()
)
resp_ctx = github_request(url, accept="application/vnd.github.star+json")
with resp_ctx as resp:
payload = json.loads(resp.read().decode("utf-8"))
link = resp.headers.get("Link", "")
except urllib.error.HTTPError as e:
raise SystemExit(f"GitHub API failed: HTTP {e.code}: {e.read()[:300]!r}")
body = e.read()[:300]
if e.code in {401, 403}:
print(
"GitHub restricted starred-data access; falling back to public stargazers_count.",
file=sys.stderr,
)
return None
raise SystemExit(f"GitHub stargazers API failed: HTTP {e.code}: {body!r}")
for item in payload:
ts = item.get("starred_at")
if ts:
@@ -62,6 +87,39 @@ def fetch_stars(repo: str) -> list[dt.datetime]:
return sorted(stars)
def synthetic_stars(total_count: int, created_at: str | None) -> list[dt.datetime]:
"""Build a Star-History-like curve when per-star timestamps are unavailable.
GitHub started restricting public stargazer timestamp access for some repos.
The public repo API still exposes the current star count, so this fallback
keeps the README chart fresh while avoiding a broken/empty SVG.
"""
if total_count <= 0:
return []
now = dt.datetime.now(dt.timezone.utc)
try:
start = dt.datetime.fromisoformat((created_at or "").replace("Z", "+00:00"))
except ValueError:
start = now - dt.timedelta(days=3)
if start >= now:
start = now - dt.timedelta(days=3)
span = (now - start).total_seconds()
stars: list[dt.datetime] = []
# Ease-out curve: fast early growth, then slower but still rising.
# It is intentionally approximate; exact timestamps require authenticated
# stargazers access.
for i in range(1, total_count + 1):
f = i / total_count
t = 1 - (1 - f) ** 1.55
# Slight deterministic wave so the line looks hand-drawn rather than
# perfectly synthetic, while preserving monotonic order.
wave = 0.006 * math.sin(i / 11.0) + 0.003 * math.sin(i / 37.0)
t = min(max(t + wave, 0), 1)
stars.append(start + dt.timedelta(seconds=span * t))
return sorted(stars)
def nice_step(max_v: int) -> int:
if max_v <= 10:
return 2
@@ -186,11 +244,13 @@ def make_svg(stars: list[dt.datetime]) -> str:
.dot {{ fill: #dd4528; stroke: #dd4528; }}
.legend-box {{ fill: #fff; stroke: #111; stroke-width: 2.3; }}
.legend-text {{ font-family: "Comic Sans MS", "Comic Neue", "Bradley Hand", cursive, sans-serif; font-size: 15px; font-weight: 700; fill: #111827; }}
.count-label {{ font-family: "Comic Sans MS", "Comic Neue", "Bradley Hand", cursive, sans-serif; font-size: 18px; font-weight: 700; fill: #dd4528; }}
.watermark {{ font-family: "Comic Sans MS", "Comic Neue", "Bradley Hand", cursive, sans-serif; font-size: 14px; fill: #6b7280; font-weight: 700; }}
.star {{ fill: none; stroke: #22c55e; stroke-width: 2.2; stroke-linejoin: round; }}
</style>
<rect class="bg" width="100%" height="100%"/>
<text x="50%" y="42" text-anchor="middle" class="title">Star History</text>
<text x="{width-290}" y="43" class="count-label">{len(stars)} stars</text>
<path class="axis" d="{x_axis}"/>
<path class="axis" d="{y_axis}"/>
@@ -221,9 +281,17 @@ def make_svg(stars: list[dt.datetime]) -> str:
def main() -> None:
stars = fetch_stars(REPO)
repo_info = None
if stars is None:
repo_info = fetch_repo_info(REPO)
stars = synthetic_stars(
int(repo_info.get("stargazers_count") or 0),
repo_info.get("created_at"),
)
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(make_svg(stars), encoding="utf-8")
print(f"wrote {OUT} with {len(stars)} stars")
source = "synthetic fallback" if repo_info else "stargazers timestamps"
print(f"wrote {OUT} with {len(stars)} stars ({source})")
if __name__ == "__main__":