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
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.27.1",
|
||||
"version": "0.28.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codepilot",
|
||||
"version": "0.27.1",
|
||||
"version": "0.28.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.47",
|
||||
"@ai-sdk/google": "^3.0.31",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codepilot",
|
||||
"version": "0.27.1",
|
||||
"version": "0.28.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "op7418",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Unit tests for structured output route logic.
|
||||
*
|
||||
* Tests verify that:
|
||||
* 1. structured_output from SDKResultSuccess is preferred over text fallback
|
||||
* 2. Text fallback is used when structured_output is absent
|
||||
* 3. Raw text is returned when JSON.parse fails on fallback
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Simulate the extraction logic from the structured route
|
||||
// (We can't import Next.js route handlers directly in node:test,
|
||||
// so we test the core logic inline.)
|
||||
|
||||
function extractStructuredResult(messages: Array<{ type: string; subtype?: string; structured_output?: unknown; result?: string; message?: unknown }>) {
|
||||
let structuredOutput: unknown = undefined;
|
||||
let resultText = '';
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type === 'result' && message.subtype === 'success') {
|
||||
if (message.structured_output !== undefined) {
|
||||
structuredOutput = message.structured_output;
|
||||
}
|
||||
if (message.result) {
|
||||
resultText = message.result;
|
||||
}
|
||||
} else if (message.type === 'assistant') {
|
||||
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
|
||||
if (structuredOutput !== undefined) {
|
||||
return { result: structuredOutput, source: 'structured_output' };
|
||||
}
|
||||
|
||||
// Fallback: try JSON parse
|
||||
if (resultText) {
|
||||
try {
|
||||
return { result: JSON.parse(resultText), source: 'text_parsed' };
|
||||
} catch {
|
||||
return { result: resultText, source: 'text_raw' };
|
||||
}
|
||||
}
|
||||
|
||||
return { result: null, source: 'empty' };
|
||||
}
|
||||
|
||||
describe('structured output extraction', () => {
|
||||
it('prefers structured_output from SDK result', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: { content: [{ type: 'text', text: '{"name":"from text"}' }] },
|
||||
},
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
structured_output: { name: 'from structured' },
|
||||
result: '{"name":"from text"}',
|
||||
},
|
||||
];
|
||||
|
||||
const out = extractStructuredResult(messages);
|
||||
assert.equal(out.source, 'structured_output');
|
||||
assert.deepEqual(out.result, { name: 'from structured' });
|
||||
});
|
||||
|
||||
it('falls back to text parsing when structured_output is absent', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: { content: [{ type: 'text', text: '{"fallback":true}' }] },
|
||||
},
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: '',
|
||||
},
|
||||
];
|
||||
|
||||
const out = extractStructuredResult(messages);
|
||||
assert.equal(out.source, 'text_parsed');
|
||||
assert.deepEqual(out.result, { fallback: true });
|
||||
});
|
||||
|
||||
it('returns raw text when JSON parse fails', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: { content: [{ type: 'text', text: 'not valid json' }] },
|
||||
},
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
},
|
||||
];
|
||||
|
||||
const out = extractStructuredResult(messages);
|
||||
assert.equal(out.source, 'text_raw');
|
||||
assert.equal(out.result, 'not valid json');
|
||||
});
|
||||
|
||||
it('returns null when no content at all', () => {
|
||||
const messages = [
|
||||
{ type: 'result', subtype: 'success' },
|
||||
];
|
||||
|
||||
const out = extractStructuredResult(messages);
|
||||
assert.equal(out.source, 'empty');
|
||||
assert.equal(out.result, null);
|
||||
});
|
||||
|
||||
it('handles structured_output that is a primitive (e.g. number)', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
structured_output: 42,
|
||||
},
|
||||
];
|
||||
|
||||
const out = extractStructuredResult(messages);
|
||||
assert.equal(out.source, 'structured_output');
|
||||
assert.equal(out.result, 42);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
+35
-3
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestEvent } from '@/types';
|
||||
import { MessageList } from '@/components/chat/MessageList';
|
||||
@@ -40,6 +40,22 @@ export default function NewChatPage() {
|
||||
const [streamingToolOutput, setStreamingToolOutput] = useState('');
|
||||
const [permissionProfile, setPermissionProfile] = useState<'default' | 'full_access'>('default');
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
// Effort level — lifted here so the first message includes it
|
||||
const [selectedEffort, setSelectedEffort] = useState<string | undefined>(undefined);
|
||||
// Thinking mode from app settings
|
||||
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
|
||||
|
||||
// Fetch thinking mode from app settings
|
||||
useEffect(() => {
|
||||
fetch('/api/settings/app')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data?.settings?.thinking_mode) {
|
||||
setThinkingMode(data.settings.thinking_mode);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
@@ -147,11 +163,25 @@ export default function NewChatPage() {
|
||||
};
|
||||
setMessages([userMessage]);
|
||||
|
||||
// Build thinking config from settings
|
||||
const thinkingConfig = thinkingMode && thinkingMode !== 'adaptive'
|
||||
? { type: thinkingMode }
|
||||
: thinkingMode === 'adaptive' ? { type: 'adaptive' } : undefined;
|
||||
|
||||
// Send the message via streaming API
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: session.id, content, mode, model: currentModel, provider_id: currentProviderId, ...(systemPromptAppend ? { systemPromptAppend } : {}) }),
|
||||
body: JSON.stringify({
|
||||
session_id: session.id,
|
||||
content,
|
||||
mode,
|
||||
model: currentModel,
|
||||
provider_id: currentProviderId,
|
||||
...(systemPromptAppend ? { systemPromptAppend } : {}),
|
||||
...(selectedEffort ? { effort: selectedEffort } : {}),
|
||||
...(thinkingConfig ? { thinking: thinkingConfig } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -318,7 +348,7 @@ export default function NewChatPage() {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, setPendingApprovalSessionId]
|
||||
[isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, selectedEffort, thinkingMode, setPendingApprovalSessionId]
|
||||
);
|
||||
|
||||
const handleCommand = useCallback((command: string) => {
|
||||
@@ -388,6 +418,8 @@ export default function NewChatPage() {
|
||||
workingDirectory={workingDir}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
effort={selectedEffort}
|
||||
onEffortChange={setSelectedEffort}
|
||||
/>
|
||||
<ChatComposerActionBar
|
||||
left={<ImageGenToggle />}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
stopStream,
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getRewindPoints,
|
||||
respondToPermission,
|
||||
clearSnapshot,
|
||||
} from '@/lib/stream-session-manager';
|
||||
@@ -48,6 +49,10 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
const [mode, setMode] = useState(initialMode || 'code');
|
||||
const [currentModel, setCurrentModel] = useState(modelName || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-model') : null) || 'sonnet');
|
||||
const [currentProviderId, setCurrentProviderId] = useState(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
|
||||
// Effort level selected in MessageInput — lifted here so it enters the stream chain
|
||||
const [selectedEffort, setSelectedEffort] = useState<string | undefined>(undefined);
|
||||
// Thinking mode from app settings
|
||||
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
|
||||
|
||||
// Sync model/provider when session data loads (props update after async fetch)
|
||||
// Unconditional: when modelName is empty (old session with no saved model),
|
||||
@@ -58,6 +63,18 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
useEffect(() => {
|
||||
setCurrentProviderId(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
|
||||
}, [providerId]);
|
||||
|
||||
// Fetch thinking mode from app settings
|
||||
useEffect(() => {
|
||||
fetch('/api/settings/app')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data?.settings?.thinking_mode) {
|
||||
setThinkingMode(data.settings.thinking_mode);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (initialPermissionProfile) {
|
||||
setPermissionProfile(initialPermissionProfile);
|
||||
@@ -78,6 +95,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
const statusText = streamSnapshot?.statusText;
|
||||
const pendingPermission = streamSnapshot?.pendingPermission ?? null;
|
||||
const permissionResolved = streamSnapshot?.permissionResolved ?? null;
|
||||
const rewindPoints = getRewindPoints(sessionId);
|
||||
|
||||
// Pending image generation notices — flushed into the next user message so the LLM knows about generated images
|
||||
const pendingImageNoticesRef = useRef<string[]>([]);
|
||||
@@ -290,6 +308,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
model: currentModel,
|
||||
providerId: currentProviderId,
|
||||
autoTrigger: true,
|
||||
thinking: buildThinkingConfig(),
|
||||
onModeChanged: (sdkMode) => {
|
||||
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
|
||||
handleModeChange(uiMode);
|
||||
@@ -468,6 +487,14 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
}
|
||||
}, [sessionId, workingDirectory]);
|
||||
|
||||
// Build SDK thinking config from settings
|
||||
const buildThinkingConfig = useCallback((): { type: string } | undefined => {
|
||||
if (!thinkingMode || thinkingMode === 'adaptive') return { type: 'adaptive' };
|
||||
if (thinkingMode === 'enabled') return { type: 'enabled' };
|
||||
if (thinkingMode === 'disabled') return { type: 'disabled' };
|
||||
return undefined;
|
||||
}, [thinkingMode]);
|
||||
|
||||
// Send message — delegates stream management to the manager
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => {
|
||||
@@ -512,6 +539,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
files,
|
||||
systemPromptAppend,
|
||||
pendingImageNotices: notices,
|
||||
effort: selectedEffort,
|
||||
thinking: buildThinkingConfig(),
|
||||
onModeChanged: (sdkMode) => {
|
||||
const uiMode = sdkMode === 'plan' ? 'plan' : 'code';
|
||||
handleModeChange(uiMode);
|
||||
@@ -521,7 +550,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
},
|
||||
});
|
||||
},
|
||||
[sessionId, isStreaming, mode, currentModel, currentProviderId, handleModeChange]
|
||||
[sessionId, isStreaming, mode, currentModel, currentProviderId, selectedEffort, buildThinkingConfig, handleModeChange]
|
||||
);
|
||||
|
||||
// Keep sendMessageRef in sync so timeout auto-retry can call it
|
||||
@@ -663,6 +692,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={loadEarlierMessages}
|
||||
rewindPoints={rewindPoints}
|
||||
sessionId={sessionId}
|
||||
/>
|
||||
{/* Permission prompt — rendered outside MessageList so it's always visible at bottom */}
|
||||
<PermissionPrompt
|
||||
@@ -692,6 +723,8 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
onAssistantTrigger={checkAssistantTrigger}
|
||||
effort={selectedEffort}
|
||||
onEffortChange={setSelectedEffort}
|
||||
/>
|
||||
<ChatComposerActionBar
|
||||
left={<ImageGenToggle />}
|
||||
|
||||
@@ -98,6 +98,9 @@ interface MessageInputProps {
|
||||
mode?: string;
|
||||
onModeChange?: (mode: string) => void;
|
||||
onAssistantTrigger?: () => void;
|
||||
/** Effort selection lifted to parent for inclusion in the stream chain */
|
||||
effort?: string;
|
||||
onEffortChange?: (effort: string | undefined) => void;
|
||||
}
|
||||
|
||||
interface PopoverItem {
|
||||
@@ -375,6 +378,8 @@ export function MessageInput({
|
||||
mode = 'code',
|
||||
onModeChange,
|
||||
onAssistantTrigger,
|
||||
effort: effortProp,
|
||||
onEffortChange,
|
||||
}: MessageInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const imageGen = useImageGen();
|
||||
@@ -932,6 +937,31 @@ export function MessageInput({
|
||||
const currentModelValue = modelName || 'sonnet';
|
||||
const currentModelOption = MODEL_OPTIONS.find((m) => m.value === currentModelValue) || MODEL_OPTIONS[0];
|
||||
|
||||
// Effort selector state — only shown when the current model supports effort
|
||||
const currentModelMeta = currentModelOption as typeof currentModelOption & { supportsEffort?: boolean; supportedEffortLevels?: string[] };
|
||||
const showEffortSelector = currentModelMeta.supportsEffort === true;
|
||||
// Use prop if provided (lifted state), otherwise local state
|
||||
const [localEffort, setLocalEffort] = useState<string>('high');
|
||||
const selectedEffort = effortProp ?? localEffort;
|
||||
const setSelectedEffort = useCallback((v: string) => {
|
||||
setLocalEffort(v);
|
||||
onEffortChange?.(v);
|
||||
}, [onEffortChange]);
|
||||
const [effortMenuOpen, setEffortMenuOpen] = useState(false);
|
||||
const effortMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close effort menu on outside click
|
||||
useEffect(() => {
|
||||
if (!effortMenuOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (effortMenuRef.current && !effortMenuRef.current.contains(e.target as Node)) {
|
||||
setEffortMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [effortMenuOpen]);
|
||||
|
||||
// Map isStreaming to ChatStatus for PromptInputSubmit
|
||||
const chatStatus: ChatStatus = isStreaming ? 'streaming' : 'ready';
|
||||
|
||||
@@ -1191,6 +1221,44 @@ export function MessageInput({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Effort selector — only visible when model supports effort */}
|
||||
{showEffortSelector && (
|
||||
<div className="relative" ref={effortMenuRef}>
|
||||
<PromptInputButton
|
||||
onClick={() => setEffortMenuOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="text-xs">{t(`messageInput.effort.${selectedEffort}` as TranslationKey)}</span>
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} className={cn("h-2.5 w-2.5 transition-transform duration-200", effortMenuOpen && "rotate-180")} />
|
||||
</PromptInputButton>
|
||||
|
||||
{effortMenuOpen && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-36 rounded-lg border bg-popover shadow-lg overflow-hidden z-50">
|
||||
<div className="px-3 py-1.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t('messageInput.effort.label' as TranslationKey)}
|
||||
</div>
|
||||
<div className="py-0.5">
|
||||
{(currentModelMeta.supportedEffortLevels || ['low', 'medium', 'high', 'max']).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm transition-colors",
|
||||
selectedEffort === level ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedEffort(level);
|
||||
setEffortMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs">{t(`messageInput.effort.${level}` as TranslationKey)}</span>
|
||||
{selectedEffort === level && <span className="text-xs">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</PromptInputTools>
|
||||
|
||||
<FileAwareSubmitButton
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import type { TranslationKey } from '@/i18n';
|
||||
import { useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
import type { Message } from '@/types';
|
||||
import {
|
||||
@@ -41,6 +42,95 @@ function ScrollOnStream({ isStreaming, messageCount }: { isStreaming: boolean; m
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind button shown on user messages that have file checkpoints.
|
||||
*/
|
||||
function RewindButton({ sessionId, userMessageId }: { sessionId: string; userMessageId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<'idle' | 'preview' | 'loading' | 'done'>('idle');
|
||||
const [preview, setPreview] = useState<{ filesChanged?: string[]; insertions?: number; deletions?: number } | null>(null);
|
||||
|
||||
const handleDryRun = useCallback(async () => {
|
||||
setState('loading');
|
||||
try {
|
||||
const res = await fetch('/api/chat/rewind', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, userMessageId, dryRun: true }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.canRewind) {
|
||||
setPreview(data);
|
||||
setState('preview');
|
||||
} else {
|
||||
setState('idle');
|
||||
}
|
||||
} catch {
|
||||
setState('idle');
|
||||
}
|
||||
}, [sessionId, userMessageId]);
|
||||
|
||||
const handleRewind = useCallback(async () => {
|
||||
setState('loading');
|
||||
try {
|
||||
const res = await fetch('/api/chat/rewind', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, userMessageId }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.canRewind !== false) {
|
||||
setState('done');
|
||||
setTimeout(() => setState('idle'), 3000);
|
||||
} else {
|
||||
setState('idle');
|
||||
}
|
||||
} catch {
|
||||
setState('idle');
|
||||
}
|
||||
}, [sessionId, userMessageId]);
|
||||
|
||||
if (state === 'done') {
|
||||
return (
|
||||
<span className="text-[10px] text-green-600 dark:text-green-400 ml-2">
|
||||
{t('messageList.rewindDone' as TranslationKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'preview' && preview) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 ml-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{preview.filesChanged?.length || 0} files, +{preview.insertions || 0}/-{preview.deletions || 0}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleRewind}
|
||||
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t('messageList.rewindConfirm' as TranslationKey)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setState('idle')}
|
||||
className="text-[10px] text-muted-foreground hover:underline"
|
||||
>
|
||||
{t('messageList.rewindCancel' as TranslationKey)}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleDryRun}
|
||||
disabled={state === 'loading'}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors ml-2 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
{state === 'loading' ? '...' : t('messageList.rewindToHere' as TranslationKey)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolUseInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -53,6 +143,11 @@ interface ToolResultInfo {
|
||||
is_error?: boolean;
|
||||
}
|
||||
|
||||
/** Rewind points contain SDK UUIDs (not local message IDs) */
|
||||
interface RewindPoint {
|
||||
userMessageId: string; // SDK UUID
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
streamingContent: string;
|
||||
@@ -65,6 +160,9 @@ interface MessageListProps {
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
/** SDK rewind points — only emitted for visible prompt-level user messages (not tool results or auto-triggers), mapped by position */
|
||||
rewindPoints?: RewindPoint[];
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
@@ -79,6 +177,8 @@ export function MessageList({
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
rewindPoints = [],
|
||||
sessionId,
|
||||
}: MessageListProps) {
|
||||
const { t } = useTranslation();
|
||||
// Scroll anchor: preserve position when older messages are prepended
|
||||
@@ -132,11 +232,28 @@ export function MessageList({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} id={`msg-${message.id}`}>
|
||||
<MessageItem message={message} />
|
||||
</div>
|
||||
))}
|
||||
{messages.map((message) => {
|
||||
// Map rewind points to visible user messages by position:
|
||||
// Backend only emits rewind_point for prompt-level user messages
|
||||
// (not tool results, not auto-trigger), so they're 1:1 with visible user messages.
|
||||
let rewindSdkUuid: string | undefined;
|
||||
if (message.role === 'user' && sessionId && rewindPoints.length > 0) {
|
||||
const userMsgsBefore = messages.filter(m => m.role === 'user');
|
||||
const userIndex = userMsgsBefore.indexOf(message);
|
||||
if (userIndex >= 0 && userIndex < rewindPoints.length) {
|
||||
rewindSdkUuid = rewindPoints[userIndex].userMessageId;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={message.id} id={`msg-${message.id}`} className="group">
|
||||
<MessageItem message={message} />
|
||||
{rewindSdkUuid && sessionId && !isStreaming && (
|
||||
<RewindButton sessionId={sessionId} userMessageId={rewindSdkUuid} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isStreaming && (
|
||||
<StreamingMessage
|
||||
|
||||
@@ -3,14 +3,22 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { PlusSignIcon, ListViewIcon, CodeIcon, Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { PlusSignIcon, ListViewIcon, CodeIcon, Loading02Icon, RefreshIcon, Wifi01Icon } from "@hugeicons/core-free-icons";
|
||||
import { McpServerList } from "@/components/plugins/McpServerList";
|
||||
import { McpServerEditor } from "@/components/plugins/McpServerEditor";
|
||||
import { ConfigEditor } from "@/components/plugins/ConfigEditor";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import type { TranslationKey } from "@/i18n";
|
||||
import type { MCPServer } from "@/types";
|
||||
|
||||
interface McpRuntimeStatus {
|
||||
name: string;
|
||||
status: 'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled';
|
||||
serverInfo?: { name: string; version: string };
|
||||
}
|
||||
|
||||
export function McpManager() {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<Record<string, MCPServer>>({});
|
||||
@@ -20,6 +28,9 @@ export function McpManager() {
|
||||
const [editingServer, setEditingServer] = useState<MCPServer | undefined>();
|
||||
const [tab, setTab] = useState<"list" | "json">("list");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [runtimeStatus, setRuntimeStatus] = useState<McpRuntimeStatus[]>([]);
|
||||
const [runtimeLoading, setRuntimeLoading] = useState(false);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
@@ -39,9 +50,37 @@ export function McpManager() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRuntimeStatus = useCallback(async () => {
|
||||
setRuntimeLoading(true);
|
||||
try {
|
||||
// Try to get active session from stream manager
|
||||
const sessionsRes = await fetch('/api/chat/sessions?status=active&limit=1');
|
||||
const sessionsData = await sessionsRes.json();
|
||||
const sessionId = sessionsData?.sessions?.[0]?.id;
|
||||
|
||||
if (!sessionId) {
|
||||
setActiveSessionId(null);
|
||||
setRuntimeStatus([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveSessionId(sessionId);
|
||||
const res = await fetch(`/api/plugins/mcp/status?sessionId=${encodeURIComponent(sessionId)}`);
|
||||
const data = await res.json();
|
||||
if (data.servers) {
|
||||
setRuntimeStatus(data.servers);
|
||||
}
|
||||
} catch {
|
||||
// Runtime status unavailable
|
||||
} finally {
|
||||
setRuntimeLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, [fetchServers]);
|
||||
fetchRuntimeStatus();
|
||||
}, [fetchServers, fetchRuntimeStatus]);
|
||||
|
||||
function handleEdit(name: string, server: MCPServer) {
|
||||
setEditingName(name);
|
||||
@@ -188,6 +227,8 @@ export function McpManager() {
|
||||
servers={servers}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
runtimeStatus={runtimeStatus}
|
||||
activeSessionId={activeSessionId || undefined}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -201,6 +242,56 @@ export function McpManager() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Runtime Status Section */}
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<HugeiconsIcon icon={Wifi01Icon} className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium">{t('mcp.runtimeStatus' as TranslationKey)}</h4>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={fetchRuntimeStatus}
|
||||
disabled={runtimeLoading}
|
||||
>
|
||||
<HugeiconsIcon icon={runtimeLoading ? Loading02Icon : RefreshIcon} className={`h-3 w-3 ${runtimeLoading ? 'animate-spin' : ''}`} />
|
||||
{t('mcp.refresh' as TranslationKey)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!activeSessionId ? (
|
||||
<p className="text-xs text-muted-foreground py-4 text-center">
|
||||
{t('mcp.noActiveSession' as TranslationKey)}
|
||||
</p>
|
||||
) : runtimeStatus.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-4 text-center">
|
||||
{t('mcp.noRuntimeStatus' as TranslationKey)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{runtimeStatus.map((s) => (
|
||||
<div key={s.name} className="flex items-center justify-between py-1.5 px-2 rounded-md bg-muted/30">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 ${
|
||||
s.status === 'connected' ? 'bg-green-500' :
|
||||
s.status === 'failed' ? 'bg-red-500' :
|
||||
s.status === 'pending' ? 'bg-blue-500' :
|
||||
s.status === 'disabled' ? 'bg-gray-400' :
|
||||
'bg-yellow-500'
|
||||
}`} />
|
||||
<span className="text-xs font-medium truncate">{s.name}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{s.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<McpServerEditor
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
|
||||
@@ -4,15 +4,24 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/com
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import { Delete02Icon, PencilIcon, ServerStack01Icon, Wifi01Icon, GlobeIcon } from "@hugeicons/core-free-icons";
|
||||
import { Delete02Icon, PencilIcon, ServerStack01Icon, Wifi01Icon, GlobeIcon, RefreshIcon, Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import type { TranslationKey } from '@/i18n';
|
||||
import type { MCPServer } from '@/types';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface McpRuntimeStatus {
|
||||
name: string;
|
||||
status: 'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled';
|
||||
serverInfo?: { name: string; version: string };
|
||||
}
|
||||
|
||||
interface McpServerListProps {
|
||||
servers: Record<string, MCPServer>;
|
||||
onEdit: (name: string, server: MCPServer) => void;
|
||||
onDelete: (name: string) => void;
|
||||
runtimeStatus?: McpRuntimeStatus[];
|
||||
activeSessionId?: string;
|
||||
}
|
||||
|
||||
function getServerTypeInfo(server: MCPServer) {
|
||||
@@ -27,9 +36,68 @@ function getServerTypeInfo(server: MCPServer) {
|
||||
}
|
||||
}
|
||||
|
||||
export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps) {
|
||||
function getStatusBadge(status: McpRuntimeStatus['status']) {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return { label: 'Connected', className: 'bg-green-500/10 text-green-600 border-green-500/20' };
|
||||
case 'failed':
|
||||
return { label: 'Failed', className: 'bg-red-500/10 text-red-600 border-red-500/20' };
|
||||
case 'needs-auth':
|
||||
return { label: 'Auth Required', className: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20' };
|
||||
case 'pending':
|
||||
return { label: 'Pending', className: 'bg-blue-500/10 text-blue-600 border-blue-500/20' };
|
||||
case 'disabled':
|
||||
return { label: 'Disabled', className: 'bg-gray-500/10 text-gray-500 border-gray-500/20' };
|
||||
default:
|
||||
return { label: status, className: '' };
|
||||
}
|
||||
}
|
||||
|
||||
export function McpServerList({ servers, onEdit, onDelete, runtimeStatus, activeSessionId }: McpServerListProps) {
|
||||
const { t } = useTranslation();
|
||||
const entries = Object.entries(servers);
|
||||
const [reconnecting, setReconnecting] = useState<Set<string>>(new Set());
|
||||
const [toggling, setToggling] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleReconnect = useCallback(async (serverName: string) => {
|
||||
if (!activeSessionId) return;
|
||||
setReconnecting(prev => new Set(prev).add(serverName));
|
||||
try {
|
||||
await fetch('/api/plugins/mcp/reconnect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: activeSessionId, serverName }),
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
} finally {
|
||||
setReconnecting(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(serverName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
const handleToggle = useCallback(async (serverName: string, enabled: boolean) => {
|
||||
if (!activeSessionId) return;
|
||||
setToggling(prev => new Set(prev).add(serverName));
|
||||
try {
|
||||
await fetch('/api/plugins/mcp/toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId: activeSessionId, serverName, enabled }),
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
} finally {
|
||||
setToggling(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(serverName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
@@ -43,10 +111,23 @@ export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps)
|
||||
);
|
||||
}
|
||||
|
||||
// Build a lookup for runtime status by server name
|
||||
const statusByName = new Map<string, McpRuntimeStatus>();
|
||||
if (runtimeStatus) {
|
||||
for (const s of runtimeStatus) {
|
||||
statusByName.set(s.name, s);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([name, server]) => {
|
||||
const typeInfo = getServerTypeInfo(server);
|
||||
const runtime = statusByName.get(name);
|
||||
const statusBadge = runtime ? getStatusBadge(runtime.status) : null;
|
||||
const isReconnecting = reconnecting.has(name);
|
||||
const isToggling = toggling.has(name);
|
||||
|
||||
return (
|
||||
<Card key={name}>
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
||||
@@ -57,17 +138,60 @@ export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps)
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{typeInfo.label}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t('provider.configured')}
|
||||
</Badge>
|
||||
{statusBadge ? (
|
||||
<Badge variant="outline" className={`text-xs shrink-0 ${statusBadge.className}`}>
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t('provider.configured')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-xs mt-1 font-mono">
|
||||
{server.url
|
||||
? server.url
|
||||
: `${server.command} ${server.args?.join(' ') || ''}`}
|
||||
</CardDescription>
|
||||
{runtime?.serverInfo && (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{runtime.serverInfo.name} v{runtime.serverInfo.version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{/* Reconnect button — only for failed servers */}
|
||||
{runtime?.status === 'failed' && activeSessionId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={isReconnecting}
|
||||
onClick={() => handleReconnect(name)}
|
||||
title={t('mcp.reconnect' as TranslationKey)}
|
||||
>
|
||||
{isReconnecting ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={RefreshIcon} className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{/* Enable button — only for disabled servers */}
|
||||
{runtime?.status === 'disabled' && activeSessionId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
disabled={isToggling}
|
||||
onClick={() => handleToggle(name, true)}
|
||||
>
|
||||
{isToggling ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
) : null}
|
||||
{t('mcp.enable' as TranslationKey)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ReloadIcon, Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { useUpdate } from "@/hooks/useUpdate";
|
||||
import { useTranslation } from "@/hooks/useTranslation";
|
||||
import { SUPPORTED_LOCALES, type Locale } from "@/i18n";
|
||||
import type { TranslationKey } from "@/i18n";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
function UpdateCard() {
|
||||
@@ -121,6 +122,8 @@ export function GeneralSection() {
|
||||
const [skipPermissions, setSkipPermissions] = useState(false);
|
||||
const [showSkipPermWarning, setShowSkipPermWarning] = useState(false);
|
||||
const [skipPermSaving, setSkipPermSaving] = useState(false);
|
||||
const [thinkingMode, setThinkingMode] = useState<string>('adaptive');
|
||||
const [accountInfo, setAccountInfo] = useState<{ email?: string; organization?: string; subscriptionType?: string } | null>(null);
|
||||
const { t, locale, setLocale } = useTranslation();
|
||||
|
||||
const fetchAppSettings = useCallback(async () => {
|
||||
@@ -130,15 +133,48 @@ export function GeneralSection() {
|
||||
const data = await res.json();
|
||||
const appSettings = data.settings || {};
|
||||
setSkipPermissions(appSettings.dangerously_skip_permissions === "true");
|
||||
if (appSettings.thinking_mode) {
|
||||
setThinkingMode(appSettings.thinking_mode);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchAccountInfo = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/sdk/account");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.account) {
|
||||
setAccountInfo(data.account);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Account info not available
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAppSettings();
|
||||
}, [fetchAppSettings]);
|
||||
fetchAccountInfo();
|
||||
}, [fetchAppSettings, fetchAccountInfo]);
|
||||
|
||||
const saveThinkingMode = async (mode: string) => {
|
||||
setThinkingMode(mode);
|
||||
try {
|
||||
await fetch("/api/settings/app", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
settings: { thinking_mode: mode },
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipPermToggle = (checked: boolean) => {
|
||||
if (checked) {
|
||||
@@ -216,6 +252,50 @@ export function GeneralSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thinking mode */}
|
||||
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">{t('settings.thinkingMode' as TranslationKey)}</h2>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.thinkingModeDesc' as TranslationKey)}</p>
|
||||
</div>
|
||||
<Select value={thinkingMode} onValueChange={saveThinkingMode}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="adaptive">{t('settings.thinkingAdaptive' as TranslationKey)}</SelectItem>
|
||||
<SelectItem value="enabled">{t('settings.thinkingEnabled' as TranslationKey)}</SelectItem>
|
||||
<SelectItem value="disabled">{t('settings.thinkingDisabled' as TranslationKey)}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account info */}
|
||||
{accountInfo && (
|
||||
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
|
||||
<h2 className="text-sm font-medium mb-2">{t('settings.accountInfo' as TranslationKey)}</h2>
|
||||
<div className="space-y-1">
|
||||
{accountInfo.email && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{t('settings.email' as TranslationKey)}:</span> {accountInfo.email}
|
||||
</p>
|
||||
)}
|
||||
{accountInfo.organization && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{t('settings.organization' as TranslationKey)}:</span> {accountInfo.organization}
|
||||
</p>
|
||||
)}
|
||||
{accountInfo.subscriptionType && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{t('settings.subscription' as TranslationKey)}:</span> {accountInfo.subscriptionType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip-permissions warning dialog */}
|
||||
<AlertDialog open={showSkipPermWarning} onOpenChange={setShowSkipPermWarning}>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface SSECallbacks {
|
||||
onToolTimeout: (toolName: string, elapsedSeconds: number) => void;
|
||||
onModeChanged: (mode: string) => void;
|
||||
onTaskUpdate: (sessionId: string) => void;
|
||||
onRewindPoint: (sdkUserMessageId: string) => void;
|
||||
onKeepAlive: () => void;
|
||||
onError: (accumulated: string) => void;
|
||||
}
|
||||
@@ -89,7 +90,7 @@ function handleSSEEvent(
|
||||
try {
|
||||
const statusData = JSON.parse(event.data);
|
||||
if (statusData.session_id) {
|
||||
callbacks.onStatus(`Connected (${statusData.model || 'claude'})`);
|
||||
callbacks.onStatus(`Connected (${statusData.requested_model || statusData.model || 'claude'})`);
|
||||
} else if (statusData.notification) {
|
||||
callbacks.onStatus(statusData.message || statusData.title || undefined);
|
||||
} else {
|
||||
@@ -147,6 +148,18 @@ function handleSSEEvent(
|
||||
return accumulated;
|
||||
}
|
||||
|
||||
case 'rewind_point': {
|
||||
try {
|
||||
const rpData = JSON.parse(event.data);
|
||||
if (rpData.userMessageId) {
|
||||
callbacks.onRewindPoint(rpData.userMessageId);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed rewind_point data
|
||||
}
|
||||
return accumulated;
|
||||
}
|
||||
|
||||
case 'keep_alive': {
|
||||
callbacks.onKeepAlive();
|
||||
return accumulated;
|
||||
@@ -238,6 +251,7 @@ export function useSSEStream() {
|
||||
onToolTimeout: (n, s) => callbacksRef.current?.onToolTimeout(n, s),
|
||||
onModeChanged: (m) => callbacksRef.current?.onModeChanged(m),
|
||||
onTaskUpdate: (s) => callbacksRef.current?.onTaskUpdate(s),
|
||||
onRewindPoint: (id) => callbacksRef.current?.onRewindPoint(id),
|
||||
onKeepAlive: () => callbacksRef.current?.onKeepAlive(),
|
||||
onError: (a) => callbacksRef.current?.onError(a),
|
||||
};
|
||||
|
||||
@@ -766,6 +766,40 @@ const en = {
|
||||
'context.estimate': 'Estimated from the most recent response',
|
||||
'context.noData': 'No context usage data yet',
|
||||
'context.unknown': 'Unknown',
|
||||
|
||||
// ── SDK Capabilities: Effort ──────────────────────────────
|
||||
'messageInput.effort.label': 'Effort Level',
|
||||
'messageInput.effort.low': 'Low',
|
||||
'messageInput.effort.medium': 'Medium',
|
||||
'messageInput.effort.high': 'High',
|
||||
'messageInput.effort.max': 'Max',
|
||||
|
||||
// ── SDK Capabilities: Rewind ──────────────────────────────
|
||||
'messageList.rewindToHere': 'Rewind to here',
|
||||
'messageList.rewindConfirm': 'Confirm rewind',
|
||||
'messageList.rewindCancel': 'Cancel',
|
||||
'messageList.rewindDone': 'Files rewound successfully',
|
||||
|
||||
// ── SDK Capabilities: MCP Runtime ─────────────────────────
|
||||
'mcp.runtimeStatus': 'Runtime Status',
|
||||
'mcp.refresh': 'Refresh',
|
||||
'mcp.noActiveSession': 'Start a conversation to see live status',
|
||||
'mcp.noRuntimeStatus': 'No runtime status available',
|
||||
'mcp.reconnect': 'Reconnect',
|
||||
'mcp.enable': 'Enable',
|
||||
|
||||
// ── SDK Capabilities: Thinking ────────────────────────────
|
||||
'settings.thinkingMode': 'Thinking Mode',
|
||||
'settings.thinkingModeDesc': 'Control Claude\'s extended thinking behavior',
|
||||
'settings.thinkingAdaptive': 'Adaptive',
|
||||
'settings.thinkingEnabled': 'Enabled',
|
||||
'settings.thinkingDisabled': 'Disabled',
|
||||
|
||||
// ── SDK Capabilities: Account ─────────────────────────────
|
||||
'settings.accountInfo': 'Account Information',
|
||||
'settings.email': 'Email',
|
||||
'settings.organization': 'Organization',
|
||||
'settings.subscription': 'Subscription',
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof en;
|
||||
|
||||
@@ -763,6 +763,40 @@ const zh: Record<TranslationKey, string> = {
|
||||
'context.estimate': '基于最近一轮响应估算',
|
||||
'context.noData': '暂无上下文统计',
|
||||
'context.unknown': '未知上下文窗口',
|
||||
|
||||
// ── SDK Capabilities: Effort ──────────────────────────────
|
||||
'messageInput.effort.label': '推理力度',
|
||||
'messageInput.effort.low': '低',
|
||||
'messageInput.effort.medium': '中',
|
||||
'messageInput.effort.high': '高',
|
||||
'messageInput.effort.max': '最大',
|
||||
|
||||
// ── SDK Capabilities: Rewind ──────────────────────────────
|
||||
'messageList.rewindToHere': '回滚到此处',
|
||||
'messageList.rewindConfirm': '确认回滚',
|
||||
'messageList.rewindCancel': '取消',
|
||||
'messageList.rewindDone': '文件回滚成功',
|
||||
|
||||
// ── SDK Capabilities: MCP Runtime ─────────────────────────
|
||||
'mcp.runtimeStatus': '运行状态',
|
||||
'mcp.refresh': '刷新',
|
||||
'mcp.noActiveSession': '开始对话后可查看实时状态',
|
||||
'mcp.noRuntimeStatus': '暂无运行状态信息',
|
||||
'mcp.reconnect': '重连',
|
||||
'mcp.enable': '启用',
|
||||
|
||||
// ── SDK Capabilities: Thinking ────────────────────────────
|
||||
'settings.thinkingMode': '思考模式',
|
||||
'settings.thinkingModeDesc': '控制 Claude 的扩展思考行为',
|
||||
'settings.thinkingAdaptive': '自适应',
|
||||
'settings.thinkingEnabled': '启用',
|
||||
'settings.thinkingDisabled': '禁用',
|
||||
|
||||
// ── SDK Capabilities: Account ─────────────────────────────
|
||||
'settings.accountInfo': '账户信息',
|
||||
'settings.email': '邮箱',
|
||||
'settings.organization': '组织',
|
||||
'settings.subscription': '订阅类型',
|
||||
};
|
||||
|
||||
export default zh;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Agent SDK Agents Registry — manages built-in agent definitions
|
||||
* that can be injected into SDK query options.
|
||||
*
|
||||
* Existing "Image Agent" and other special agents in CodePilot are
|
||||
* handled via system prompt injection, not SDK agents. This registry
|
||||
* is for future SDK-native agent integration.
|
||||
*/
|
||||
|
||||
import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
const GLOBAL_KEY = '__agentSdkAgents__' as const;
|
||||
|
||||
function getRegistry(): Map<string, AgentDefinition> {
|
||||
if (!(globalThis as Record<string, unknown>)[GLOBAL_KEY]) {
|
||||
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = new Map<string, AgentDefinition>();
|
||||
}
|
||||
return (globalThis as Record<string, unknown>)[GLOBAL_KEY] as Map<string, AgentDefinition>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a built-in agent definition.
|
||||
*/
|
||||
export function registerAgent(name: string, definition: AgentDefinition): void {
|
||||
getRegistry().set(name, definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a built-in agent.
|
||||
*/
|
||||
export function unregisterAgent(name: string): void {
|
||||
getRegistry().delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered agent definitions as a record suitable for SDK Options.agents.
|
||||
*/
|
||||
export function getRegisteredAgents(): Record<string, AgentDefinition> {
|
||||
const result: Record<string, AgentDefinition> = {};
|
||||
for (const [name, def] of getRegistry()) {
|
||||
result[name] = def;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific registered agent.
|
||||
*/
|
||||
export function getAgent(name: string): AgentDefinition | undefined {
|
||||
return getRegistry().get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any agents are registered.
|
||||
*/
|
||||
export function hasRegisteredAgents(): boolean {
|
||||
return getRegistry().size > 0;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Agent SDK Capabilities Cache — per-provider capability cache that captures
|
||||
* SDK data (models, commands, account info, MCP status) from active Query instances.
|
||||
*
|
||||
* Uses cache-on-first-query pattern: after each query() initialization,
|
||||
* capabilities are captured and cached, keyed by providerId, so different
|
||||
* providers never pollute each other's data.
|
||||
*
|
||||
* Uses globalThis pattern (same as conversation-registry.ts) to survive
|
||||
* Next.js HMR without losing state.
|
||||
*/
|
||||
|
||||
import type { Query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type {
|
||||
ModelInfo,
|
||||
SlashCommand,
|
||||
AccountInfo,
|
||||
McpServerStatus,
|
||||
} from '@anthropic-ai/claude-agent-sdk';
|
||||
import { getConversation } from './conversation-registry';
|
||||
|
||||
// ==========================================
|
||||
// Cache structure
|
||||
// ==========================================
|
||||
|
||||
interface ProviderCapabilityCache {
|
||||
models: ModelInfo[];
|
||||
commands: SlashCommand[];
|
||||
account: AccountInfo | null;
|
||||
mcpStatus: McpServerStatus[];
|
||||
capturedAt: number;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const GLOBAL_KEY = '__agentSdkCapabilities__' as const;
|
||||
|
||||
/** Returns the per-provider cache Map. */
|
||||
function getCacheMap(): Map<string, ProviderCapabilityCache> {
|
||||
if (!(globalThis as Record<string, unknown>)[GLOBAL_KEY]) {
|
||||
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = new Map<string, ProviderCapabilityCache>();
|
||||
}
|
||||
return (globalThis as Record<string, unknown>)[GLOBAL_KEY] as Map<string, ProviderCapabilityCache>;
|
||||
}
|
||||
|
||||
function getOrCreateCache(providerId: string): ProviderCapabilityCache {
|
||||
const map = getCacheMap();
|
||||
let cache = map.get(providerId);
|
||||
if (!cache) {
|
||||
cache = {
|
||||
models: [],
|
||||
commands: [],
|
||||
account: null,
|
||||
mcpStatus: [],
|
||||
capturedAt: 0,
|
||||
sessionId: '',
|
||||
};
|
||||
map.set(providerId, cache);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Capture
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Check if a conversation object is a real Query instance (not a resume-fallback
|
||||
* async generator wrapper). The resume fallback in claude-client.ts wraps the
|
||||
* iterator in a plain async generator that lacks Query control methods.
|
||||
*/
|
||||
function isRealQuery(conversation: unknown): conversation is Query {
|
||||
return (
|
||||
conversation != null &&
|
||||
typeof (conversation as Query).supportedModels === 'function' &&
|
||||
typeof (conversation as Query).supportedCommands === 'function' &&
|
||||
typeof (conversation as Query).accountInfo === 'function' &&
|
||||
typeof (conversation as Query).mcpServerStatus === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture all capabilities from an active Query instance.
|
||||
* Should be called fire-and-forget after registerConversation().
|
||||
* Safe to call with non-Query objects (resume fallback) — will silently skip.
|
||||
*
|
||||
* @param providerId - The provider ID that owns this session (e.g. 'env', a DB provider ID)
|
||||
*/
|
||||
export async function captureCapabilities(
|
||||
sessionId: string,
|
||||
conversation: unknown,
|
||||
providerId: string = 'env',
|
||||
): Promise<void> {
|
||||
if (!isRealQuery(conversation)) {
|
||||
console.log('[capabilities] Skipping capture — not a real Query instance');
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = getOrCreateCache(providerId);
|
||||
|
||||
try {
|
||||
const [models, commands, account, mcpStatus] = await Promise.allSettled([
|
||||
conversation.supportedModels(),
|
||||
conversation.supportedCommands(),
|
||||
conversation.accountInfo(),
|
||||
conversation.mcpServerStatus(),
|
||||
]);
|
||||
|
||||
cache.models = models.status === 'fulfilled' ? models.value : cache.models;
|
||||
cache.commands = commands.status === 'fulfilled' ? commands.value : cache.commands;
|
||||
cache.account = account.status === 'fulfilled' ? account.value : cache.account;
|
||||
cache.mcpStatus = mcpStatus.status === 'fulfilled' ? mcpStatus.value : cache.mcpStatus;
|
||||
cache.capturedAt = Date.now();
|
||||
cache.sessionId = sessionId;
|
||||
|
||||
console.log(
|
||||
`[capabilities] Captured for provider="${providerId}":`,
|
||||
`models=${cache.models.length}`,
|
||||
`commands=${cache.commands.length}`,
|
||||
`account=${cache.account ? 'yes' : 'no'}`,
|
||||
`mcpServers=${cache.mcpStatus.length}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[capabilities] Capture failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Read cached data (scoped by provider)
|
||||
// ==========================================
|
||||
|
||||
export function getCachedModels(providerId: string = 'env'): ModelInfo[] {
|
||||
return getOrCreateCache(providerId).models;
|
||||
}
|
||||
|
||||
export function getCachedCommands(providerId: string = 'env'): SlashCommand[] {
|
||||
return getOrCreateCache(providerId).commands;
|
||||
}
|
||||
|
||||
export function getCachedAccountInfo(providerId: string = 'env'): AccountInfo | null {
|
||||
return getOrCreateCache(providerId).account;
|
||||
}
|
||||
|
||||
export function getCachedMcpStatus(providerId: string = 'env'): McpServerStatus[] {
|
||||
return getOrCreateCache(providerId).mcpStatus;
|
||||
}
|
||||
|
||||
export function getCapabilityCacheAge(providerId: string = 'env'): number {
|
||||
const { capturedAt } = getOrCreateCache(providerId);
|
||||
return capturedAt === 0 ? Infinity : Date.now() - capturedAt;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Refresh (from active Query)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Refresh MCP server status from an active Query instance.
|
||||
* Falls back to cached data if the session has no active conversation.
|
||||
*/
|
||||
export async function refreshMcpStatus(sessionId: string, providerId: string = 'env'): Promise<McpServerStatus[]> {
|
||||
const conversation = getConversation(sessionId);
|
||||
if (!isRealQuery(conversation)) {
|
||||
return getOrCreateCache(providerId).mcpStatus;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await conversation.mcpServerStatus();
|
||||
const cache = getOrCreateCache(providerId);
|
||||
cache.mcpStatus = status;
|
||||
cache.capturedAt = Date.now();
|
||||
return status;
|
||||
} catch (error) {
|
||||
console.warn('[capabilities] MCP status refresh failed:', error);
|
||||
return getOrCreateCache(providerId).mcpStatus;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, Permis
|
||||
import { isImageFile } from '@/types';
|
||||
import { registerPendingPermission } from './permission-registry';
|
||||
import { registerConversation, unregisterConversation } from './conversation-registry';
|
||||
import { captureCapabilities } from './agent-sdk-capabilities';
|
||||
import { getSetting, getActiveProvider, updateSdkSessionId, createPermissionRequest } from './db';
|
||||
import { findClaudeBinary, findGitBash, getExpandedPath } from './platform';
|
||||
import { notifyPermissionRequest, notifyGeneric } from './telegram-bot';
|
||||
@@ -281,6 +282,13 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
onRuntimeStatusChange,
|
||||
imageAgentMode,
|
||||
bypassPermissions: sessionBypassPermissions,
|
||||
thinking,
|
||||
effort,
|
||||
outputFormat,
|
||||
agents,
|
||||
agent,
|
||||
enableFileCheckpointing,
|
||||
autoTrigger,
|
||||
} = options;
|
||||
|
||||
return new ReadableStream<string>({
|
||||
@@ -430,6 +438,26 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
queryOptions.mcpServers = toSdkMcpConfig(mcpServers);
|
||||
}
|
||||
|
||||
// Pass through SDK-specific options from ClaudeStreamOptions
|
||||
if (thinking) {
|
||||
queryOptions.thinking = thinking;
|
||||
}
|
||||
if (effort) {
|
||||
queryOptions.effort = effort;
|
||||
}
|
||||
if (outputFormat) {
|
||||
queryOptions.outputFormat = outputFormat;
|
||||
}
|
||||
if (agents) {
|
||||
queryOptions.agents = agents as Options['agents'];
|
||||
}
|
||||
if (agent) {
|
||||
queryOptions.agent = agent;
|
||||
}
|
||||
if (enableFileCheckpointing) {
|
||||
queryOptions.enableFileCheckpointing = true;
|
||||
}
|
||||
|
||||
// Resume session if we have an SDK session ID from a previous conversation turn.
|
||||
// Pre-check: verify working_directory exists before attempting resume.
|
||||
// Resume depends on session context (cwd/project scope), so if the
|
||||
@@ -732,6 +760,13 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
|
||||
registerConversation(sessionId, conversation);
|
||||
|
||||
// Fire-and-forget: capture SDK capabilities for UI consumption
|
||||
// Scope to provider so different providers don't pollute each other's cache
|
||||
const capProviderId = activeProvider?.api_key ? (options.provider as ApiProvider & { id?: string })?.id || 'custom' : 'env';
|
||||
captureCapabilities(sessionId, conversation, capProviderId).catch((err) => {
|
||||
console.warn('[claude-client] Capability capture failed:', err);
|
||||
});
|
||||
|
||||
let lastAssistantText = '';
|
||||
let tokenUsage: TokenUsage | null = null;
|
||||
|
||||
@@ -792,6 +827,20 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit rewind_point for file checkpointing — only for prompt-level
|
||||
// user messages (parent_tool_use_id === null), and skip auto-trigger
|
||||
// turns which are invisible to the user (onboarding/check-in).
|
||||
if (
|
||||
userMsg.parent_tool_use_id === null &&
|
||||
!autoTrigger &&
|
||||
userMsg.uuid
|
||||
) {
|
||||
controller.enqueue(formatSSE({
|
||||
type: 'rewind_point',
|
||||
data: JSON.stringify({ userMessageId: userMsg.uuid }),
|
||||
}));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -816,6 +865,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
data: JSON.stringify({
|
||||
session_id: sysMsg.session_id,
|
||||
model: sysMsg.model,
|
||||
requested_model: model,
|
||||
tools: sysMsg.tools,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -42,6 +42,7 @@ interface ActiveStream {
|
||||
toolTimeoutInfo: { toolName: string; elapsedSeconds: number } | null;
|
||||
isIdleTimeout: boolean;
|
||||
sendMessageFn: ((content: string, files?: FileAttachment[]) => void) | null;
|
||||
rewindPoints: Array<{ userMessageId: string }>;
|
||||
}
|
||||
|
||||
export interface StartStreamParams {
|
||||
@@ -59,6 +60,10 @@ export interface StartStreamParams {
|
||||
onModeChanged?: (mode: string) => void;
|
||||
/** Reference to the outer sendMessage so tool-timeout auto-retry works */
|
||||
sendMessageFn?: (content: string, files?: FileAttachment[]) => void;
|
||||
/** SDK effort level (low/medium/high/max) — only sent when model supports it */
|
||||
effort?: string;
|
||||
/** SDK thinking config */
|
||||
thinking?: { type: string; budgetTokens?: number };
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@@ -187,6 +192,7 @@ export function startStream(params: StartStreamParams): void {
|
||||
toolTimeoutInfo: null,
|
||||
isIdleTimeout: false,
|
||||
sendMessageFn: params.sendMessageFn ?? null,
|
||||
rewindPoints: [],
|
||||
};
|
||||
|
||||
map.set(params.sessionId, stream);
|
||||
@@ -228,6 +234,8 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi
|
||||
...(params.files && params.files.length > 0 ? { files: params.files } : {}),
|
||||
...(params.systemPromptAppend ? { systemPromptAppend: params.systemPromptAppend } : {}),
|
||||
...(params.autoTrigger ? { autoTrigger: true } : {}),
|
||||
...(params.effort ? { effort: params.effort } : {}),
|
||||
...(params.thinking ? { thinking: params.thinking } : {}),
|
||||
}),
|
||||
signal: stream.abortController.signal,
|
||||
});
|
||||
@@ -324,6 +332,10 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi
|
||||
markActive();
|
||||
window.dispatchEvent(new CustomEvent('tasks-updated'));
|
||||
},
|
||||
onRewindPoint: (sdkUserMessageId) => {
|
||||
markActive();
|
||||
stream.rewindPoints = [...stream.rewindPoints, { userMessageId: sdkUserMessageId }];
|
||||
},
|
||||
onKeepAlive: () => {
|
||||
markActive();
|
||||
},
|
||||
@@ -497,7 +509,21 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi
|
||||
export function stopStream(sessionId: string): void {
|
||||
const stream = getStreamsMap().get(sessionId);
|
||||
if (stream && stream.snapshot.phase === 'active') {
|
||||
stream.abortController.abort();
|
||||
// Try graceful interrupt first, fallback to abort
|
||||
fetch('/api/chat/interrupt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
}).catch(() => {
|
||||
// Interrupt failed, force abort
|
||||
}).finally(() => {
|
||||
// Always abort after a short delay to ensure cleanup
|
||||
setTimeout(() => {
|
||||
if (stream.snapshot.phase === 'active') {
|
||||
stream.abortController.abort();
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,6 +565,11 @@ export function isStreamActive(sessionId: string): boolean {
|
||||
return stream?.snapshot.phase === 'active' || false;
|
||||
}
|
||||
|
||||
export function getRewindPoints(sessionId: string): Array<{ userMessageId: string }> {
|
||||
const stream = getStreamsMap().get(sessionId);
|
||||
return stream?.rewindPoints ?? [];
|
||||
}
|
||||
|
||||
export function getActiveSessionIds(): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const [id, stream] of getStreamsMap()) {
|
||||
|
||||
+24
-1
@@ -123,7 +123,15 @@ export interface ProviderModelGroup {
|
||||
provider_id: string; // provider DB id, or 'env' for environment variables
|
||||
provider_name: string;
|
||||
provider_type: string;
|
||||
models: Array<{ value: string; label: string; contextWindow?: number }>;
|
||||
models: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
contextWindow?: number;
|
||||
description?: string;
|
||||
supportsEffort?: boolean;
|
||||
supportedEffortLevels?: string[];
|
||||
supportsAdaptiveThinking?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CreateProviderRequest {
|
||||
@@ -359,6 +367,7 @@ export type SSEEventType =
|
||||
| 'mode_changed' // SDK permission mode changed (e.g. plan → code)
|
||||
| 'task_update' // SDK TodoWrite task sync
|
||||
| 'keep_alive' // SDK keep-alive heartbeat (resets idle timer)
|
||||
| 'rewind_point' // SDK user message with rewind checkpoint
|
||||
| 'done'; // stream complete
|
||||
|
||||
export interface SSEEvent {
|
||||
@@ -817,4 +826,18 @@ export interface ClaudeStreamOptions {
|
||||
onRuntimeStatusChange?: (status: string) => void;
|
||||
/** Per-session bypass: when true, skip all permission checks for this session */
|
||||
bypassPermissions?: boolean;
|
||||
/** Thinking configuration for the query */
|
||||
thinking?: { type: 'adaptive' } | { type: 'enabled'; budgetTokens?: number } | { type: 'disabled' };
|
||||
/** Effort level for the query */
|
||||
effort?: 'low' | 'medium' | 'high' | 'max';
|
||||
/** Output format for structured responses */
|
||||
outputFormat?: { type: 'json_schema'; schema: Record<string, unknown> };
|
||||
/** Custom agent definitions */
|
||||
agents?: Record<string, { description: string; prompt?: string; tools?: string[]; disallowedTools?: string[] }>;
|
||||
/** Agent name for the main thread */
|
||||
agent?: string;
|
||||
/** Enable file checkpointing for rewind support */
|
||||
enableFileCheckpointing?: boolean;
|
||||
/** When true, this is an auto-trigger turn (invisible to user) — skip rewind point emission */
|
||||
autoTrigger?: boolean;
|
||||
}
|
||||
Reference in new issue
Block a user