Files
Claude-Code-Workflow/ccw/src/tools/resume-strategy.ts
catlog22 abce912ee5 feat(frontend): implement comprehensive API Settings Management Interface
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
2026-02-01 23:58:04 +08:00

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';
}
}