feat: integrate Claude Agent SDK capabilities — model/command discovery, effort/thinking, MCP runtime, file rewind, structured output
Systematically integrate high-value SDK capabilities while preserving the multi-provider architecture: SDK capability discovery (per-provider cache): - agent-sdk-capabilities.ts: cache models, commands, account info, MCP status per provider via Map<providerId, cache> to prevent cross-provider pollution - claude-client.ts: fire-and-forget captureCapabilities() after query() init - providers/models: merge SDK models with static defaults - skills: merge SDK slash commands with filesystem skills Query options passthrough: - thinking (adaptive/enabled/disabled), effort (low/medium/high/max), outputFormat, agents, enableFileCheckpointing - /chat page.tsx: first-message path now includes effort/thinking (was missing) - stream-session-manager: passes effort/thinking in POST body Graceful interrupt: - /api/chat/interrupt: conversation.interrupt() before abort fallback - stream-session-manager: tries interrupt first, aborts after 2s timeout MCP runtime management: - /api/plugins/mcp/status: live status from active Query, auto-resolves providerId from session DB record (prevents wrong-provider cache writes) - /api/plugins/mcp/reconnect + toggle: reconnect/enable/disable servers - McpManager + McpServerList: runtime status indicators with action buttons File checkpointing & rewind: - enableFileCheckpointing=true for code mode by default - rewind_point SSE: only emitted for prompt-level user messages (parent_tool_use_id===null) and skips autoTrigger turns - /api/chat/rewind: dry-run preview + actual rewind via rewindFiles() - MessageList: RewindButton with dry-run preview Structured output: - /api/chat/structured: reads SDKResultSuccess.structured_output as primary path, text fallback only when absent Account info & thinking config: - /api/sdk/account: cached account info display - Settings: thinking_mode in ALLOWED_KEYS, thinking mode selector in UI - Effort selector in MessageInput with lifted state pattern New files: 11 route handlers + 2 lib modules + 1 test file Modified files: 18 (types, client, manager, UI components, i18n) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c02c135fe
commit
c18776f4ca
30 files changed
+1487
-28
No files matched your search
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConversation } from '@/lib/conversation-registry';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { sessionId } = await request.json();
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const conversation = getConversation(sessionId);
|
||||
if (!conversation) {
|
||||
return NextResponse.json({ interrupted: false });
|
||||
}
|
||||
|
||||
await conversation.interrupt();
|
||||
|
||||
return NextResponse.json({ interrupted: true });
|
||||
} catch (error) {
|
||||
console.error('[interrupt] Failed to interrupt:', error);
|
||||
return NextResponse.json({ interrupted: false, error: String(error) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConversation } from '@/lib/conversation-registry';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { sessionId, model } = await request.json();
|
||||
|
||||
if (!sessionId || !model) {
|
||||
return NextResponse.json({ error: 'sessionId and model are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const conversation = getConversation(sessionId);
|
||||
if (!conversation) {
|
||||
return NextResponse.json({ applied: false });
|
||||
}
|
||||
|
||||
await conversation.setModel(model);
|
||||
|
||||
return NextResponse.json({ applied: true });
|
||||
} catch (error) {
|
||||
console.error('[model] Failed to set model:', error);
|
||||
return NextResponse.json({ applied: false, error: String(error) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConversation } from '@/lib/conversation-registry';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { sessionId, userMessageId, dryRun } = await request.json();
|
||||
|
||||
if (!sessionId || !userMessageId) {
|
||||
return NextResponse.json({ error: 'sessionId and userMessageId are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const conversation = getConversation(sessionId);
|
||||
if (!conversation) {
|
||||
return NextResponse.json({ canRewind: false, error: 'No active conversation' });
|
||||
}
|
||||
|
||||
const result = await conversation.rewindFiles(userMessageId, { dryRun: !!dryRun });
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('[rewind] Failed to rewind:', error);
|
||||
return NextResponse.json({ canRewind: false, error: String(error) });
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,42 @@ import { NextRequest } from 'next/server';
|
||||
import { streamClaude } from '@/lib/claude-client';
|
||||
import { addMessage, getMessages, getSession, updateSessionTitle, updateSdkSessionId, updateSessionModel, updateSessionProvider, updateSessionProviderId, getSetting, getProvider, getDefaultProviderId, acquireSessionLock, renewSessionLock, releaseSessionLock, setSessionRuntimeStatus, syncSdkTasks } from '@/lib/db';
|
||||
import { notifySessionStart, notifySessionComplete, notifySessionError } from '@/lib/telegram-bot';
|
||||
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
|
||||
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment, ClaudeStreamOptions } from '@/types';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type { MCPServerConfig } from '@/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Read MCP server configs from ~/.claude.json and ~/.claude/settings.json */
|
||||
function loadMcpServers(): Record<string, MCPServerConfig> | undefined {
|
||||
try {
|
||||
const readJson = (p: string): Record<string, unknown> => {
|
||||
if (!fs.existsSync(p)) return {};
|
||||
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return {}; }
|
||||
};
|
||||
const userConfig = readJson(path.join(os.homedir(), '.claude.json'));
|
||||
const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json'));
|
||||
const merged = {
|
||||
...((userConfig.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
...((settings.mcpServers || {}) as Record<string, MCPServerConfig>),
|
||||
};
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let activeSessionId: string | undefined;
|
||||
let activeLockId: string | undefined;
|
||||
|
||||
try {
|
||||
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean } = await request.json();
|
||||
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger } = body;
|
||||
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number; provider_id?: string; systemPromptAppend?: string; autoTrigger?: boolean; thinking?: unknown; effort?: string; enableFileCheckpointing?: boolean } = await request.json();
|
||||
const { session_id, content, model, mode, files, toolTimeout, provider_id, systemPromptAppend, autoTrigger, thinking, effort, enableFileCheckpointing } = body;
|
||||
|
||||
console.log('[chat API] content length:', content.length, 'first 200 chars:', content.slice(0, 200));
|
||||
console.log('[chat API] systemPromptAppend:', systemPromptAppend ? `${systemPromptAppend.length} chars` : 'none');
|
||||
@@ -292,6 +313,10 @@ Start by greeting the user and asking the first question.
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
// Load MCP servers from Claude config files so the SDK knows about them
|
||||
// even when settingSources skips 'user' (custom provider scenario).
|
||||
const mcpServers = loadMcpServers();
|
||||
|
||||
// Stream Claude response, using SDK session ID for resume if available
|
||||
console.log('[chat API] streamClaude params:', {
|
||||
promptLength: content.length,
|
||||
@@ -313,8 +338,13 @@ Start by greeting the user and asking the first question.
|
||||
imageAgentMode: !!systemPromptAppend,
|
||||
toolTimeoutSeconds: toolTimeout || 300,
|
||||
provider: resolvedProvider,
|
||||
mcpServers,
|
||||
conversationHistory: historyMsgs,
|
||||
bypassPermissions: session.permission_profile === 'full_access',
|
||||
thinking: thinking as ClaudeStreamOptions['thinking'],
|
||||
effort: effort as ClaudeStreamOptions['effort'],
|
||||
enableFileCheckpointing: enableFileCheckpointing ?? (effectiveMode === 'code'),
|
||||
autoTrigger: !!autoTrigger,
|
||||
onRuntimeStatusChange: (status: string) => {
|
||||
try { setSessionRuntimeStatus(session_id, status); } catch { /* best effort */ }
|
||||
},
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { Options, SDKResultSuccess } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { prompt, outputFormat, options } = body;
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json({ error: 'prompt is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate outputFormat: must be { type: 'json_schema', schema: { ... } }
|
||||
if (
|
||||
!outputFormat ||
|
||||
typeof outputFormat !== 'object' ||
|
||||
outputFormat.type !== 'json_schema' ||
|
||||
!outputFormat.schema ||
|
||||
typeof outputFormat.schema !== 'object'
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'outputFormat must be { type: "json_schema", schema: { ... } }' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const queryOptions: Partial<Options> = {
|
||||
cwd: options?.cwd || process.cwd(),
|
||||
model: options?.model,
|
||||
outputFormat,
|
||||
};
|
||||
|
||||
// Collect the result message which contains structured_output
|
||||
let structuredOutput: unknown = undefined;
|
||||
let resultText = '';
|
||||
|
||||
for await (const message of query({
|
||||
prompt,
|
||||
options: queryOptions as Options,
|
||||
})) {
|
||||
if (message.type === 'result' && message.subtype === 'success') {
|
||||
const successResult = message as SDKResultSuccess;
|
||||
// Primary path: read structured_output directly from the SDK result
|
||||
if (successResult.structured_output !== undefined) {
|
||||
structuredOutput = successResult.structured_output;
|
||||
}
|
||||
// Also capture result text as fallback
|
||||
if (successResult.result) {
|
||||
resultText = successResult.result;
|
||||
}
|
||||
} else if (message.type === 'assistant') {
|
||||
// Fallback: accumulate assistant text in case structured_output is absent
|
||||
const msg = message.message as { content?: Array<{ type: string; text?: string }> } | string;
|
||||
if (typeof msg === 'string') {
|
||||
resultText += msg;
|
||||
} else if (msg && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
resultText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer structured_output from the SDK result message
|
||||
if (structuredOutput !== undefined) {
|
||||
return NextResponse.json({ result: structuredOutput });
|
||||
}
|
||||
|
||||
// Fallback: try to parse accumulated text as JSON
|
||||
if (resultText) {
|
||||
try {
|
||||
const parsed = JSON.parse(resultText);
|
||||
return NextResponse.json({ result: parsed });
|
||||
} catch {
|
||||
return NextResponse.json({ result: resultText });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ result: null });
|
||||
} catch (error) {
|
||||
console.error('[structured] Structured query failed:', error);
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConversation } from '@/lib/conversation-registry';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { sessionId, serverName } = await request.json();
|
||||
|
||||
if (!sessionId || !serverName) {
|
||||
return NextResponse.json({ error: 'sessionId and serverName are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const conversation = getConversation(sessionId);
|
||||
if (!conversation) {
|
||||
return NextResponse.json({ success: false, error: 'No active conversation' }, { status: 404 });
|
||||
}
|
||||
|
||||
await conversation.reconnectMcpServer(serverName);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[mcp/reconnect] Failed to reconnect MCP server:', error);
|
||||
return NextResponse.json({ success: false, error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { refreshMcpStatus, getCachedMcpStatus, getCapabilityCacheAge } from '@/lib/agent-sdk-capabilities';
|
||||
import { getSession } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const sessionId = request.nextUrl.searchParams.get('sessionId');
|
||||
// Accept explicit providerId, or resolve from session's stored provider_id
|
||||
let providerId = request.nextUrl.searchParams.get('providerId');
|
||||
|
||||
if (!providerId && sessionId) {
|
||||
const session = getSession(sessionId);
|
||||
providerId = session?.provider_id || 'env';
|
||||
}
|
||||
|
||||
providerId = providerId || 'env';
|
||||
|
||||
let servers;
|
||||
if (sessionId) {
|
||||
servers = await refreshMcpStatus(sessionId, providerId);
|
||||
} else {
|
||||
servers = getCachedMcpStatus(providerId);
|
||||
}
|
||||
|
||||
const cacheAge = getCapabilityCacheAge(providerId);
|
||||
const cachedAt = cacheAge === Infinity ? null : Date.now() - cacheAge;
|
||||
|
||||
return NextResponse.json({ servers, cached_at: cachedAt });
|
||||
} catch (error) {
|
||||
console.error('[mcp/status] Failed to get MCP status:', error);
|
||||
return NextResponse.json({ servers: [], cached_at: null, error: String(error) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getConversation } from '@/lib/conversation-registry';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { sessionId, serverName, enabled } = await request.json();
|
||||
|
||||
if (!sessionId || !serverName || typeof enabled !== 'boolean') {
|
||||
return NextResponse.json({ error: 'sessionId, serverName, and enabled (boolean) are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const conversation = getConversation(sessionId);
|
||||
if (!conversation) {
|
||||
return NextResponse.json({ success: false, error: 'No active conversation' }, { status: 404 });
|
||||
}
|
||||
|
||||
await conversation.toggleMcpServer(serverName, enabled);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[mcp/toggle] Failed to toggle MCP server:', error);
|
||||
return NextResponse.json({ success: false, error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,28 @@ export async function GET() {
|
||||
}),
|
||||
});
|
||||
|
||||
// If SDK has discovered models, use them for the env group
|
||||
try {
|
||||
const { getCachedModels } = await import('@/lib/agent-sdk-capabilities');
|
||||
const sdkModels = getCachedModels('env');
|
||||
if (sdkModels.length > 0) {
|
||||
groups[0].models = sdkModels.map(m => {
|
||||
const cw = getContextWindow(m.value);
|
||||
return {
|
||||
value: m.value,
|
||||
label: m.displayName,
|
||||
description: m.description,
|
||||
supportsEffort: m.supportsEffort,
|
||||
supportedEffortLevels: m.supportedEffortLevels,
|
||||
supportsAdaptiveThinking: m.supportsAdaptiveThinking,
|
||||
...(cw != null ? { contextWindow: cw } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// SDK capabilities not available, keep defaults
|
||||
}
|
||||
|
||||
// Provider types that are not LLMs (e.g. image generation) — skip in chat model selector
|
||||
const MEDIA_PROVIDER_TYPES = new Set(['gemini-image']);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedAccountInfo, getCapabilityCacheAge } from '@/lib/agent-sdk-capabilities';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const providerId = request.nextUrl.searchParams.get('providerId') || 'env';
|
||||
const account = getCachedAccountInfo(providerId);
|
||||
const cacheAge = getCapabilityCacheAge(providerId);
|
||||
const cachedAt = cacheAge === Infinity ? 0 : Date.now() - cacheAge;
|
||||
|
||||
return NextResponse.json({ account, cached_at: cachedAt });
|
||||
} catch (error) {
|
||||
console.error('[account] Failed to get account info:', error);
|
||||
return NextResponse.json({ account: null, cached_at: 0, error: String(error) });
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const ALLOWED_KEYS = [
|
||||
'anthropic_base_url',
|
||||
'dangerously_skip_permissions',
|
||||
'locale',
|
||||
'thinking_mode',
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
|
||||
@@ -320,6 +320,29 @@ export async function GET(request: NextRequest) {
|
||||
const all = [...globalSkills, ...projectSkills, ...dedupedProjectSkills, ...installedSkills, ...pluginSkills];
|
||||
console.log(`[skills] Found: global=${globalSkills.length}, project=${projectSkills.length}, projectSkills=${dedupedProjectSkills.length}, installed=${installedSkills.length}, plugin=${pluginSkills.length}`);
|
||||
|
||||
// Merge SDK slash commands if available
|
||||
try {
|
||||
const { getCachedCommands } = await import('@/lib/agent-sdk-capabilities');
|
||||
const sdkCommands = getCachedCommands('env');
|
||||
if (sdkCommands.length > 0) {
|
||||
const existingNames = new Set(all.map(s => s.name));
|
||||
for (const cmd of sdkCommands) {
|
||||
if (!existingNames.has(cmd.name)) {
|
||||
all.push({
|
||||
name: cmd.name,
|
||||
description: cmd.description || `SDK command: /${cmd.name}`,
|
||||
content: '', // SDK commands don't have local content
|
||||
source: 'sdk' as typeof all[number]['source'],
|
||||
filePath: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(`[skills] Added ${sdkCommands.length} SDK commands (${sdkCommands.filter(c => !existingNames.has(c.name)).length} unique)`);
|
||||
}
|
||||
} catch {
|
||||
// SDK capabilities not available, skip
|
||||
}
|
||||
|
||||
return NextResponse.json({ skills: all });
|
||||
} catch (error) {
|
||||
console.error('[skills] Error:', error);
|
||||
|
||||
Reference in new issue
Block a user