feat: v0.4.0 - provider icon system, settings modal & polish

- Add provider icon system with extracted icon library (cc-switch based)

- Persist icon and icon_color in SQLite and config.toml

- Add Settings modal (theme / language / update check / silent download)

- Add useTheme and useUpdateCheck hooks

- Add provider icon picker (IconPicker) with search and grid

- Fix icon frame 2-layer color difference in ProviderEdit

- Dashboard heatmap and daily usage improvements

- Sessions page polish and adaptive layout

- Bump version to 0.4.0
This commit is contained in:
KimiSwitch Dev committed 2026-07-28 16:01:11 +08:00
1 parent 14d48500bf
commit ebf2dd28d5
134 files changed
+2898 -326

No files matched your search

+21 -21
View File
@@ -58,7 +58,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
return (
<div className="mt-6 space-y-4">
<h3 className="text-gray-400 text-sm font-medium">{t("agentSettings")}</h3>
<h3 className="text-content-muted text-sm font-medium">{t("agentSettings")}</h3>
<Card title={t("enableThinking")}>
<Checkbox
@@ -67,7 +67,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
onChange={(checked) => updateThinking({ enabled: checked })}
/>
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm text-gray-400">{t("thinkingLevel")}</span>
<span className="text-sm text-content-muted">{t("thinkingLevel")}</span>
<Segmented
options={THINKING_LEVELS.map((lvl) => ({
key: lvl,
@@ -86,7 +86,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
disabled={!thinkingEnabled}
onChange={(checked) => updateThinking({ keep: checked ? "all" : false })}
/>
<p className="text-xs text-gray-500">{t("thinkingContextHint")}</p>
<p className="text-xs text-content-muted">{t("thinkingContextHint")}</p>
</Card>
<Card title={t("loopControlSettings")}>
@@ -120,7 +120,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
{(settings.permission?.rules ?? []).map((rule, idx) => (
<div key={idx} className="flex items-center gap-2">
<select
className="bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
className="bg-input border border-border rounded px-2 py-1 text-sm"
value={rule.decision ?? "allow"}
onChange={(e) => {
const rules = [...(settings.permission?.rules ?? [])];
@@ -139,7 +139,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
</select>
<input
type="text"
className="flex-1 min-w-0 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
className="flex-1 min-w-0 bg-input border border-border rounded px-2 py-1 text-sm"
value={rule.pattern ?? ""}
placeholder={t("permissionPattern")}
onChange={(e) => {
@@ -171,7 +171,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
{ decision: "allow", pattern: "" },
]);
}}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]"
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2"
>
{t("addRule")}
</button>
@@ -184,7 +184,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
{ decision: "deny", pattern: "Bash(rm -rf*)" },
]);
}}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]"
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2"
>
{t("addCommonRules")}
</button>
@@ -198,7 +198,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
<input
list="hook-events"
type="text"
className="w-32 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
className="w-32 bg-input border border-border rounded px-2 py-1 text-sm"
value={hook.event ?? ""}
placeholder={t("hookEvent")}
onChange={(e) => {
@@ -214,7 +214,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
</datalist>
<input
type="text"
className="w-24 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
className="w-24 bg-input border border-border rounded px-2 py-1 text-sm"
value={hook.matcher ?? ""}
placeholder={t("hookMatcher")}
onChange={(e) => {
@@ -225,7 +225,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
/>
<input
type="text"
className="flex-1 min-w-0 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
className="flex-1 min-w-0 bg-input border border-border rounded px-2 py-1 text-sm"
value={hook.command ?? ""}
placeholder={t("hookCommand")}
onChange={(e) => {
@@ -236,7 +236,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
/>
<input
type="number"
className="w-20 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm"
className="w-20 bg-input border border-border rounded px-2 py-1 text-sm"
value={hook.timeout ?? ""}
placeholder={t("hookTimeout")}
onChange={(e) => {
@@ -267,7 +267,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
onClick={() => {
setHooks([...(settings.hooks ?? []), { event: "", matcher: "", command: "" }]);
}}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529]"
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2"
>
{t("addHook")}
</button>
@@ -278,8 +278,8 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-4 space-y-3">
<h4 className="text-gray-400 text-sm font-medium">{title}</h4>
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
<h4 className="text-content-muted text-sm font-medium">{title}</h4>
{children}
</div>
);
@@ -299,7 +299,7 @@ function Checkbox({
return (
<label
className={`flex items-center gap-2 text-sm ${
disabled ? "text-gray-600" : "text-[#e5e5e7]"
disabled ? "text-content-muted" : "text-content-primary"
}`}
>
<input
@@ -307,7 +307,7 @@ function Checkbox({
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
className="rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
{label}
</label>
@@ -324,8 +324,8 @@ function NumberField({
onChange: (value: number | undefined) => void;
}) {
return (
<label className="flex items-center gap-3 text-sm text-[#e5e5e7]">
<span className="text-gray-400">{label}</span>
<label className="flex items-center gap-3 text-sm text-content-primary">
<span className="text-content-muted">{label}</span>
<input
type="number"
value={value ?? ""}
@@ -333,7 +333,7 @@ function NumberField({
const raw = e.target.value;
onChange(raw === "" ? undefined : Number(raw));
}}
className="w-32 bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-32 bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
);
@@ -352,7 +352,7 @@ function Segmented({
}) {
return (
<div
className={`flex items-center rounded overflow-hidden border border-[#2a2a2e] ${
className={`flex items-center rounded overflow-hidden border border-border ${
disabled ? "opacity-50" : ""
}`}
>
@@ -365,7 +365,7 @@ function Segmented({
className={`px-3 py-1 text-sm ${
value === opt.key
? "bg-blue-600 text-white"
: "bg-[#1f1f23] text-gray-400 hover:bg-[#252529]"
: "bg-input text-content-muted hover:bg-hover-2"
}`}
>
{opt.label}
+70
View File
@@ -0,0 +1,70 @@
import { useMemo, useState } from "react";
import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon";
import { iconList } from "../icons/extracted";
import { searchIcons, getIconMetadata } from "../icons/extracted/metadata";
interface IconPickerProps {
value?: string | null;
onValueChange: (icon: string) => void;
}
export function IconPicker({ value, onValueChange }: IconPickerProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const filteredIcons = useMemo(() => {
if (!searchQuery.trim()) return iconList;
return searchIcons(searchQuery);
}, [searchQuery]);
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-content-muted mb-2">
{t("iconPickerSearch")}
</label>
<input
type="text"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder={t("iconPickerSearchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="max-h-[60vh] overflow-y-auto pr-1">
<div className="grid grid-cols-6 sm:grid-cols-8 lg:grid-cols-10 gap-2">
{filteredIcons.map((iconName) => {
const meta = getIconMetadata(iconName);
const isSelected = value === iconName;
return (
<button
key={iconName}
type="button"
onClick={() => onValueChange(iconName)}
className={`flex flex-col items-center gap-1 p-2 rounded-lg border-2 transition-all duration-150 hover:bg-hover ${
isSelected
? "border-blue-500 bg-blue-500/10"
: "border-transparent hover:border-border"
}`}
title={meta?.displayName || iconName}
>
<ProviderIcon name={iconName} icon={iconName} size={32} />
<span className="text-xs text-content-muted truncate w-full text-center">
{meta?.displayName || iconName}
</span>
</button>
);
})}
</div>
{filteredIcons.length === 0 && (
<div className="text-center py-8 text-content-muted text-sm">
{t("iconPickerNoResults")}
</div>
)}
</div>
</div>
);
}
+134 -52
View File
@@ -1,10 +1,14 @@
import { useEffect, useId, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh";
import { getDefaultMaxContextSize } from "../lib/model-defaults";
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
import { getIconMetadata } from "../icons/extracted/metadata";
import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { ProviderIcon } from "./ProviderIcon";
import { IconPicker } from "./IconPicker";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
const KNOWN_CAPABILITIES = [
@@ -111,6 +115,7 @@ export function ProviderEdit({
const [activeTab, setActiveTab] = useState<"basic" | "models" | "json">("basic");
const [showApiKey, setShowApiKey] = useState(false);
const [showIconPicker, setShowIconPicker] = useState(false);
useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type);
@@ -121,13 +126,13 @@ export function ProviderEdit({
}, [provider.provider_type, agent]);
return (
<div className="h-full flex flex-col bg-[#0f0f11]">
<div className="h-full flex flex-col bg-app">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-[#2a2a2e] bg-[#16161a]">
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-panel">
<button
type="button"
onClick={onBack}
className="p-2 rounded-lg hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="p-2 rounded-lg hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
← {t("back")}
</button>
@@ -150,7 +155,7 @@ export function ProviderEdit({
</div>
{/* Tabs */}
<div className="flex items-center gap-1 px-4 border-b border-[#2a2a2e] bg-[#16161a]">
<div className="flex items-center gap-1 px-4 border-b border-border bg-panel">
{[
{ key: "basic", label: t("basicInfo") },
{ key: "models", label: t("modelMapping") },
@@ -163,7 +168,7 @@ export function ProviderEdit({
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors focus:ring-2 focus:ring-blue-500 focus:outline-none ${
activeTab === tab.key
? "border-blue-500 text-blue-400"
: "border-transparent text-gray-400 hover:text-[#e5e5e7]"
: "border-transparent text-content-muted hover:text-content-primary"
}`}
>
{tab.label}
@@ -176,27 +181,56 @@ export function ProviderEdit({
{activeTab === "basic" && (
<div className="space-y-6">
{/* Basic info */}
<section className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-5">
<h3 className="font-medium mb-4 text-[#e5e5e7]">{t("basicInfo")}</h3>
<section className="bg-panel border border-border rounded-xl p-5">
<h3 className="font-medium mb-4 text-content-primary">{t("basicInfo")}</h3>
{/* Icon selector */}
<div className="flex flex-col items-center gap-2 mb-5">
<button
type="button"
onClick={() => setShowIconPicker(true)}
className="w-20 h-20 p-3 rounded-xl border-2 border-border hover:border-blue-500 transition-colors cursor-pointer flex items-center justify-center focus:ring-2 focus:ring-blue-500 focus:outline-none"
title={provider.icon ? t("clickToChangeIcon") : t("clickToSelectIcon")}
>
<ProviderIcon
name={provider.name}
icon={provider.icon}
color={provider.icon_color}
size={48}
/>
</button>
{provider.icon && (
<button
type="button"
onClick={() =>
onChange({ ...provider, icon: null, icon_color: null })
}
className="text-xs text-content-muted hover:text-content-primary underline"
>
{t("clearIcon")}
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor={nameId} className="block text-sm text-gray-400 mb-1.5">
<label htmlFor={nameId} className="block text-sm text-content-muted mb-1.5">
{t("providerName")}
</label>
<input
id={nameId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.name}
onChange={(e) => onChange({ ...provider, name: e.target.value })}
/>
</div>
<div>
<label htmlFor={noteId} className="block text-sm text-gray-400 mb-1.5">
<label htmlFor={noteId} className="block text-sm text-content-muted mb-1.5">
{t("note")}
</label>
<input
id={noteId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.note || ""}
placeholder={t("notePlaceholder")}
onChange={(e) =>
@@ -205,12 +239,12 @@ export function ProviderEdit({
/>
</div>
<div className="col-span-1 md:col-span-2">
<label htmlFor={officialUrlId} className="block text-sm text-gray-400 mb-1.5">
<label htmlFor={officialUrlId} className="block text-sm text-content-muted mb-1.5">
{t("officialUrl")}
</label>
<input
id={officialUrlId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.official_url || ""}
onChange={(e) =>
onChange({ ...provider, official_url: e.target.value || null })
@@ -225,9 +259,9 @@ export function ProviderEdit({
onChange={(e) =>
onChange({ ...provider, managed: e.target.checked })
}
className="w-4 h-4 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
className="w-4 h-4 rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="managed" className="text-sm text-gray-400">
<label htmlFor="managed" className="text-sm text-content-muted">
{t("managedProvider")}
</label>
</div>
@@ -235,16 +269,16 @@ export function ProviderEdit({
</section>
{/* API settings */}
<section className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-5">
<h3 className="font-medium mb-4 text-[#e5e5e7]">{t("apiSettings")}</h3>
<section className="bg-panel border border-border rounded-xl p-5">
<h3 className="font-medium mb-4 text-content-primary">{t("apiSettings")}</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1.5">
<label className="block text-sm text-content-muted mb-1.5">
{t("apiFormat")}
</label>
<select
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.provider_type}
onChange={(e) =>
onChange({ ...provider, provider_type: e.target.value as ProviderType })
@@ -261,14 +295,14 @@ export function ProviderEdit({
{!provider.managed && (
<div>
<label htmlFor={apiKeyId} className="block text-sm text-gray-400 mb-1.5">
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
{t("apiKey")}
</label>
<div className="relative">
<input
id={apiKeyId}
type={showApiKey ? "text" : "password"}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key || ""}
onChange={(e) =>
onChange({ ...provider, api_key: e.target.value || null })
@@ -277,7 +311,7 @@ export function ProviderEdit({
<button
type="button"
onClick={() => setShowApiKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 text-xs px-1"
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary text-xs px-1"
>
{showApiKey ? t("hide") : t("show")}
</button>
@@ -286,19 +320,19 @@ export function ProviderEdit({
)}
<div>
<label htmlFor={baseUrlId} className="block text-sm text-gray-400 mb-1.5">
<label htmlFor={baseUrlId} className="block text-sm text-content-muted mb-1.5">
{t("requestUrl")}
</label>
<input
id={baseUrlId}
className="w-full bg-[#1f1f23] border border-[#2a2a2e] rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.base_url || ""}
placeholder={defaultBaseUrl(agent, provider.provider_type)}
onChange={(e) =>
onChange({ ...provider, base_url: e.target.value || null })
}
/>
<div className="mt-2 text-xs text-orange-400/80 bg-orange-900/20 border border-orange-500/20 rounded-lg px-3 py-2">
<div className="mt-2 text-xs text-orange-600/80 dark:text-orange-600 dark:text-orange-400/80 bg-orange-100 dark:bg-orange-900/20 border border-orange-300 dark:border-orange-500/20 rounded-lg px-3 py-2">
{t("baseUrlHint", { type: provider.provider_type })}
</div>
</div>
@@ -337,6 +371,52 @@ export function ProviderEdit({
/>
)}
</div>
{showIconPicker &&
createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onClick={() => setShowIconPicker(false)}
role="dialog"
aria-modal="true"
>
<div
className="w-full max-w-4xl rounded-xl border border-border bg-panel shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
<h2 className="text-lg font-semibold text-content-primary">
{t("selectIcon")}
</h2>
<button
type="button"
onClick={() => setShowIconPicker(false)}
className="rounded-md p-1 text-content-muted hover:bg-hover hover:text-content-primary transition-colors"
aria-label={t("close")}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-5">
<IconPicker
value={provider.icon}
onValueChange={(iconName) => {
const meta = getIconMetadata(iconName);
onChange({
...provider,
icon: iconName,
icon_color: meta?.defaultColor ?? null,
});
setShowIconPicker(false);
}}
/>
</div>
</div>
</div>,
document.body
)}
</div>
);
}
@@ -400,7 +480,9 @@ function ModelMapping({
const toAdd: Model[] = [];
for (const dm of discovered) {
if (!selected.has(dm.id)) continue;
const alias = dm.id.replace(/[^a-zA-Z0-9_-]/g, "-");
const safeProvider = provider.name.replace(/\//g, "-");
const safeModelId = dm.id.replace(/\//g, "-");
const alias = `${safeProvider}/${safeModelId}`;
const ref = getModelRef(dm.id);
// Priority: API-provided value > models.dev reference > regex fallback
const max_context_size =
@@ -430,14 +512,14 @@ function ModelMapping({
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-[#e5e5e7]">{t("modelMapping")}</h3>
<p className="text-xs text-gray-500 mt-1">{t("modelMappingDesc")}</p>
<h3 className="font-medium text-content-primary">{t("modelMapping")}</h3>
<p className="text-xs text-content-muted mt-1">{t("modelMappingDesc")}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onModelAdd}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("oneClickSetup")}
</button>
@@ -459,16 +541,16 @@ function ModelMapping({
)}
{discovered && (
<div className="bg-[#16161a] border border-[#2a2a2e] rounded-xl p-4 space-y-3">
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
<div className="text-sm font-medium">
{t("discoveredModels", { count: discovered.length })}
</div>
<label className="flex items-center gap-2 text-sm text-[#e5e5e7] cursor-pointer">
<label className="flex items-center gap-2 text-sm text-content-primary cursor-pointer">
<input
type="checkbox"
checked={fetchThinking}
onChange={(e) => setFetchThinking(e.target.checked)}
className="w-4 h-4 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
className="w-4 h-4 rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
{t("fetchEnableThinking")}
</label>
@@ -476,16 +558,16 @@ function ModelMapping({
{discovered.map((m) => (
<label
key={m.id}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-[#1f1f23] p-1.5 rounded"
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-input p-1.5 rounded"
>
<input
type="checkbox"
checked={selected.has(m.id)}
onChange={() => toggleSelect(m.id)}
/>
<span className="font-mono text-xs text-gray-300">{m.id}</span>
<span className="font-mono text-xs text-content-primary">{m.id}</span>
{m.display_name && (
<span className="text-gray-500">({m.display_name})</span>
<span className="text-content-muted">({m.display_name})</span>
)}
</label>
))}
@@ -501,9 +583,9 @@ function ModelMapping({
</div>
)}
<div className="bg-[#16161a] border border-[#2a2a2e] rounded-xl overflow-auto">
<div className="bg-panel border border-border rounded-xl overflow-auto">
<table className="w-full min-w-[640px] text-sm">
<thead className="bg-[#1f1f23] text-gray-400">
<thead className="bg-input text-content-muted">
<tr>
<th className="text-left px-4 py-3 font-medium">{t("displayName")}</th>
<th className="text-left px-4 py-3 font-medium">{t("actualModel")}</th>
@@ -518,15 +600,15 @@ function ModelMapping({
</thead>
<tbody className="divide-y divide-[#2a2a2e]">
{models.map((m) => (
<tr key={m.alias} className="hover:bg-[#1c1c20]">
<tr key={m.alias} className="hover:bg-hover">
<td className="px-4 py-2">
<span className="text-sm text-gray-400">
<span className="text-sm text-content-muted">
{m.model ? `${m.model}[${m.provider}]` : m.alias}
</span>
</td>
<td className="px-4 py-2">
<input
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full bg-transparent border border-border rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={m.model}
onChange={(e) =>
onModelChange({ ...m, model: e.target.value })
@@ -538,7 +620,7 @@ function ModelMapping({
type="number"
min={0}
step={1024}
className="w-full bg-transparent border border-[#2a2a2e] rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full bg-transparent border border-border rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={m.max_context_size}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
@@ -559,7 +641,7 @@ function ModelMapping({
supports_1m: e.target.checked,
})
}
className="w-4 h-4 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
className="w-4 h-4 rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
</td>
{agent === "kimi_code" && (
@@ -578,8 +660,8 @@ function ModelMapping({
onClick={() => onSetDefault(m.alias)}
className={`text-xs px-2 py-1 rounded border ${
defaultModel === m.alias
? "bg-green-900/30 text-green-400 border-green-500/30"
: "border-[#2a2a2e] text-gray-400 hover:bg-[#252529]"
? "bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30"
: "border-border text-content-muted hover:bg-hover-2"
}`}
>
{defaultModel === m.alias ? t("isDefault") : t("setDefault")}
@@ -603,7 +685,7 @@ function ModelMapping({
</tbody>
</table>
{models.length === 0 && (
<div className="text-center py-8 text-gray-500 text-sm">
<div className="text-center py-8 text-content-muted text-sm">
{t("noModelMappings")}
</div>
)}
@@ -612,7 +694,7 @@ function ModelMapping({
<button
type="button"
onClick={onModelAdd}
className="px-4 py-2 border border-[#2a2a2e] rounded-lg hover:bg-[#252529] text-sm"
className="px-4 py-2 border border-border rounded-lg hover:bg-hover-2 text-sm"
>
{t("addModelMapping")}
</button>
@@ -659,9 +741,9 @@ function JsonPreview({
return (
<div className="w-full h-full flex flex-col">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-[#e5e5e7]">{t("configJson")}</h3>
<h3 className="font-medium text-content-primary">{t("configJson")}</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{t("readOnlyPreview")}</span>
<span className="text-xs text-content-muted">{t("readOnlyPreview")}</span>
<button
type="button"
onClick={handleApply}
@@ -677,7 +759,7 @@ function JsonPreview({
</div>
)}
<textarea
className="flex-1 min-h-[40vh] bg-[#16161a] border border-[#2a2a2e] rounded-xl p-4 overflow-auto text-xs font-mono text-green-400 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
className="flex-1 min-h-[40vh] bg-panel border border-border rounded-xl p-4 overflow-auto text-xs font-mono text-green-600 dark:text-green-400 focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
@@ -710,19 +792,19 @@ function CapabilitiesCell({
{KNOWN_CAPABILITIES.map((cap) => (
<label
key={cap}
className="flex items-center gap-1 text-xs text-gray-400 cursor-pointer"
className="flex items-center gap-1 text-xs text-content-muted cursor-pointer"
>
<input
type="checkbox"
checked={capabilities.includes(cap)}
onChange={(e) => toggle(cap, e.target.checked)}
className="w-3.5 h-3.5 rounded border-[#2a2a2e] bg-[#1f1f23] text-blue-600 focus:ring-blue-500"
className="w-3.5 h-3.5 rounded border-border bg-input text-blue-600 focus:ring-blue-500"
/>
{t(CAPABILITY_LABELS[cap])}
</label>
))}
<input
className="w-28 bg-transparent border border-[#2a2a2e] rounded px-1.5 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-28 bg-transparent border border-border rounded px-1.5 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={customs.join(", ")}
placeholder={t("customCapabilities")}
onChange={(e) => {
+137
View File
@@ -0,0 +1,137 @@
import { useMemo } from "react";
import { inferIcon } from "../icons/inference";
import { getIcon, getIconUrl, hasIcon, isUrlIcon } from "../icons/extracted";
import { getIconMetadata } from "../icons/extracted/metadata";
interface ProviderIconProps {
name: string;
icon?: string | null;
color?: string | null;
size?: number;
}
/**
* Provider icon renderer:
* 1. Explicit icon prop (extracted icons)
* 2. Name-based keyword inference (simplified brand icons)
* 3. Initials fallback on a muted background
*
* The same provider name/icon always yields the same result.
*/
export function ProviderIcon({ name, icon, color, size = 44 }: ProviderIconProps) {
const explicitIcon = icon?.trim() || undefined;
const explicitSvg = useMemo(() => {
if (explicitIcon && !isUrlIcon(explicitIcon) && hasIcon(explicitIcon)) {
return getIcon(explicitIcon);
}
return "";
}, [explicitIcon]);
const explicitUrl = useMemo(() => {
if (explicitIcon && isUrlIcon(explicitIcon)) {
return getIconUrl(explicitIcon);
}
return "";
}, [explicitIcon]);
const explicitColor = useMemo(() => {
if (color && typeof color === "string" && color.trim() !== "") {
return color;
}
if (explicitIcon) {
const metadata = getIconMetadata(explicitIcon);
if (metadata?.defaultColor && metadata.defaultColor !== "currentColor") {
return metadata.defaultColor;
}
}
return undefined;
}, [color, explicitIcon]);
// Tier 1: explicit extracted icon
if (explicitSvg) {
return (
<div
className="flex items-center justify-center rounded-xl shrink-0"
style={{
width: size,
height: size,
backgroundColor: (explicitColor || "#6b7280") + "1a",
}}
>
<div
className="flex items-center justify-center"
style={{
color: explicitColor,
width: size * 0.58,
height: size * 0.58,
}}
dangerouslySetInnerHTML={{ __html: explicitSvg }}
/>
</div>
);
}
if (explicitUrl) {
return (
<div
className="flex items-center justify-center rounded-xl shrink-0 overflow-hidden"
style={{
width: size,
height: size,
backgroundColor: (explicitColor || "#6b7280") + "1a",
}}
>
<img
src={explicitUrl}
alt={name}
title={name}
className="object-contain"
style={{ width: size * 0.68, height: size * 0.68 }}
loading="lazy"
/>
</div>
);
}
// Tier 2: name-based inference (simplified brands)
const inferred = inferIcon(name);
if (inferred) {
const { svg, color: inferredColor } = inferred.icon;
const effectiveColor = color || inferredColor;
return (
<div
className="flex items-center justify-center rounded-xl shrink-0"
style={{ width: size, height: size, backgroundColor: effectiveColor + "1a" }}
>
<div
className="flex items-center justify-center"
style={{ color: effectiveColor, width: size * 0.58, height: size * 0.58 }}
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
);
}
// Tier 3: initials fallback
const initials = name
.split(/[\s\-_\/\.]+/)
.filter(Boolean)
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<div
className="flex items-center justify-center rounded-xl shrink-0 font-semibold bg-muted text-muted-foreground"
style={{
width: size,
height: size,
fontSize: Math.round(size * 0.38),
}}
>
{initials || "?"}
</div>
);
}
+21 -56
View File
@@ -1,4 +1,5 @@
import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon";
import type { Agent, Model, Provider } from "../types";
interface ProviderListProps {
@@ -12,41 +13,6 @@ interface ProviderListProps {
agent: Agent;
}
const PROVIDER_ICONS: Record<string, string> = {
kimi: "K",
anthropic: "A",
openai: "O",
openai_responses: "R",
"google-genai": "G",
vertexai: "V",
};
function getInitial(provider: Provider): string {
return (
PROVIDER_ICONS[provider.provider_type] ||
provider.name.charAt(0).toUpperCase()
);
}
function getProviderColor(type: string): string {
switch (type) {
case "kimi":
return "from-blue-500 to-cyan-400";
case "anthropic":
return "from-orange-500 to-red-400";
case "openai":
return "from-green-500 to-emerald-400";
case "openai_responses":
return "from-teal-500 to-green-400";
case "google-genai":
return "from-purple-500 to-pink-400";
case "vertexai":
return "from-indigo-500 to-purple-400";
default:
return "from-gray-500 to-gray-400";
}
}
export function ProviderList({
providers,
defaultModel,
@@ -60,8 +26,8 @@ export function ProviderList({
const { t } = useTranslation();
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2a2e]">
<h2 className="font-medium text-[#e5e5e7]">{t("providers")}</h2>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="font-medium text-content-primary">{t("providers")}</h2>
<button
type="button"
onClick={onAdd}
@@ -86,7 +52,7 @@ export function ProviderList({
<div className="flex-1 overflow-auto p-4">
{providers.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="text-center py-12 text-content-muted">
<div className="text-4xl mb-3 opacity-30">⊘</div>
<div>{t("noProviders")}</div>
<button
@@ -125,40 +91,39 @@ export function ProviderList({
key={provider.name}
className={`group relative flex items-center gap-4 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
isActive
? "bg-green-900/10 border-green-500/30 hover:border-green-500/50 hover:bg-green-900/20"
: "bg-[#16161a] border-[#2a2a2e] hover:border-[#3a3a42] hover:bg-[#1c1c20]"
? "bg-green-50 dark:bg-green-900/10 border-green-300 dark:border-green-500/30 hover:border-green-400 dark:border-green-500/50 hover:bg-green-100 dark:bg-green-900/20"
: "bg-panel border-border hover:border-strong hover:bg-hover"
}`}
onClick={() => onEdit(provider.name)}
>
<div
className={`w-11 h-11 rounded-xl bg-gradient-to-br ${getProviderColor(
provider.provider_type
)} flex items-center justify-center text-white font-bold text-lg shadow-lg`}
>
{getInitial(provider)}
</div>
<ProviderIcon
name={provider.name}
icon={provider.icon}
color={provider.icon_color}
size={44}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[#e5e5e7] truncate">
<h3 className="font-semibold text-content-primary truncate">
{provider.name}
</h3>
{isActive && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-900/30 text-green-400 border border-green-500/30">
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/30">
{t("inUse")}
</span>
)}
{provider.note && (
<span className="text-xs text-gray-500 truncate max-w-[200px]">
<span className="text-xs text-content-muted truncate max-w-[200px]">
{provider.note}
</span>
)}
</div>
<div className="mt-1 flex items-center gap-3 text-sm text-gray-400 flex-wrap">
<div className="mt-1 flex items-center gap-3 text-sm text-content-muted flex-wrap">
<span className="font-mono text-xs">
{provider.official_url || provider.base_url || t("noUrl")}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-[#252529] text-gray-400 border border-[#2a2a2e]">
<span className="text-xs px-2 py-0.5 rounded-full bg-hover-2 text-content-muted border border-border">
{provider.provider_type}
</span>
<span className="text-xs">
@@ -170,7 +135,7 @@ export function ProviderList({
<div className="flex items-center flex-wrap justify-end gap-2">
{defaultModel &&
models[defaultModel]?.provider === provider.name && (
<span className="text-xs px-2 py-1 rounded-full bg-green-900/30 text-green-400 border border-green-500/20">
<span className="text-xs px-2 py-1 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/20">
{t("defaultModel", { name: defaultModelName ?? "" })}
</span>
)}
@@ -190,7 +155,7 @@ export function ProviderList({
</button>
{agent === "kimi_code" && (
<span
className="text-sm text-gray-500 hover:text-gray-300 cursor-help select-none"
className="text-sm text-content-muted hover:text-content-primary cursor-help select-none"
title={t("switchReloadHint")}
aria-label={t("switchReloadHint")}
>
@@ -203,7 +168,7 @@ export function ProviderList({
e.stopPropagation();
onEdit(provider.name);
}}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
{t("edit")}
</button>
@@ -213,7 +178,7 @@ export function ProviderList({
e.stopPropagation();
onDelete(provider.name);
}}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-red-900/30 hover:border-red-500/30 text-red-400 focus:ring-2 focus:ring-red-500 focus:outline-none"
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-red-900/30 hover:border-red-500/30 text-red-400 focus:ring-2 focus:ring-red-500 focus:outline-none"
>
{t("delete")}
</button>
+346
View File
@@ -0,0 +1,346 @@
import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useTranslation, type Language } from "../i18n";
import { useTheme, type Theme } from "../hooks/useTheme";
import type { UpdateInfo } from "../hooks/useUpdateCheck";
function formatVersion(version: string): string {
const v = version.trim();
return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`;
}
interface SettingsModalProps {
open: boolean;
onClose: () => void;
updateInfo: UpdateInfo | null;
checking: boolean;
onCheckUpdate: () => void;
lastChecked: number | null;
}
export function SettingsModal({
open,
onClose,
updateInfo,
checking,
onCheckUpdate,
lastChecked,
}: SettingsModalProps) {
const { t, lang, setLang } = useTranslation();
const { theme, setTheme } = useTheme();
const [appVersion, setAppVersion] = useState("");
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadedPath, setDownloadedPath] = useState<string | null>(null);
const [downloadError, setDownloadError] = useState<string | null>(null);
useEffect(() => {
if (open) {
invoke<string>("get_app_version").then(setAppVersion).catch(() => {});
}
}, [open]);
// Listen to download progress events
useEffect(() => {
const unlisteners: (() => void)[] = [];
const setupProgress = async () => {
const un1 = await listen<{ downloaded: number; total: number; progress: number }>(
"download-progress",
(event) => {
setDownloadProgress(event.payload.progress);
}
);
unlisteners.push(() => un1());
const un2 = await listen<{ path: string }>(
"download-complete",
(event) => {
setDownloadedPath(event.payload.path);
setDownloadProgress(100);
}
);
unlisteners.push(() => un2());
};
setupProgress();
return () => unlisteners.forEach((fn) => fn());
}, []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const handleDownload = async () => {
if (!updateInfo?.downloadUrl) return;
setDownloadProgress(0);
setDownloadError(null);
setDownloadedPath(null);
try {
const path = await invoke<string>("download_update", {
url: updateInfo.downloadUrl,
});
setDownloadedPath(path);
setDownloadProgress(100);
} catch (e) {
setDownloadError(e instanceof Error ? e.message : String(e));
setDownloadProgress(null);
}
};
const handleInstall = () => {
if (!downloadedPath) return;
invoke("open_installer", { path: downloadedPath }).catch(() => {});
};
if (!open) return null;
const themeOptions: { value: Theme; icon: ReactNode; label: string }[] = [
{
value: "light",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
),
label: t("themeLight"),
},
{
value: "dark",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
),
label: t("themeDark"),
},
{
value: "auto",
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 21h8M12 16v5" />
</svg>
),
label: t("themeAuto"),
},
];
const fmtLastChecked = (ts: number | null) => {
if (!ts) return "—";
const d = new Date(ts);
return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
};
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<div
className="w-full max-w-lg rounded-xl border border-border bg-panel shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
<h2 className="text-lg font-semibold text-content-primary">
{t("settingsTitle")}
</h2>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-content-muted hover:bg-hover hover:text-content-primary transition-colors"
aria-label={t("close")}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto p-5 space-y-5">
{/* Appearance */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("settingsAppearance")}
</h3>
<div className="space-y-3">
{/* Theme */}
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-content-muted">
{t("themeLight")} / {t("themeDark")}
</span>
<div className="flex h-8 items-center gap-1 bg-input rounded border border-border p-0.5">
{themeOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setTheme(opt.value)}
title={opt.label}
className={`flex h-7 items-center gap-1 px-2 text-sm rounded transition-colors ${
theme === opt.value
? "bg-blue-600 text-white"
: "text-content-muted hover:text-content-primary"
}`}
>
{opt.icon}
<span className="hidden sm:inline">{opt.label}</span>
</button>
))}
</div>
</div>
{/* Language */}
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-content-muted">{t("language")}</span>
<select
value={lang}
onChange={(e) => setLang(e.target.value as Language)}
className="h-8 bg-input border border-border rounded px-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
>
<option value="zh">{t("zh")}</option>
<option value="en">{t("en")}</option>
</select>
</div>
</div>
</section>
{/* Updates */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("settingsUpdates")}
</h3>
<div className="space-y-2 rounded-lg border border-border bg-input p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("currentVersion")}</span>
<span className="font-medium text-content-primary tabular-nums">
v{appVersion || "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("latestVersion")}</span>
<span className="font-medium text-content-primary tabular-nums">
{updateInfo ? formatVersion(updateInfo.latest) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-content-muted">{t("lastChecked")}</span>
<span className="text-content-muted tabular-nums">
{fmtLastChecked(lastChecked)}
</span>
</div>
<div className="pt-2 flex items-center gap-2">
<button
type="button"
onClick={onCheckUpdate}
disabled={checking}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors"
>
{checking ? t("checking") : t("checkUpdate")}
</button>
{updateInfo?.updateAvailable && downloadProgress === null && !downloadedPath && (
<button
type="button"
onClick={handleDownload}
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-500 transition-colors"
>
{t("downloadUpdate")} ↓
</button>
)}
{downloadedPath && (
<button
type="button"
onClick={handleInstall}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors"
>
{t("install")} ⏎
</button>
)}
</div>
{/* Download progress bar */}
{downloadProgress !== null && downloadProgress < 100 && (
<div className="pt-1">
<div className="flex items-center justify-between text-xs text-content-muted mb-1">
<span>{t("downloading")}</span>
<span className="tabular-nums">{downloadProgress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-hover">
<div
className="h-full rounded-full bg-blue-500 transition-all duration-150"
style={{ width: `${downloadProgress}%` }}
/>
</div>
</div>
)}
{downloadedPath && (
<div className="pt-1 text-sm text-green-600 dark:text-green-500">
✓ {t("downloadComplete")}
</div>
)}
{downloadError && (
<div className="pt-1 text-sm text-red-500">
{t("updateCheckFailed")}: {downloadError}
</div>
)}
{updateInfo?.updateAvailable && (
<div className="pt-1 text-sm text-green-600 dark:text-green-500">
✦ {t("updateAvailable")}: v{updateInfo.latest}
</div>
)}
{updateInfo && !updateInfo.updateAvailable && (
<div className="pt-1 text-sm text-content-muted">
✓ {t("upToDate")}
</div>
)}
</div>
</section>
{/* About */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("settingsAbout")}
</h3>
<div className="space-y-1.5 text-sm text-content-muted">
<div className="font-medium text-content-primary">Kimi Switch</div>
<div>{t("appDescription")}</div>
<div className="text-xs text-content-muted">v{appVersion} · MIT License</div>
<div className="pt-2">
<span className="text-content-muted">{t("referenceProject")}: </span>
<button
type="button"
onClick={() =>
openUrl("https://github.com/JochenYang/kimicode-dashboard")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
kimicode-dashboard
</button>
<span className="text-content-muted"> (MIT, © JochenYang)</span>
</div>
</div>
</section>
</div>
</div>
</div>,
document.body
);
}
+5 -5
View File
@@ -49,7 +49,7 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
if (!daily?.length) {
return (
<div className="flex h-48 items-center justify-center text-sm text-gray-500">
<div className="flex h-48 items-center justify-center text-sm text-content-muted">
{t("noData")}
</div>
);
@@ -58,11 +58,11 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
const max = Math.max(...daily.map((d) => d.totalTokens || 0), 1);
return (
<div className="space-y-3">
<div className="flex h-full flex-col gap-3">
{/* Legend */}
<div className="flex flex-wrap gap-x-4 gap-y-1">
{modelNames.map((m) => (
<span key={m} className="flex items-center gap-1.5 text-[11px] text-gray-400">
<span key={m} className="flex items-center gap-1.5 text-[11px] text-content-muted">
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: colorMap[m] }}
@@ -73,7 +73,7 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
</div>
{/* Bars */}
<div className="flex h-52 items-end gap-1 overflow-x-auto px-0.5 pb-1">
<div className="flex min-h-[180px] flex-1 items-end gap-1 overflow-x-auto px-0.5 pb-1">
{daily.map((d) => {
const h = Math.max(4, Math.round(((d.totalTokens || 0) / max) * 170));
const total = d.totalTokens || 1;
@@ -121,7 +121,7 @@ export function DailyBars({ daily, modelNames }: DailyBarsProps) {
/>
))}
</div>
<span className="whitespace-nowrap text-[10px] text-gray-500">
<span className="whitespace-nowrap text-[10px] text-content-muted">
{d.date.slice(5)}
</span>
</button>
+17 -17
View File
@@ -56,16 +56,16 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
aria-label={day.date}
>
<div
className="w-full max-w-lg rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-2xl"
className="w-full max-w-lg rounded-xl border border-border bg-panel shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between border-b border-[#2a2a2e] px-5 py-3.5">
<div className="flex items-start justify-between border-b border-border px-5 py-3.5">
<div>
<div className="text-[11px] uppercase tracking-wider text-gray-500">
<div className="text-[11px] uppercase tracking-wider text-content-muted">
{t("dayDetail")}
</div>
<div className="mt-0.5 text-lg font-semibold text-[#e5e5e7]">
<div className="mt-0.5 text-lg font-semibold text-content-primary">
{day.date}
</div>
</div>
@@ -73,7 +73,7 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
type="button"
onClick={onClose}
aria-label={t("close")}
className="rounded-md p-1 text-gray-500 hover:bg-[#2a2a2e] hover:text-gray-200 transition-colors"
className="rounded-md p-1 text-content-muted hover:bg-border hover:text-content-primary transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -117,11 +117,11 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
{/* Per-model breakdown */}
<div className="px-5 pb-4">
<div className="mb-2 text-[11px] uppercase tracking-wider text-gray-500">
<div className="mb-2 text-[11px] uppercase tracking-wider text-content-muted">
{t("modelDistribution")} · {segments.length}
</div>
{segments.length === 0 ? (
<div className="py-6 text-center text-sm text-gray-500">
<div className="py-6 text-center text-sm text-content-muted">
{t("noData")}
</div>
) : (
@@ -135,19 +135,19 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span
className="truncate text-[#e5e5e7]"
className="truncate text-content-primary"
title={s.model}
>
{shortModel(s.model)}
</span>
<span className="shrink-0 text-xs text-gray-400 tabular-nums">
<span className="shrink-0 text-xs text-content-muted tabular-nums">
{fmtTokens(s.tokens)}{" "}
<span className="text-gray-500">
<span className="text-content-muted">
· {s.pct.toFixed(1)}%
</span>
</span>
</div>
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-[#2a2a2e]">
<div className="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-border">
<div
className="h-full rounded-full transition-all"
style={{
@@ -164,7 +164,7 @@ export function DailyDetailModal({ day, colorMap, onClose }: DailyDetailModalPro
</div>
{/* Footer hint */}
<div className="border-t border-[#2a2a2e] px-5 py-2 text-[11px] text-gray-500">
<div className="border-t border-border px-5 py-2 text-[11px] text-content-muted">
{t("modalEscHint")}
</div>
</div>
@@ -183,15 +183,15 @@ function SummaryChip({
}) {
const valueClass =
tone === "orange"
? "text-orange-400"
? "text-orange-600 dark:text-orange-400"
: tone === "blue"
? "text-blue-400"
: tone === "green"
? "text-emerald-400"
: "text-[#e5e5e7]";
? "text-emerald-600 dark:text-emerald-400"
: "text-content-primary";
return (
<div className="rounded-md bg-[#1c1c20] px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-gray-500">
<div className="rounded-md bg-hover px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-content-muted">
{label}
</div>
<div className={`mt-0.5 text-sm font-semibold tabular-nums ${valueClass}`}>
+54 -52
View File
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
import { DailyBars } from "./DailyBars";
@@ -21,10 +22,10 @@ function Card({
children: ReactNode;
}) {
return (
<div className="rounded-xl border border-[#2a2a2e] bg-[#16161a]">
<div className="border-b border-[#2a2a2e] px-4 py-2.5 flex items-baseline justify-between">
<h3 className="text-sm font-medium text-[#e5e5e7]">{title}</h3>
{subtitle && <span className="text-xs text-gray-500">{subtitle}</span>}
<div className="rounded-xl border border-border bg-panel">
<div className="border-b border-border px-4 py-2.5 flex items-baseline justify-between">
<h3 className="text-sm font-medium text-content-primary">{title}</h3>
{subtitle && <span className="text-xs text-content-muted">{subtitle}</span>}
</div>
<div className="p-4">{children}</div>
</div>
@@ -33,12 +34,12 @@ function Card({
function KpiCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="rounded-xl border border-[#2a2a2e] bg-[#16161a] p-3">
<div className="text-[11px] text-gray-500">{label}</div>
<div className="mt-1 text-lg font-semibold text-[#e5e5e7] tracking-tight">
<div className="rounded-xl border border-border bg-panel p-3">
<div className="text-[11px] text-content-muted">{label}</div>
<div className="mt-1 text-lg font-semibold text-content-primary tracking-tight">
{value}
</div>
{sub && <div className="mt-0.5 text-[11px] text-gray-500">{sub}</div>}
{sub && <div className="mt-0.5 text-[11px] text-content-muted">{sub}</div>}
</div>
);
}
@@ -50,7 +51,7 @@ export function DashboardPage() {
if (loading && !data) {
return (
<div className="flex h-full items-center justify-center text-gray-500">
<div className="flex h-full items-center justify-center text-content-muted">
<div className="animate-spin h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full" />
</div>
);
@@ -61,7 +62,7 @@ export function DashboardPage() {
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="text-red-400 mb-2">加载失败</div>
<div className="text-sm text-gray-500">{error}</div>
<div className="text-sm text-content-muted">{error}</div>
<button
onClick={() => refresh()}
className="mt-4 px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white text-sm"
@@ -109,7 +110,7 @@ export function DashboardPage() {
<div className="h-full overflow-auto p-4 space-y-4">
{/* Range tabs + refresh */}
<div className="flex items-center justify-between">
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
<div className="flex items-center bg-input border border-border rounded p-0.5">
{RANGES.map((r) => (
<button
key={r.value}
@@ -118,7 +119,7 @@ export function DashboardPage() {
className={`px-3 py-1 text-sm rounded transition-colors ${
range === r.value
? "bg-blue-600 text-white"
: "text-gray-400 hover:text-[#e5e5e7]"
: "text-content-muted hover:text-content-primary"
}`}
>
{r.label}
@@ -129,7 +130,7 @@ export function DashboardPage() {
type="button"
onClick={() => refresh()}
disabled={loading}
className="px-3 py-1.5 text-sm border border-[#2a2a2e] rounded hover:bg-[#252529] text-gray-400 hover:text-[#e5e5e7] disabled:opacity-50"
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 text-content-muted hover:text-content-primary disabled:opacity-50"
>
{loading ? "刷新中…" : "↻ 刷新"}
</button>
@@ -149,14 +150,14 @@ export function DashboardPage() {
className={`rounded-xl border p-4 text-left transition-colors ${
active
? "border-blue-500/50 ring-1 ring-blue-500/30 bg-blue-900/10"
: "border-[#2a2a2e] bg-[#16161a] hover:border-[#3a3a42] hover:bg-[#1c1c20]"
: "border-border bg-panel hover:border-strong hover:bg-hover"
}`}
>
<div className="text-xs text-gray-500">{r.label}</div>
<div className="mt-1 text-lg font-semibold text-[#e5e5e7] tracking-tight">
<div className="text-xs text-content-muted">{r.label}</div>
<div className="mt-1 text-lg font-semibold text-content-primary tracking-tight">
{fmtTokens(tot.totalTokens)}
</div>
<div className="mt-1 text-[11px] text-gray-500">
<div className="mt-1 text-[11px] text-content-muted">
{fmtInt(tot.requests)} 次 · {fmtUsd(tot.costUsd)} · {fmtPct(tot.cacheHitRate)}
</div>
</button>
@@ -186,20 +187,22 @@ export function DashboardPage() {
: `按模型着色 · ${daily.length} 天`
}
>
<DailyBars
daily={daily.length > 30 ? daily.slice(-30) : daily}
modelNames={modelNames}
/>
<div className="h-full min-h-[240px]">
<DailyBars
daily={daily.length > 30 ? daily.slice(-30) : daily}
modelNames={modelNames}
/>
</div>
</Card>
<Card title="模型用量" subtitle={`${models.length} 个模型`}>
{models.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500">暂无数据</div>
<div className="py-8 text-center text-sm text-content-muted">暂无数据</div>
) : (
<div className="max-h-[360px] overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[#16161a]">
<tr className="text-left text-xs text-gray-500 border-b border-[#2a2a2e]">
<thead className="sticky top-0 bg-panel">
<tr className="text-left text-xs text-content-muted border-b border-border">
<th className="pb-2 pr-3 font-normal">模型</th>
<th className="pb-2 pr-3 font-normal text-right">请求</th>
<th className="pb-2 pr-3 font-normal text-right">Token</th>
@@ -211,7 +214,7 @@ export function DashboardPage() {
{visibleModels.map((m) => (
<tr
key={m.model}
className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]"
className="border-b border-border/50 hover:bg-hover"
>
<td className="py-2 pr-3">
<div className="flex items-center gap-2">
@@ -225,17 +228,17 @@ export function DashboardPage() {
}}
/>
<div className="min-w-0">
<div className="text-[#e5e5e7] truncate">{m.modelDisplay || m.model}</div>
<div className="text-content-primary truncate">{m.modelDisplay || m.model}</div>
{m.costEstimated && (
<span className="text-[10px] text-yellow-600">估算</span>
)}
</div>
</div>
</td>
<td className="py-2 pr-3 text-right text-gray-400">{fmtInt(m.requests)}</td>
<td className="py-2 pr-3 text-right text-gray-400">{fmtTokens(m.totalTokens)}</td>
<td className="py-2 pr-3 text-right text-gray-400">{fmtPct(m.cacheHitRate)}</td>
<td className="py-2 text-right text-orange-400">{fmtUsd(m.costUsd)}</td>
<td className="py-2 pr-3 text-right text-content-muted">{fmtInt(m.requests)}</td>
<td className="py-2 pr-3 text-right text-content-muted">{fmtTokens(m.totalTokens)}</td>
<td className="py-2 pr-3 text-right text-content-muted">{fmtPct(m.cacheHitRate)}</td>
<td className="py-2 text-right text-orange-600 dark:text-orange-400">{fmtUsd(m.costUsd)}</td>
</tr>
))}
</tbody>
@@ -264,13 +267,13 @@ export function DashboardPage() {
}
>
{recent.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500">暂无数据</div>
<div className="py-8 text-center text-sm text-content-muted">暂无数据</div>
) : (
<>
<div className="overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[#16161a]">
<tr className="text-left text-xs text-gray-500 border-b border-[#2a2a2e]">
<thead className="sticky top-0 bg-panel">
<tr className="text-left text-xs text-content-muted border-b border-border">
<th className="pb-2 pr-4 font-normal">时间</th>
<th className="pb-2 pr-4 font-normal">模型</th>
<th className="pb-2 pr-4 font-normal text-right">输入</th>
@@ -281,15 +284,15 @@ export function DashboardPage() {
</thead>
<tbody>
{recentPageRows.map((r, i) => (
<tr key={recentStart + i} className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]">
<td className="py-1.5 pr-4 text-gray-500 whitespace-nowrap">
<tr key={recentStart + i} className="border-b border-border/50 hover:bg-hover">
<td className="py-1.5 pr-4 text-content-muted whitespace-nowrap">
{fmtTime(r.time)}
</td>
<td className="py-1.5 pr-4 text-[#e5e5e7]">{r.modelDisplay || r.model}</td>
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.inputOther)}</td>
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.output)}</td>
<td className="py-1.5 pr-4 text-right text-gray-400">{fmtTokens(r.inputCacheRead)}</td>
<td className="py-1.5 text-right text-orange-400">{fmtUsd(r.costUsd)}</td>
<td className="py-1.5 pr-4 text-content-primary">{r.modelDisplay || r.model}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputOther)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.output)}</td>
<td className="py-1.5 pr-4 text-right text-content-muted">{fmtTokens(r.inputCacheRead)}</td>
<td className="py-1.5 text-right text-orange-600 dark:text-orange-400">{fmtUsd(r.costUsd)}</td>
</tr>
))}
</tbody>
@@ -297,7 +300,7 @@ export function DashboardPage() {
</div>
{/* Pagination footer */}
<div className="mt-2 flex items-center justify-between gap-2 border-t border-[#2a2a2e] pt-2 text-xs text-gray-500">
<div className="mt-2 flex items-center justify-between gap-2 border-t border-border pt-2 text-xs text-content-muted">
<span>
第 {fmtInt(recentCurrentPage)} / {fmtInt(recentTotalPages)} 页 ·{" "}
{fmtInt(recentStart + 1)}–{fmtInt(Math.min(recentStart + RECENT_PAGE_SIZE, recent.length))} 条 ·{" "}
@@ -308,7 +311,7 @@ export function DashboardPage() {
type="button"
disabled={recentCurrentPage <= 1}
onClick={() => setRecentPage(1)}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="首页"
>
«
@@ -317,7 +320,7 @@ export function DashboardPage() {
type="button"
disabled={recentCurrentPage <= 1}
onClick={() => setRecentPage((p) => Math.max(1, p - 1))}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="上一页"
>
‹
@@ -326,7 +329,7 @@ export function DashboardPage() {
type="button"
disabled={recentCurrentPage >= recentTotalPages}
onClick={() => setRecentPage((p) => Math.min(recentTotalPages, p + 1))}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="下一页"
>
›
@@ -335,7 +338,7 @@ export function DashboardPage() {
type="button"
disabled={recentCurrentPage >= recentTotalPages}
onClick={() => setRecentPage(recentTotalPages)}
className="px-2 py-1 rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 rounded border border-border hover:bg-hover-2 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="末页"
>
»
@@ -347,19 +350,18 @@ export function DashboardPage() {
</Card>
{/* Attribution footer */}
<div className="text-xs text-gray-500 pb-2">
<div className="text-xs text-content-muted pb-2">
扫描 {data.meta.filesScanned} 个文件 · {fmtInt(data.meta.recordCount)} 条记录 ·{" "}
{data.meta.home}
<br />
仪表盘功能基于{" "}
<a
href="https://github.com/JochenYang/kimicode-dashboard"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-400 underline"
<button
type="button"
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
kimicode-dashboard
</a>{" "}
</button>{" "}
(MIT,© JochenYang)移植
</div>
</div>
+9 -9
View File
@@ -9,7 +9,7 @@ interface HeatmapProps {
}
const LEVEL_BG = [
"bg-[#1a1a1e]", // 0 — empty / no usage
"bg-cell-empty", // 0 — empty / no usage
"bg-emerald-900/55",
"bg-emerald-700/65",
"bg-emerald-500/85",
@@ -101,7 +101,7 @@ export function Heatmap({ heatmap }: HeatmapProps) {
if (!cells.length) {
return (
<div className="flex h-28 items-center justify-center text-sm text-gray-500">
<div className="flex h-28 items-center justify-center text-sm text-content-muted">
{t("noData")}
</div>
);
@@ -129,7 +129,7 @@ export function Heatmap({ heatmap }: HeatmapProps) {
return (
<div
key={wi}
className="text-[10px] leading-none text-gray-500 whitespace-nowrap overflow-visible"
className="text-[10px] leading-none text-content-muted whitespace-nowrap overflow-visible"
>
{label ? label.label : ""}
</div>
@@ -146,7 +146,7 @@ export function Heatmap({ heatmap }: HeatmapProps) {
{dowLabels.map((d, i) => (
<div
key={d}
className={`flex items-center text-[10px] text-gray-500 ${
className={`flex items-center text-[10px] text-content-muted ${
i % 2 === 1 ? "opacity-100" : "opacity-0"
}`}
style={{ height: CELL }}
@@ -193,7 +193,7 @@ export function Heatmap({ heatmap }: HeatmapProps) {
</div>
</div>
<div className="flex items-center justify-between gap-3 text-[11px] text-gray-500">
<div className="flex items-center justify-between gap-3 text-[11px] text-content-muted">
<span>
{heatmap.start} → {heatmap.end}
</span>
@@ -234,15 +234,15 @@ function HeatmapTooltip({ state }: { state: HoverState }) {
return (
<div
style={style}
className="rounded-md border border-gray-700 bg-gray-900 px-3 py-1.5 text-xs text-gray-100 shadow-xl"
className="rounded-md border border-border bg-panel px-3 py-1.5 text-xs text-content-primary shadow-xl"
role="tooltip"
>
<div className="font-medium text-gray-50">{cell.date}</div>
<div className="text-gray-400">
<div className="font-medium text-content-primary">{cell.date}</div>
<div className="text-content-muted">
{fmtTokens(cell.totalTokens)} · {fmtUsd(cell.costUsd)} ·{" "}
{fmtInt(cell.requests)} {t("requests")}
</div>
<div className="text-gray-400">
<div className="text-content-muted">
{t("cacheHitRate")}: {fmtPct(cell.cacheHitRate)}
</div>
</div>
+73 -73
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useSessions } from "../../hooks/useSessions";
import { fmtInt, fmtTime } from "../../lib/dashboard-format";
import { useTranslation } from "../../i18n";
@@ -203,14 +204,14 @@ export function SessionsPage() {
<div className="flex h-full flex-col overflow-hidden p-4 space-y-3">
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-[#e5e5e7]">{t("sessionsTitle")}</h2>
<p className="mt-1 max-w-[60ch] text-sm text-gray-500">{t("sessionsSubtitle")}</p>
<h2 className="text-lg font-semibold text-content-primary">{t("sessionsTitle")}</h2>
<p className="mt-1 max-w-[60ch] text-sm text-content-muted">{t("sessionsSubtitle")}</p>
</div>
<button
type="button"
onClick={() => refresh()}
disabled={loading}
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50"
>
{loading ? t("refreshing") : t("refresh")}
</button>
@@ -224,10 +225,10 @@ export function SessionsPage() {
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[260px_1fr]">
{/* Workspace rail */}
<div className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a]">
<div className="shrink-0 border-b border-[#2a2a2e] px-4 py-2.5">
<div className="text-sm font-medium text-[#e5e5e7]">{t("workspaces")}</div>
<div className="text-xs text-gray-500 mt-0.5">{t("workspaceIsolatedHint")}</div>
<div className="flex min-h-0 flex-col overflow-hidden rounded-xl border border-border bg-panel">
<div className="shrink-0 border-b border-border px-4 py-2.5">
<div className="text-sm font-medium text-content-primary">{t("workspaces")}</div>
<div className="text-xs text-content-muted mt-0.5">{t("workspaceIsolatedHint")}</div>
</div>
<div className="flex min-h-0 flex-1 flex-col p-2 overflow-hidden">
<button
@@ -235,8 +236,8 @@ export function SessionsPage() {
onClick={() => setWorkspace("all")}
className={`flex w-full shrink-0 items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${
workspace === "all"
? "bg-blue-600/20 text-[#e5e5e7]"
: "text-gray-400 hover:bg-[#1f1f23] hover:text-[#e5e5e7]"
? "bg-blue-600/20 text-content-primary"
: "text-content-muted hover:bg-input hover:text-content-primary"
}`}
>
<span>{t("allWorkspaces")}</span>
@@ -250,14 +251,14 @@ export function SessionsPage() {
<div
key={w.id}
className={`group flex items-start gap-1 rounded-md ${
workspace === w.id ? "bg-blue-600/20" : "hover:bg-[#1f1f23]"
workspace === w.id ? "bg-blue-600/20" : "hover:bg-input"
}`}
>
<button
type="button"
onClick={() => setWorkspace(w.id)}
className={`flex min-w-0 flex-1 flex-col gap-0.5 px-3 py-2 text-left ${
workspace === w.id ? "text-[#e5e5e7]" : "text-gray-400 group-hover:text-[#e5e5e7]"
workspace === w.id ? "text-content-primary" : "text-content-muted group-hover:text-content-primary"
}`}
title={w.root || w.id}
>
@@ -265,7 +266,7 @@ export function SessionsPage() {
<span className="truncate text-sm font-medium">{w.name || w.id}</span>
<span className="shrink-0 text-[11px] tabular-nums">
{isEmpty ? (
<span className="rounded border border-[#2a2a2e] px-1.5 py-0 text-[10px] text-gray-500">
<span className="rounded border border-border px-1.5 py-0 text-[10px] text-content-muted">
{t("emptyWorkspace")}
</span>
) : (
@@ -297,7 +298,7 @@ export function SessionsPage() {
);
})}
{!workspaces.length && !loading && (
<div className="px-3 py-6 text-center text-xs text-gray-500">{t("noSessions")}</div>
<div className="px-3 py-6 text-center text-xs text-content-muted">{t("noSessions")}</div>
)}
</div>
</div>
@@ -306,7 +307,7 @@ export function SessionsPage() {
{/* Sessions table */}
<div className="flex min-h-0 flex-col space-y-3 overflow-hidden">
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center bg-[#1f1f23] border border-[#2a2a2e] rounded p-0.5">
<div className="flex items-center bg-input border border-border rounded p-0.5">
{(
[
["active", t("statusActive")],
@@ -324,7 +325,7 @@ export function SessionsPage() {
className={`px-3 py-1 text-sm rounded transition-colors ${
status === value
? "bg-blue-600 text-white"
: "text-gray-400 hover:text-[#e5e5e7]"
: "text-content-muted hover:text-content-primary"
}`}
>
{label}
@@ -336,21 +337,21 @@ export function SessionsPage() {
<button
type="button"
onClick={toggleSelectAll}
className="px-2 py-1 text-xs text-gray-400 hover:text-[#e5e5e7]"
className="px-2 py-1 text-xs text-content-muted hover:text-content-primary"
>
{allSelected ? t("deselectAll") : t("selectAll")}
</button>
)}
{selected.size > 0 && (
<>
<span className="text-xs text-gray-500">
<span className="text-xs text-content-muted">
{t("selectedCount", { n: String(selected.size) })}
</span>
{status !== "archived" && (
<button
type="button"
onClick={bulkArchive}
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529]"
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2"
>
{t("archive")}
</button>
@@ -359,7 +360,7 @@ export function SessionsPage() {
<button
type="button"
onClick={bulkUnarchive}
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529]"
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2"
>
{t("unarchive")}
</button>
@@ -379,7 +380,7 @@ export function SessionsPage() {
<select
value={workspace}
onChange={(e) => setWorkspace(e.target.value)}
className="bg-[#1f1f23] border border-[#2a2a2e] rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none max-w-[200px]"
className="bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none max-w-[200px]"
>
<option value="all">{t("allWorkspaces")}</option>
{workspaces.map((w) => (
@@ -391,21 +392,21 @@ export function SessionsPage() {
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a]">
<div className="shrink-0 border-b border-[#2a2a2e] px-4 py-2.5">
<div className="text-sm font-medium text-[#e5e5e7]">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-panel">
<div className="shrink-0 border-b border-border px-4 py-2.5">
<div className="text-sm font-medium text-content-primary">
{workspace === "all"
? t("allWorkspaces")
: workspaceMap.get(workspace)?.name || workspace}
</div>
<div className="text-xs text-gray-500 mt-0.5">
<div className="text-xs text-content-muted mt-0.5">
{t("sessionsInView", { n: String(sessions.length) })}
{workspace !== "all" ? ` · ${t("isolatedToWorkspace")}` : ""}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-[#16161a] text-left text-gray-500 border-b border-[#2a2a2e]">
<thead className="sticky top-0 z-10 bg-panel text-left text-content-muted border-b border-border">
<tr>
<th className="w-10 px-3 py-2 font-normal">
<input
@@ -431,7 +432,7 @@ export function SessionsPage() {
<tbody>
{sessions.length === 0 ? (
<tr>
<td colSpan={7} className="py-10 text-center text-gray-500">
<td colSpan={7} className="py-10 text-center text-content-muted">
{loading ? t("scanning") : t("noSessions")}
</td>
</tr>
@@ -443,7 +444,7 @@ export function SessionsPage() {
return (
<tr
key={key}
className="border-b border-[#2a2a2e]/50 hover:bg-[#1c1c20]"
className="border-b border-border/50 hover:bg-hover"
>
<td className="px-3 py-2">
<input
@@ -457,18 +458,18 @@ export function SessionsPage() {
<div className="flex min-w-0 flex-col gap-0.5">
<button
type="button"
className="truncate text-left font-medium text-[#e5e5e7] hover:text-blue-400 hover:underline"
className="truncate text-left font-medium text-content-primary hover:text-blue-400 hover:underline"
title={row.title || t("openPreview")}
onClick={() => openPreview(row)}
>
{row.title || row.id}
</button>
<span className="truncate font-mono text-[10px] text-gray-500" title={row.id}>
<span className="truncate font-mono text-[10px] text-content-muted" title={row.id}>
{row.id}
</span>
{row.workDir && (
<span
className="truncate font-mono text-[10px] text-gray-500/80"
className="truncate font-mono text-[10px] text-content-muted/80"
title={row.workDir}
>
{row.workDir}
@@ -477,25 +478,25 @@ export function SessionsPage() {
</div>
</td>
<td className="px-2 py-2 max-w-[160px]">
<span className="truncate text-sm text-gray-400">
<span className="truncate text-sm text-content-muted">
{ws?.name || row.workspaceId}
</span>
</td>
<td className="px-2 py-2 whitespace-nowrap">
{row.status === "archived" ? (
<span className="rounded-md bg-amber-900/30 text-amber-300 px-2 py-0.5 text-xs">
<span className="rounded-md bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 px-2 py-0.5 text-xs">
{t("statusArchived")}
</span>
) : (
<span className="rounded-md bg-emerald-900/30 text-emerald-300 px-2 py-0.5 text-xs">
<span className="rounded-md bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 px-2 py-0.5 text-xs">
{t("statusActive")}
</span>
)}
</td>
<td className="px-2 py-2 text-right tabular-nums text-gray-400">
<td className="px-2 py-2 text-right tabular-nums text-content-muted">
{fmtBytes(row.bytes)}
</td>
<td className="px-2 py-2 whitespace-nowrap tabular-nums text-gray-400">
<td className="px-2 py-2 whitespace-nowrap tabular-nums text-content-muted">
{fmtTime(parseTime(row.updatedAt), locale)}
</td>
<td className="px-2 py-2 text-right">
@@ -506,7 +507,7 @@ export function SessionsPage() {
disabled={busy}
onClick={() => onArchive(row)}
title={t("archive")}
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2 disabled:opacity-50"
>
{t("archive")}
</button>
@@ -516,7 +517,7 @@ export function SessionsPage() {
disabled={busy}
onClick={() => onUnarchive(row)}
title={t("unarchive")}
className="px-2 py-1 text-xs rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2 disabled:opacity-50"
>
{t("unarchive")}
</button>
@@ -544,34 +545,33 @@ export function SessionsPage() {
</div>
{/* Attribution */}
<div className="text-xs text-gray-500 pb-2">
<div className="text-xs text-content-muted pb-2">
{data?.home && <>{data.home}</>}
<br />
会话管理功能基于{" "}
<a
href="https://github.com/JochenYang/kimicode-dashboard"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-400 underline"
<button
type="button"
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
kimicode-dashboard
</a>{" "}
</button>{" "}
(MIT,© JochenYang)移植
</div>
{/* Confirm dialog */}
{confirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-xl">
<div className="w-full max-w-md rounded-xl border border-border bg-panel shadow-xl">
<div className="space-y-3 p-5">
<h3 className="text-base font-semibold text-[#e5e5e7]">
<h3 className="text-base font-semibold text-content-primary">
{confirm.mode === "bulk"
? t("confirmBulkTitle")
: confirm.mode === "workspace"
? t("confirmDeleteWorkspaceTitle")
: t("confirmDeleteTitle")}
</h3>
<p className="text-sm text-gray-400">
<p className="text-sm text-content-muted">
{confirm.mode === "bulk"
? t("confirmBulkDelete", { n: String(confirm.rows.length) })
: confirm.mode === "workspace"
@@ -579,42 +579,42 @@ export function SessionsPage() {
: t("confirmDeleteSession")}
</p>
{confirm.mode === "one" && (
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5">
<div className="rounded-md border border-border bg-input px-3 py-2.5">
<div className="truncate text-sm font-medium">
{confirm.row.title || confirm.row.id}
</div>
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">
<div className="mt-1 break-all font-mono text-[11px] text-content-muted">
{confirm.row.id}
</div>
</div>
)}
{confirm.mode === "bulk" && (
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5 text-sm text-gray-400">
<div className="rounded-md border border-border bg-input px-3 py-2.5 text-sm text-content-muted">
{t("selectedCount", { n: String(confirm.rows.length) })}
</div>
)}
{confirm.mode === "workspace" && (
<div className="rounded-md border border-[#2a2a2e] bg-[#1f1f23] px-3 py-2.5">
<div className="rounded-md border border-border bg-input px-3 py-2.5">
<div className="truncate text-sm font-medium">
{confirm.workspace.name || confirm.workspace.id}
</div>
{confirm.workspace.root && (
<div className="mt-1 break-all font-mono text-[11px] text-gray-500">
<div className="mt-1 break-all font-mono text-[11px] text-content-muted">
{confirm.workspace.root}
</div>
)}
<div className="mt-1 font-mono text-[11px] text-gray-500">
<div className="mt-1 font-mono text-[11px] text-content-muted">
{confirm.workspace.id}
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-[#2a2a2e] bg-[#121214] px-5 py-3">
<div className="flex justify-end gap-2 border-t border-border bg-footer px-5 py-3">
<button
type="button"
disabled={confirmBusy}
onClick={() => setConfirm(null)}
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529] disabled:opacity-50"
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50"
>
{t("cancel")}
</button>
@@ -638,12 +638,12 @@ export function SessionsPage() {
{/* Preview dialog */}
{preview && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="flex w-full max-w-2xl max-h-[min(80vh,36rem)] flex-col overflow-hidden rounded-xl border border-[#2a2a2e] bg-[#16161a] shadow-xl">
<div className="shrink-0 space-y-2 border-b border-[#2a2a2e] p-5">
<h3 className="text-base font-semibold text-[#e5e5e7]">{t("previewTitle")}</h3>
<p className="text-sm text-gray-500">{t("previewHint")}</p>
<div className="text-xs text-gray-500">
<div className="truncate font-medium text-[#e5e5e7]">
<div className="flex w-full max-w-2xl max-h-[min(80vh,36rem)] flex-col overflow-hidden rounded-xl border border-border bg-panel shadow-xl">
<div className="shrink-0 space-y-2 border-b border-border p-5">
<h3 className="text-base font-semibold text-content-primary">{t("previewTitle")}</h3>
<p className="text-sm text-content-muted">{t("previewHint")}</p>
<div className="text-xs text-content-muted">
<div className="truncate font-medium text-content-primary">
{preview.row.title || preview.row.id}
</div>
<div className="mt-0.5 break-all font-mono opacity-80">{preview.row.id}</div>
@@ -651,7 +651,7 @@ export function SessionsPage() {
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-5">
{preview.loading && (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-gray-500">
<div className="flex items-center justify-center gap-2 py-10 text-sm text-content-muted">
<div className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full" />
{t("scanning")}
</div>
@@ -662,7 +662,7 @@ export function SessionsPage() {
</div>
)}
{!preview.loading && !preview.error && !preview.messages.length && (
<div className="py-10 text-center text-sm text-gray-500">{t("previewEmpty")}</div>
<div className="py-10 text-center text-sm text-content-muted">{t("previewEmpty")}</div>
)}
{preview.messages.map((m, i) => {
const roleLabel =
@@ -685,14 +685,14 @@ export function SessionsPage() {
);
})}
{preview.truncated && (
<div className="text-center text-xs text-gray-500">{t("previewTruncated")}</div>
<div className="text-center text-xs text-content-muted">{t("previewTruncated")}</div>
)}
</div>
<div className="shrink-0 flex justify-end border-t border-[#2a2a2e] bg-[#121214] px-5 py-3">
<div className="shrink-0 flex justify-end border-t border-border bg-footer px-5 py-3">
<button
type="button"
onClick={() => setPreview(null)}
className="px-3 py-1.5 text-sm rounded border border-[#2a2a2e] hover:bg-[#252529]"
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2"
>
{t("cancel")}
</button>
@@ -727,24 +727,24 @@ function PreviewMessageCard({
return (
<div
className={`rounded-lg border border-[#2a2a2e] px-3 py-2.5 ${
className={`rounded-lg border border-border px-3 py-2.5 ${
role === "user"
? "bg-blue-600/10"
: role === "assistant"
? "bg-[#1f1f23]"
: "bg-[#16161a]"
? "bg-input"
: "bg-panel"
}`}
>
<div className="mb-1 flex items-center justify-between gap-2 text-[11px] text-gray-500">
<span className="font-medium text-gray-300">{roleLabel}</span>
<div className="mb-1 flex items-center justify-between gap-2 text-[11px] text-content-muted">
<span className="font-medium text-content-primary">{roleLabel}</span>
{time ? (
<span className="tabular-nums">{fmtTime(time, locale)}</span>
) : null}
</div>
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-gray-200">
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-content-primary">
{visible}
{!expanded && isLong && (
<span className="text-gray-500"> …</span>
<span className="text-content-muted"> …</span>
)}
</pre>
{isLong && (