mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc4a31c0e | ||
|
|
12d279a30a | ||
|
|
e0e7d1f7c8 | ||
|
|
8aeacba04c | ||
|
|
a5c5e50436 |
+41
-1
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.2.26",
|
||||
"version": "0.2.29",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+1
-1
@@ -346,7 +346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.2.26"
|
||||
version = "0.2.29"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+887
-132
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user