feat(queue): implement queue scheduler service and API routes

- Added QueueSchedulerService to manage task queue lifecycle, including state machine, dependency resolution, and session management.
- Implemented HTTP API endpoints for queue scheduling:
  - POST /api/queue/execute: Submit items to the scheduler.
  - GET /api/queue/scheduler/state: Retrieve full scheduler state.
  - POST /api/queue/scheduler/start: Start scheduling loop with items.
  - POST /api/queue/scheduler/pause: Pause scheduling.
  - POST /api/queue/scheduler/stop: Graceful stop of the scheduler.
  - POST /api/queue/scheduler/config: Update scheduler configuration.
- Introduced types for queue items, scheduler state, and WebSocket messages to ensure type safety and compatibility with the backend.
- Added static model lists for LiteLLM as a fallback for available models.
This commit is contained in:
catlog22
2026-02-27 20:53:46 +08:00
parent 5b54f38aa3
commit 75173312c1
47 changed files with 3813 additions and 307 deletions

View File

@@ -260,7 +260,7 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp
{/* Main Panel */}
<div
className={cn(
'fixed top-0 right-0 h-full w-[640px] bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
'fixed top-0 right-0 h-full w-[1568px] bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
role="dialog"

View File

@@ -2,11 +2,13 @@
// AssistantMessage Component
// ========================================
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Bot, ChevronDown, Copy, Check } from 'lucide-react';
import { Bot, ChevronDown, Copy, Check, FileJson } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { JsonCard } from '../components/JsonCard';
import { detectJsonInLine } from '../utils/jsonDetector';
// Status indicator component
interface StatusIndicatorProps {
@@ -94,6 +96,11 @@ export function AssistantMessage({
const [isExpanded, setIsExpanded] = useState(true);
const [copied, setCopied] = useState(false);
// Detect JSON in content
const jsonDetection = useMemo(() => {
return detectJsonInLine(content);
}, [content]);
useEffect(() => {
if (copied) {
const timer = setTimeout(() => setCopied(false), 2000);
@@ -126,6 +133,14 @@ export function AssistantMessage({
{modelName}
</span>
{/* JSON indicator badge */}
{jsonDetection?.isJson && (
<span className="flex items-center gap-0.5 text-[9px] text-violet-500 dark:text-violet-400 bg-violet-200/50 dark:bg-violet-800/50 px-1 rounded">
<FileJson className="h-2.5 w-2.5" />
JSON
</span>
)}
<div className="flex items-center gap-1.5 ml-auto">
<StatusIndicator status={status} duration={duration} />
<ChevronDown
@@ -141,11 +156,21 @@ export function AssistantMessage({
{isExpanded && (
<>
<div className="px-2.5 py-2 bg-violet-50/40 dark:bg-violet-950/30">
<div className="bg-white/60 dark:bg-black/30 rounded border border-violet-200/40 dark:border-violet-800/30 p-2.5">
<div className="text-xs text-foreground whitespace-pre-wrap break-words leading-relaxed">
{content}
{jsonDetection?.isJson && jsonDetection.parsed ? (
/* JSON detected - use collapsible JsonCard */
<JsonCard
data={jsonDetection.parsed}
type="stdout"
onCopy={handleCopy}
/>
) : (
/* Plain text content */
<div className="bg-white/60 dark:bg-black/30 rounded border border-violet-200/40 dark:border-violet-800/30 p-2.5">
<div className="text-xs text-foreground whitespace-pre-wrap break-words leading-relaxed">
{content}
</div>
</div>
</div>
)}
</div>
{/* Metadata Footer - simplified */}

View File

@@ -17,7 +17,7 @@ export function MessageExample() {
<SystemMessage
title="Session Started"
timestamp={Date.now()}
metadata="gemini-2.5-pro | Context: 28 files"
metadata="Context: 28 files"
content="CLI execution started: gemini (analysis mode)"
/>

View File

@@ -19,6 +19,7 @@ import {
import { useCliExecutionDetail } from '@/hooks/useCliExecution';
import { useNativeSession } from '@/hooks/useNativeSession';
import type { ConversationRecord, ConversationTurn, NativeSessionTurn, NativeTokenInfo, NativeToolCall } from '@/lib/api';
import { getToolVariant } from '@/lib/cli-tool-theme';
type ViewMode = 'per-turn' | 'concatenated' | 'native';
type ConcatFormat = 'plain' | 'yaml' | 'json';
@@ -69,16 +70,12 @@ function getStatusInfo(status: string) {
}
/**
* Get badge variant for tool name
* Ensure prompt is a string (handle legacy object data)
*/
function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' {
const variants: Record<string, 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info'> = {
gemini: 'info',
codex: 'success',
qwen: 'warning',
opencode: 'secondary',
};
return variants[tool] || 'secondary';
function ensureString(value: unknown): string {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') return JSON.stringify(value);
return String(value ?? '');
}
/**
@@ -95,7 +92,7 @@ function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFo
for (const turn of turns) {
parts.push(`--- Turn ${turn.turn} ---`);
parts.push('USER:');
parts.push(turn.prompt);
parts.push(ensureString(turn.prompt));
parts.push('');
parts.push('ASSISTANT:');
parts.push(turn.output.stdout || formatMessage({ id: 'cli-manager.streamPanel.noOutput' }));
@@ -118,7 +115,7 @@ function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFo
yaml.push(` - turn: ${turn.turn}`);
yaml.push(` timestamp: ${turn.timestamp}`);
yaml.push(` prompt: |`);
turn.prompt.split('\n').forEach(line => {
ensureString(turn.prompt).split('\n').forEach(line => {
yaml.push(` ${line}`);
});
yaml.push(` response: |`);
@@ -140,7 +137,7 @@ function buildConcatenatedPrompt(execution: ConversationRecord, format: ConcatFo
turns.map((t) => ({
turn: t.turn,
timestamp: t.timestamp,
prompt: t.prompt,
prompt: ensureString(t.prompt),
response: t.output.stdout || '',
})),
null,
@@ -212,7 +209,7 @@ function TurnSection({ turn, isLatest, isExpanded, onToggle }: TurnSectionProps)
{formatMessage({ id: 'cli-manager.streamPanel.userPrompt' })}
</h4>
<pre className="p-3 bg-muted/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed">
{turn.prompt}
{ensureString(turn.prompt)}
</pre>
</div>
@@ -462,7 +459,7 @@ function NativeTurnCard({ turn, isLatest, isExpanded, onToggle }: NativeTurnCard
<div className="p-4 space-y-3">
{turn.content && (
<pre className="p-3 bg-background/50 rounded-lg text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-80 overflow-y-auto">
{turn.content}
{ensureString(turn.content)}
</pre>
)}

View File

@@ -198,7 +198,9 @@ export function ConversationCard({
{/* Prompt preview */}
<p className="text-sm text-foreground line-clamp-2 mb-2">
{execution.prompt_preview}
{typeof execution.prompt_preview === 'string'
? execution.prompt_preview
: JSON.stringify(execution.prompt_preview)}
</p>
{/* Meta info */}

View File

@@ -24,6 +24,7 @@ import {
} from '@/components/ui/Dialog';
import { useNativeSession } from '@/hooks/useNativeSession';
import { SessionTimeline } from './SessionTimeline';
import { getToolVariant } from '@/lib/cli-tool-theme';
// ========== Types ==========
@@ -35,19 +36,6 @@ export interface NativeSessionPanelProps {
// ========== Helpers ==========
/**
* Get badge variant for tool name
*/
function getToolVariant(tool: string): 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info' {
const variants: Record<string, 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'info'> = {
gemini: 'info',
codex: 'success',
qwen: 'warning',
opencode: 'secondary',
};
return variants[tool?.toLowerCase()] || 'secondary';
}
/**
* Truncate a string to a max length with ellipsis
*/

View File

@@ -54,6 +54,15 @@ interface ToolCallPanelProps {
// ========== Helpers ==========
/**
* Ensure content is a string (handle legacy object data like {text: "..."})
*/
function ensureString(value: unknown): string {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') return JSON.stringify(value);
return String(value ?? '');
}
/**
* Format token number with compact notation
*/
@@ -319,7 +328,7 @@ function TurnNode({ turn, isLatest, isLast }: TurnNodeProps) {
{turn.content && (
<div className="p-4">
<pre className="text-sm whitespace-pre-wrap overflow-x-auto font-mono leading-relaxed max-h-64 overflow-y-auto">
{turn.content}
{ensureString(turn.content)}
</pre>
</div>
)}