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

@@ -4,9 +4,9 @@ import { providerApiService } from '@electron/service/provider-api-service';
import type { HostApiContext } from './context';
import type { HostApiRequest } from './route-utils';
import { normalizeRequest } from './route-utils';
import { handleAgentRoutes } from './routes/agents';
import { handleFileRoutes } from './routes/files';
import { handleGatewayRoutes } from './routes/gateway';
import { handleModelRoutes } from './routes/models';
import { handleProviderRoutes } from './routes/providers';
import { handleSessionRoutes } from './routes/sessions';
@@ -17,7 +17,7 @@ type RouteHandler = (
const routeHandlers: RouteHandler[] = [
handleProviderRoutes,
handleAgentRoutes,
handleModelRoutes,
handleGatewayRoutes,
handleFileRoutes,
handleSessionRoutes,

View File

@@ -5,7 +5,7 @@ import {
buildMainSessionKey,
normalizeAgentId,
type AgentSummary,
} from '@runtime/lib/agents';
} from '@runtime/lib/models';
import type { HostApiContext } from '../context';
import type { NormalizedHostApiRequest } from '../route-utils';
import { ok } from '../route-utils';
@@ -18,10 +18,10 @@ function formatModelDisplay(modelRef: string | null | undefined, fallbackLabel:
return parts[parts.length - 1] || trimmed;
}
function buildMainAgent(defaultAccount: ProviderAccount | null): AgentSummary {
function buildMainModel(defaultAccount: ProviderAccount | null): AgentSummary {
return {
id: DEFAULT_AGENT_ID,
name: 'Main Agent',
name: 'Main Model',
isDefault: true,
providerAccountId: defaultAccount?.id ?? null,
modelRef: defaultAccount?.model ?? null,
@@ -32,7 +32,7 @@ function buildMainAgent(defaultAccount: ProviderAccount | null): AgentSummary {
};
}
function buildProviderBackedAgents(accounts: ProviderAccount[]): AgentSummary[] {
function buildProviderBackedModels(accounts: ProviderAccount[]): AgentSummary[] {
const seen = new Set<string>();
const summaries: AgentSummary[] = [];
@@ -57,12 +57,12 @@ function buildProviderBackedAgents(accounts: ProviderAccount[]): AgentSummary[]
return summaries;
}
export async function handleAgentRoutes(
export async function handleModelRoutes(
request: NormalizedHostApiRequest,
ctx: HostApiContext,
) {
const { pathname, method } = request;
if (pathname !== '/api/agents' || method !== 'GET') {
if ((pathname !== '/api/models' && pathname !== '/api/agents') || method !== 'GET') {
return null;
}
@@ -72,14 +72,15 @@ export async function handleAgentRoutes(
const defaultAccountId = ctx.providerApiService.getDefault().accountId;
const defaultAccount = accounts.find((account) => account.id === defaultAccountId) ?? accounts[0] ?? null;
const agents = [
buildMainAgent(defaultAccount),
...buildProviderBackedAgents(accounts),
const models = [
buildMainModel(defaultAccount),
...buildProviderBackedModels(accounts),
];
return ok({
success: true,
agents,
models,
agents: models,
defaultAgentId: DEFAULT_AGENT_ID,
defaultProviderAccountId: defaultAccount?.id ?? null,
defaultModelRef: defaultAccount?.model ?? null,

View File

@@ -1,6 +1,9 @@
import { sessionStore } from '@electron/gateway/session-store';
import { getTranscriptFilePath } from '@electron/utils/token-usage-writer';
import { buildAgentSessionKey, normalizeAgentSessionKey, parseSessionKey } from '@runtime/lib/agents';
import {
getTranscriptFilePath,
getTranscriptPathCandidates,
} from '@electron/utils/token-usage-writer';
import { buildAgentSessionKey, normalizeAgentSessionKey, parseSessionKey } from '@runtime/lib/models';
import type { HostApiContext } from '../context';
import type { NormalizedHostApiRequest } from '../route-utils';
import { fail, ok, parseJsonBody } from '../route-utils';
@@ -41,20 +44,45 @@ export async function handleSessionRoutes(
try {
const fsP = await import('node:fs/promises');
const transcriptPath = getTranscriptFilePath(identity.sessionKey);
let raw: string;
let transcriptPath = getTranscriptFilePath(identity.sessionKey);
let raw: string | null = null;
let lastError: unknown = null;
try {
raw = await fsP.readFile(transcriptPath, 'utf8');
} catch (error: any) {
const requestedSessionKey = request.url.searchParams.get('sessionKey')?.trim() || '';
if (error?.code === 'ENOENT' && requestedSessionKey && requestedSessionKey !== identity.sessionKey) {
raw = await fsP.readFile(getTranscriptFilePath(requestedSessionKey), 'utf8');
} else {
throw error;
for (const candidatePath of getTranscriptPathCandidates(identity.sessionKey)) {
try {
raw = await fsP.readFile(candidatePath, 'utf8');
transcriptPath = candidatePath;
break;
} catch (error) {
lastError = error;
if ((error as { code?: string } | null)?.code !== 'ENOENT') {
throw error;
}
}
}
if (raw === null) {
const requestedSessionKey = request.url.searchParams.get('sessionKey')?.trim() || '';
if (requestedSessionKey && requestedSessionKey !== identity.sessionKey) {
for (const candidatePath of getTranscriptPathCandidates(requestedSessionKey)) {
try {
raw = await fsP.readFile(candidatePath, 'utf8');
transcriptPath = candidatePath;
break;
} catch (candidateError) {
lastError = candidateError;
if ((candidateError as { code?: string } | null)?.code !== 'ENOENT') {
throw candidateError;
}
}
}
}
}
if (raw === null) {
throw lastError ?? Object.assign(new Error('Transcript not found'), { code: 'ENOENT' });
}
const lines = raw.split(/\r?\n/).filter(Boolean);
const messages = lines.flatMap((line) => {
try {
@@ -90,19 +118,18 @@ export async function handleSessionRoutes(
sessionStore.deleteSession(sessionKey);
const transcriptPath = getTranscriptFilePath(sessionKey);
try {
const fsP = await import('node:fs/promises');
await fsP.rename(transcriptPath, transcriptPath.replace(/\.jsonl$/, '.deleted.jsonl'));
} catch {
if (rawSessionKey && rawSessionKey !== sessionKey) {
try {
const fsP = await import('node:fs/promises');
const legacyTranscriptPath = getTranscriptFilePath(rawSessionKey);
await fsP.rename(legacyTranscriptPath, legacyTranscriptPath.replace(/\.jsonl$/, '.deleted.jsonl'));
} catch {
// Best effort: transcript may not exist yet.
}
const fsP = await import('node:fs/promises');
const transcriptPathCandidates = Array.from(new Set([
...getTranscriptPathCandidates(sessionKey),
...(rawSessionKey && rawSessionKey !== sessionKey ? getTranscriptPathCandidates(rawSessionKey) : []),
]));
for (const transcriptPath of transcriptPathCandidates) {
try {
await fsP.rename(transcriptPath, transcriptPath.replace(/\.jsonl$/, '.deleted.jsonl'));
break;
} catch {
continue;
}
}

View File

@@ -3,7 +3,7 @@ import { createProvider } from '@electron/providers';
import type { BaseProvider } from '@electron/providers/BaseProvider';
import { providerApiService } from '@electron/service/provider-api-service';
import logManager from '@electron/service/logger';
import { normalizeAgentSessionKey } from '@runtime/lib/agents';
import { normalizeAgentSessionKey } from '@runtime/lib/models';
import type { RawMessage } from '@runtime/shared/chat-model';
import { sessionStore } from '../session-store';
import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types';

View File

@@ -2,7 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { app } from 'electron';
import logManager from '@electron/service/logger';
import { normalizeAgentSessionKey } from '@runtime/lib/agents';
import { normalizeAgentSessionKey } from '@runtime/lib/models';
import type { RawMessage } from '@runtime/shared/chat-model';
let sessionsFilePath: string | null = null;

View File

@@ -1,21 +1,39 @@
import { app } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { parseSessionKey } from '@runtime/lib/agents';
import { parseSessionKey } from '@runtime/lib/models';
export function getTranscriptFilePath(sessionKey: string): string {
const PRIMARY_TRANSCRIPT_ROOT_DIR = 'models';
const LEGACY_TRANSCRIPT_ROOT_DIR = 'agents';
function buildTranscriptFilePath(sessionKey: string, rootDirName: string): string {
const parsed = parseSessionKey(sessionKey);
let agentId = parsed.isAgentSession ? parsed.agentId : 'default';
const agentId = parsed.isAgentSession ? parsed.agentId : 'default';
let sessionId = parsed.isAgentSession ? parsed.sessionId : sessionKey;
if (!sessionId) {
sessionId = 'unknown';
}
const baseDir = path.join(app.getPath('userData'), 'agents', agentId, 'sessions');
const baseDir = path.join(app.getPath('userData'), rootDirName, agentId, 'sessions');
return path.join(baseDir, `${sessionId}.jsonl`);
}
export function getTranscriptFilePath(sessionKey: string): string {
return buildTranscriptFilePath(sessionKey, PRIMARY_TRANSCRIPT_ROOT_DIR);
}
export function getLegacyTranscriptFilePath(sessionKey: string): string {
return buildTranscriptFilePath(sessionKey, LEGACY_TRANSCRIPT_ROOT_DIR);
}
export function getTranscriptPathCandidates(sessionKey: string): string[] {
return Array.from(new Set([
getTranscriptFilePath(sessionKey),
getLegacyTranscriptFilePath(sessionKey),
]));
}
export function appendTranscriptLine(sessionKey: string, lineObject: any): void {
const filePath = getTranscriptFilePath(sessionKey);
fs.mkdirSync(path.dirname(filePath), { recursive: true });

View File

@@ -14,12 +14,14 @@ export {
type TokenUsageHistoryEntry,
} from './token-usage-core';
async function listAgentIdsWithSessionDirs(): Promise<string[]> {
const agentsDir = join(app.getPath('userData'), 'agents');
const TRANSCRIPT_ROOT_DIR_NAMES = ['models', 'agents'] as const;
async function listAgentIdsWithSessionDirs(rootDirName: string): Promise<string[]> {
const rootDir = join(app.getPath('userData'), rootDirName);
const agentIds = new Set<string>();
try {
const agentEntries = await readdir(agentsDir, { withFileTypes: true });
const agentEntries = await readdir(rootDir, { withFileTypes: true });
for (const entry of agentEntries) {
if (entry.isDirectory()) {
const normalized = entry.name.trim();
@@ -36,38 +38,45 @@ async function listAgentIdsWithSessionDirs(): Promise<string[]> {
}
async function listRecentSessionFiles(): Promise<Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }>> {
const agentsDir = join(app.getPath('userData'), 'agents');
const filesBySession = new Map<string, { filePath: string; sessionId: string; agentId: string; mtimeMs: number }>();
try {
const agentEntries = await listAgentIdsWithSessionDirs();
const files: Array<{ filePath: string; sessionId: string; agentId: string; mtimeMs: number }> = [];
for (const rootDirName of TRANSCRIPT_ROOT_DIR_NAMES) {
const agentEntries = await listAgentIdsWithSessionDirs(rootDirName);
for (const agentId of agentEntries) {
const sessionsDir = join(agentsDir, agentId, 'sessions');
try {
const sessionEntries = await readdir(sessionsDir);
for (const agentId of agentEntries) {
const sessionsDir = join(app.getPath('userData'), rootDirName, agentId, 'sessions');
try {
const sessionEntries = await readdir(sessionsDir);
for (const fileName of sessionEntries) {
const sessionId = extractSessionIdFromTranscriptFileName(fileName);
if (!sessionId) continue;
const filePath = join(sessionsDir, fileName);
try {
const fileStat = await stat(filePath);
files.push({
filePath,
sessionId,
agentId,
mtimeMs: fileStat.mtimeMs,
});
} catch {
continue;
for (const fileName of sessionEntries) {
const sessionId = extractSessionIdFromTranscriptFileName(fileName);
if (!sessionId) continue;
const filePath = join(sessionsDir, fileName);
try {
const fileStat = await stat(filePath);
const sessionKey = `${agentId}:${sessionId}`;
const existing = filesBySession.get(sessionKey);
if (!existing || fileStat.mtimeMs > existing.mtimeMs) {
filesBySession.set(sessionKey, {
filePath,
sessionId,
agentId,
mtimeMs: fileStat.mtimeMs,
});
}
} catch {
continue;
}
}
} catch {
continue;
}
} catch {
continue;
}
}
const files = [...filesBySession.values()];
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
return files;
} catch {