mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
Implement a complete API Management Interface for React frontend with split- panel layout, migrating all features from legacy JS frontend. New Features: - API Settings page with 5 tabs: Providers, Endpoints, Cache, Model Pools, CLI Settings - Provider Management: CRUD operations, multi-key rotation, health checks, test connection - Endpoint Management: CRUD operations, cache strategy configuration, enable/disable toggle - Cache Settings: Global configuration, statistics display, clear cache functionality - Model Pool Management: CRUD operations, auto-discovery feature, provider exclusion - CLI Settings Management: Provider-based and Direct modes, full CRUD support - Multi-Key Settings Modal: Manage API keys with rotation strategies and weights - Manage Models Modal: View and manage models per provider (LLM and Embedding) - Sync to CodexLens: Integration handler for provider configuration sync Technical Implementation: - Created 12 new React components in components/api-settings/ - Extended lib/api.ts with 460+ lines of API client functions - Created hooks/useApiSettings.ts with 772 lines of TanStack Query hooks - Added RadioGroup UI component for form selections - Implemented unified error handling with useNotifications across all operations - Complete i18n support (500+ keys in English and Chinese) - Route integration (/api-settings) and sidebar navigation Code Quality: - All acceptance criteria from plan.json verified - Code review passed with Gemini (all 7 IMPL tasks complete) - Follows existing patterns: Shadcn UI, TanStack Query, react-intl, Lucide icons
363 lines
10 KiB
TypeScript
363 lines
10 KiB
TypeScript
/**
|
|
* Resume Strategy Engine - Determines optimal resume approach
|
|
* Supports native resume, prompt concatenation, and hybrid modes
|
|
*/
|
|
|
|
import type { ConversationTurn, ConversationRecord, NativeSessionMapping } from './cli-history-store.js';
|
|
|
|
/**
|
|
* Emit user warning for silent fallback scenarios
|
|
*/
|
|
function warnUser(message: string): void {
|
|
console.warn(`[ccw] ${message}`);
|
|
}
|
|
|
|
// Strategy types
|
|
export type ResumeStrategy = 'native' | 'prompt-concat' | 'hybrid';
|
|
|
|
// Resume decision result
|
|
export interface ResumeDecision {
|
|
strategy: ResumeStrategy;
|
|
nativeSessionId?: string; // Native UUID for native/hybrid modes
|
|
isLatest?: boolean; // Use latest/--last flag
|
|
contextTurns?: ConversationTurn[]; // Turns to include as context prefix
|
|
primaryConversationId?: string; // Primary conversation for append
|
|
}
|
|
|
|
// Resume strategy options
|
|
export interface ResumeStrategyOptions {
|
|
tool: string;
|
|
resumeIds: string[]; // CCW IDs to resume from
|
|
customId?: string; // New custom ID (fork scenario)
|
|
forceNative?: boolean; // Force native resume
|
|
forcePromptConcat?: boolean; // Force prompt concatenation
|
|
|
|
// Lookup functions (dependency injection)
|
|
getNativeSessionId: (ccwId: string) => string | null;
|
|
getConversation: (ccwId: string) => ConversationRecord | null;
|
|
getConversationTool: (ccwId: string) => string | null;
|
|
}
|
|
|
|
/**
|
|
* Determine the optimal resume strategy based on scenario
|
|
*
|
|
* Scenarios:
|
|
* 1. Single append (no customId) → native if mapping exists
|
|
* 2. Fork (customId provided) → prompt-concat (new conversation)
|
|
* 3. Merge multiple → hybrid (primary native + others as context)
|
|
* 4. Cross-tool → prompt-concat (tools differ)
|
|
* 5. resume=true (latest) → native with isLatest flag
|
|
*/
|
|
export function determineResumeStrategy(options: ResumeStrategyOptions): ResumeDecision {
|
|
const {
|
|
tool,
|
|
resumeIds,
|
|
customId,
|
|
forceNative,
|
|
forcePromptConcat,
|
|
getNativeSessionId,
|
|
getConversation,
|
|
getConversationTool
|
|
} = options;
|
|
|
|
// Force prompt concatenation
|
|
if (forcePromptConcat) {
|
|
return buildPromptConcatDecision(resumeIds, getConversation);
|
|
}
|
|
|
|
// No resume IDs - new conversation
|
|
if (resumeIds.length === 0) {
|
|
return { strategy: 'prompt-concat' };
|
|
}
|
|
|
|
// Scenario 5: resume=true (latest) - use native latest
|
|
// This is handled before this function is called, but included for completeness
|
|
|
|
// Scenario 2: Fork (customId provided) → always prompt-concat
|
|
if (customId) {
|
|
return buildPromptConcatDecision(resumeIds, getConversation);
|
|
}
|
|
|
|
// Scenario 4: Check for cross-tool resume
|
|
const crossTool = resumeIds.some(id => {
|
|
const convTool = getConversationTool(id);
|
|
return convTool && convTool !== tool;
|
|
});
|
|
|
|
if (crossTool) {
|
|
warnUser('Cross-tool resume: using prompt concatenation (different tool)');
|
|
return buildPromptConcatDecision(resumeIds, getConversation);
|
|
}
|
|
|
|
// Scenario 1: Single append
|
|
if (resumeIds.length === 1) {
|
|
const nativeId = getNativeSessionId(resumeIds[0]);
|
|
|
|
if (nativeId || forceNative) {
|
|
return {
|
|
strategy: 'native',
|
|
nativeSessionId: nativeId || undefined,
|
|
primaryConversationId: resumeIds[0]
|
|
};
|
|
}
|
|
|
|
// No native mapping, fall back to prompt-concat
|
|
warnUser('No native session mapping found, using prompt concatenation');
|
|
return buildPromptConcatDecision(resumeIds, getConversation);
|
|
}
|
|
|
|
// Scenario 3: Merge multiple conversations → hybrid mode
|
|
return buildHybridDecision(resumeIds, tool, getNativeSessionId, getConversation);
|
|
}
|
|
|
|
/**
|
|
* Build prompt-concat decision with all turns loaded
|
|
*/
|
|
function buildPromptConcatDecision(
|
|
resumeIds: string[],
|
|
getConversation: (ccwId: string) => ConversationRecord | null
|
|
): ResumeDecision {
|
|
const allTurns: ConversationTurn[] = [];
|
|
let hasMissingConversation = false;
|
|
|
|
for (const id of resumeIds) {
|
|
const conversation = getConversation(id);
|
|
if (conversation) {
|
|
// Add source ID to each turn for tracking
|
|
const turnsWithSource = conversation.turns.map(turn => ({
|
|
...turn,
|
|
_sourceId: id
|
|
}));
|
|
allTurns.push(...turnsWithSource as ConversationTurn[]);
|
|
} else {
|
|
hasMissingConversation = true;
|
|
}
|
|
}
|
|
|
|
// Warn if any conversation was not found
|
|
if (hasMissingConversation) {
|
|
warnUser('One or more resume IDs not found, using prompt concatenation (new session created)');
|
|
}
|
|
|
|
// Sort by timestamp
|
|
allTurns.sort((a, b) =>
|
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
);
|
|
|
|
return {
|
|
strategy: 'prompt-concat',
|
|
contextTurns: allTurns,
|
|
primaryConversationId: resumeIds[0]
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build hybrid decision: primary uses native, others as context prefix
|
|
*/
|
|
function buildHybridDecision(
|
|
resumeIds: string[],
|
|
tool: string,
|
|
getNativeSessionId: (ccwId: string) => string | null,
|
|
getConversation: (ccwId: string) => ConversationRecord | null
|
|
): ResumeDecision {
|
|
// Find the first ID with native session mapping
|
|
let primaryId: string | null = null;
|
|
let nativeId: string | null = null;
|
|
|
|
for (const id of resumeIds) {
|
|
const native = getNativeSessionId(id);
|
|
if (native) {
|
|
primaryId = id;
|
|
nativeId = native;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If no native mapping found, use first as primary
|
|
if (!primaryId) {
|
|
primaryId = resumeIds[0];
|
|
}
|
|
|
|
// Collect context turns from non-primary conversations
|
|
const contextTurns: ConversationTurn[] = [];
|
|
|
|
for (const id of resumeIds) {
|
|
if (id === primaryId && nativeId) {
|
|
// Skip primary if using native - its context is handled natively
|
|
continue;
|
|
}
|
|
|
|
const conversation = getConversation(id);
|
|
if (conversation) {
|
|
const turnsWithSource = conversation.turns.map(turn => ({
|
|
...turn,
|
|
_sourceId: id
|
|
}));
|
|
contextTurns.push(...turnsWithSource as ConversationTurn[]);
|
|
}
|
|
}
|
|
|
|
// Sort context turns by timestamp
|
|
contextTurns.sort((a, b) =>
|
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
);
|
|
|
|
// If we have native ID, use hybrid; otherwise fall back to prompt-concat
|
|
if (nativeId) {
|
|
return {
|
|
strategy: 'hybrid',
|
|
nativeSessionId: nativeId,
|
|
contextTurns: contextTurns.length > 0 ? contextTurns : undefined,
|
|
primaryConversationId: primaryId
|
|
};
|
|
}
|
|
|
|
// No native mapping, use full prompt-concat
|
|
return buildPromptConcatDecision(resumeIds, getConversation);
|
|
}
|
|
|
|
/**
|
|
* Build context prefix for hybrid mode
|
|
* Formats non-primary conversation turns as context
|
|
*/
|
|
export function buildContextPrefix(
|
|
contextTurns: ConversationTurn[],
|
|
format: 'plain' | 'yaml' | 'json' = 'plain'
|
|
): string {
|
|
if (!contextTurns || contextTurns.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
const maxOutputLength = 4096; // Truncate long outputs
|
|
|
|
switch (format) {
|
|
case 'yaml':
|
|
return buildYamlContext(contextTurns, maxOutputLength);
|
|
case 'json':
|
|
return buildJsonContext(contextTurns, maxOutputLength);
|
|
default:
|
|
return buildPlainContext(contextTurns, maxOutputLength);
|
|
}
|
|
}
|
|
|
|
function buildPlainContext(turns: ConversationTurn[], maxLength: number): string {
|
|
const lines: string[] = [
|
|
'=== MERGED CONTEXT FROM OTHER CONVERSATIONS ===',
|
|
''
|
|
];
|
|
|
|
for (const turn of turns) {
|
|
const sourceId = (turn as any)._sourceId || 'unknown';
|
|
lines.push(`--- Turn ${turn.turn} [${sourceId}] ---`);
|
|
lines.push(`USER:`);
|
|
lines.push(turn.prompt);
|
|
lines.push('');
|
|
lines.push(`ASSISTANT:`);
|
|
const output = turn.output.stdout || '';
|
|
lines.push(output.length > maxLength ? output.substring(0, maxLength) + '\n[truncated]' : output);
|
|
lines.push('');
|
|
}
|
|
|
|
lines.push('=== END MERGED CONTEXT ===');
|
|
lines.push('');
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function buildYamlContext(turns: ConversationTurn[], maxLength: number): string {
|
|
const lines: string[] = [
|
|
'merged_context:',
|
|
' source: "other_conversations"',
|
|
' turns:'
|
|
];
|
|
|
|
for (const turn of turns) {
|
|
const sourceId = (turn as any)._sourceId || 'unknown';
|
|
const output = turn.output.stdout || '';
|
|
const truncatedOutput = output.length > maxLength
|
|
? output.substring(0, maxLength) + '\n[truncated]'
|
|
: output;
|
|
|
|
lines.push(` - turn: ${turn.turn}`);
|
|
lines.push(` source: "${sourceId}"`);
|
|
lines.push(` user: |`);
|
|
lines.push(turn.prompt.split('\n').map(l => ` ${l}`).join('\n'));
|
|
lines.push(` assistant: |`);
|
|
lines.push(truncatedOutput.split('\n').map(l => ` ${l}`).join('\n'));
|
|
}
|
|
|
|
lines.push('');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function buildJsonContext(turns: ConversationTurn[], maxLength: number): string {
|
|
const context = {
|
|
merged_context: {
|
|
source: 'other_conversations',
|
|
turns: turns.map(turn => {
|
|
const output = turn.output.stdout || '';
|
|
return {
|
|
turn: turn.turn,
|
|
source: (turn as any)._sourceId || 'unknown',
|
|
user: turn.prompt,
|
|
assistant: output.length > maxLength
|
|
? output.substring(0, maxLength) + '\n[truncated]'
|
|
: output
|
|
};
|
|
})
|
|
}
|
|
};
|
|
|
|
return JSON.stringify(context, null, 2) + '\n\n';
|
|
}
|
|
|
|
/**
|
|
* Check if a resume scenario requires native resume
|
|
*/
|
|
export function shouldUseNativeResume(
|
|
tool: string,
|
|
resumeIds: string[],
|
|
customId: string | undefined,
|
|
getNativeSessionId: (ccwId: string) => string | null,
|
|
getConversationTool: (ccwId: string) => string | null
|
|
): boolean {
|
|
// Fork always uses prompt-concat
|
|
if (customId) return false;
|
|
|
|
// No resume IDs
|
|
if (resumeIds.length === 0) return false;
|
|
|
|
// Cross-tool not supported natively
|
|
const crossTool = resumeIds.some(id => {
|
|
const convTool = getConversationTool(id);
|
|
return convTool && convTool !== tool;
|
|
});
|
|
if (crossTool) return false;
|
|
|
|
// Single resume with native mapping
|
|
if (resumeIds.length === 1) {
|
|
return !!getNativeSessionId(resumeIds[0]);
|
|
}
|
|
|
|
// Merge: at least one needs native mapping for hybrid
|
|
return resumeIds.some(id => !!getNativeSessionId(id));
|
|
}
|
|
|
|
/**
|
|
* Get resume mode description for logging
|
|
*/
|
|
export function getResumeModeDescription(decision: ResumeDecision): string {
|
|
switch (decision.strategy) {
|
|
case 'native':
|
|
return `Native resume (session: ${decision.nativeSessionId || 'latest'})`;
|
|
case 'hybrid':
|
|
const contextCount = decision.contextTurns?.length || 0;
|
|
return `Hybrid (native + ${contextCount} context turns)`;
|
|
case 'prompt-concat':
|
|
const turnCount = decision.contextTurns?.length || 0;
|
|
return `Prompt concat (${turnCount} turns)`;
|
|
default:
|
|
return 'Unknown';
|
|
}
|
|
}
|