Files
CodePilot/src/app/api/chat/route.ts
T
7418andClaude Opus 4.6 19aba25c8f feat: assistant workspace — persistent AI personality & memory via Markdown files (#165)
Add "Assistant Workspace" feature that lets users designate a directory
(e.g. Obsidian vault) containing 4 Markdown files (soul.md, user.md,
memory.md, claude.md) to define AI personality, user profile, long-term
memory, and execution rules. These files are automatically injected into
the system prompt for assistant project sessions.

Core library (src/lib/assistant-workspace.ts):
- Workspace validation, initialization with template files
- File loading with 8000-char truncation, XML prompt assembly (40k limit)
- State management via .assistant/state.json
- Directory docs generation (README.ai.md + PATH.ai.md)
- Case-insensitive file name fallback (soul.md → SOUL.md)

System prompt injection (src/app/api/chat/route.ts):
- Scoped to sessions where working_directory === workspace path
- Workspace prompt prepended before session-level system prompt
- Onboarding/check-in instructions injected as <assistant-project-task>
- autoTrigger flag skips user message save and title update

Chat-based onboarding (10 questions) & daily check-in (3 questions):
- AI conducts conversation, outputs structured completion code blocks
- Frontend detects onboarding-complete/checkin-complete blocks
- Backend calls Claude API to generate file content from answers
- Fallback to raw answers if API call fails

Auto-trigger pipeline:
- Mount trigger (500ms delay) + focus fallback in MessageInput
- assistantTriggerFiredRef prevents duplicate triggers per mount
- hookTriggeredSessionId in state.json prevents cross-reload duplicates
- Daily check-in only triggers in the most recent workspace session
- hookTriggeredSessionId cleared via __clear__ sentinel after completion

API endpoints:
- GET/PUT /api/settings/workspace — path CRUD + file status
- POST /api/workspace/session — create (onboarding) or reuse (check-in)
- POST /api/workspace/onboarding — AI-generate soul.md + user.md
- POST /api/workspace/checkin — AI-update memory.md + user.md
- POST /api/workspace/hook-triggered — set/clear trigger guard
- GET /api/workspace/latest-session — latest session lookup
- POST /api/workspace/docs — refresh directory documentation

UI:
- Settings "Assistant" tab with workspace path, file status, controls
- Violet badge on workspace project group in ChatListPanel
- useAssistantWorkspace hook for state queries

Also includes:
- 102 passing tests (16 new regression tests for workspace logic)
- i18n keys for en.ts and zh.ts
- Handover documentation and execution plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 20:31:42 +08:00

523 lines
21 KiB
TypeScript

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 crypto from 'crypto';
import fs from 'fs';
import path from 'path';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
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;
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');
if (!session_id || !content) {
return new Response(JSON.stringify({ error: 'session_id and content are required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const session = getSession(session_id);
if (!session) {
return new Response(JSON.stringify({ error: 'Session not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Acquire exclusive lock for this session to prevent concurrent requests
const lockId = crypto.randomBytes(8).toString('hex');
const lockAcquired = acquireSessionLock(session_id, lockId, `chat-${process.pid}`, 600);
if (!lockAcquired) {
return new Response(
JSON.stringify({ error: 'Session is busy processing another request', code: 'SESSION_BUSY' }),
{ status: 409, headers: { 'Content-Type': 'application/json' } },
);
}
activeSessionId = session_id;
activeLockId = lockId;
setSessionRuntimeStatus(session_id, 'running');
// Telegram notification: session started (fire-and-forget)
const telegramNotifyOpts = {
sessionId: session_id,
sessionTitle: session.title !== 'New Chat' ? session.title : content.slice(0, 50),
workingDirectory: session.working_directory,
};
notifySessionStart(telegramNotifyOpts).catch(() => {});
// Save user message — persist file metadata so attachments survive page reload
// Skip saving for autoTrigger messages (invisible system triggers for assistant hooks)
let savedContent = content;
let fileMeta: Array<{ id: string; name: string; type: string; size: number; filePath: string }> | undefined;
if (!autoTrigger) {
if (files && files.length > 0) {
const workDir = session.working_directory;
const uploadDir = path.join(workDir, '.codepilot-uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
fileMeta = files.map((f) => {
const safeName = path.basename(f.name).replace(/[^a-zA-Z0-9._-]/g, '_');
const filePath = path.join(uploadDir, `${Date.now()}-${safeName}`);
const buffer = Buffer.from(f.data, 'base64');
fs.writeFileSync(filePath, buffer);
return { id: f.id, name: f.name, type: f.type, size: buffer.length, filePath };
});
savedContent = `<!--files:${JSON.stringify(fileMeta)}-->${content}`;
}
addMessage(session_id, 'user', savedContent);
// Auto-generate title from first message if still default
if (session.title === 'New Chat') {
const title = content.slice(0, 50) + (content.length > 50 ? '...' : '');
updateSessionTitle(session_id, title);
}
}
// Determine model: request override > session model > default setting
const effectiveModel = model || session.model || getSetting('default_model') || undefined;
// Persist model and provider to session so usage stats can group by model+provider.
// This runs on every message but the DB writes are cheap (single UPDATE by PK).
if (effectiveModel && effectiveModel !== session.model) {
updateSessionModel(session_id, effectiveModel);
}
// Resolve provider: explicit provider_id > default_provider_id > environment variables
let resolvedProvider: import('@/types').ApiProvider | undefined;
const effectiveProviderId = provider_id || session.provider_id || '';
if (effectiveProviderId && effectiveProviderId !== 'env') {
resolvedProvider = getProvider(effectiveProviderId);
if (!resolvedProvider) {
// Requested provider not found, try default
const defaultId = getDefaultProviderId();
if (defaultId) {
resolvedProvider = getProvider(defaultId);
}
}
} else if (!effectiveProviderId) {
// No provider specified, try default
const defaultId = getDefaultProviderId();
if (defaultId) {
resolvedProvider = getProvider(defaultId);
}
}
// effectiveProviderId === 'env' → resolvedProvider stays undefined → uses env vars
const providerName = resolvedProvider?.name || '';
if (providerName !== (session.provider_name || '')) {
updateSessionProvider(session_id, providerName);
}
const persistProviderId = effectiveProviderId || provider_id || '';
if (persistProviderId !== (session.provider_id || '')) {
updateSessionProviderId(session_id, persistProviderId);
}
// Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools)
const effectiveMode = mode || session.mode || 'code';
let permissionMode: string;
let systemPromptOverride: string | undefined;
switch (effectiveMode) {
case 'plan':
permissionMode = 'plan';
break;
case 'ask':
permissionMode = 'default';
systemPromptOverride = (session.system_prompt || '') +
'\n\nYou are in Ask mode. Answer questions and provide information only. Do not use any tools, do not read or write files, do not execute commands. Only respond with text.';
break;
default: // 'code'
permissionMode = 'acceptEdits';
break;
}
const abortController = new AbortController();
// Handle client disconnect
request.signal.addEventListener('abort', () => {
abortController.abort();
});
// Convert file attachments to the format expected by streamClaude.
// Include filePath from the already-saved files so claude-client can
// reference the on-disk copies instead of writing them again.
const fileAttachments: FileAttachment[] | undefined = files && files.length > 0
? files.map((f, i) => {
const meta = fileMeta?.find((m: { id: string }) => m.id === f.id);
return {
id: f.id || `file-${Date.now()}-${i}`,
name: f.name,
type: f.type,
size: f.size,
data: (meta?.filePath && !f.type.startsWith('image/')) ? '' : f.data, // Keep base64 for images (needed for vision); clear for non-images (read from disk)
filePath: meta?.filePath,
};
})
: undefined;
// Load assistant workspace prompt if configured
let workspacePrompt = '';
let assistantProjectInstructions = '';
try {
const workspacePath = getSetting('assistant_workspace_path');
if (workspacePath) {
const { loadWorkspaceFiles, assembleWorkspacePrompt, loadState, needsDailyCheckIn } = await import('@/lib/assistant-workspace');
// Only inject workspace files for assistant project sessions
const sessionWd = session.working_directory || '';
const isAssistantProject = sessionWd === workspacePath;
if (isAssistantProject) {
const files = loadWorkspaceFiles(workspacePath);
workspacePrompt = assembleWorkspacePrompt(files);
const state = loadState(workspacePath);
if (!state.onboardingComplete) {
// First-time onboarding: instruct AI to ask onboarding questions
assistantProjectInstructions = `<assistant-project-task type="onboarding">
You are now in the assistant workspace onboarding session. Your task is to interview the user to build their profile.
Ask the following 10 questions ONE AT A TIME. Wait for the user's answer before asking the next question. Be conversational and friendly.
1. How should I address you?
2. What name should I use for myself?
3. Do you prefer "concise and direct" or "detailed explanations"?
4. Do you prefer "minimal interruptions" or "proactive suggestions"?
5. What are your three hard boundaries?
6. What are your three most important current goals?
7. Do you prefer output as "lists", "reports", or "conversation summaries"?
8. What information may be written to long-term memory?
9. What information must never be written to long-term memory?
10. What three things should I do first when entering a project?
After all questions are answered, output a summary of the collected answers in exactly this format — this is critical for the system to process your answers:
\`\`\`onboarding-complete
{"q1":"answer1","q2":"answer2","q3":"answer3","q4":"answer4","q5":"answer5","q6":"answer6","q7":"answer7","q8":"answer8","q9":"answer9","q10":"answer10"}
\`\`\`
Do NOT try to write files yourself. The system will automatically generate soul.md and user.md from your collected answers.
Start by greeting the user and asking the first question.
</assistant-project-task>`;
} else if (needsDailyCheckIn(state)) {
// Daily check-in: instruct AI to ask 3 quick questions
assistantProjectInstructions = `<assistant-project-task type="daily-checkin">
You are now in the assistant workspace daily check-in session. Ask the user these 3 questions ONE AT A TIME:
1. What did you work on or accomplish today?
2. Any changes to your current priorities or goals?
3. Anything you'd like me to remember going forward?
After collecting all 3 answers, output a summary in exactly this format:
\`\`\`checkin-complete
{"q1":"answer1","q2":"answer2","q3":"answer3"}
\`\`\`
Do NOT try to write files yourself. The system will automatically update memory.md and user.md from your collected answers.
Start by greeting the user and asking the first question.
</assistant-project-task>`;
}
}
}
} catch (e) {
console.warn('[chat API] Failed to load assistant workspace:', e);
}
// Append per-request system prompt (e.g. skill injection for image generation)
let finalSystemPrompt = systemPromptOverride || session.system_prompt || undefined;
if (systemPromptAppend) {
finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + systemPromptAppend;
}
// Workspace prompt goes first (base personality), session prompt after (task override)
if (workspacePrompt) {
finalSystemPrompt = workspacePrompt + '\n\n' + (finalSystemPrompt || '');
}
// Assistant project instructions go after workspace prompt
if (assistantProjectInstructions) {
finalSystemPrompt = (finalSystemPrompt || '') + '\n\n' + assistantProjectInstructions;
}
// Load recent conversation history from DB as fallback context.
// This is used when SDK session resume is unavailable or fails,
// so the model still has conversation context.
const { messages: recentMsgs } = getMessages(session_id, { limit: 50 });
// Exclude the user message we just saved (last in the list) — it's already the prompt
const historyMsgs = recentMsgs.slice(0, -1).map(m => ({
role: m.role as 'user' | 'assistant',
content: m.content,
}));
// Stream Claude response, using SDK session ID for resume if available
console.log('[chat API] streamClaude params:', {
promptLength: content.length,
promptFirst200: content.slice(0, 200),
sdkSessionId: session.sdk_session_id || 'none',
systemPromptLength: finalSystemPrompt?.length || 0,
systemPromptFirst200: finalSystemPrompt?.slice(0, 200) || 'none',
});
const stream = streamClaude({
prompt: content,
sessionId: session_id,
sdkSessionId: session.sdk_session_id || undefined,
model: effectiveModel,
systemPrompt: finalSystemPrompt,
workingDirectory: session.sdk_cwd || session.working_directory || undefined,
abortController,
permissionMode,
files: fileAttachments,
imageAgentMode: !!systemPromptAppend,
toolTimeoutSeconds: toolTimeout || 300,
provider: resolvedProvider,
conversationHistory: historyMsgs,
onRuntimeStatusChange: (status: string) => {
try { setSessionRuntimeStatus(session_id, status); } catch { /* best effort */ }
},
});
// Tee the stream: one for client, one for collecting the response
const [streamForClient, streamForCollect] = stream.tee();
// Periodically renew the session lock so long-running tasks don't expire
const lockRenewalInterval = setInterval(() => {
try { renewSessionLock(session_id, lockId, 600); } catch { /* best effort */ }
}, 60_000);
// Save assistant message in background, with cleanup callback to release lock
collectStreamResponse(streamForCollect, session_id, telegramNotifyOpts, () => {
clearInterval(lockRenewalInterval);
releaseSessionLock(session_id, lockId);
setSessionRuntimeStatus(session_id, 'idle');
});
return new Response(streamForClient, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
} catch (error) {
// Release lock and reset status on error (only if lock was acquired)
if (activeSessionId && activeLockId) {
try {
releaseSessionLock(activeSessionId, activeLockId);
setSessionRuntimeStatus(activeSessionId, 'idle', error instanceof Error ? error.message : 'Unknown error');
} catch { /* best effort */ }
}
const message = error instanceof Error ? error.message : 'Internal server error';
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
async function collectStreamResponse(
stream: ReadableStream<string>,
sessionId: string,
telegramOpts: { sessionId?: string; sessionTitle?: string; workingDirectory?: string },
onComplete?: () => void,
) {
const reader = stream.getReader();
const contentBlocks: MessageContentBlock[] = [];
let currentText = '';
let tokenUsage: TokenUsage | null = null;
let hasError = false;
let errorMessage = '';
// Dedup layer: skip duplicate tool_result events by tool_use_id
const seenToolResultIds = new Set<string>();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = value.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event: SSEEvent = JSON.parse(line.slice(6));
if (event.type === 'permission_request' || event.type === 'tool_output') {
// Skip permission_request and tool_output events - not saved as message content
} else if (event.type === 'text') {
currentText += event.data;
} else if (event.type === 'tool_use') {
// Flush any accumulated text before the tool use block
if (currentText.trim()) {
contentBlocks.push({ type: 'text', text: currentText });
currentText = '';
}
try {
const toolData = JSON.parse(event.data);
contentBlocks.push({
type: 'tool_use',
id: toolData.id,
name: toolData.name,
input: toolData.input,
});
} catch {
// skip malformed tool_use data
}
} else if (event.type === 'tool_result') {
try {
const resultData = JSON.parse(event.data);
const newBlock = {
type: 'tool_result' as const,
tool_use_id: resultData.tool_use_id,
content: resultData.content,
is_error: resultData.is_error || false,
};
// Last-wins: if same tool_use_id already exists, replace it
// (user handler's result may be more complete than PostToolUse's)
if (seenToolResultIds.has(resultData.tool_use_id)) {
const idx = contentBlocks.findIndex(
(b) => b.type === 'tool_result' && 'tool_use_id' in b && b.tool_use_id === resultData.tool_use_id
);
if (idx >= 0) {
contentBlocks[idx] = newBlock;
}
} else {
seenToolResultIds.add(resultData.tool_use_id);
contentBlocks.push(newBlock);
}
} catch {
// skip malformed tool_result data
}
} else if (event.type === 'status') {
// Capture SDK session_id and model from init event and persist them
try {
const statusData = JSON.parse(event.data);
if (statusData.session_id) {
updateSdkSessionId(sessionId, statusData.session_id);
}
if (statusData.model) {
updateSessionModel(sessionId, statusData.model);
}
} catch {
// skip malformed status data
}
} else if (event.type === 'task_update') {
// Sync SDK TodoWrite tasks to local DB
try {
const taskData = JSON.parse(event.data);
if (taskData.session_id && taskData.todos) {
syncSdkTasks(taskData.session_id, taskData.todos);
}
} catch {
// skip malformed task_update data
}
} else if (event.type === 'error') {
hasError = true;
errorMessage = event.data || 'Unknown error';
} else if (event.type === 'result') {
try {
const resultData = JSON.parse(event.data);
if (resultData.usage) {
tokenUsage = resultData.usage;
}
if (resultData.is_error) {
hasError = true;
}
// Also capture session_id from result if we missed it from init
if (resultData.session_id) {
updateSdkSessionId(sessionId, resultData.session_id);
}
} catch {
// skip malformed result data
}
}
} catch {
// skip malformed lines
}
}
}
}
// Flush any remaining text
if (currentText.trim()) {
contentBlocks.push({ type: 'text', text: currentText });
}
if (contentBlocks.length > 0) {
// If the message is text-only (no tool calls), store as plain text
// for backward compatibility with existing message rendering.
// If it contains tool calls, store as structured JSON.
const hasToolBlocks = contentBlocks.some(
(b) => b.type === 'tool_use' || b.type === 'tool_result'
);
const content = hasToolBlocks
? JSON.stringify(contentBlocks)
: contentBlocks
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
.map((b) => b.text)
.join('')
.trim();
if (content) {
addMessage(
sessionId,
'assistant',
content,
tokenUsage ? JSON.stringify(tokenUsage) : null,
);
}
}
} catch (e) {
hasError = true;
errorMessage = e instanceof Error ? e.message : 'Stream reading error';
// Stream reading error - best effort save
if (currentText.trim()) {
contentBlocks.push({ type: 'text', text: currentText });
}
if (contentBlocks.length > 0) {
const hasToolBlocks = contentBlocks.some(
(b) => b.type === 'tool_use' || b.type === 'tool_result'
);
const content = hasToolBlocks
? JSON.stringify(contentBlocks)
: contentBlocks
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
.map((b) => b.text)
.join('')
.trim();
if (content) {
addMessage(sessionId, 'assistant', content);
}
}
} finally {
// Telegram notifications: completion or error (fire-and-forget)
if (hasError) {
notifySessionError(errorMessage, telegramOpts).catch(() => {});
} else {
// Extract text summary for the completion notification
const textSummary = contentBlocks
.filter((b): b is Extract<MessageContentBlock, { type: 'text' }> => b.type === 'text')
.map((b) => b.text)
.join('')
.trim();
notifySessionComplete(textSummary || undefined, telegramOpts).catch(() => {});
}
onComplete?.();
}
}