feat: add models management and usage history components

- Introduced RequestContentDialog for displaying request content details.
- Added UsageBarChart for visualizing token usage data.
- Implemented UsageHistorySection to manage and display usage history with filtering and pagination.
- Created provider-types for managing provider-related types.
- Developed ModelsPage to encapsulate models configuration, providers, and usage history.
- Defined usage-history types and utility functions for managing usage data.
- Updated routing to include models page and redirect agents to models.
- Refactored chat store to integrate models instead of agents.
- Established models store for managing model-related state and data fetching.
This commit is contained in:
duanshuwen
2026-04-18 09:41:59 +08:00
parent 1205a96661
commit 85d92b937f
28 changed files with 343 additions and 258 deletions

View File

@@ -8,7 +8,7 @@ import blueLogo from '../../assets/images/login/blue_logo.png';
const MENU_MARKS: Record<string, typeof House> = {
'/home': House,
'/knowledge': Book,
'/agents': Cpu,
'/models': Cpu,
'/skills': Puzzle,
'/cron': Clock,
'/scripts': Code,

View File

@@ -15,15 +15,15 @@ import {
import { IPC_EVENTS } from '../../lib/constants';
import { invokeIpc } from '../../lib/host-api';
import {
agentsStore,
channelStore,
chatStore,
getCompletedTasks,
getPendingTasks,
modelsStore,
taskStore,
useAgentsStore,
useChannelStore,
useChatStore,
useModelsStore,
useTaskStore,
type StagedAttachment,
} from '../../stores';
@@ -149,7 +149,7 @@ function mapMessages(messages: RawMessage[], streamingMessage: RawMessage | null
}
export default function HomePage() {
const agentsState = useAgentsStore();
const modelsState = useModelsStore();
const chat = useChatStore();
const taskState = useTaskStore();
const channelState = useChannelStore();
@@ -161,7 +161,7 @@ export default function HomePage() {
const [addChannelDialogOpen, setAddChannelDialogOpen] = useState(false);
useEffect(() => {
void agentsStore.init();
void modelsStore.init();
void chatStore.init();
void taskStore.init();
void channelStore.init();
@@ -212,10 +212,10 @@ export default function HomePage() {
const latestTask = currentTaskSource[0];
const visibleMessages = mapMessages(chat.messages, chat.streamingMessage);
const selectedAgentId = agentsState.agents.some((agent) => agent.id === chat.currentAgentId)
const selectedModelId = modelsState.models.some((model) => model.id === chat.currentAgentId)
? chat.currentAgentId
: agentsState.defaultAgentId;
const currentAgent = agentsState.agents.find((agent) => agent.id === selectedAgentId) || null;
: modelsState.defaultAgentId;
const currentModel = modelsState.models.find((model) => model.id === selectedModelId) || null;
async function handleSendMessage(): Promise<void> {
const sent = await chatStore.sendMessage(inputMessage, attachments);
@@ -288,7 +288,7 @@ export default function HomePage() {
loading={!chat.initialized}
selectedConversationId={chat.currentSessionKey}
onNewChat={() => {
void chatStore.newSession(selectedAgentId || undefined);
void chatStore.newSession(selectedModelId || undefined);
}}
onSelectConversation={(conversationId) => {
chatStore.switchSession(conversationId);
@@ -316,23 +316,23 @@ export default function HomePage() {
<h2 className="text-base font-semibold text-[#171717] dark:text-gray-100"></h2>
<div className="mt-1 text-xs text-[#99A0AE] dark:text-gray-500">
{chat.gatewayStatus === 'connected' ? '已连接' : chat.gatewayStatus === 'reconnecting' ? '重连中' : '未连接'}
{currentAgent ? ` · 当前代理${currentAgent.name}` : ''}
{currentModel ? ` · 当前模型${currentModel.name}` : ''}
</div>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-[#525866] dark:text-gray-300">
<span></span>
<span></span>
<select
className="rounded-full border border-[#E5E8EE] bg-white px-3 py-1.5 text-xs text-[#525866] outline-none transition-colors hover:border-[#2B7FFF] dark:border-[#2a2a2d] dark:bg-[#232327] dark:text-gray-300"
disabled={agentsState.loading || agentsState.agents.length === 0}
value={selectedAgentId}
disabled={modelsState.loading || modelsState.models.length === 0}
value={selectedModelId}
onChange={(event) => {
chatStore.selectAgent(event.target.value);
}}
>
{agentsState.agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
{modelsState.models.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
@@ -341,7 +341,7 @@ export default function HomePage() {
type="button"
className="rounded-full border border-[#E5E8EE] px-3 py-1.5 text-xs text-[#525866] transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#2a2a2d] dark:text-gray-300"
onClick={() => {
void agentsStore.load();
void modelsStore.load();
void chatStore.loadSessions();
void chatStore.loadHistory();
}}

View File

@@ -1,16 +1,16 @@
import { useEffect } from 'react';
import { agentsStore, useAgentsStore } from '../../../stores';
import { modelsStore, useModelsStore } from '../../../stores';
const CHIP_CLASS_NAME = [
'rounded-full border px-2.5 py-1 text-[11px] leading-none',
'border-[#E5E8EE] text-[#525866] dark:border-[#2a2a2d] dark:text-gray-300',
].join(' ');
export default function AgentsSection() {
const agentState = useAgentsStore();
export default function ModelsSection() {
const modelsState = useModelsStore();
useEffect(() => {
void agentsStore.init();
void modelsStore.init();
}, []);
return (
@@ -18,43 +18,43 @@ export default function AgentsSection() {
<div className="flex items-end justify-between gap-4">
<div>
<h3 className="text-[18px] font-semibold leading-[24px] text-[#171717] dark:text-gray-100">
Agents Snapshot
Models Snapshot
</h3>
<p className="mt-1 text-[13px] leading-[20px] text-[#99A0AE] dark:text-gray-500">
`agents` `mainSessionKey`
`/api/models` `mainSessionKey`
</p>
</div>
<button
type="button"
className="rounded-full border border-[#E5E8EE] px-3 py-1.5 text-[12px] text-[#525866] transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#2a2a2d] dark:text-gray-300"
onClick={() => {
void agentsStore.load();
void modelsStore.load();
}}
>
Agents
Models
</button>
</div>
{agentState.error ? (
{modelsState.error ? (
<div className="rounded-[14px] border border-[#F1D4D4] bg-[#FFF5F5] px-4 py-3 text-sm text-[#A53A3A] dark:border-[#4b2a2a] dark:bg-[#2a1f1f] dark:text-[#f7b8b8]">
{agentState.error}
{modelsState.error}
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
{agentState.agents.map((agent) => (
{modelsState.models.map((model) => (
<article
key={agent.id}
key={model.id}
className="rounded-[16px] border border-[#E5E8EE] bg-[#FAFBFC] p-4 dark:border-[#2a2a2d] dark:bg-[#202024]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold text-[#171717] dark:text-gray-100">
{agent.name}
{model.name}
</div>
<div className="mt-1 text-[12px] text-[#99A0AE] dark:text-gray-500">{agent.id}</div>
<div className="mt-1 text-[12px] text-[#99A0AE] dark:text-gray-500">{model.id}</div>
</div>
{agent.isDefault ? (
{model.isDefault ? (
<span className="rounded-full bg-[#EFF6FF] px-2.5 py-1 text-[11px] font-medium text-[#2B7FFF] dark:bg-[#1d2633]">
</span>
@@ -62,20 +62,20 @@ export default function AgentsSection() {
</div>
<div className="mt-4 flex flex-wrap gap-2">
<span className={CHIP_CLASS_NAME}>Provider: {agent.providerAccountId || '--'}</span>
<span className={CHIP_CLASS_NAME}>Model: {agent.modelDisplay || '--'}</span>
<span className={CHIP_CLASS_NAME}>Provider: {model.providerAccountId || '--'}</span>
<span className={CHIP_CLASS_NAME}>Model: {model.modelDisplay || '--'}</span>
</div>
<div className="mt-4 rounded-[12px] border border-dashed border-[#DCE5F1] bg-white px-3 py-2 text-[12px] leading-[18px] text-[#525866] dark:border-[#2a2a2d] dark:bg-[#17171a] dark:text-gray-300">
<div className="font-medium text-[#171717] dark:text-gray-100">mainSessionKey</div>
<div className="mt-1 break-all">{agent.mainSessionKey}</div>
<div className="mt-1 break-all">{model.mainSessionKey}</div>
</div>
</article>
))}
{!agentState.loading && agentState.agents.length === 0 ? (
{!modelsState.loading && modelsState.models.length === 0 ? (
<div className="rounded-[16px] border border-dashed border-[#DCE5F1] bg-[#FAFBFC] px-4 py-6 text-sm text-[#525866] dark:border-[#2a2a2d] dark:bg-[#202024] dark:text-gray-300">
agent provider agent snapshot
provider model snapshot
</div>
) : null}
</div>

View File

@@ -1,8 +1,8 @@
import AgentsSection from './components/AgentsSection';
import ModelsSection from './components/ModelsSection';
import ProvidersSection from './components/ProvidersSection';
import UsageHistorySection from './components/UsageHistorySection';
export default function AgentsPage() {
export default function ModelsPage() {
return (
<section className="h-full w-full min-h-0">
<div className="flex h-full w-full min-h-0 flex-col rounded-[16px] bg-white p-[20px] dark:bg-[#1b1b1d]">
@@ -18,7 +18,7 @@ export default function AgentsPage() {
</div>
<div className="min-h-0 flex-1 space-y-12 overflow-y-auto pb-10 pr-2">
<AgentsSection />
<ModelsSection />
<ProvidersSection />
<UsageHistorySection />
</div>

View File

@@ -3,7 +3,7 @@ import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-
import MainLayout from '../components/layout/MainLayout';
import HomePage from '../pages/Home';
import LoginPage from '../pages/Login';
import AgentsPage from '../pages/agents';
import ModelsPage from '../pages/Models';
import SkillsPage from '../pages/Skills';
import CronPage from '../pages/Cron';
import ScriptsPage from '../pages/Scripts';
@@ -45,7 +45,8 @@ export function AppRouter() {
<Route element={<RequireAuth />}>
<Route element={<MainLayout />}>
<Route path="/home" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/models" element={<ModelsPage />} />
<Route path="/agents" element={<Navigate to="/models" replace />} />
<Route path="/skills" element={<SkillsPage />} />
<Route path="/cron" element={<CronPage />} />
<Route path="/scripts" element={<ScriptsPage />} />

View File

@@ -1,6 +1,6 @@
export type AppPath =
| '/home'
| '/agents'
| '/models'
| '/skills'
| '/cron'
| '/scripts'
@@ -27,7 +27,7 @@ export const DEFAULT_PATH: WorkspacePath = '/home';
export const NAV_ITEMS: NavItem[] = [
{ path: '/home', labelKey: 'sidebar.home' },
{ path: '/knowledge', labelKey: 'sidebar.knowledge' },
{ path: '/agents', labelKey: 'sidebar.models' },
{ path: '/models', labelKey: 'sidebar.models' },
{ path: '/skills', labelKey: 'sidebar.skills' },
{ path: '/cron', labelKey: 'sidebar.cron' },
{ path: '/scripts', labelKey: 'sidebar.scripts' },
@@ -37,12 +37,13 @@ export const NAV_ITEMS: NavItem[] = [
export function normalizeWorkspacePath(pathname: string): WorkspacePath {
switch (pathname) {
case '/knowledge':
case '/models':
case '/agents':
case '/skills':
case '/cron':
case '/scripts':
case '/setting':
return pathname;
return pathname === '/agents' ? '/models' : pathname;
case '/home':
default:
return DEFAULT_PATH;

View File

@@ -7,13 +7,13 @@ import {
normalizeAgentId,
normalizeAgentSessionKey,
parseSessionKey,
} from '@runtime/lib/agents';
} from '@runtime/lib/models';
import type { ChatSession, RawMessage, ToolStatus } from '../shared/chat-model';
import { extractText, isToolOnlyMessage } from '../shared/chat-model';
import { gatewayRpc, onGatewayEvent } from '../lib/gateway-client';
import { hostApiFetch } from '../lib/host-api';
import type { GatewayEvent } from '../types/runtime';
import { agentsStore } from './agents';
import { modelsStore } from './models';
const SESSION_LOAD_MIN_INTERVAL_MS = 1200;
const HISTORY_LOAD_MIN_INTERVAL_MS = 800;
@@ -101,19 +101,19 @@ function patchState(patch: Partial<ChatStoreState>): ChatStoreState {
function getAgentIdFromSessionKey(sessionKey: string): string {
const parsed = parseSessionKey(normalizeAgentSessionKey(sessionKey));
if (parsed.isAgentSession) return parsed.agentId;
return agentsStore.getState().defaultAgentId || FALLBACK_AGENT_ID;
return modelsStore.getState().defaultAgentId || FALLBACK_AGENT_ID;
}
function getDefaultAgentId(): string {
return agentsStore.getState().defaultAgentId || FALLBACK_AGENT_ID;
return modelsStore.getState().defaultAgentId || FALLBACK_AGENT_ID;
}
function getDefaultMainSessionKey(): string {
return agentsStore.resolveMainSessionKey(getDefaultAgentId()) || FALLBACK_MAIN_SESSION_KEY;
return modelsStore.resolveMainSessionKey(getDefaultAgentId()) || FALLBACK_MAIN_SESSION_KEY;
}
function resolveMainSessionKeyForAgent(agentId: string | null | undefined): string {
return agentsStore.resolveMainSessionKey(agentId || getDefaultAgentId()) || getDefaultMainSessionKey();
return modelsStore.resolveMainSessionKey(agentId || getDefaultAgentId()) || getDefaultMainSessionKey();
}
function buildNewSessionKey(agentId: string | null | undefined): string {
@@ -208,7 +208,7 @@ async function resolveDefaultAccountId(): Promise<string | null> {
}
async function resolveProviderAccountIdForAgent(agentId: string | null | undefined): Promise<string | null> {
const mappedAccountId = agentsStore.resolveProviderAccountId(agentId);
const mappedAccountId = modelsStore.resolveProviderAccountId(agentId);
if (mappedAccountId) {
return mappedAccountId;
}
@@ -283,7 +283,7 @@ async function subscribeToGateway(): Promise<void> {
}
async function loadSessions(): Promise<void> {
await agentsStore.init();
await modelsStore.init();
const now = Date.now();
if (loadSessionsInFlight) {
await loadSessionsInFlight;
@@ -850,7 +850,7 @@ function clearError(): void {
}
async function initChatStore(): Promise<void> {
await agentsStore.init();
await modelsStore.init();
await subscribeToGateway();
await loadSessions();
}

View File

@@ -1,5 +1,5 @@
export * from './settings';
export * from './agents';
export * from './models';
export * from './chat';
export * from './task';
export * from './channel';

View File

@@ -5,15 +5,15 @@ import {
buildMainSessionKey,
normalizeAgentId,
type AgentSummary,
type AgentsSnapshot,
} from '@runtime/lib/agents';
type ModelsSnapshot,
} from '@runtime/lib/models';
import { hostApiFetch } from '../lib/host-api';
export interface AgentsStoreState {
export interface ModelsStoreState {
initialized: boolean;
loading: boolean;
error: string | null;
agents: AgentSummary[];
models: AgentSummary[];
defaultAgentId: string;
defaultProviderAccountId: string | null;
defaultModelRef: string | null;
@@ -22,12 +22,12 @@ export interface AgentsStoreState {
const listeners = new Set<() => void>();
let loadAgentsInFlight: Promise<void> | null = null;
let state: AgentsStoreState = {
let loadModelsInFlight: Promise<void> | null = null;
let state: ModelsStoreState = {
initialized: false,
loading: false,
error: null,
agents: [],
models: [],
defaultAgentId: DEFAULT_AGENT_ID,
defaultProviderAccountId: null,
defaultModelRef: null,
@@ -40,49 +40,51 @@ function emit(): void {
}
}
function patchState(patch: Partial<AgentsStoreState>): AgentsStoreState {
function patchState(patch: Partial<ModelsStoreState>): ModelsStoreState {
state = { ...state, ...patch };
emit();
return state;
}
function sanitizeAgent(agent: AgentSummary): AgentSummary {
const normalizedId = normalizeAgentId(agent.id);
const normalizedMainSessionKey = agent.mainSessionKey || buildMainSessionKey(normalizedId);
function sanitizeModel(model: AgentSummary): AgentSummary {
const normalizedId = normalizeAgentId(model.id);
const normalizedMainSessionKey = model.mainSessionKey || buildMainSessionKey(normalizedId);
return {
id: normalizedId,
name: agent.name || normalizedId,
isDefault: Boolean(agent.isDefault),
providerAccountId: agent.providerAccountId ?? null,
modelRef: agent.modelRef ?? null,
modelDisplay: agent.modelDisplay || agent.modelRef || agent.name || normalizedId,
name: model.name || normalizedId,
isDefault: Boolean(model.isDefault),
providerAccountId: model.providerAccountId ?? null,
modelRef: model.modelRef ?? null,
modelDisplay: model.modelDisplay || model.modelRef || model.name || normalizedId,
mainSessionKey: normalizedMainSessionKey,
vendorId: agent.vendorId ?? null,
source: agent.source,
vendorId: model.vendorId ?? null,
source: model.source,
};
}
async function loadAgents(): Promise<void> {
if (loadAgentsInFlight) {
await loadAgentsInFlight;
async function loadModels(): Promise<void> {
if (loadModelsInFlight) {
await loadModelsInFlight;
return;
}
loadAgentsInFlight = (async () => {
loadModelsInFlight = (async () => {
patchState({ loading: true, error: null });
try {
const snapshot = await hostApiFetch<AgentsSnapshot & { success?: boolean }>('/api/agents');
const agents = Array.isArray(snapshot?.agents)
? snapshot.agents.map((agent) => sanitizeAgent(agent))
const snapshot = await hostApiFetch<ModelsSnapshot & { success?: boolean }>('/api/models');
const models = Array.isArray(snapshot?.models)
? snapshot.models.map((model) => sanitizeModel(model))
: Array.isArray(snapshot?.agents)
? snapshot.agents.map((model) => sanitizeModel(model))
: [];
patchState({
initialized: true,
loading: false,
error: null,
agents,
models,
defaultAgentId: snapshot?.defaultAgentId ? normalizeAgentId(snapshot.defaultAgentId) : DEFAULT_AGENT_ID,
defaultProviderAccountId: snapshot?.defaultProviderAccountId ?? null,
defaultModelRef: snapshot?.defaultModelRef ?? null,
@@ -98,9 +100,9 @@ async function loadAgents(): Promise<void> {
})();
try {
await loadAgentsInFlight;
await loadModelsInFlight;
} finally {
loadAgentsInFlight = null;
loadModelsInFlight = null;
}
}
@@ -109,36 +111,36 @@ function subscribe(listener: () => void): () => void {
return () => listeners.delete(listener);
}
function getSnapshot(): AgentsStoreState {
function getSnapshot(): ModelsStoreState {
return state;
}
function getAgentById(agentId: string | null | undefined): AgentSummary | undefined {
const normalizedId = normalizeAgentId(agentId);
return state.agents.find((agent) => agent.id === normalizedId);
function getModelById(modelId: string | null | undefined): AgentSummary | undefined {
const normalizedId = normalizeAgentId(modelId);
return state.models.find((model) => model.id === normalizedId);
}
function resolveMainSessionKey(agentId: string | null | undefined): string {
const normalizedId = normalizeAgentId(agentId || state.defaultAgentId);
return getAgentById(normalizedId)?.mainSessionKey || buildMainSessionKey(normalizedId, state.mainSessionSuffix);
return getModelById(normalizedId)?.mainSessionKey || buildMainSessionKey(normalizedId, state.mainSessionSuffix);
}
function resolveProviderAccountId(agentId: string | null | undefined): string | null {
const normalizedId = normalizeAgentId(agentId || state.defaultAgentId);
return getAgentById(normalizedId)?.providerAccountId ?? state.defaultProviderAccountId;
return getModelById(normalizedId)?.providerAccountId ?? state.defaultProviderAccountId;
}
export const agentsStore = {
export const modelsStore = {
subscribe,
getSnapshot,
getState: () => state,
init: loadAgents,
load: loadAgents,
getAgentById,
init: loadModels,
load: loadModels,
getModelById,
resolveMainSessionKey,
resolveProviderAccountId,
};
export function useAgentsStore(): AgentsStoreState {
return useSyncExternalStore(agentsStore.subscribe, agentsStore.getSnapshot, agentsStore.getSnapshot);
export function useModelsStore(): ModelsStoreState {
return useSyncExternalStore(modelsStore.subscribe, modelsStore.getSnapshot, modelsStore.getSnapshot);
}