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:
1 parent
14d48500bf
commit
ebf2dd28d5
134 files changed
+2898
-326
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in new issue
Block a user