mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-21 12:43:18 +08:00
feat: 支持session管理&claude支持更丰富的配置
This commit is contained in:
@@ -5,8 +5,10 @@
|
||||
<template #header>
|
||||
<AgentTopBar
|
||||
:project-label="projectLabel"
|
||||
:session-label="sessionLabel"
|
||||
:connection-state="connectionState"
|
||||
@toggle:project-menu="toggleProjectMenu"
|
||||
@toggle:session-menu="toggleSessionMenu"
|
||||
@toggle:settings-menu="toggleSettingsMenu"
|
||||
/>
|
||||
</template>
|
||||
@@ -38,7 +40,7 @@
|
||||
|
||||
<!-- Click-outside handler for menus (z-40) -->
|
||||
<div
|
||||
v-if="projectMenuOpen || settingsMenuOpen"
|
||||
v-if="projectMenuOpen || sessionMenuOpen || settingsMenuOpen"
|
||||
class="fixed inset-0 z-40"
|
||||
@click="closeMenus"
|
||||
/>
|
||||
@@ -65,12 +67,38 @@
|
||||
@save="handleSaveSettings"
|
||||
/>
|
||||
|
||||
<AgentSessionMenu
|
||||
:open="sessionMenuOpen"
|
||||
:sessions="sessions.sessions.value"
|
||||
:selected-session-id="sessions.selectedSessionId.value"
|
||||
:is-loading="sessions.isLoadingSessions.value"
|
||||
:is-creating="sessions.isCreatingSession.value"
|
||||
:error="sessions.sessionError.value"
|
||||
@session:select="handleSessionSelect"
|
||||
@session:new="handleNewSession"
|
||||
@session:delete="handleDeleteSession"
|
||||
@session:rename="handleRenameSession"
|
||||
@session:settings="handleOpenSessionSettings"
|
||||
@session:reset="handleResetSession"
|
||||
/>
|
||||
|
||||
<AgentSettingsMenu
|
||||
:open="settingsMenuOpen"
|
||||
:theme="themeState.theme.value"
|
||||
@theme:set="handleThemeChange"
|
||||
@reconnect="handleReconnect"
|
||||
/>
|
||||
|
||||
<!-- Session Settings Panel -->
|
||||
<AgentSessionSettingsPanel
|
||||
:open="sessionSettingsOpen"
|
||||
:session="sessions.selectedSession.value"
|
||||
:management-info="currentManagementInfo"
|
||||
:is-loading="sessionSettingsLoading"
|
||||
:is-saving="sessionSettingsSaving"
|
||||
@close="handleCloseSessionSettings"
|
||||
@save="handleSaveSessionSettings"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -83,6 +111,7 @@ import {
|
||||
useAgentServer,
|
||||
useAgentChat,
|
||||
useAgentProjects,
|
||||
useAgentSessions,
|
||||
useAttachments,
|
||||
useAgentTheme,
|
||||
useAgentThreads,
|
||||
@@ -96,8 +125,11 @@ import {
|
||||
AgentComposer,
|
||||
AgentConversation,
|
||||
AgentProjectMenu,
|
||||
AgentSessionMenu,
|
||||
AgentSettingsMenu,
|
||||
AgentSessionSettingsPanel,
|
||||
} from './agent-chat';
|
||||
import type { SessionSettings } from './agent-chat/AgentSessionSettingsPanel.vue';
|
||||
|
||||
// Model utilities
|
||||
import { getModelsForCli } from '@/common/agent-models';
|
||||
@@ -126,10 +158,30 @@ function getNormalizedModel(): string {
|
||||
}
|
||||
const isPickingDirectory = ref(false);
|
||||
const projectMenuOpen = ref(false);
|
||||
const sessionMenuOpen = ref(false);
|
||||
const settingsMenuOpen = ref(false);
|
||||
|
||||
// Initialize composables
|
||||
// Session settings panel state
|
||||
const sessionSettingsOpen = ref(false);
|
||||
const sessionSettingsLoading = ref(false);
|
||||
const sessionSettingsSaving = ref(false);
|
||||
const currentManagementInfo = ref<import('chrome-mcp-shared').AgentManagementInfo | null>(null);
|
||||
|
||||
// Initialize composables - sessions must be declared first for sessionId access
|
||||
const sessions = useAgentSessions({
|
||||
getServerPort: () => server.serverPort.value,
|
||||
ensureServer: () => server.ensureNativeServer(),
|
||||
onSessionChanged: (sessionId: string) => {
|
||||
// Reconnect SSE and reload history when session changes
|
||||
if (projects.selectedProjectId.value) {
|
||||
server.openEventSource();
|
||||
loadSessionHistory(sessionId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const server = useAgentServer({
|
||||
getSessionId: () => sessions.selectedSessionId.value,
|
||||
onMessage: (event) => chat.handleRealtimeEvent(event),
|
||||
onError: (error) => {
|
||||
chat.errorMessage.value = error;
|
||||
@@ -138,7 +190,7 @@ const server = useAgentServer({
|
||||
|
||||
const chat = useAgentChat({
|
||||
getServerPort: () => server.serverPort.value,
|
||||
getSessionId: () => server.sessionId.value,
|
||||
getSessionId: () => sessions.selectedSessionId.value,
|
||||
ensureServer: () => server.ensureNativeServer(),
|
||||
openEventSource: () => server.openEventSource(),
|
||||
});
|
||||
@@ -168,12 +220,40 @@ const projectLabel = computed(() => {
|
||||
return project?.name ?? 'No project';
|
||||
});
|
||||
|
||||
const sessionLabel = computed(() => {
|
||||
const session = sessions.selectedSession.value;
|
||||
// Priority: preview (first user message) > name > 'New Session'
|
||||
return session?.preview || session?.name || 'New Session';
|
||||
});
|
||||
|
||||
const connectionState = computed(() => {
|
||||
if (server.isServerReady.value) return 'ready';
|
||||
if (server.nativeConnected.value) return 'connecting';
|
||||
return 'disconnected';
|
||||
});
|
||||
|
||||
// Load chat history for a specific session
|
||||
async function loadSessionHistory(sessionId: string): Promise<void> {
|
||||
const serverPort = server.serverPort.value;
|
||||
if (!serverPort || !sessionId) return;
|
||||
|
||||
try {
|
||||
const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}/history`;
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const messages = data.messages || [];
|
||||
const converted = convertStoredMessages(messages);
|
||||
chat.setMessages(converted);
|
||||
} else {
|
||||
chat.setMessages([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load session history:', error);
|
||||
chat.setMessages([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert stored messages to AgentMessage format
|
||||
function convertStoredMessages(stored: AgentStoredMessage[]): AgentMessage[] {
|
||||
return stored.map((m) => ({
|
||||
@@ -192,16 +272,31 @@ function convertStoredMessages(stored: AgentStoredMessage[]): AgentMessage[] {
|
||||
// Menu handlers
|
||||
function toggleProjectMenu(): void {
|
||||
projectMenuOpen.value = !projectMenuOpen.value;
|
||||
if (projectMenuOpen.value) settingsMenuOpen.value = false;
|
||||
if (projectMenuOpen.value) {
|
||||
sessionMenuOpen.value = false;
|
||||
settingsMenuOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSessionMenu(): void {
|
||||
sessionMenuOpen.value = !sessionMenuOpen.value;
|
||||
if (sessionMenuOpen.value) {
|
||||
projectMenuOpen.value = false;
|
||||
settingsMenuOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSettingsMenu(): void {
|
||||
settingsMenuOpen.value = !settingsMenuOpen.value;
|
||||
if (settingsMenuOpen.value) projectMenuOpen.value = false;
|
||||
if (settingsMenuOpen.value) {
|
||||
projectMenuOpen.value = false;
|
||||
sessionMenuOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenus(): void {
|
||||
projectMenuOpen.value = false;
|
||||
sessionMenuOpen.value = false;
|
||||
settingsMenuOpen.value = false;
|
||||
}
|
||||
|
||||
@@ -217,6 +312,86 @@ async function handleReconnect(): Promise<void> {
|
||||
await server.reconnect();
|
||||
}
|
||||
|
||||
// Session handlers
|
||||
async function handleSessionSelect(sessionId: string): Promise<void> {
|
||||
await sessions.selectSession(sessionId);
|
||||
closeMenus();
|
||||
}
|
||||
|
||||
async function handleNewSession(): Promise<void> {
|
||||
const projectId = projects.selectedProjectId.value;
|
||||
if (!projectId) return;
|
||||
|
||||
const session = await sessions.createSession(projectId, {
|
||||
engineName: (selectedCli.value as 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm') || 'claude',
|
||||
name: `Session ${sessions.sessions.value.length + 1}`,
|
||||
});
|
||||
|
||||
if (session) {
|
||||
chat.setMessages([]);
|
||||
}
|
||||
closeMenus();
|
||||
}
|
||||
|
||||
async function handleDeleteSession(sessionId: string): Promise<void> {
|
||||
await sessions.deleteSession(sessionId);
|
||||
}
|
||||
|
||||
async function handleRenameSession(sessionId: string, name: string): Promise<void> {
|
||||
await sessions.renameSession(sessionId, name);
|
||||
}
|
||||
|
||||
async function handleOpenSessionSettings(sessionId: string): Promise<void> {
|
||||
closeMenus();
|
||||
sessionSettingsOpen.value = true;
|
||||
sessionSettingsLoading.value = true;
|
||||
currentManagementInfo.value = null;
|
||||
|
||||
try {
|
||||
// Fetch Claude SDK management info if this is a Claude session
|
||||
const session = sessions.sessions.value.find((s) => s.id === sessionId);
|
||||
if (session?.engineName === 'claude') {
|
||||
const info = await sessions.fetchClaudeInfo(sessionId);
|
||||
if (info) {
|
||||
currentManagementInfo.value = info.managementInfo;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
sessionSettingsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetSession(sessionId: string): Promise<void> {
|
||||
closeMenus();
|
||||
const result = await sessions.resetConversation(sessionId);
|
||||
if (result) {
|
||||
chat.setMessages([]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloseSessionSettings(): void {
|
||||
sessionSettingsOpen.value = false;
|
||||
currentManagementInfo.value = null;
|
||||
}
|
||||
|
||||
async function handleSaveSessionSettings(settings: SessionSettings): Promise<void> {
|
||||
const sessionId = sessions.selectedSessionId.value;
|
||||
if (!sessionId) return;
|
||||
|
||||
sessionSettingsSaving.value = true;
|
||||
try {
|
||||
await sessions.updateSession(sessionId, {
|
||||
model: settings.model || null,
|
||||
permissionMode: settings.permissionMode || null,
|
||||
systemPromptConfig: settings.systemPromptConfig,
|
||||
});
|
||||
sessionSettingsOpen.value = false;
|
||||
currentManagementInfo.value = null;
|
||||
} finally {
|
||||
sessionSettingsSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Project handlers
|
||||
async function handleProjectSelect(projectId: string): Promise<void> {
|
||||
projects.selectedProjectId.value = projectId;
|
||||
@@ -227,6 +402,11 @@ async function handleProjectSelect(projectId: string): Promise<void> {
|
||||
model.value = project.selectedModel ?? '';
|
||||
useCcr.value = project.useCcr ?? false;
|
||||
}
|
||||
// Load sessions for the new project
|
||||
await sessions.ensureDefaultSession(
|
||||
projectId,
|
||||
(selectedCli.value as 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm') || 'claude',
|
||||
);
|
||||
closeMenus();
|
||||
}
|
||||
|
||||
@@ -284,18 +464,27 @@ function handleAttachmentAdd(): void {
|
||||
|
||||
// Send handler
|
||||
async function handleSend(): Promise<void> {
|
||||
const dbSessionId = sessions.selectedSessionId.value;
|
||||
if (!dbSessionId) {
|
||||
chat.errorMessage.value = 'No session selected.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture input before clearing for preview update
|
||||
const messageText = chat.input.value;
|
||||
|
||||
chat.attachments.value = attachments.attachments.value;
|
||||
|
||||
// Use normalized model to ensure valid value is sent
|
||||
const normalizedModel = getNormalizedModel();
|
||||
|
||||
// Session-level config is now used by backend; no need to pass cliPreference/model
|
||||
await chat.send({
|
||||
cliPreference: selectedCli.value || undefined,
|
||||
model: normalizedModel || undefined,
|
||||
projectId: projects.selectedProjectId.value || undefined,
|
||||
projectRoot: projects.projectRootOverride.value || undefined,
|
||||
dbSessionId,
|
||||
});
|
||||
|
||||
// Update session preview with first user message (if not already set)
|
||||
sessions.updateSessionPreview(dbSessionId, messageText);
|
||||
|
||||
attachments.clearAttachments();
|
||||
}
|
||||
|
||||
@@ -326,15 +515,27 @@ onMounted(async () => {
|
||||
await projects.saveSelectedProjectId();
|
||||
}
|
||||
|
||||
// Load chat history and settings
|
||||
// Load settings and sessions
|
||||
if (projects.selectedProjectId.value) {
|
||||
await projects.loadChatHistory(projects.selectedProjectId.value);
|
||||
const project = projects.selectedProject.value;
|
||||
if (project) {
|
||||
selectedCli.value = project.preferredCli ?? '';
|
||||
model.value = project.selectedModel ?? '';
|
||||
useCcr.value = project.useCcr ?? false;
|
||||
}
|
||||
|
||||
// Load sessions for the project and ensure a default session exists
|
||||
await sessions.loadSelectedSessionId();
|
||||
await sessions.ensureDefaultSession(
|
||||
projects.selectedProjectId.value,
|
||||
(selectedCli.value as 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm') || 'claude',
|
||||
);
|
||||
|
||||
// Open SSE connection and load history for the selected session
|
||||
if (sessions.selectedSessionId.value) {
|
||||
server.openEventSource();
|
||||
await loadSessionHistory(sessions.selectedSessionId.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed top-12 left-4 right-4 z-50 py-2 max-w-[calc(100%-2rem)]"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface, #ffffff)',
|
||||
border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
|
||||
borderRadius: 'var(--ac-radius-inner, 8px)',
|
||||
boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.1))',
|
||||
}"
|
||||
>
|
||||
<!-- Current Session Actions (only when a session is selected) -->
|
||||
<template v-if="selectedSession">
|
||||
<div
|
||||
class="px-3 py-1 text-[10px] font-bold uppercase tracking-wider"
|
||||
:style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
|
||||
>
|
||||
Current Session
|
||||
</div>
|
||||
<div class="px-3 py-2 flex flex-col gap-1">
|
||||
<button
|
||||
class="w-full px-2 py-1.5 text-left text-xs rounded ac-menu-item flex items-center gap-2"
|
||||
:style="{ color: 'var(--ac-text, #1a1a1a)' }"
|
||||
@click="handleOpenSettings"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Session settings...
|
||||
</button>
|
||||
<button
|
||||
class="w-full px-2 py-1.5 text-left text-xs rounded ac-menu-item flex items-center gap-2"
|
||||
:style="{ color: 'var(--ac-danger, #dc2626)' }"
|
||||
@click="handleResetConversation"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Reset conversation...
|
||||
</button>
|
||||
</div>
|
||||
<div class="mx-3 my-1 border-t" :style="{ borderColor: 'var(--ac-border, #e5e5e5)' }" />
|
||||
</template>
|
||||
|
||||
<!-- Sessions Section -->
|
||||
<div
|
||||
class="px-3 py-1 text-[10px] font-bold uppercase tracking-wider"
|
||||
:style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
|
||||
>
|
||||
Sessions
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="px-3 py-4 text-center text-xs"
|
||||
:style="{ color: 'var(--ac-text-muted, #6e6e6e)' }"
|
||||
>
|
||||
Loading sessions...
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-else-if="sessions.length === 0"
|
||||
class="px-3 py-4 text-center text-xs"
|
||||
:style="{ color: 'var(--ac-text-muted, #6e6e6e)' }"
|
||||
>
|
||||
No sessions yet
|
||||
</div>
|
||||
|
||||
<!-- Session List -->
|
||||
<div v-else class="max-h-[240px] overflow-y-auto ac-scroll">
|
||||
<div v-for="session in sessions" :key="session.id" class="group relative">
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm flex items-center justify-between ac-menu-item"
|
||||
:style="{
|
||||
color:
|
||||
selectedSessionId === session.id
|
||||
? 'var(--ac-accent, #c87941)'
|
||||
: 'var(--ac-text, #1a1a1a)',
|
||||
}"
|
||||
@click="handleSessionSelect(session.id)"
|
||||
>
|
||||
<div class="flex-1 min-w-0 pr-16">
|
||||
<!-- Session Name (inline editing) -->
|
||||
<div class="truncate flex items-center gap-2">
|
||||
<template v-if="editingSessionId === session.id">
|
||||
<input
|
||||
ref="renameInputRef"
|
||||
v-model="editingName"
|
||||
type="text"
|
||||
class="w-full px-1 py-0.5 text-sm rounded border"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface, #ffffff)',
|
||||
borderColor: 'var(--ac-accent, #c87941)',
|
||||
color: 'var(--ac-text, #1a1a1a)',
|
||||
outline: 'none',
|
||||
}"
|
||||
@click.stop
|
||||
@keydown.enter="confirmRename(session.id)"
|
||||
@keydown.escape="cancelRename"
|
||||
@blur="confirmRename(session.id)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ getSessionDisplayName(session) }}</span>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded"
|
||||
:style="{
|
||||
backgroundColor: getEngineColor(session.engineName),
|
||||
color: '#ffffff',
|
||||
}"
|
||||
>
|
||||
{{ session.engineName }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Session Info -->
|
||||
<div
|
||||
class="text-[10px] truncate flex items-center gap-2"
|
||||
:style="{
|
||||
fontFamily: 'var(--ac-font-mono, monospace)',
|
||||
color: 'var(--ac-text-subtle, #a8a29e)',
|
||||
}"
|
||||
>
|
||||
<span v-if="session.model">{{ session.model }}</span>
|
||||
<span>{{ formatDate(session.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons (shown on hover) -->
|
||||
<div
|
||||
class="absolute right-8 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<!-- Rename Button -->
|
||||
<button
|
||||
v-if="editingSessionId !== session.id"
|
||||
class="p-1 rounded ac-btn"
|
||||
:style="{ color: 'var(--ac-text-muted, #6e6e6e)' }"
|
||||
title="Rename session"
|
||||
@click.stop="startRename(session)"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Delete Button -->
|
||||
<button
|
||||
class="p-1 rounded ac-btn"
|
||||
:style="{ color: 'var(--ac-danger, #dc2626)' }"
|
||||
title="Delete session"
|
||||
@click.stop="handleDeleteSession(session.id)"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selected Check -->
|
||||
<svg
|
||||
v-if="selectedSessionId === session.id"
|
||||
class="w-4 h-4 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Session Button -->
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm ac-menu-item"
|
||||
:style="{ color: 'var(--ac-link, #3b82f6)' }"
|
||||
:disabled="isCreating"
|
||||
@click="handleNewSession"
|
||||
>
|
||||
{{ isCreating ? 'Creating...' : '+ New Session' }}
|
||||
</button>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="px-3 py-1 text-[10px]" :style="{ color: 'var(--ac-danger, #dc2626)' }">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import type { AgentSession } from 'chrome-mcp-shared';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
sessions: AgentSession[];
|
||||
selectedSessionId: string;
|
||||
isLoading: boolean;
|
||||
isCreating: boolean;
|
||||
error: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'session:select': [sessionId: string];
|
||||
'session:new': [];
|
||||
'session:delete': [sessionId: string];
|
||||
'session:rename': [sessionId: string, name: string];
|
||||
'session:settings': [sessionId: string];
|
||||
'session:reset': [sessionId: string];
|
||||
}>();
|
||||
|
||||
// Inline rename state
|
||||
const editingSessionId = ref<string | null>(null);
|
||||
const editingName = ref('');
|
||||
const renameInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Computed
|
||||
const selectedSession = computed(() => {
|
||||
return props.sessions.find((s) => s.id === props.selectedSessionId) || null;
|
||||
});
|
||||
|
||||
function getEngineColor(engineName: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
claude: '#c87941',
|
||||
codex: '#10a37f',
|
||||
cursor: '#8b5cf6',
|
||||
qwen: '#6366f1',
|
||||
glm: '#ef4444',
|
||||
};
|
||||
return colors[engineName] || '#6b7280';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a session.
|
||||
* Priority: preview (first user message) > name > 'Unnamed Session'
|
||||
*/
|
||||
function getSessionDisplayName(session: AgentSession): string {
|
||||
// Use preview if available (first user message)
|
||||
if (session.preview) {
|
||||
return session.preview;
|
||||
}
|
||||
// Fall back to session name
|
||||
if (session.name) {
|
||||
return session.name;
|
||||
}
|
||||
return 'Unnamed Session';
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function handleSessionSelect(sessionId: string): void {
|
||||
// Don't select if we're editing
|
||||
if (editingSessionId.value) return;
|
||||
emit('session:select', sessionId);
|
||||
}
|
||||
|
||||
function handleNewSession(): void {
|
||||
emit('session:new');
|
||||
}
|
||||
|
||||
function handleDeleteSession(sessionId: string): void {
|
||||
if (confirm('Delete this session? This cannot be undone.')) {
|
||||
emit('session:delete', sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline rename handlers
|
||||
function startRename(session: AgentSession): void {
|
||||
editingSessionId.value = session.id;
|
||||
editingName.value = session.name || '';
|
||||
nextTick(() => {
|
||||
renameInputRef.value?.focus();
|
||||
renameInputRef.value?.select();
|
||||
});
|
||||
}
|
||||
|
||||
function confirmRename(sessionId: string): void {
|
||||
const trimmedName = editingName.value.trim();
|
||||
if (trimmedName && editingSessionId.value === sessionId) {
|
||||
emit('session:rename', sessionId, trimmedName);
|
||||
}
|
||||
cancelRename();
|
||||
}
|
||||
|
||||
function cancelRename(): void {
|
||||
editingSessionId.value = null;
|
||||
editingName.value = '';
|
||||
}
|
||||
|
||||
// Current session actions
|
||||
function handleOpenSettings(): void {
|
||||
if (props.selectedSessionId) {
|
||||
emit('session:settings', props.selectedSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetConversation(): void {
|
||||
if (
|
||||
props.selectedSessionId &&
|
||||
confirm(
|
||||
'Reset this conversation? All messages will be deleted and the session will start fresh.',
|
||||
)
|
||||
) {
|
||||
emit('session:reset', props.selectedSessionId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
+439
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/40" />
|
||||
|
||||
<!-- Panel -->
|
||||
<div
|
||||
class="relative w-full max-w-md mx-4 max-h-[85vh] overflow-hidden flex flex-col"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface, #ffffff)',
|
||||
border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
|
||||
borderRadius: 'var(--ac-radius-outer, 12px)',
|
||||
boxShadow: 'var(--ac-shadow-float, 0 4px 20px -2px rgba(0,0,0,0.2))',
|
||||
}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-3 border-b"
|
||||
:style="{ borderColor: 'var(--ac-border, #e5e5e5)' }"
|
||||
>
|
||||
<h2 class="text-sm font-semibold" :style="{ color: 'var(--ac-text, #1a1a1a)' }">
|
||||
Session Settings
|
||||
</h2>
|
||||
<button
|
||||
class="p-1 rounded ac-btn"
|
||||
:style="{ color: 'var(--ac-text-muted, #6e6e6e)' }"
|
||||
@click="handleClose"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content (scrollable) -->
|
||||
<div class="flex-1 overflow-y-auto ac-scroll px-4 py-3 space-y-4">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="py-8 text-center">
|
||||
<div class="text-sm" :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">
|
||||
Loading session info...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Session Info -->
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider"
|
||||
:style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
|
||||
>
|
||||
Session Info
|
||||
</label>
|
||||
<div class="text-xs space-y-1" :style="{ color: 'var(--ac-text, #1a1a1a)' }">
|
||||
<div class="flex justify-between">
|
||||
<span :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">Engine</span>
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded text-[10px]"
|
||||
:style="{
|
||||
backgroundColor: getEngineColor(session?.engineName || ''),
|
||||
color: '#ffffff',
|
||||
}"
|
||||
>
|
||||
{{ session?.engineName || 'Unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="localModel" class="flex justify-between">
|
||||
<span :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">Model</span>
|
||||
<span class="font-mono text-[10px]">{{ localModel }}</span>
|
||||
</div>
|
||||
<div v-if="session?.engineSessionId" class="flex justify-between">
|
||||
<span :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">Engine Session</span>
|
||||
<span class="font-mono text-[10px] truncate max-w-[180px]">{{
|
||||
session.engineSessionId
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Selection -->
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider"
|
||||
:style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
v-model="localModel"
|
||||
class="w-full px-2 py-1.5 text-xs rounded border"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface, #ffffff)',
|
||||
borderColor: 'var(--ac-border, #e5e5e5)',
|
||||
color: 'var(--ac-text, #1a1a1a)',
|
||||
}"
|
||||
>
|
||||
<option value="">Default (server setting)</option>
|
||||
<option v-for="m in availableModels" :key="m.id" :value="m.id">
|
||||
{{ m.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Permission Mode (Claude only) -->
|
||||
<div v-if="isClaudeEngine" class="space-y-2">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider"
|
||||
:style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
|
||||
>
|
||||
Permission Mode
|
||||
</label>
|
||||
<select
|
||||
v-model="localPermissionMode"
|
||||
class="w-full px-2 py-1.5 text-xs rounded border"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface, #ffffff)',
|
||||
borderColor: 'var(--ac-border, #e5e5e5)',
|
||||
color: 'var(--ac-text, #1a1a1a)',
|
||||
}"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="default">default - Ask for approval</option>
|
||||
<option value="acceptEdits">acceptEdits - Auto-accept file edits</option>
|
||||
<option value="bypassPermissions">bypassPermissions - Auto-accept all</option>
|
||||
<option value="plan">plan - Plan mode only</option>
|
||||
<option value="dontAsk">dontAsk - No confirmation</option>
|
||||
</select>
|
||||
<p class="text-[10px]" :style="{ color: 'var(--ac-text-subtle, #a8a29e)' }">
|
||||
Controls how the Claude SDK handles tool approval requests.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- System Prompt Config (Claude only) -->
|
||||
<div v-if="isClaudeEngine" class="space-y-2">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider"
|
||||
:style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
|
||||
>
|
||||
System Prompt
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="!localUseCustomPrompt"
|
||||
@change="localUseCustomPrompt = false"
|
||||
/>
|
||||
<span :style="{ color: 'var(--ac-text, #1a1a1a)' }">Use preset (claude_code)</span>
|
||||
</label>
|
||||
<div v-if="!localUseCustomPrompt" class="pl-5">
|
||||
<label class="flex items-center gap-2 text-[10px]">
|
||||
<input v-model="localAppendToPrompt" type="checkbox" />
|
||||
<span :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }"
|
||||
>Append custom text</span
|
||||
>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="localAppendToPrompt"
|
||||
v-model="localPromptAppend"
|
||||
class="mt-1 w-full px-2 py-1.5 text-xs rounded border resize-none"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface, #ffffff)',
|
||||
borderColor: 'var(--ac-border, #e5e5e5)',
|
||||
color: 'var(--ac-text, #1a1a1a)',
|
||||
fontFamily: 'var(--ac-font-mono, monospace)',
|
||||
}"
|
||||
rows="3"
|
||||
placeholder="Additional instructions to append..."
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
:checked="localUseCustomPrompt"
|
||||
@change="localUseCustomPrompt = true"
|
||||
/>
|
||||
<span :style="{ color: 'var(--ac-text, #1a1a1a)' }">Use custom prompt</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-if="localUseCustomPrompt"
|
||||
v-model="localCustomPrompt"
|
||||
class="w-full px-2 py-1.5 text-xs rounded border resize-none"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface, #ffffff)',
|
||||
borderColor: 'var(--ac-border, #e5e5e5)',
|
||||
color: 'var(--ac-text, #1a1a1a)',
|
||||
fontFamily: 'var(--ac-font-mono, monospace)',
|
||||
}"
|
||||
rows="4"
|
||||
placeholder="Enter custom system prompt..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Management Info (Claude only, read-only) -->
|
||||
<div v-if="isClaudeEngine && managementInfo" class="space-y-2">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider"
|
||||
:style="{ color: 'var(--ac-text-subtle, #a8a29e)' }"
|
||||
>
|
||||
SDK Info
|
||||
</label>
|
||||
<div
|
||||
class="text-[10px] space-y-1 p-2 rounded"
|
||||
:style="{ backgroundColor: 'var(--ac-surface-inset, #f5f5f5)' }"
|
||||
>
|
||||
<div v-if="managementInfo.model" class="flex justify-between">
|
||||
<span :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">Active Model</span>
|
||||
<span class="font-mono" :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">{{
|
||||
managementInfo.model
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="managementInfo.claudeCodeVersion" class="flex justify-between">
|
||||
<span :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">Claude Code</span>
|
||||
<span class="font-mono" :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">{{
|
||||
managementInfo.claudeCodeVersion
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="managementInfo.tools?.length" class="flex justify-between">
|
||||
<span :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">Tools</span>
|
||||
<span :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">{{
|
||||
managementInfo.tools.length
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="managementInfo.mcpServers?.length" class="flex justify-between">
|
||||
<span :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">MCP Servers</span>
|
||||
<span :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }">{{
|
||||
managementInfo.mcpServers.length
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tool List (expandable) -->
|
||||
<details v-if="managementInfo.tools?.length" class="text-[10px]">
|
||||
<summary class="cursor-pointer" :style="{ color: 'var(--ac-link, #3b82f6)' }">
|
||||
View tools ({{ managementInfo.tools.length }})
|
||||
</summary>
|
||||
<div
|
||||
class="mt-1 p-2 rounded max-h-32 overflow-y-auto ac-scroll"
|
||||
:style="{ backgroundColor: 'var(--ac-surface-inset, #f5f5f5)' }"
|
||||
>
|
||||
<div
|
||||
v-for="tool in managementInfo.tools"
|
||||
:key="tool"
|
||||
class="font-mono truncate"
|
||||
:style="{ color: 'var(--ac-text-muted, #6e6e6e)' }"
|
||||
>
|
||||
{{ tool }}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<!-- MCP Server List (expandable) -->
|
||||
<details v-if="managementInfo.mcpServers?.length" class="text-[10px]">
|
||||
<summary class="cursor-pointer" :style="{ color: 'var(--ac-link, #3b82f6)' }">
|
||||
View MCP servers ({{ managementInfo.mcpServers.length }})
|
||||
</summary>
|
||||
<div
|
||||
class="mt-1 p-2 rounded max-h-32 overflow-y-auto ac-scroll"
|
||||
:style="{ backgroundColor: 'var(--ac-surface-inset, #f5f5f5)' }"
|
||||
>
|
||||
<div
|
||||
v-for="server in managementInfo.mcpServers"
|
||||
:key="server.name"
|
||||
class="font-mono truncate flex justify-between gap-2"
|
||||
:style="{ color: 'var(--ac-text-muted, #6e6e6e)' }"
|
||||
>
|
||||
<span>{{ server.name }}</span>
|
||||
<span
|
||||
class="text-[9px] px-1 rounded"
|
||||
:style="{
|
||||
backgroundColor: server.status === 'connected' ? '#10b981' : '#6b7280',
|
||||
color: '#fff',
|
||||
}"
|
||||
>{{ server.status }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="flex items-center justify-end gap-2 px-4 py-3 border-t"
|
||||
:style="{ borderColor: 'var(--ac-border, #e5e5e5)' }"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs rounded ac-btn"
|
||||
:style="{
|
||||
color: 'var(--ac-text-muted, #6e6e6e)',
|
||||
border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
|
||||
}"
|
||||
@click="handleClose"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs rounded ac-btn"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-accent, #c87941)',
|
||||
color: '#ffffff',
|
||||
}"
|
||||
:disabled="isSaving"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ isSaving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import type { AgentSession, AgentManagementInfo, AgentSystemPromptConfig } from 'chrome-mcp-shared';
|
||||
import { getModelsForCli } from '@/common/agent-models';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
session: AgentSession | null;
|
||||
managementInfo: AgentManagementInfo | null;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
save: [settings: SessionSettings];
|
||||
}>();
|
||||
|
||||
export interface SessionSettings {
|
||||
model: string;
|
||||
permissionMode: string;
|
||||
systemPromptConfig: AgentSystemPromptConfig | null;
|
||||
}
|
||||
|
||||
// Local state
|
||||
const localModel = ref('');
|
||||
const localPermissionMode = ref('');
|
||||
const localUseCustomPrompt = ref(false);
|
||||
const localCustomPrompt = ref('');
|
||||
const localAppendToPrompt = ref(false);
|
||||
const localPromptAppend = ref('');
|
||||
|
||||
// Computed
|
||||
const isClaudeEngine = computed(() => props.session?.engineName === 'claude');
|
||||
|
||||
const availableModels = computed(() => {
|
||||
if (!props.session?.engineName) return [];
|
||||
return getModelsForCli(props.session.engineName);
|
||||
});
|
||||
|
||||
// Initialize local state when session changes
|
||||
watch(
|
||||
() => props.session,
|
||||
(session) => {
|
||||
if (session) {
|
||||
localModel.value = session.model || '';
|
||||
localPermissionMode.value = session.permissionMode || '';
|
||||
|
||||
// Parse system prompt config based on type
|
||||
const config = session.systemPromptConfig;
|
||||
if (config) {
|
||||
if (config.type === 'custom') {
|
||||
localUseCustomPrompt.value = true;
|
||||
localCustomPrompt.value = config.text || '';
|
||||
localAppendToPrompt.value = false;
|
||||
localPromptAppend.value = '';
|
||||
} else if (config.type === 'preset') {
|
||||
localUseCustomPrompt.value = false;
|
||||
localCustomPrompt.value = '';
|
||||
localAppendToPrompt.value = !!config.append;
|
||||
localPromptAppend.value = config.append || '';
|
||||
}
|
||||
} else {
|
||||
localUseCustomPrompt.value = false;
|
||||
localCustomPrompt.value = '';
|
||||
localAppendToPrompt.value = false;
|
||||
localPromptAppend.value = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function getEngineColor(engineName: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
claude: '#c87941',
|
||||
codex: '#10a37f',
|
||||
cursor: '#8b5cf6',
|
||||
qwen: '#6366f1',
|
||||
glm: '#ef4444',
|
||||
};
|
||||
return colors[engineName] || '#6b7280';
|
||||
}
|
||||
|
||||
function handleClose(): void {
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function handleSave(): void {
|
||||
// Build systemPromptConfig based on local state
|
||||
let systemPromptConfig: AgentSystemPromptConfig | null = null;
|
||||
|
||||
if (localUseCustomPrompt.value && localCustomPrompt.value.trim()) {
|
||||
systemPromptConfig = {
|
||||
type: 'custom',
|
||||
text: localCustomPrompt.value.trim(),
|
||||
};
|
||||
} else if (localAppendToPrompt.value && localPromptAppend.value.trim()) {
|
||||
systemPromptConfig = {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
append: localPromptAppend.value.trim(),
|
||||
};
|
||||
} else {
|
||||
// Use default preset without append
|
||||
systemPromptConfig = {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
};
|
||||
}
|
||||
|
||||
const settings: SessionSettings = {
|
||||
model: localModel.value.trim(),
|
||||
permissionMode: localPermissionMode.value,
|
||||
systemPromptConfig,
|
||||
};
|
||||
emit('save', settings);
|
||||
}
|
||||
</script>
|
||||
@@ -43,6 +43,32 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Session Breadcrumb -->
|
||||
<div class="h-3 w-[1px] flex-shrink-0" :style="{ backgroundColor: 'var(--ac-border)' }" />
|
||||
<button
|
||||
class="flex items-center gap-1.5 text-xs px-2 py-1 rounded truncate group ac-btn"
|
||||
:style="{
|
||||
fontFamily: 'var(--ac-font-mono)',
|
||||
color: 'var(--ac-text-subtle)',
|
||||
}"
|
||||
@click="$emit('toggle:sessionMenu')"
|
||||
>
|
||||
<span class="truncate">{{ sessionLabel }}</span>
|
||||
<svg
|
||||
class="w-3 h-3 opacity-50 group-hover:opacity-100 transition-opacity"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connection / Status / Settings -->
|
||||
@@ -88,11 +114,13 @@ export type ConnectionState = 'ready' | 'connecting' | 'disconnected';
|
||||
|
||||
const props = defineProps<{
|
||||
projectLabel: string;
|
||||
sessionLabel: string;
|
||||
connectionState: ConnectionState;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'toggle:projectMenu': [];
|
||||
'toggle:sessionMenu': [];
|
||||
'toggle:settingsMenu': [];
|
||||
}>();
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export { default as AgentTimeline } from './AgentTimeline.vue';
|
||||
export { default as AgentTimelineItem } from './AgentTimelineItem.vue';
|
||||
export { default as AgentSettingsMenu } from './AgentSettingsMenu.vue';
|
||||
export { default as AgentProjectMenu } from './AgentProjectMenu.vue';
|
||||
export { default as AgentSessionMenu } from './AgentSessionMenu.vue';
|
||||
export { default as AgentSessionSettingsPanel } from './AgentSessionSettingsPanel.vue';
|
||||
|
||||
// Timeline step components
|
||||
export { default as TimelineNarrativeStep } from './timeline/TimelineNarrativeStep.vue';
|
||||
|
||||
+94
-12
@@ -1,31 +1,105 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-[11px] font-bold uppercase tracking-wider w-8 flex-shrink-0"
|
||||
:style="{ color: 'var(--ac-text-subtle)' }"
|
||||
<!-- 螺旋动画图标(仅 running/starting 状态显示) -->
|
||||
<svg
|
||||
v-if="isRunning"
|
||||
class="loading-scribble w-4 h-4 flex-shrink-0"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
>
|
||||
Run
|
||||
</span>
|
||||
<span class="text-xs italic flex items-center gap-2" :style="{ color: 'var(--ac-text-muted)' }">
|
||||
{{ item.text || defaultText }}
|
||||
<path
|
||||
d="M50 50 C50 48, 52 46, 54 46 C58 46, 60 50, 60 54 C60 60, 54 64, 48 64 C40 64, 36 56, 36 48 C36 38, 44 32, 54 32 C66 32, 74 42, 74 54 C74 68, 62 78, 48 78 C32 78, 22 64, 22 48 C22 30, 36 18, 54 18 C74 18, 88 34, 88 54 C88 76, 72 92, 50 92"
|
||||
stroke="var(--ac-accent, #D97757)"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- shimmer 文案(running 状态)或普通文案 -->
|
||||
<span
|
||||
class="text-xs italic"
|
||||
:class="{ 'text-shimmer': isRunning }"
|
||||
:style="{ color: isRunning ? undefined : 'var(--ac-text-muted)' }"
|
||||
>
|
||||
{{ displayText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import type { TimelineItem } from '../../../composables/useAgentThreads';
|
||||
import { getRandomLoadingText } from '../../../utils/loading-texts';
|
||||
|
||||
const props = defineProps<{
|
||||
item: Extract<TimelineItem, { kind: 'status' }>;
|
||||
}>();
|
||||
|
||||
// 是否处于运行状态
|
||||
const isRunning = computed(
|
||||
() => props.item.status === 'running' || props.item.status === 'starting',
|
||||
);
|
||||
|
||||
// 随机文案(仅 running 状态使用)
|
||||
const randomText = ref(getRandomLoadingText());
|
||||
|
||||
// 定时更新文案的 timeout ID
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 记录上一次的运行状态,用于判断状态变化
|
||||
let wasRunning = false;
|
||||
|
||||
// 启动定时器
|
||||
function startInterval(): void {
|
||||
if (timeoutId) return;
|
||||
// 5-8 秒随机间隔更新文案
|
||||
const scheduleNext = () => {
|
||||
timeoutId = setTimeout(
|
||||
() => {
|
||||
randomText.value = getRandomLoadingText();
|
||||
scheduleNext();
|
||||
},
|
||||
5000 + Math.random() * 3000,
|
||||
);
|
||||
};
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
// 停止定时器
|
||||
function stopInterval(): void {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 监听运行状态变化 - 只在状态真正变化时才处理
|
||||
watch(isRunning, (running) => {
|
||||
// 只在从非运行变为运行时,才重新生成文案并启动定时器
|
||||
if (running && !wasRunning) {
|
||||
randomText.value = getRandomLoadingText();
|
||||
startInterval();
|
||||
} else if (!running && wasRunning) {
|
||||
stopInterval();
|
||||
}
|
||||
wasRunning = running;
|
||||
});
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
wasRunning = isRunning.value;
|
||||
if (isRunning.value) {
|
||||
startInterval();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopInterval();
|
||||
});
|
||||
|
||||
// 非运行状态的默认文案
|
||||
const defaultText = computed(() => {
|
||||
switch (props.item.status) {
|
||||
case 'starting':
|
||||
return 'Starting...';
|
||||
case 'running':
|
||||
return 'Working...';
|
||||
case 'completed':
|
||||
return 'Done';
|
||||
case 'error':
|
||||
@@ -36,4 +110,12 @@ const defaultText = computed(() => {
|
||||
return 'Ready';
|
||||
}
|
||||
});
|
||||
|
||||
// 最终显示的文案
|
||||
const displayText = computed(() => {
|
||||
if (isRunning.value) {
|
||||
return randomText.value;
|
||||
}
|
||||
return props.item.text || defaultText.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
export { useAgentServer } from './useAgentServer';
|
||||
export { useAgentChat } from './useAgentChat';
|
||||
export { useAgentProjects } from './useAgentProjects';
|
||||
export { useAgentSessions } from './useAgentSessions';
|
||||
export { useAttachments } from './useAttachments';
|
||||
export { useAgentTheme, preloadAgentTheme, THEME_LABELS } from './useAgentTheme';
|
||||
export { useAgentThreads } from './useAgentThreads';
|
||||
@@ -12,6 +13,7 @@ export { useAgentThreads } from './useAgentThreads';
|
||||
export type { UseAgentServerOptions } from './useAgentServer';
|
||||
export type { UseAgentChatOptions } from './useAgentChat';
|
||||
export type { UseAgentProjectsOptions } from './useAgentProjects';
|
||||
export type { UseAgentSessionsOptions } from './useAgentSessions';
|
||||
export type { AgentThemeId, UseAgentTheme } from './useAgentTheme';
|
||||
export type {
|
||||
AgentThread,
|
||||
|
||||
@@ -103,6 +103,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
model?: string;
|
||||
projectId?: string;
|
||||
projectRoot?: string;
|
||||
dbSessionId?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const trimmed = input.value.trim();
|
||||
@@ -142,6 +143,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
model: chatOptions.model?.trim() || undefined,
|
||||
projectId: chatOptions.projectId || undefined,
|
||||
projectRoot: chatOptions.projectRoot?.trim() || undefined,
|
||||
dbSessionId: chatOptions.dbSessionId || undefined,
|
||||
attachments: attachments.value.length > 0 ? attachments.value : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ interface ServerStatus {
|
||||
}
|
||||
|
||||
export interface UseAgentServerOptions {
|
||||
/**
|
||||
* Get the session ID for SSE routing.
|
||||
* Must be provided by caller (typically DB session ID).
|
||||
*/
|
||||
getSessionId?: () => string;
|
||||
onMessage?: (event: RealtimeEvent) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
@@ -24,7 +29,6 @@ export function useAgentServer(options: UseAgentServerOptions = {}) {
|
||||
const nativeConnected = ref(false);
|
||||
const serverStatus = ref<ServerStatus | null>(null);
|
||||
const connecting = ref(false);
|
||||
const sessionId = ref<string>('');
|
||||
const engines = ref<AgentEngineInfo[]>([]);
|
||||
const eventSource = ref<EventSource | null>(null);
|
||||
|
||||
@@ -33,16 +37,14 @@ export function useAgentServer(options: UseAgentServerOptions = {}) {
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const BASE_RECONNECT_DELAY = 1000;
|
||||
|
||||
// Track which sessionId the current SSE connection is subscribed to
|
||||
let currentStreamSessionId: string | null = null;
|
||||
|
||||
// Computed
|
||||
const isServerReady = computed(() => {
|
||||
return nativeConnected.value && serverStatus.value?.isRunning && serverPort.value !== null;
|
||||
});
|
||||
|
||||
// Generate session ID
|
||||
function generateSessionId(): string {
|
||||
return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
// Check native host connection using existing message type
|
||||
async function checkNativeHost(): Promise<boolean> {
|
||||
try {
|
||||
@@ -150,20 +152,22 @@ export function useAgentServer(options: UseAgentServerOptions = {}) {
|
||||
return eventSource.value !== null && eventSource.value.readyState === EventSource.OPEN;
|
||||
}
|
||||
|
||||
// Open SSE connection (skip if already connected)
|
||||
// Open SSE connection (skip if already connected to same session)
|
||||
function openEventSource(): void {
|
||||
if (!serverPort.value || !sessionId.value) return;
|
||||
const targetSessionId = options.getSessionId?.()?.trim() ?? '';
|
||||
if (!serverPort.value || !targetSessionId) return;
|
||||
|
||||
// Skip if already connected to avoid message loss during reconnection
|
||||
if (isEventSourceConnected()) {
|
||||
console.log('[AgentServer] SSE already connected, skipping reconnect');
|
||||
// Skip if already connected to the same session
|
||||
if (isEventSourceConnected() && currentStreamSessionId === targetSessionId) {
|
||||
console.log('[AgentServer] SSE already connected to session, skipping reconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
// Close existing connection only if in CONNECTING or CLOSED state
|
||||
// Close existing connection before subscribing to a new session
|
||||
closeEventSource();
|
||||
|
||||
const url = `http://127.0.0.1:${serverPort.value}/agent/chat/${encodeURIComponent(sessionId.value)}/stream`;
|
||||
currentStreamSessionId = targetSessionId;
|
||||
const url = `http://127.0.0.1:${serverPort.value}/agent/chat/${encodeURIComponent(targetSessionId)}/stream`;
|
||||
const es = new EventSource(url);
|
||||
|
||||
es.onopen = () => {
|
||||
@@ -209,6 +213,7 @@ export function useAgentServer(options: UseAgentServerOptions = {}) {
|
||||
eventSource.value.close();
|
||||
eventSource.value = null;
|
||||
}
|
||||
currentStreamSessionId = null;
|
||||
}
|
||||
|
||||
// Reconnect to server
|
||||
@@ -223,11 +228,8 @@ export function useAgentServer(options: UseAgentServerOptions = {}) {
|
||||
|
||||
// Initialize
|
||||
async function initialize(): Promise<void> {
|
||||
sessionId.value = generateSessionId();
|
||||
await ensureNativeServer();
|
||||
if (isServerReady.value) {
|
||||
openEventSource();
|
||||
}
|
||||
// Note: SSE connection is now opened explicitly when session is ready
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
@@ -241,7 +243,6 @@ export function useAgentServer(options: UseAgentServerOptions = {}) {
|
||||
nativeConnected,
|
||||
serverStatus,
|
||||
connecting,
|
||||
sessionId,
|
||||
engines,
|
||||
eventSource,
|
||||
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Composable for managing Agent Sessions.
|
||||
* Sessions represent independent conversations within a project.
|
||||
* Each session has its own engine configuration, chat history, and resume state.
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import type {
|
||||
AgentSession,
|
||||
AgentCliPreference,
|
||||
CreateAgentSessionInput,
|
||||
UpdateAgentSessionInput,
|
||||
AgentStoredMessage,
|
||||
AgentManagementInfo,
|
||||
} from 'chrome-mcp-shared';
|
||||
|
||||
const STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id';
|
||||
|
||||
export interface UseAgentSessionsOptions {
|
||||
getServerPort: () => number | null;
|
||||
ensureServer: () => Promise<boolean>;
|
||||
onSessionChanged?: (sessionId: string) => void;
|
||||
onHistoryLoaded?: (messages: AgentStoredMessage[]) => void;
|
||||
}
|
||||
|
||||
export function useAgentSessions(options: UseAgentSessionsOptions) {
|
||||
// State
|
||||
const sessions = ref<AgentSession[]>([]);
|
||||
const selectedSessionId = ref<string>('');
|
||||
const isLoadingSessions = ref(false);
|
||||
const isCreatingSession = ref(false);
|
||||
const sessionError = ref<string | null>(null);
|
||||
|
||||
// Computed
|
||||
const selectedSession = computed(() => {
|
||||
return sessions.value.find((s) => s.id === selectedSessionId.value) || null;
|
||||
});
|
||||
|
||||
const hasSessions = computed(() => sessions.value.length > 0);
|
||||
|
||||
// Load selected session from storage
|
||||
async function loadSelectedSessionId(): Promise<void> {
|
||||
try {
|
||||
const result = await chrome.storage.local.get(STORAGE_KEY_SELECTED_SESSION);
|
||||
if (result[STORAGE_KEY_SELECTED_SESSION]) {
|
||||
selectedSessionId.value = result[STORAGE_KEY_SELECTED_SESSION];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load selected session ID:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Save selected session to storage
|
||||
async function saveSelectedSessionId(): Promise<void> {
|
||||
try {
|
||||
await chrome.storage.local.set({
|
||||
[STORAGE_KEY_SELECTED_SESSION]: selectedSessionId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save selected session ID:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch sessions for a project
|
||||
async function fetchSessions(projectId: string): Promise<void> {
|
||||
const serverPort = options.getServerPort();
|
||||
if (!serverPort || !projectId) return;
|
||||
|
||||
isLoadingSessions.value = true;
|
||||
sessionError.value = null;
|
||||
|
||||
try {
|
||||
const url = `http://127.0.0.1:${serverPort}/agent/projects/${encodeURIComponent(projectId)}/sessions`;
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
sessions.value = data.sessions || [];
|
||||
|
||||
// If we have sessions but no selection, select the most recent one
|
||||
if (sessions.value.length > 0 && !selectedSessionId.value) {
|
||||
selectedSessionId.value = sessions.value[0].id;
|
||||
await saveSelectedSessionId();
|
||||
}
|
||||
} else {
|
||||
const text = await response.text().catch(() => '');
|
||||
sessionError.value = text || `HTTP ${response.status}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sessions:', error);
|
||||
sessionError.value = error instanceof Error ? error.message : 'Failed to fetch sessions';
|
||||
} finally {
|
||||
isLoadingSessions.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new session
|
||||
async function createSession(
|
||||
projectId: string,
|
||||
input: CreateAgentSessionInput,
|
||||
): Promise<AgentSession | null> {
|
||||
const ready = await options.ensureServer();
|
||||
const serverPort = options.getServerPort();
|
||||
if (!ready || !serverPort) {
|
||||
sessionError.value = 'Server not available';
|
||||
return null;
|
||||
}
|
||||
|
||||
isCreatingSession.value = true;
|
||||
sessionError.value = null;
|
||||
|
||||
try {
|
||||
const url = `http://127.0.0.1:${serverPort}/agent/projects/${encodeURIComponent(projectId)}/sessions`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const session = data.session as AgentSession | undefined;
|
||||
|
||||
if (session?.id) {
|
||||
// Add to local list and select it
|
||||
sessions.value = [session, ...sessions.value];
|
||||
selectedSessionId.value = session.id;
|
||||
await saveSelectedSessionId();
|
||||
options.onSessionChanged?.(session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
sessionError.value = 'Session created but response is invalid';
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
sessionError.value = error instanceof Error ? error.message : 'Failed to create session';
|
||||
return null;
|
||||
} finally {
|
||||
isCreatingSession.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get a session by ID
|
||||
async function getSession(sessionId: string): Promise<AgentSession | null> {
|
||||
const serverPort = options.getServerPort();
|
||||
if (!serverPort || !sessionId) return null;
|
||||
|
||||
try {
|
||||
const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`;
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.session || null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update a session
|
||||
async function updateSession(
|
||||
sessionId: string,
|
||||
updates: UpdateAgentSessionInput,
|
||||
): Promise<AgentSession | null> {
|
||||
const serverPort = options.getServerPort();
|
||||
if (!serverPort || !sessionId) return null;
|
||||
|
||||
try {
|
||||
const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const session = data.session as AgentSession | undefined;
|
||||
|
||||
if (session?.id) {
|
||||
// Update local list
|
||||
const index = sessions.value.findIndex((s) => s.id === session.id);
|
||||
if (index !== -1) {
|
||||
sessions.value[index] = session;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to update session:', error);
|
||||
sessionError.value = error instanceof Error ? error.message : 'Failed to update session';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a session
|
||||
async function deleteSession(sessionId: string): Promise<boolean> {
|
||||
const serverPort = options.getServerPort();
|
||||
if (!serverPort || !sessionId) return false;
|
||||
|
||||
try {
|
||||
const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}`;
|
||||
const response = await fetch(url, { method: 'DELETE' });
|
||||
|
||||
if (response.ok || response.status === 204) {
|
||||
// Remove from local list
|
||||
sessions.value = sessions.value.filter((s) => s.id !== sessionId);
|
||||
|
||||
// If deleted session was selected, select another one
|
||||
if (selectedSessionId.value === sessionId) {
|
||||
selectedSessionId.value = sessions.value[0]?.id || '';
|
||||
await saveSelectedSessionId();
|
||||
if (selectedSessionId.value) {
|
||||
options.onSessionChanged?.(selectedSessionId.value);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete session:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Select a session
|
||||
async function selectSession(sessionId: string): Promise<void> {
|
||||
if (selectedSessionId.value === sessionId) return;
|
||||
|
||||
selectedSessionId.value = sessionId;
|
||||
await saveSelectedSessionId();
|
||||
options.onSessionChanged?.(sessionId);
|
||||
}
|
||||
|
||||
// Create a default session for a project if none exist
|
||||
async function ensureDefaultSession(
|
||||
projectId: string,
|
||||
engineName: AgentCliPreference = 'claude',
|
||||
): Promise<AgentSession | null> {
|
||||
await fetchSessions(projectId);
|
||||
|
||||
// If sessions exist, select the first one if none selected
|
||||
if (sessions.value.length > 0) {
|
||||
if (
|
||||
!selectedSessionId.value ||
|
||||
!sessions.value.find((s) => s.id === selectedSessionId.value)
|
||||
) {
|
||||
await selectSession(sessions.value[0].id);
|
||||
}
|
||||
return selectedSession.value;
|
||||
}
|
||||
|
||||
// Create default session
|
||||
return createSession(projectId, {
|
||||
engineName,
|
||||
name: 'Default Session',
|
||||
});
|
||||
}
|
||||
|
||||
// Rename a session
|
||||
async function renameSession(sessionId: string, name: string): Promise<boolean> {
|
||||
const result = await updateSession(sessionId, { name });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
// Reset a session conversation (delete messages + clear engineSessionId)
|
||||
async function resetConversation(sessionId: string): Promise<{
|
||||
deletedMessages: number;
|
||||
clearedEngineSessionId: boolean;
|
||||
session: AgentSession | null;
|
||||
} | null> {
|
||||
const ready = await options.ensureServer();
|
||||
const serverPort = options.getServerPort();
|
||||
if (!ready || !serverPort || !sessionId) {
|
||||
sessionError.value = 'Server not available';
|
||||
return null;
|
||||
}
|
||||
|
||||
sessionError.value = null;
|
||||
|
||||
try {
|
||||
const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}/reset`;
|
||||
const response = await fetch(url, { method: 'POST' });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const session = data.session as AgentSession | null;
|
||||
|
||||
// Update local session state
|
||||
if (session?.id) {
|
||||
const index = sessions.value.findIndex((s) => s.id === session.id);
|
||||
if (index !== -1) {
|
||||
sessions.value[index] = session;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deletedMessages: typeof data.deletedMessages === 'number' ? data.deletedMessages : 0,
|
||||
clearedEngineSessionId: data.clearedEngineSessionId === true,
|
||||
session,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to reset conversation:', error);
|
||||
sessionError.value = error instanceof Error ? error.message : 'Failed to reset conversation';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Claude SDK management info for a session
|
||||
async function fetchClaudeInfo(sessionId: string): Promise<{
|
||||
managementInfo: AgentManagementInfo | null;
|
||||
sessionId: string;
|
||||
engineName: string;
|
||||
} | null> {
|
||||
const serverPort = options.getServerPort();
|
||||
if (!serverPort || !sessionId) return null;
|
||||
|
||||
try {
|
||||
const url = `http://127.0.0.1:${serverPort}/agent/sessions/${encodeURIComponent(sessionId)}/claude-info`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
managementInfo: data.managementInfo ?? null,
|
||||
sessionId: data.sessionId ?? sessionId,
|
||||
engineName: data.engineName ?? '',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Claude info:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear sessions when project changes
|
||||
function clearSessions(): void {
|
||||
sessions.value = [];
|
||||
selectedSessionId.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session preview locally (without server call).
|
||||
* Used when sending the first message to update the display immediately.
|
||||
*/
|
||||
function updateSessionPreview(sessionId: string, preview: string): void {
|
||||
const index = sessions.value.findIndex((s) => s.id === sessionId);
|
||||
if (index !== -1) {
|
||||
// Only update if there's no existing preview (first message)
|
||||
if (!sessions.value[index].preview) {
|
||||
// Truncate to 50 chars with ellipsis
|
||||
const maxLen = 50;
|
||||
const trimmed = preview.trim().replace(/\s+/g, ' ');
|
||||
const truncated = trimmed.length > maxLen ? trimmed.slice(0, maxLen - 1) + '…' : trimmed;
|
||||
sessions.value[index] = { ...sessions.value[index], preview: truncated };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
sessions,
|
||||
selectedSessionId,
|
||||
isLoadingSessions,
|
||||
isCreatingSession,
|
||||
sessionError,
|
||||
|
||||
// Computed
|
||||
selectedSession,
|
||||
hasSessions,
|
||||
|
||||
// Methods
|
||||
loadSelectedSessionId,
|
||||
saveSelectedSessionId,
|
||||
fetchSessions,
|
||||
createSession,
|
||||
getSession,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
selectSession,
|
||||
ensureDefaultSession,
|
||||
renameSession,
|
||||
resetConversation,
|
||||
fetchClaudeInfo,
|
||||
clearSessions,
|
||||
updateSessionPreview,
|
||||
};
|
||||
}
|
||||
@@ -450,4 +450,79 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Loading Animation - Shimmer Text & Scribble Icon
|
||||
============================================================ */
|
||||
|
||||
/* 文案 shimmer 渐变动画 */
|
||||
.agent-theme .text-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--ac-accent, #d97757) 0%,
|
||||
var(--ac-accent-hover, #ffcab0) 50%,
|
||||
var(--ac-accent, #d97757) 100%
|
||||
);
|
||||
background-size: 200% auto;
|
||||
color: transparent;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: ac-shimmer 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ac-shimmer {
|
||||
to {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 螺旋图标 - 笔迹重绘动画 */
|
||||
.agent-theme .loading-scribble path {
|
||||
stroke-dasharray: 300;
|
||||
stroke-dashoffset: 300;
|
||||
animation: ac-scribble-draw 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-theme .loading-scribble {
|
||||
animation: ac-slight-rotate 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ac-scribble-draw {
|
||||
0% {
|
||||
stroke-dashoffset: 300;
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: -300;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ac-slight-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect reduced motion preference for loading animations */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-theme .text-shimmer {
|
||||
animation: none;
|
||||
background: none;
|
||||
color: var(--ac-accent);
|
||||
}
|
||||
|
||||
.agent-theme .loading-scribble,
|
||||
.agent-theme .loading-scribble path {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.agent-theme .loading-scribble path {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 随机 Loading 文案
|
||||
* 用于 TimelineStatusStep 组件展示趣味等待提示
|
||||
*/
|
||||
|
||||
const loadingTexts = [
|
||||
// 必选神梗
|
||||
'本来应该从从容容游刃有余',
|
||||
'现在是匆匆忙忙连滚带爬',
|
||||
'我知道你很急,但是先别急',
|
||||
'在知识的海洋里狗刨',
|
||||
'让子弹再飞一会儿',
|
||||
'正在为您手搓答案',
|
||||
'浪浪山小妖怪集结中',
|
||||
'别催,已经在写了(新建文件夹)',
|
||||
'正在汗流浃背地思考中',
|
||||
'CPU 都要给我干烧了',
|
||||
// 生活气息
|
||||
'村咖慢焙,精华需要时间',
|
||||
'知识煎饼翻面中',
|
||||
'敬自己一杯,马上好',
|
||||
'正在把灵感放入烤箱',
|
||||
'让答案再泡一会儿',
|
||||
'情绪价值拉满中',
|
||||
'正在为您编织语言的毛衣',
|
||||
// 脑洞大开
|
||||
'神经元蹦迪中',
|
||||
'熬夜的猫头鹰在思考',
|
||||
'给答案上色中',
|
||||
'正在疯狂翻阅知识库',
|
||||
'大脑马戏团开演',
|
||||
'正在把 0 和 1 捏在一起',
|
||||
'正在憋个大招',
|
||||
'放大镜有点起雾,擦擦',
|
||||
'试图理解这个离谱的需求',
|
||||
// 玄幻
|
||||
'正在施法,莫打扰',
|
||||
'唤醒硅基朋友',
|
||||
'正在连接赛博空间的智慧',
|
||||
'道友请留步,正在推演',
|
||||
'穿越知识黑洞',
|
||||
'正在反向解析人类意图',
|
||||
'水晶球有点模糊,拍两下',
|
||||
// 职场
|
||||
'代码跑得比记者还快',
|
||||
'主理人已上线,请稍候',
|
||||
'快马加鞭赶来中',
|
||||
'正在光速搬运知识',
|
||||
'拼图最后一块',
|
||||
'答案即将杀青',
|
||||
'发射倒计时',
|
||||
'目标锁定中',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取随机 Loading 文案
|
||||
*/
|
||||
export function getRandomLoadingText(): string {
|
||||
return loadingTexts[Math.floor(Math.random() * loadingTexts.length)];
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import type { AgentMessage, RealtimeEvent } from './types';
|
||||
import { AgentStreamManager } from './stream-manager';
|
||||
import { getProject, touchProjectActivity, updateProjectClaudeSessionId } from './project-service';
|
||||
import { createMessage as persistAgentMessage } from './message-service';
|
||||
import {
|
||||
getSession,
|
||||
updateEngineSessionId,
|
||||
updateManagementInfo,
|
||||
type AgentSession,
|
||||
} from './session-service';
|
||||
|
||||
export interface AgentChatServiceOptions {
|
||||
engines: AgentEngine[];
|
||||
@@ -60,13 +66,34 @@ export class AgentChatService {
|
||||
}
|
||||
|
||||
const requestId = payload.requestId || randomUUID();
|
||||
const projectId = payload.projectId;
|
||||
let projectId = payload.projectId;
|
||||
// Normalize empty string to undefined
|
||||
const rawDbSessionId =
|
||||
typeof payload.dbSessionId === 'string' ? payload.dbSessionId.trim() : '';
|
||||
const dbSessionId = rawDbSessionId || undefined;
|
||||
|
||||
// Load session from database if dbSessionId is provided
|
||||
let dbSession: AgentSession | undefined;
|
||||
if (dbSessionId) {
|
||||
dbSession = await getSession(dbSessionId);
|
||||
if (!dbSession) {
|
||||
throw new Error(`Session not found for id: ${dbSessionId}`);
|
||||
}
|
||||
// Validate project association
|
||||
if (projectId && dbSession.projectId !== projectId) {
|
||||
throw new Error(`Session ${dbSessionId} does not belong to project: ${projectId}`);
|
||||
}
|
||||
// Use session's project if not explicitly provided
|
||||
if (!projectId) {
|
||||
projectId = dbSession.projectId;
|
||||
}
|
||||
}
|
||||
|
||||
let projectRoot = payload.projectRoot;
|
||||
let projectPreferredCli: EngineName | undefined;
|
||||
let projectSelectedModel: string | undefined;
|
||||
let activeClaudeSessionId: string | undefined;
|
||||
let projectUseCcr: boolean | undefined;
|
||||
let resumeClaudeSessionId: string | undefined;
|
||||
|
||||
if (!projectRoot && projectId) {
|
||||
const project = await getProject(projectId);
|
||||
@@ -76,20 +103,43 @@ export class AgentChatService {
|
||||
projectRoot = project.rootPath;
|
||||
projectPreferredCli = project.preferredCli as EngineName | undefined;
|
||||
projectSelectedModel = project.selectedModel;
|
||||
activeClaudeSessionId = project.activeClaudeSessionId;
|
||||
projectUseCcr = project.useCcr;
|
||||
|
||||
// Legacy fallback: if caller does not use sessions table, use project-level resume id
|
||||
if (!dbSessionId) {
|
||||
resumeClaudeSessionId = project.activeClaudeSessionId;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve engine name - session binding takes precedence
|
||||
let engineName: EngineName;
|
||||
if (dbSession) {
|
||||
engineName = dbSession.engineName as EngineName;
|
||||
// Validate cliPreference matches session engine
|
||||
if (payload.cliPreference && payload.cliPreference !== engineName) {
|
||||
throw new Error(
|
||||
`cliPreference (${payload.cliPreference}) does not match session.engineName (${engineName})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
engineName = this.resolveEngineName(
|
||||
payload.cliPreference as EngineName | undefined,
|
||||
projectPreferredCli,
|
||||
);
|
||||
}
|
||||
|
||||
const engineName = this.resolveEngineName(
|
||||
payload.cliPreference as EngineName | undefined,
|
||||
projectPreferredCli,
|
||||
);
|
||||
const engine = this.engines.get(engineName);
|
||||
if (!engine) {
|
||||
throw new Error(`No agent engine registered for ${engineName}`);
|
||||
}
|
||||
|
||||
const effectiveModel = payload.model?.trim() || projectSelectedModel;
|
||||
// Model priority: request > session > project
|
||||
const effectiveModel = payload.model?.trim() || dbSession?.model || projectSelectedModel;
|
||||
|
||||
// For Claude engine with session, use session's engineSessionId for resume
|
||||
if (dbSession && engineName === 'claude') {
|
||||
resumeClaudeSessionId = dbSession.engineSessionId;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -184,9 +234,21 @@ export class AgentChatService {
|
||||
}
|
||||
},
|
||||
// Callback to persist Claude session ID when SDK returns system/init message
|
||||
persistClaudeSessionId: projectId
|
||||
// Prefer session-level persistence over project-level
|
||||
persistClaudeSessionId: dbSessionId
|
||||
? async (claudeSessionId: string) => {
|
||||
await updateProjectClaudeSessionId(projectId, claudeSessionId);
|
||||
await updateEngineSessionId(dbSessionId, claudeSessionId);
|
||||
}
|
||||
: projectId
|
||||
? async (claudeSessionId: string) => {
|
||||
await updateProjectClaudeSessionId(projectId, claudeSessionId);
|
||||
}
|
||||
: undefined,
|
||||
// Callback to persist management info from system:init message
|
||||
// Only available when using session-level persistence
|
||||
persistManagementInfo: dbSessionId
|
||||
? async (info) => {
|
||||
await updateManagementInfo(dbSessionId, info);
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
@@ -199,10 +261,16 @@ export class AgentChatService {
|
||||
requestId,
|
||||
attachments: payload.attachments,
|
||||
projectId,
|
||||
// Pass active Claude session ID for session resumption (ClaudeEngine only)
|
||||
resumeClaudeSessionId: activeClaudeSessionId,
|
||||
dbSessionId,
|
||||
// Session-level configuration for ClaudeEngine
|
||||
permissionMode: dbSession?.permissionMode,
|
||||
allowDangerouslySkipPermissions: dbSession?.allowDangerouslySkipPermissions,
|
||||
systemPromptConfig: dbSession?.systemPromptConfig,
|
||||
optionsConfig: dbSession?.optionsConfig,
|
||||
// Pass Claude session ID for session resumption (ClaudeEngine only)
|
||||
resumeClaudeSessionId: engineName === 'claude' ? resumeClaudeSessionId : undefined,
|
||||
// Pass useCcr flag for Claude Code Router support (ClaudeEngine only)
|
||||
useCcr: projectUseCcr,
|
||||
useCcr: engineName === 'claude' ? projectUseCcr : undefined,
|
||||
};
|
||||
|
||||
// Create abort controller for cancellation support
|
||||
|
||||
@@ -65,6 +65,26 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS projects_last_active_idx ON projects(last_active_at);
|
||||
|
||||
-- Sessions table
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
engine_name TEXT NOT NULL,
|
||||
engine_session_id TEXT,
|
||||
name TEXT,
|
||||
model TEXT,
|
||||
permission_mode TEXT NOT NULL DEFAULT 'bypassPermissions',
|
||||
allow_dangerously_skip_permissions TEXT,
|
||||
system_prompt_config TEXT,
|
||||
options_config TEXT,
|
||||
management_info TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_project_id_idx ON sessions(project_id);
|
||||
CREATE INDEX IF NOT EXISTS sessions_engine_name_idx ON sessions(engine_name);
|
||||
|
||||
-- Messages table
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -41,6 +41,65 @@ export const projects = sqliteTable(
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Sessions Table
|
||||
// ============================================================
|
||||
|
||||
export const sessions = sqliteTable(
|
||||
'sessions',
|
||||
{
|
||||
id: text().primaryKey(),
|
||||
projectId: text('project_id')
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||
/**
|
||||
* Engine name: claude, codex, cursor, qwen, glm, etc.
|
||||
*/
|
||||
engineName: text('engine_name').notNull(),
|
||||
/**
|
||||
* Engine-specific session ID for resumption.
|
||||
* For Claude: SDK's session_id from system:init message.
|
||||
*/
|
||||
engineSessionId: text('engine_session_id'),
|
||||
/**
|
||||
* User-defined session name for display.
|
||||
*/
|
||||
name: text(),
|
||||
/**
|
||||
* Model override for this session.
|
||||
*/
|
||||
model: text(),
|
||||
/**
|
||||
* Permission mode: default, acceptEdits, bypassPermissions, plan, dontAsk.
|
||||
*/
|
||||
permissionMode: text('permission_mode').notNull().default('bypassPermissions'),
|
||||
/**
|
||||
* Whether to allow bypassing interactive permission prompts.
|
||||
* Stored as '1' (true) or null (false).
|
||||
*/
|
||||
allowDangerouslySkipPermissions: text('allow_dangerously_skip_permissions'),
|
||||
/**
|
||||
* JSON: System prompt configuration.
|
||||
* Format: { type: 'custom', text: string } | { type: 'preset', preset: 'claude_code', append?: string }
|
||||
*/
|
||||
systemPromptConfig: text('system_prompt_config'),
|
||||
/**
|
||||
* JSON: Engine/session option overrides (settingSources, tools, betas, etc.).
|
||||
*/
|
||||
optionsConfig: text('options_config'),
|
||||
/**
|
||||
* JSON: Cached management info (supported models, commands, account, MCP servers, etc.).
|
||||
*/
|
||||
managementInfo: text('management_info'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
projectIdIdx: index('sessions_project_id_idx').on(table.projectId),
|
||||
engineNameIdx: index('sessions_engine_name_idx').on(table.engineName),
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Messages Table
|
||||
// ============================================================
|
||||
@@ -76,5 +135,7 @@ export const messages = sqliteTable(
|
||||
|
||||
export type ProjectRow = typeof projects.$inferSelect;
|
||||
export type ProjectInsert = typeof projects.$inferInsert;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
export type SessionInsert = typeof sessions.$inferInsert;
|
||||
export type MessageRow = typeof messages.$inferSelect;
|
||||
export type MessageInsert = typeof messages.$inferInsert;
|
||||
|
||||
@@ -61,6 +61,10 @@ export class ClaudeEngine implements AgentEngine {
|
||||
signal,
|
||||
attachments,
|
||||
projectId,
|
||||
permissionMode,
|
||||
allowDangerouslySkipPermissions,
|
||||
systemPromptConfig,
|
||||
optionsConfig,
|
||||
resumeClaudeSessionId,
|
||||
useCcr,
|
||||
} = options;
|
||||
@@ -356,23 +360,16 @@ export class ClaudeEngine implements AgentEngine {
|
||||
console.error(`[ClaudeEngine] Starting query with model: ${resolvedModel}`);
|
||||
console.error(`[ClaudeEngine] Working directory: ${repoPath}`);
|
||||
|
||||
// Process image attachments
|
||||
const imageFiles: string[] = [];
|
||||
// SDK 0.1.69 does not support `images` option. Image inputs must be implemented via
|
||||
// SDKUserMessage image blocks (AsyncIterable prompt mode). For now, attachments are logged and skipped.
|
||||
if (attachments && attachments.length > 0) {
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.type === 'image') {
|
||||
try {
|
||||
const tempFile = await this.writeAttachmentToTemp(attachment);
|
||||
imageFiles.push(tempFile);
|
||||
} catch (err) {
|
||||
console.error('[ClaudeEngine] Failed to write attachment to temp file:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.error(
|
||||
`[ClaudeEngine] Warning: ${attachments.length} attachment(s) provided but SDK 0.1.69 images option is not supported`,
|
||||
);
|
||||
}
|
||||
|
||||
// Start Claude Agent SDK query
|
||||
// Session resumption: if resumeClaudeSessionId is provided (from project's activeClaudeSessionId),
|
||||
// Session resumption: if resumeClaudeSessionId is provided (from sessions.engineSessionId or legacy project),
|
||||
// pass it as 'resume' to continue a previous Claude conversation.
|
||||
// If not provided, SDK will create a new session.
|
||||
|
||||
@@ -387,18 +384,156 @@ export class ClaudeEngine implements AgentEngine {
|
||||
await this.validateAndWarnCcrConfig(sessionId, requestId, ctx);
|
||||
}
|
||||
|
||||
// Resolve permission mode from session config or use default
|
||||
// SDK default is 'default', but AgentChat defaults to 'bypassPermissions' for headless operation
|
||||
const allowedPermissionModes = new Set([
|
||||
'default',
|
||||
'acceptEdits',
|
||||
'bypassPermissions',
|
||||
'plan',
|
||||
'dontAsk',
|
||||
]);
|
||||
const normalizedPermissionMode =
|
||||
typeof permissionMode === 'string' ? permissionMode.trim() : '';
|
||||
|
||||
let resolvedPermissionMode: string;
|
||||
if (normalizedPermissionMode === '') {
|
||||
// No permission mode specified - use AgentChat default for headless operation
|
||||
resolvedPermissionMode = 'bypassPermissions';
|
||||
} else if (allowedPermissionModes.has(normalizedPermissionMode)) {
|
||||
// Valid permission mode - use as specified
|
||||
resolvedPermissionMode = normalizedPermissionMode;
|
||||
} else {
|
||||
// Invalid permission mode - fall back to SDK default and warn
|
||||
console.error(
|
||||
`[ClaudeEngine] Invalid permissionMode "${normalizedPermissionMode}", falling back to SDK default "default"`,
|
||||
);
|
||||
resolvedPermissionMode = 'default';
|
||||
}
|
||||
|
||||
// allowDangerouslySkipPermissions must be true when using bypassPermissions mode
|
||||
// SDK requirement: bypass mode requires explicit acknowledgment via allowDangerouslySkipPermissions=true
|
||||
const resolvedAllowDangerouslySkipPermissions = (() => {
|
||||
const explicitValue =
|
||||
typeof allowDangerouslySkipPermissions === 'boolean'
|
||||
? allowDangerouslySkipPermissions
|
||||
: undefined;
|
||||
|
||||
if (resolvedPermissionMode === 'bypassPermissions') {
|
||||
// Force true for bypassPermissions mode - SDK requirement
|
||||
if (explicitValue === false) {
|
||||
console.error(
|
||||
'[ClaudeEngine] Warning: allowDangerouslySkipPermissions=false is incompatible with bypassPermissions mode, forcing to true',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// For non-bypass modes, use explicit value or default to false
|
||||
return explicitValue ?? false;
|
||||
})();
|
||||
|
||||
// Parse optionsConfig for additional SDK options
|
||||
const optionsRecord =
|
||||
optionsConfig && typeof optionsConfig === 'object' && !Array.isArray(optionsConfig)
|
||||
? (optionsConfig as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
// Resolve setting sources
|
||||
// SDK isolation mode: settingSources=[] prevents loading any filesystem settings
|
||||
// Default behavior: include 'project' to load CLAUDE.md
|
||||
const resolvedSettingSources = (() => {
|
||||
const allowedSettingSources = new Set(['user', 'project', 'local']);
|
||||
const raw = optionsRecord?.settingSources;
|
||||
|
||||
// Check for explicit isolation mode (empty array)
|
||||
if (Array.isArray(raw) && raw.length === 0) {
|
||||
console.error('[ClaudeEngine] Isolation mode enabled: settingSources=[]');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse provided sources
|
||||
if (Array.isArray(raw)) {
|
||||
const sources: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === 'string' && allowedSettingSources.has(entry)) {
|
||||
sources.push(entry);
|
||||
}
|
||||
}
|
||||
// If valid sources were provided, use them as-is (trust user config)
|
||||
if (sources.length > 0) {
|
||||
return sources;
|
||||
}
|
||||
}
|
||||
|
||||
// Default: include 'project' to load CLAUDE.md
|
||||
return ['project'];
|
||||
})();
|
||||
|
||||
// Resolve system prompt from session config
|
||||
const resolvedSystemPrompt = (() => {
|
||||
if (typeof systemPromptConfig === 'string') {
|
||||
const trimmed = systemPromptConfig.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (
|
||||
!systemPromptConfig ||
|
||||
typeof systemPromptConfig !== 'object' ||
|
||||
Array.isArray(systemPromptConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const record = systemPromptConfig as Record<string, unknown>;
|
||||
const type = record.type;
|
||||
if (type === 'custom' && typeof record.text === 'string') {
|
||||
const trimmed = record.text.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (type === 'preset' && record.preset === 'claude_code') {
|
||||
// Trim append and ignore empty strings to avoid "append is empty but object is passed" edge case
|
||||
const rawAppend = typeof record.append === 'string' ? record.append.trim() : '';
|
||||
const append = rawAppend.length > 0 ? rawAppend : undefined;
|
||||
return append
|
||||
? { type: 'preset' as const, preset: 'claude_code' as const, append }
|
||||
: { type: 'preset' as const, preset: 'claude_code' as const };
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// Create internal AbortController that mirrors the external signal
|
||||
// SDK expects abortController option, not raw AbortSignal
|
||||
const internalAbortController = new AbortController();
|
||||
if (signal) {
|
||||
// Propagate external abort to internal controller
|
||||
if (signal.aborted) {
|
||||
internalAbortController.abort();
|
||||
} else {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
internalAbortController.abort();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const queryOptions: Record<string, unknown> = {
|
||||
cwd: repoPath,
|
||||
additionalDirectories: [repoPath],
|
||||
model: resolvedModel,
|
||||
// Both permissionMode and allowDangerouslySkipPermissions are required for auto-approval
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
// Permission settings are session-configurable (defaults preserve previous behavior)
|
||||
permissionMode: resolvedPermissionMode,
|
||||
allowDangerouslySkipPermissions: resolvedAllowDangerouslySkipPermissions,
|
||||
// Enable streaming: emit stream_event with content_block_delta for real-time UI updates
|
||||
// Without this, SDK only outputs aggregated assistant/result messages
|
||||
includePartialMessages: true,
|
||||
images: imageFiles.length > 0 ? imageFiles : undefined,
|
||||
executable: process.execPath,
|
||||
// Load CLAUDE.md / .claude/settings.json from the project root
|
||||
settingSources: resolvedSettingSources,
|
||||
// Custom system prompt if provided
|
||||
systemPrompt: resolvedSystemPrompt,
|
||||
// AbortController for cancellation support - SDK uses this to terminate underlying processes
|
||||
abortController: internalAbortController,
|
||||
// Pass merged env to support Claude Code Router (CCR)
|
||||
// This allows users to set ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN via:
|
||||
// 1. eval "$(ccr activate)" before launching Chrome
|
||||
@@ -415,6 +550,98 @@ export class ClaudeEngine implements AgentEngine {
|
||||
},
|
||||
};
|
||||
|
||||
// Apply additional SDK options from optionsConfig
|
||||
if (optionsRecord) {
|
||||
const isStringArray = (value: unknown): value is string[] =>
|
||||
Array.isArray(value) && value.every((v) => typeof v === 'string');
|
||||
|
||||
if (isStringArray(optionsRecord.allowedTools)) {
|
||||
queryOptions.allowedTools = optionsRecord.allowedTools;
|
||||
}
|
||||
if (isStringArray(optionsRecord.disallowedTools)) {
|
||||
queryOptions.disallowedTools = optionsRecord.disallowedTools;
|
||||
}
|
||||
|
||||
const tools = optionsRecord.tools;
|
||||
if (isStringArray(tools)) {
|
||||
queryOptions.tools = tools;
|
||||
} else if (tools && typeof tools === 'object' && !Array.isArray(tools)) {
|
||||
const toolsRecord = tools as Record<string, unknown>;
|
||||
if (toolsRecord.type === 'preset' && toolsRecord.preset === 'claude_code') {
|
||||
queryOptions.tools = { type: 'preset', preset: 'claude_code' };
|
||||
}
|
||||
}
|
||||
|
||||
if (isStringArray(optionsRecord.betas)) {
|
||||
queryOptions.betas = optionsRecord.betas;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof optionsRecord.maxThinkingTokens === 'number' &&
|
||||
Number.isFinite(optionsRecord.maxThinkingTokens)
|
||||
) {
|
||||
queryOptions.maxThinkingTokens = optionsRecord.maxThinkingTokens;
|
||||
}
|
||||
if (typeof optionsRecord.maxTurns === 'number' && Number.isFinite(optionsRecord.maxTurns)) {
|
||||
queryOptions.maxTurns = optionsRecord.maxTurns;
|
||||
}
|
||||
if (
|
||||
typeof optionsRecord.maxBudgetUsd === 'number' &&
|
||||
Number.isFinite(optionsRecord.maxBudgetUsd)
|
||||
) {
|
||||
queryOptions.maxBudgetUsd = optionsRecord.maxBudgetUsd;
|
||||
}
|
||||
|
||||
if (
|
||||
optionsRecord.mcpServers &&
|
||||
typeof optionsRecord.mcpServers === 'object' &&
|
||||
!Array.isArray(optionsRecord.mcpServers)
|
||||
) {
|
||||
queryOptions.mcpServers = optionsRecord.mcpServers;
|
||||
}
|
||||
if (
|
||||
optionsRecord.outputFormat &&
|
||||
typeof optionsRecord.outputFormat === 'object' &&
|
||||
!Array.isArray(optionsRecord.outputFormat)
|
||||
) {
|
||||
queryOptions.outputFormat = optionsRecord.outputFormat;
|
||||
}
|
||||
if (typeof optionsRecord.enableFileCheckpointing === 'boolean') {
|
||||
queryOptions.enableFileCheckpointing = optionsRecord.enableFileCheckpointing;
|
||||
}
|
||||
if (
|
||||
optionsRecord.sandbox &&
|
||||
typeof optionsRecord.sandbox === 'object' &&
|
||||
!Array.isArray(optionsRecord.sandbox)
|
||||
) {
|
||||
queryOptions.sandbox = optionsRecord.sandbox;
|
||||
}
|
||||
|
||||
// Merge session-level env overrides with base claudeEnv
|
||||
// Session env takes precedence over process env (useful for per-session API keys, etc.)
|
||||
if (
|
||||
optionsRecord.env &&
|
||||
typeof optionsRecord.env === 'object' &&
|
||||
!Array.isArray(optionsRecord.env)
|
||||
) {
|
||||
const sessionEnv = optionsRecord.env as Record<string, unknown>;
|
||||
const mergedEnv = { ...claudeEnv };
|
||||
for (const [key, value] of Object.entries(sessionEnv)) {
|
||||
if (typeof value === 'string') {
|
||||
mergedEnv[key] = value;
|
||||
}
|
||||
}
|
||||
// Ensure Node.js bin directory is still in PATH after merge
|
||||
// Session may have overwritten PATH, which would break child processes
|
||||
const nodeBinDir = path.dirname(process.execPath);
|
||||
const mergedPath = mergedEnv.PATH || mergedEnv.Path || '';
|
||||
if (!mergedPath.includes(nodeBinDir)) {
|
||||
mergedEnv.PATH = [nodeBinDir, mergedPath].filter(Boolean).join(path.delimiter);
|
||||
}
|
||||
queryOptions.env = mergedEnv;
|
||||
}
|
||||
}
|
||||
|
||||
// Add resume option if we have a valid Claude session ID
|
||||
if (resumeClaudeSessionId) {
|
||||
queryOptions.resume = resumeClaudeSessionId;
|
||||
@@ -685,22 +912,175 @@ export class ClaudeEngine implements AgentEngine {
|
||||
emitAssistant(true);
|
||||
}
|
||||
} else if (message.type === 'system') {
|
||||
// Handle system messages - capture session_id from init message
|
||||
// Handle system messages
|
||||
const record = message as unknown as Record<string, unknown>;
|
||||
if (record.subtype === 'init' && record.session_id) {
|
||||
const claudeSessionId = String(record.session_id);
|
||||
console.error(`[ClaudeEngine] Session initialized: ${claudeSessionId}`);
|
||||
const subtype = this.pickFirstString(record.subtype);
|
||||
|
||||
// Persist the session ID if callback is provided and projectId exists
|
||||
if (ctx.persistClaudeSessionId && projectId) {
|
||||
try {
|
||||
await ctx.persistClaudeSessionId(claudeSessionId);
|
||||
console.error(`[ClaudeEngine] Session ID persisted for project: ${projectId}`);
|
||||
} catch (persistError) {
|
||||
// Log but don't fail the request - session persistence is best-effort
|
||||
console.error('[ClaudeEngine] Failed to persist session ID:', persistError);
|
||||
if (subtype === 'init') {
|
||||
// system:init - contains session_id and management information
|
||||
const claudeSessionId = record.session_id ? String(record.session_id) : undefined;
|
||||
|
||||
if (claudeSessionId) {
|
||||
console.error(`[ClaudeEngine] Session initialized: ${claudeSessionId}`);
|
||||
|
||||
// Persist the session ID if callback is provided and projectId exists
|
||||
if (ctx.persistClaudeSessionId && projectId) {
|
||||
try {
|
||||
await ctx.persistClaudeSessionId(claudeSessionId);
|
||||
console.error(`[ClaudeEngine] Session ID persisted for project: ${projectId}`);
|
||||
} catch (persistError) {
|
||||
console.error('[ClaudeEngine] Failed to persist session ID:', persistError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and persist management information
|
||||
if (ctx.persistManagementInfo) {
|
||||
try {
|
||||
const managementInfo = {
|
||||
tools: Array.isArray(record.tools)
|
||||
? record.tools.filter((t): t is string => typeof t === 'string')
|
||||
: undefined,
|
||||
agents: Array.isArray(record.agents)
|
||||
? record.agents.filter((a): a is string => typeof a === 'string')
|
||||
: undefined,
|
||||
// SDK returns plugins as { name, path }[] objects
|
||||
plugins: Array.isArray(record.plugins)
|
||||
? (record.plugins as Array<{ name?: string; path?: string }>)
|
||||
.filter((p) => p && typeof p.name === 'string')
|
||||
.map((p) => ({
|
||||
name: String(p.name),
|
||||
path: p.path ? String(p.path) : undefined,
|
||||
}))
|
||||
: undefined,
|
||||
skills: Array.isArray(record.skills)
|
||||
? record.skills.filter((s): s is string => typeof s === 'string')
|
||||
: undefined,
|
||||
mcpServers: Array.isArray(record.mcp_servers)
|
||||
? (record.mcp_servers as Array<{ name?: string; status?: string }>)
|
||||
.filter((s) => s && typeof s.name === 'string')
|
||||
.map((s) => ({
|
||||
name: String(s.name),
|
||||
status: String(s.status || 'unknown'),
|
||||
}))
|
||||
: undefined,
|
||||
slashCommands: Array.isArray(record.slash_commands)
|
||||
? record.slash_commands.filter((c): c is string => typeof c === 'string')
|
||||
: undefined,
|
||||
model: this.pickFirstString(record.model),
|
||||
permissionMode: this.pickFirstString(record.permissionMode),
|
||||
cwd: this.pickFirstString(record.cwd),
|
||||
outputStyle: this.pickFirstString(record.output_style),
|
||||
betas: Array.isArray(record.betas)
|
||||
? record.betas.filter((b): b is string => typeof b === 'string')
|
||||
: undefined,
|
||||
claudeCodeVersion: this.pickFirstString(record.claude_code_version),
|
||||
apiKeySource: this.pickFirstString(record.apiKeySource),
|
||||
};
|
||||
|
||||
await ctx.persistManagementInfo(managementInfo);
|
||||
console.error('[ClaudeEngine] Management info persisted');
|
||||
} catch (persistError) {
|
||||
console.error('[ClaudeEngine] Failed to persist management info:', persistError);
|
||||
}
|
||||
}
|
||||
} else if (subtype === 'status') {
|
||||
// system:status - log for debugging (e.g., compacting)
|
||||
const statusText = this.pickFirstString(record.status);
|
||||
console.error(`[ClaudeEngine] System status: ${statusText || 'unknown'}`);
|
||||
}
|
||||
} else if (message.type === 'auth_status') {
|
||||
// Handle authentication status - SDK fields: isAuthenticating, output, error
|
||||
const record = message as unknown as Record<string, unknown>;
|
||||
const isAuthenticating = record.isAuthenticating === true;
|
||||
const output = Array.isArray(record.output)
|
||||
? record.output.filter((o): o is string => typeof o === 'string')
|
||||
: [];
|
||||
const authError = this.pickFirstString(record.error);
|
||||
|
||||
console.error(
|
||||
`[ClaudeEngine] Auth status: isAuthenticating=${isAuthenticating}, hasError=${!!authError}`,
|
||||
);
|
||||
|
||||
// Build content from output or error
|
||||
const content = authError || output.join('\n') || 'Authentication in progress...';
|
||||
|
||||
// Determine if login is required:
|
||||
// - Not currently authenticating AND (has error OR output contains login keywords)
|
||||
const outputText = output.join(' ').toLowerCase();
|
||||
const requiresLogin =
|
||||
!isAuthenticating &&
|
||||
(!!authError ||
|
||||
outputText.includes('login') ||
|
||||
outputText.includes('authenticate') ||
|
||||
outputText.includes('sign in'));
|
||||
|
||||
// Emit auth status as a system message so UI can display login prompts
|
||||
const authSystemMessage: AgentMessage = {
|
||||
id: randomUUID(),
|
||||
sessionId,
|
||||
role: 'system',
|
||||
content,
|
||||
messageType: 'status',
|
||||
cliSource: this.name,
|
||||
requestId,
|
||||
isStreaming: false,
|
||||
isFinal: !isAuthenticating,
|
||||
createdAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
cli_type: 'claude',
|
||||
event_type: 'auth_status',
|
||||
isAuthenticating,
|
||||
output,
|
||||
error: authError,
|
||||
requires_login: requiresLogin,
|
||||
},
|
||||
};
|
||||
|
||||
ctx.emit({ type: 'message', data: authSystemMessage });
|
||||
} else if (message.type === 'tool_progress') {
|
||||
// Handle tool progress - SDK fields: tool_use_id, tool_name, parent_tool_use_id, elapsed_time_seconds
|
||||
const record = message as unknown as Record<string, unknown>;
|
||||
const toolUseId = this.pickFirstString(record.tool_use_id);
|
||||
const toolName = this.pickFirstString(record.tool_name);
|
||||
const parentToolUseId = this.pickFirstString(record.parent_tool_use_id);
|
||||
const elapsedTimeSeconds =
|
||||
typeof record.elapsed_time_seconds === 'number'
|
||||
? record.elapsed_time_seconds
|
||||
: undefined;
|
||||
|
||||
if (toolName || toolUseId) {
|
||||
const displayName = toolName || toolUseId || 'tool';
|
||||
const elapsedStr =
|
||||
elapsedTimeSeconds !== undefined ? ` (${elapsedTimeSeconds.toFixed(1)}s)` : '';
|
||||
console.error(`[ClaudeEngine] Tool progress: ${displayName}${elapsedStr}`);
|
||||
|
||||
// Use tool_use_id as message id if available, so UI can update the same progress entry
|
||||
const messageId = toolUseId ? `progress-${toolUseId}` : randomUUID();
|
||||
|
||||
// Emit tool progress as a tool message
|
||||
const progressMessage: AgentMessage = {
|
||||
id: messageId,
|
||||
sessionId,
|
||||
role: 'tool',
|
||||
content: `${displayName} in progress${elapsedStr}`,
|
||||
messageType: 'tool_use',
|
||||
cliSource: this.name,
|
||||
requestId,
|
||||
isStreaming: true,
|
||||
isFinal: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
cli_type: 'claude',
|
||||
event_type: 'tool_progress',
|
||||
toolUseId,
|
||||
toolName,
|
||||
parentToolUseId,
|
||||
elapsedTimeSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
ctx.emit({ type: 'message', data: progressMessage });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,30 @@ export interface EngineInitOptions {
|
||||
* When provided, engines can use this to save/load session state.
|
||||
*/
|
||||
projectId?: string;
|
||||
/**
|
||||
* Optional database session ID (sessions.id) for session-scoped configuration and persistence.
|
||||
*/
|
||||
dbSessionId?: string;
|
||||
/**
|
||||
* Optional session-scoped permission mode override (Claude SDK option).
|
||||
*/
|
||||
permissionMode?: string;
|
||||
/**
|
||||
* Optional session-scoped permission bypass override (Claude SDK option).
|
||||
*/
|
||||
allowDangerouslySkipPermissions?: boolean;
|
||||
/**
|
||||
* Optional session-scoped system prompt configuration.
|
||||
*/
|
||||
systemPromptConfig?: unknown;
|
||||
/**
|
||||
* Optional session-scoped engine option overrides.
|
||||
*/
|
||||
optionsConfig?: unknown;
|
||||
/**
|
||||
* Optional Claude session ID (UUID) for resuming a previous session.
|
||||
* Only applicable to ClaudeEngine; retrieved from project's activeClaudeSessionId.
|
||||
* Only applicable to ClaudeEngine; retrieved from sessions.engineSessionId (preferred)
|
||||
* or project's activeClaudeSessionId (legacy fallback).
|
||||
*/
|
||||
resumeClaudeSessionId?: string;
|
||||
/**
|
||||
@@ -36,6 +57,31 @@ export interface EngineInitOptions {
|
||||
*/
|
||||
export type ClaudeSessionPersistCallback = (sessionId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Management information extracted from Claude SDK system:init message.
|
||||
*/
|
||||
export interface ClaudeManagementInfo {
|
||||
tools?: string[];
|
||||
agents?: string[];
|
||||
/** Plugins with name and path (SDK returns { name, path }[]) */
|
||||
plugins?: Array<{ name: string; path?: string }>;
|
||||
skills?: string[];
|
||||
mcpServers?: Array<{ name: string; status: string }>;
|
||||
slashCommands?: string[];
|
||||
model?: string;
|
||||
permissionMode?: string;
|
||||
cwd?: string;
|
||||
outputStyle?: string;
|
||||
betas?: string[];
|
||||
claudeCodeVersion?: string;
|
||||
apiKeySource?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to persist management information after SDK initialization.
|
||||
*/
|
||||
export type ManagementInfoPersistCallback = (info: ClaudeManagementInfo) => Promise<void>;
|
||||
|
||||
export type EngineName = 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm';
|
||||
|
||||
export interface EngineExecutionContext {
|
||||
@@ -48,6 +94,11 @@ export interface EngineExecutionContext {
|
||||
* Only called by ClaudeEngine when projectId is provided.
|
||||
*/
|
||||
persistClaudeSessionId?: ClaudeSessionPersistCallback;
|
||||
/**
|
||||
* Optional callback to persist management information after SDK initialization.
|
||||
* Only called by ClaudeEngine when dbSessionId is provided.
|
||||
*/
|
||||
persistManagementInfo?: ManagementInfoPersistCallback;
|
||||
}
|
||||
|
||||
export interface AgentEngine {
|
||||
|
||||
@@ -170,18 +170,63 @@ export async function deleteMessagesByProjectId(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages by session ID.
|
||||
* Get messages by session ID with optional pagination.
|
||||
* Returns messages sorted by creation time (oldest first).
|
||||
*
|
||||
* @param sessionId - The session ID to filter by
|
||||
* @param limit - Maximum number of messages to return (0 = no limit)
|
||||
* @param offset - Number of messages to skip
|
||||
*/
|
||||
export async function getMessagesBySessionId(sessionId: string): Promise<AgentStoredMessage[]> {
|
||||
export async function getMessagesBySessionId(
|
||||
sessionId: string,
|
||||
limit = 0,
|
||||
offset = 0,
|
||||
): Promise<AgentStoredMessage[]> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
|
||||
const query = db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.sessionId, sessionId))
|
||||
.orderBy(asc(messages.createdAt));
|
||||
|
||||
if (limit > 0) {
|
||||
query.limit(limit);
|
||||
}
|
||||
if (offset > 0) {
|
||||
query.offset(offset);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
return rows.map(rowToMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of messages by session ID.
|
||||
*/
|
||||
export async function getMessagesCountBySessionId(sessionId: string): Promise<number> {
|
||||
const db = getDb();
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(messages)
|
||||
.where(eq(messages.sessionId, sessionId));
|
||||
return result[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all messages for a session.
|
||||
* Returns the number of deleted messages.
|
||||
*/
|
||||
export async function deleteMessagesBySessionId(sessionId: string): Promise<number> {
|
||||
const db = getDb();
|
||||
|
||||
const beforeCount = await getMessagesCountBySessionId(sessionId);
|
||||
await db.delete(messages).where(eq(messages.sessionId, sessionId));
|
||||
const afterCount = await getMessagesCountBySessionId(sessionId);
|
||||
|
||||
return beforeCount - afterCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages by request ID.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Session Service - Database-backed implementation using Drizzle ORM.
|
||||
*
|
||||
* Provides CRUD operations for agent sessions with:
|
||||
* - Type-safe database queries
|
||||
* - Engine-agnostic session configuration storage
|
||||
* - JSON config and management info caching
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq, desc, and, asc } from 'drizzle-orm';
|
||||
import { getDb, sessions, messages, type SessionRow } from './db';
|
||||
import type { EngineName } from './engines/types';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* System prompt configuration options.
|
||||
*/
|
||||
export type SystemPromptConfig =
|
||||
| { type: 'custom'; text: string }
|
||||
| { type: 'preset'; preset: 'claude_code'; append?: string };
|
||||
|
||||
/**
|
||||
* Tools configuration - can be a list of tool names or a preset.
|
||||
*/
|
||||
export type ToolsConfig = string[] | { type: 'preset'; preset: 'claude_code' };
|
||||
|
||||
/**
|
||||
* Session options configuration (stored as JSON).
|
||||
*/
|
||||
export interface SessionOptionsConfig {
|
||||
settingSources?: string[];
|
||||
allowedTools?: string[];
|
||||
disallowedTools?: string[];
|
||||
tools?: ToolsConfig;
|
||||
betas?: string[];
|
||||
maxThinkingTokens?: number;
|
||||
maxTurns?: number;
|
||||
maxBudgetUsd?: number;
|
||||
mcpServers?: Record<string, unknown>;
|
||||
outputFormat?: Record<string, unknown>;
|
||||
enableFileCheckpointing?: boolean;
|
||||
sandbox?: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached management information from Claude SDK.
|
||||
*/
|
||||
export interface ManagementInfo {
|
||||
models?: Array<{ value: string; displayName: string; description: string }>;
|
||||
commands?: Array<{ name: string; description: string; argumentHint: string }>;
|
||||
account?: { email?: string; organization?: string; subscriptionType?: string };
|
||||
mcpServers?: Array<{ name: string; status: string }>;
|
||||
tools?: string[];
|
||||
agents?: string[];
|
||||
/** Plugins with name and path (SDK returns { name, path }[]) */
|
||||
plugins?: Array<{ name: string; path?: string }>;
|
||||
skills?: string[];
|
||||
slashCommands?: string[];
|
||||
model?: string;
|
||||
permissionMode?: string;
|
||||
cwd?: string;
|
||||
outputStyle?: string;
|
||||
betas?: string[];
|
||||
claudeCodeVersion?: string;
|
||||
apiKeySource?: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent session representation.
|
||||
*/
|
||||
export interface AgentSession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
engineName: string;
|
||||
engineSessionId?: string;
|
||||
name?: string;
|
||||
/** Preview text from first user message, for display in session list */
|
||||
preview?: string;
|
||||
model?: string;
|
||||
permissionMode: string;
|
||||
allowDangerouslySkipPermissions: boolean;
|
||||
systemPromptConfig?: SystemPromptConfig;
|
||||
optionsConfig?: SessionOptionsConfig;
|
||||
managementInfo?: ManagementInfo;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a new session.
|
||||
*/
|
||||
export interface CreateSessionOptions {
|
||||
id?: string;
|
||||
engineSessionId?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
permissionMode?: string;
|
||||
allowDangerouslySkipPermissions?: boolean;
|
||||
systemPromptConfig?: SystemPromptConfig;
|
||||
optionsConfig?: SessionOptionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for updating an existing session.
|
||||
*/
|
||||
export interface UpdateSessionInput {
|
||||
engineSessionId?: string | null;
|
||||
name?: string | null;
|
||||
model?: string | null;
|
||||
permissionMode?: string | null;
|
||||
allowDangerouslySkipPermissions?: boolean | null;
|
||||
systemPromptConfig?: SystemPromptConfig | null;
|
||||
optionsConfig?: SessionOptionsConfig | null;
|
||||
managementInfo?: ManagementInfo | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// JSON Parsing Utilities
|
||||
// ============================================================
|
||||
|
||||
function parseJson<T>(value: string | null): T | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyJson<T>(value: T | null | undefined): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Type Conversion
|
||||
// ============================================================
|
||||
|
||||
function rowToSession(row: SessionRow): AgentSession {
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
engineName: row.engineName,
|
||||
engineSessionId: row.engineSessionId ?? undefined,
|
||||
name: row.name ?? undefined,
|
||||
model: row.model ?? undefined,
|
||||
permissionMode: row.permissionMode,
|
||||
allowDangerouslySkipPermissions: row.allowDangerouslySkipPermissions === '1',
|
||||
systemPromptConfig: parseJson<SystemPromptConfig>(row.systemPromptConfig),
|
||||
optionsConfig: parseJson<SessionOptionsConfig>(row.optionsConfig),
|
||||
managementInfo: parseJson<ManagementInfo>(row.managementInfo),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Create a new session for a project.
|
||||
*/
|
||||
export async function createSession(
|
||||
projectId: string,
|
||||
engineName: EngineName,
|
||||
options: CreateSessionOptions = {},
|
||||
): Promise<AgentSession> {
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Resolve permission mode - AgentChat defaults to bypassPermissions for headless operation
|
||||
const resolvedPermissionMode = options.permissionMode?.trim() || 'bypassPermissions';
|
||||
|
||||
// SDK requires allowDangerouslySkipPermissions=true when using bypassPermissions mode
|
||||
// If explicitly provided, use that value; otherwise infer from permission mode
|
||||
const resolvedAllowDangerouslySkipPermissions =
|
||||
typeof options.allowDangerouslySkipPermissions === 'boolean'
|
||||
? options.allowDangerouslySkipPermissions
|
||||
: resolvedPermissionMode === 'bypassPermissions';
|
||||
|
||||
const sessionData = {
|
||||
id: options.id?.trim() || randomUUID(),
|
||||
projectId,
|
||||
engineName,
|
||||
engineSessionId: options.engineSessionId?.trim() || null,
|
||||
name: options.name?.trim() || null,
|
||||
model: options.model?.trim() || null,
|
||||
permissionMode: resolvedPermissionMode,
|
||||
allowDangerouslySkipPermissions: resolvedAllowDangerouslySkipPermissions ? '1' : null,
|
||||
systemPromptConfig: stringifyJson(options.systemPromptConfig),
|
||||
optionsConfig: stringifyJson(options.optionsConfig),
|
||||
managementInfo: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await db.insert(sessions).values(sessionData);
|
||||
return rowToSession(sessionData as SessionRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session by ID.
|
||||
*/
|
||||
export async function getSession(sessionId: string): Promise<AgentSession | undefined> {
|
||||
const db = getDb();
|
||||
const rows = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1);
|
||||
return rows.length > 0 ? rowToSession(rows[0]) : undefined;
|
||||
}
|
||||
|
||||
/** Maximum length for preview text */
|
||||
const MAX_PREVIEW_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* Truncate text to max length with ellipsis.
|
||||
*/
|
||||
function truncatePreview(text: string, maxLength: number = MAX_PREVIEW_LENGTH): string {
|
||||
const trimmed = text.trim().replace(/\s+/g, ' ');
|
||||
if (trimmed.length <= maxLength) return trimmed;
|
||||
return trimmed.slice(0, maxLength - 1) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions for a project, sorted by most recently updated.
|
||||
* Includes preview from first user message for each session.
|
||||
*/
|
||||
export async function getSessionsByProject(projectId: string): Promise<AgentSession[]> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.projectId, projectId))
|
||||
.orderBy(desc(sessions.updatedAt));
|
||||
|
||||
// Get first user message for each session as preview
|
||||
const sessionsWithPreview = await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const session = rowToSession(row);
|
||||
|
||||
// Query first user message for this session
|
||||
const firstUserMessages = await db
|
||||
.select({ content: messages.content })
|
||||
.from(messages)
|
||||
.where(and(eq(messages.sessionId, row.id), eq(messages.role, 'user')))
|
||||
.orderBy(asc(messages.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (firstUserMessages.length > 0 && firstUserMessages[0].content) {
|
||||
session.preview = truncatePreview(firstUserMessages[0].content);
|
||||
}
|
||||
|
||||
return session;
|
||||
}),
|
||||
);
|
||||
|
||||
return sessionsWithPreview;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sessions for a project filtered by engine name.
|
||||
*/
|
||||
export async function getSessionsByProjectAndEngine(
|
||||
projectId: string,
|
||||
engineName: EngineName,
|
||||
): Promise<AgentSession[]> {
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.projectId, projectId), eq(sessions.engineName, engineName)))
|
||||
.orderBy(desc(sessions.updatedAt));
|
||||
return rows.map(rowToSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing session.
|
||||
*/
|
||||
export async function updateSession(sessionId: string, updates: UpdateSessionInput): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (updates.engineSessionId !== undefined) {
|
||||
updateData.engineSessionId = updates.engineSessionId?.trim() || null;
|
||||
}
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
updateData.name = updates.name?.trim() || null;
|
||||
}
|
||||
|
||||
if (updates.model !== undefined) {
|
||||
updateData.model = updates.model?.trim() || null;
|
||||
}
|
||||
|
||||
if (updates.permissionMode !== undefined) {
|
||||
updateData.permissionMode = updates.permissionMode?.trim() || 'bypassPermissions';
|
||||
}
|
||||
|
||||
if (updates.allowDangerouslySkipPermissions !== undefined) {
|
||||
updateData.allowDangerouslySkipPermissions = updates.allowDangerouslySkipPermissions
|
||||
? '1'
|
||||
: null;
|
||||
}
|
||||
|
||||
if (updates.systemPromptConfig !== undefined) {
|
||||
updateData.systemPromptConfig = stringifyJson(updates.systemPromptConfig);
|
||||
}
|
||||
|
||||
if (updates.optionsConfig !== undefined) {
|
||||
updateData.optionsConfig = stringifyJson(updates.optionsConfig);
|
||||
}
|
||||
|
||||
if (updates.managementInfo !== undefined) {
|
||||
updateData.managementInfo = stringifyJson(updates.managementInfo);
|
||||
}
|
||||
|
||||
await db.update(sessions).set(updateData).where(eq(sessions.id, sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session by ID.
|
||||
* Note: Messages associated with this session are NOT automatically deleted.
|
||||
* The caller should handle message cleanup if needed.
|
||||
*/
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the engine session ID (e.g., Claude SDK session_id).
|
||||
*/
|
||||
export async function updateEngineSessionId(
|
||||
sessionId: string,
|
||||
engineSessionId: string | null,
|
||||
): Promise<void> {
|
||||
await updateSession(sessionId, { engineSessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cached management information.
|
||||
*/
|
||||
export async function updateManagementInfo(
|
||||
sessionId: string,
|
||||
info: ManagementInfo | null,
|
||||
): Promise<void> {
|
||||
// Add timestamp to management info
|
||||
const infoWithTimestamp = info ? { ...info, lastUpdated: new Date().toISOString() } : null;
|
||||
await updateSession(sessionId, { managementInfo: infoWithTimestamp });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a default session for a project and engine.
|
||||
* Useful for backwards compatibility - creates a session if none exists.
|
||||
*/
|
||||
export async function getOrCreateDefaultSession(
|
||||
projectId: string,
|
||||
engineName: EngineName,
|
||||
options: CreateSessionOptions = {},
|
||||
): Promise<AgentSession> {
|
||||
const existingSessions = await getSessionsByProjectAndEngine(projectId, engineName);
|
||||
|
||||
if (existingSessions.length > 0) {
|
||||
// Return the most recently updated session
|
||||
return existingSessions[0];
|
||||
}
|
||||
|
||||
// Create a new default session
|
||||
return createSession(projectId, engineName, {
|
||||
...options,
|
||||
name: options.name || `Default ${engineName} session`,
|
||||
});
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const HTTP_STATUS = {
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
NOT_FOUND: 404,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
GATEWAY_TIMEOUT: 504,
|
||||
} as const;
|
||||
|
||||
@@ -24,11 +24,33 @@ import {
|
||||
import {
|
||||
createMessage as createStoredMessage,
|
||||
deleteMessagesByProjectId,
|
||||
deleteMessagesBySessionId,
|
||||
getMessagesByProjectId,
|
||||
getMessagesCountByProjectId,
|
||||
getMessagesBySessionId,
|
||||
getMessagesCountBySessionId,
|
||||
} from '../../agent/message-service';
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
getSession,
|
||||
getSessionsByProject,
|
||||
getSessionsByProjectAndEngine,
|
||||
updateSession,
|
||||
type CreateSessionOptions,
|
||||
type UpdateSessionInput,
|
||||
} from '../../agent/session-service';
|
||||
import { getProject } from '../../agent/project-service';
|
||||
import { getDefaultWorkspaceDir, getDefaultProjectRoot } from '../../agent/storage';
|
||||
import { openDirectoryPicker } from '../../agent/directory-picker';
|
||||
import type { EngineName } from '../../agent/engines/types';
|
||||
|
||||
// Valid engine names for validation
|
||||
const VALID_ENGINE_NAMES: readonly EngineName[] = ['claude', 'codex', 'cursor', 'qwen', 'glm'];
|
||||
|
||||
function isValidEngineName(name: string): name is EngineName {
|
||||
return VALID_ENGINE_NAMES.includes(name as EngineName);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
@@ -212,6 +234,334 @@ export function registerAgentRoutes(fastify: FastifyInstance, options: AgentRout
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Session Routes
|
||||
// ============================================================
|
||||
|
||||
// List sessions for a project
|
||||
fastify.get(
|
||||
'/agent/projects/:projectId/sessions',
|
||||
async (request: FastifyRequest<{ Params: { projectId: string } }>, reply: FastifyReply) => {
|
||||
const { projectId } = request.params;
|
||||
if (!projectId) {
|
||||
return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = await getSessionsByProject(projectId);
|
||||
return reply.status(HTTP_STATUS.OK).send({ sessions });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
fastify.log.error({ err: error }, 'Failed to list sessions');
|
||||
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
|
||||
error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create a new session for a project
|
||||
fastify.post(
|
||||
'/agent/projects/:projectId/sessions',
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string };
|
||||
Body: CreateSessionOptions & { engineName: string };
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { projectId } = request.params;
|
||||
const body = request.body || {};
|
||||
|
||||
if (!projectId) {
|
||||
return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });
|
||||
}
|
||||
if (!body.engineName) {
|
||||
return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'engineName is required' });
|
||||
}
|
||||
if (!isValidEngineName(body.engineName)) {
|
||||
return reply.status(HTTP_STATUS.BAD_REQUEST).send({
|
||||
error: `Invalid engineName. Must be one of: ${VALID_ENGINE_NAMES.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify project exists
|
||||
const project = await getProject(projectId);
|
||||
if (!project) {
|
||||
return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
const session = await createSession(projectId, body.engineName, {
|
||||
name: body.name,
|
||||
model: body.model,
|
||||
permissionMode: body.permissionMode,
|
||||
allowDangerouslySkipPermissions: body.allowDangerouslySkipPermissions,
|
||||
systemPromptConfig: body.systemPromptConfig,
|
||||
optionsConfig: body.optionsConfig,
|
||||
});
|
||||
return reply.status(HTTP_STATUS.CREATED).send({ session });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
fastify.log.error({ err: error }, 'Failed to create session');
|
||||
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
|
||||
error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get a specific session
|
||||
fastify.get(
|
||||
'/agent/sessions/:sessionId',
|
||||
async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {
|
||||
const { sessionId } = request.params;
|
||||
if (!sessionId) {
|
||||
return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) {
|
||||
return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });
|
||||
}
|
||||
return reply.status(HTTP_STATUS.OK).send({ session });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
fastify.log.error({ err: error }, 'Failed to get session');
|
||||
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
|
||||
error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update a session
|
||||
fastify.patch(
|
||||
'/agent/sessions/:sessionId',
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { sessionId: string };
|
||||
Body: UpdateSessionInput;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { sessionId } = request.params;
|
||||
const updates = request.body || {};
|
||||
|
||||
if (!sessionId) {
|
||||
return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await getSession(sessionId);
|
||||
if (!existing) {
|
||||
return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
await updateSession(sessionId, updates);
|
||||
const updated = await getSession(sessionId);
|
||||
return reply.status(HTTP_STATUS.OK).send({ session: updated });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
fastify.log.error({ err: error }, 'Failed to update session');
|
||||
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
|
||||
error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a session
|
||||
fastify.delete(
|
||||
'/agent/sessions/:sessionId',
|
||||
async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {
|
||||
const { sessionId } = request.params;
|
||||
if (!sessionId) {
|
||||
return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteSession(sessionId);
|
||||
return reply.status(HTTP_STATUS.NO_CONTENT).send();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
fastify.log.error({ err: error }, 'Failed to delete session');
|
||||
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
|
||||
error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get message history for a session
|
||||
fastify.get(
|
||||
'/agent/sessions/:sessionId/history',
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { sessionId: string };
|
||||
Querystring: { limit?: string; offset?: string };
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { sessionId } = request.params;
|
||||
if (!sessionId) {
|
||||
return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
|
||||
}
|
||||
|
||||
const limitRaw = request.query.limit;
|
||||
const offsetRaw = request.query.offset;
|
||||
const limit = Number.parseInt(limitRaw || '', 10);
|
||||
const offset = Number.parseInt(offsetRaw || '', 10);
|
||||
const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 0;
|
||||
const safeOffset = Number.isFinite(offset) && offset >= 0 ? offset : 0;
|
||||
|
||||
try {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) {
|
||||
return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const [messages, totalCount] = await Promise.all([
|
||||
getMessagesBySessionId(sessionId, safeLimit, safeOffset),
|
||||
getMessagesCountBySessionId(sessionId),
|
||||
]);
|
||||
|
||||
return reply.status(HTTP_STATUS.OK).send({
|
||||
success: true,
|
||||
sessionId,
|
||||
messages,
|
||||
totalCount,
|
||||
pagination: {
|
||||
limit: safeLimit,
|
||||
offset: safeOffset,
|
||||
count: messages.length,
|
||||
hasMore: safeLimit > 0 ? safeOffset + messages.length < totalCount : false,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
fastify.log.error({ err: error }, 'Failed to get session history');
|
||||
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
|
||||
error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Reset a session conversation (clear messages + engineSessionId)
|
||||
fastify.post(
|
||||
'/agent/sessions/:sessionId/reset',
|
||||
async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {
|
||||
const { sessionId } = request.params;
|
||||
if (!sessionId) {
|
||||
return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await getSession(sessionId);
|
||||
if (!existing) {
|
||||
return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
// Clear resume state first, then delete messages
|
||||
await updateSession(sessionId, { engineSessionId: null });
|
||||
const deletedMessages = await deleteMessagesBySessionId(sessionId);
|
||||
const updated = await getSession(sessionId);
|
||||
|
||||
return reply.status(HTTP_STATUS.OK).send({
|
||||
success: true,
|
||||
sessionId,
|
||||
deletedMessages,
|
||||
clearedEngineSessionId: Boolean(existing.engineSessionId),
|
||||
session: updated || null,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
fastify.log.error({ err: error }, 'Failed to reset session');
|
||||
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
|
||||
error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get Claude management info for a session
|
||||
fastify.get(
|
||||
'/agent/sessions/:sessionId/claude-info',
|
||||
async (request: FastifyRequest<{ Params: { sessionId: string } }>, reply: FastifyReply) => {
|
||||
const { sessionId } = request.params;
|
||||
if (!sessionId) {
|
||||
return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'sessionId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) {
|
||||
return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
return reply.status(HTTP_STATUS.OK).send({
|
||||
managementInfo: session.managementInfo || null,
|
||||
sessionId,
|
||||
engineName: session.engineName,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
fastify.log.error({ err: error }, 'Failed to get Claude info');
|
||||
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
|
||||
error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get aggregated Claude management info for a project
|
||||
// Returns the most recent management info from any Claude session in the project
|
||||
fastify.get(
|
||||
'/agent/projects/:projectId/claude-info',
|
||||
async (request: FastifyRequest<{ Params: { projectId: string } }>, reply: FastifyReply) => {
|
||||
const { projectId } = request.params;
|
||||
if (!projectId) {
|
||||
return reply.status(HTTP_STATUS.BAD_REQUEST).send({ error: 'projectId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const project = await getProject(projectId);
|
||||
if (!project) {
|
||||
return reply.status(HTTP_STATUS.NOT_FOUND).send({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
// Get only Claude sessions (more efficient than fetching all and filtering)
|
||||
const claudeSessions = await getSessionsByProjectAndEngine(projectId, 'claude');
|
||||
const sessionsWithInfo = claudeSessions.filter((s) => s.managementInfo);
|
||||
|
||||
// Sort by lastUpdated in management info (fallback to session.updatedAt for old data)
|
||||
sessionsWithInfo.sort((a, b) => {
|
||||
const aTime = a.managementInfo?.lastUpdated || a.updatedAt || '';
|
||||
const bTime = b.managementInfo?.lastUpdated || b.updatedAt || '';
|
||||
return bTime.localeCompare(aTime);
|
||||
});
|
||||
|
||||
const latestInfo = sessionsWithInfo[0]?.managementInfo || null;
|
||||
const sourceSessionId = sessionsWithInfo[0]?.id;
|
||||
|
||||
return reply.status(HTTP_STATUS.OK).send({
|
||||
managementInfo: latestInfo,
|
||||
sourceSessionId,
|
||||
projectId,
|
||||
sessionsWithInfo: sessionsWithInfo.length,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
fastify.log.error({ err: error }, 'Failed to get project Claude info');
|
||||
return reply.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send({
|
||||
error: message || ERROR_MESSAGES.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Chat Message Routes
|
||||
// ============================================================
|
||||
|
||||
@@ -93,6 +93,12 @@ export interface AgentActRequest {
|
||||
* solely on ad-hoc paths.
|
||||
*/
|
||||
projectId?: string;
|
||||
/**
|
||||
* Optional database session ID (sessions.id). When provided, the backend
|
||||
* will load session-level configuration (engine, model, permission mode,
|
||||
* resume ids, etc.) from the sessions table.
|
||||
*/
|
||||
dbSessionId?: string;
|
||||
/**
|
||||
* Optional project root / workspace directory on the local filesystem
|
||||
* that the engine should use as its working directory.
|
||||
@@ -144,6 +150,107 @@ export interface AgentEngineInfo {
|
||||
supportsMcp?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Session Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* System prompt configuration for a session.
|
||||
*/
|
||||
export type AgentSystemPromptConfig =
|
||||
| { type: 'custom'; text: string }
|
||||
| { type: 'preset'; preset: 'claude_code'; append?: string };
|
||||
|
||||
/**
|
||||
* Tools configuration - can be a list of tool names or a preset.
|
||||
*/
|
||||
export type AgentToolsConfig = string[] | { type: 'preset'; preset: 'claude_code' };
|
||||
|
||||
/**
|
||||
* Session options configuration.
|
||||
*/
|
||||
export interface AgentSessionOptionsConfig {
|
||||
settingSources?: string[];
|
||||
allowedTools?: string[];
|
||||
disallowedTools?: string[];
|
||||
tools?: AgentToolsConfig;
|
||||
betas?: string[];
|
||||
maxThinkingTokens?: number;
|
||||
maxTurns?: number;
|
||||
maxBudgetUsd?: number;
|
||||
mcpServers?: Record<string, unknown>;
|
||||
outputFormat?: Record<string, unknown>;
|
||||
enableFileCheckpointing?: boolean;
|
||||
sandbox?: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached management information from Claude SDK.
|
||||
*/
|
||||
export interface AgentManagementInfo {
|
||||
tools?: string[];
|
||||
agents?: string[];
|
||||
plugins?: Array<{ name: string; path?: string }>;
|
||||
skills?: string[];
|
||||
mcpServers?: Array<{ name: string; status: string }>;
|
||||
slashCommands?: string[];
|
||||
model?: string;
|
||||
permissionMode?: string;
|
||||
cwd?: string;
|
||||
outputStyle?: string;
|
||||
betas?: string[];
|
||||
claudeCodeVersion?: string;
|
||||
apiKeySource?: string;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent session - represents an independent conversation within a project.
|
||||
*/
|
||||
export interface AgentSession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
engineName: AgentCliPreference;
|
||||
engineSessionId?: string;
|
||||
name?: string;
|
||||
/** Preview text from first user message, for display in session list */
|
||||
preview?: string;
|
||||
model?: string;
|
||||
permissionMode: string;
|
||||
allowDangerouslySkipPermissions: boolean;
|
||||
systemPromptConfig?: AgentSystemPromptConfig;
|
||||
optionsConfig?: AgentSessionOptionsConfig;
|
||||
managementInfo?: AgentManagementInfo;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a new session.
|
||||
*/
|
||||
export interface CreateAgentSessionInput {
|
||||
engineName: AgentCliPreference;
|
||||
name?: string;
|
||||
model?: string;
|
||||
permissionMode?: string;
|
||||
allowDangerouslySkipPermissions?: boolean;
|
||||
systemPromptConfig?: AgentSystemPromptConfig;
|
||||
optionsConfig?: AgentSessionOptionsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for updating a session.
|
||||
*/
|
||||
export interface UpdateAgentSessionInput {
|
||||
name?: string | null;
|
||||
model?: string | null;
|
||||
permissionMode?: string | null;
|
||||
allowDangerouslySkipPermissions?: boolean | null;
|
||||
systemPromptConfig?: AgentSystemPromptConfig | null;
|
||||
optionsConfig?: AgentSessionOptionsConfig | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Stored Message (for persistence)
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
**# Claude Agent SDK 能力对齐分析文档**
|
||||
|
||||
> 本文档记录了 AgentChat ClaudeEngine 与 Claude Agent SDK 0.1.69 的能力对比分析,以及与 other/Claudable 实现的功能差异。
|
||||
|
||||
**## 1. 概述**
|
||||
|
||||
**### 1.1 背景**
|
||||
|
||||
当前 AgentChat 中的 ClaudeEngine 已经可以正常进行会话,但需要确保:
|
||||
|
||||
1. 100% 覆盖 other/Claudable 中的 Claude 相关能力
|
||||
|
||||
2. 支持 Claude Agent SDK 的所有核心特性
|
||||
|
||||
3. 支持 Claude 的管理能力(MCP servers、models、commands、account 等)
|
||||
|
||||
**### 1.2 分析范围**
|
||||
|
||||
- **AgentChat ClaudeEngine**: `app/native-server/src/agent/engines/claude.ts`
|
||||
|
||||
- **Claudable Claude 实现**: `other/Claudable/lib/services/cli/claude.ts`
|
||||
|
||||
- **Claude Agent SDK**: `@anthropic-ai/claude-agent-sdk@0.1.69`
|
||||
|
||||
**### 1.3 当前状态**
|
||||
|
||||
| 项目 | SDK 版本 | 实现文件 |
|
||||
|
||||
|-----|---------|---------|
|
||||
|
||||
| AgentChat | 0.1.69 | `app/native-server/src/agent/engines/claude.ts:36` |
|
||||
|
||||
| Claudable | 0.1.22 | `other/Claudable/lib/services/cli/claude.ts:563` |
|
||||
|
||||
---
|
||||
|
||||
**## 2. 关键决策**
|
||||
|
||||
| 决策项 | 结论 | 说明 |
|
||||
|
||||
|-------|-----|------|
|
||||
|
||||
| 覆盖口径 | 能力级对齐 | 功能等价即可,不要求字段名完全一致 |
|
||||
|
||||
| SDK 版本 | 0.1.69 | 以 AgentChat 当前使用版本为准 |
|
||||
|
||||
| 路由粒度 | sessionId | 保持 sessionId,支持一个项目多个 session |
|
||||
|
||||
| 架构方案 | 每次 act 创建新 query + resume | 非常驻 Query,简单且满足需求 |
|
||||
|
||||
| 管理能力 | act 内自动采集缓存 | 避免额外启动进程 |
|
||||
|
||||
| 权限模式 | 可配置 | UI 选项让用户选择 |
|
||||
|
||||
---
|
||||
|
||||
**## 3. SDK Options 完整对比表**
|
||||
|
||||
以下是 Claude Agent SDK 0.1.69 的 `Options` 接口(定义于 `agentSdkTypes.d.ts:685`)与当前 AgentChat ClaudeEngine 的对比。
|
||||
|
||||
**### 3.1 已实现字段**
|
||||
|
||||
| 字段 | SDK 类型 | AgentChat 实现 | 代码位置 |
|
||||
|
||||
|-----|---------|---------------|---------|
|
||||
|
||||
| `cwd` | string | 已实现 | `claude.ts:391` |
|
||||
|
||||
| `additionalDirectories` | string[] | Hard-coded 为 [cwd] | `claude.ts:392` |
|
||||
|
||||
| `model` | string | 已实现(请求/项目/默认) | `claude.ts:393` |
|
||||
|
||||
| `includePartialMessages` | boolean | 已启用 (true) | `claude.ts:399` |
|
||||
|
||||
| `env` | Record<string, string> | 已实现(CCR 支持) | `claude.ts:406` |
|
||||
|
||||
| `stderr` | callback | 已实现 | `claude.ts:407` |
|
||||
|
||||
| `executable` | string | 传了 process.execPath | `claude.ts:401` |
|
||||
|
||||
| `resume` | string | 部分实现(project 级别) | `claude.ts:420` |
|
||||
|
||||
**### 3.2 Hard-coded 字段(需改为可配置)**
|
||||
|
||||
| 字段 | 当前值 | 问题 | 优先级 |
|
||||
|
||||
|-----|-------|------|-------|
|
||||
|
||||
| `permissionMode` | 'bypassPermissions' | 强制绕过权限检查,存在安全风险 | P0 |
|
||||
|
||||
| `allowDangerouslySkipPermissions` | true | 与 permissionMode 联动,强制开启 | P0 |
|
||||
|
||||
**### 3.3 未实现字段**
|
||||
|
||||
| 字段 | SDK 类型 | 功能说明 | 优先级 |
|
||||
|
||||
|-----|---------|---------|-------|
|
||||
|
||||
| `abortController` | AbortController | SDK 原生取消支持 | P0 |
|
||||
|
||||
| `settingSources` | string[] | 加载 CLAUDE.md 等配置 | P0 |
|
||||
|
||||
| `systemPrompt` | string | 自定义系统提示词 | P1 |
|
||||
|
||||
| `agents` | AgentDefinition[] | 自定义子代理 | P1 |
|
||||
|
||||
| `allowedTools` | string[] | 工具白名单 | P1 |
|
||||
|
||||
| `disallowedTools` | string[] | 工具黑名单 | P1 |
|
||||
|
||||
| `tools` | string[] | 指定可用工具 | P1 |
|
||||
|
||||
| `betas` | string[] | Beta 特性(如 1M context) | P1 |
|
||||
|
||||
| `hooks` | HooksConfig | Pre/Post 工具钩子 | P1 |
|
||||
|
||||
| `maxThinkingTokens` | number | 思考 token 上限 | P1 |
|
||||
|
||||
| `maxTurns` | number | 最大对话轮数 | P1 |
|
||||
|
||||
| `maxBudgetUsd` | number | 成本上限 | P1 |
|
||||
|
||||
| `mcpServers` | McpServerConfig[] | SDK 管理的 MCP 服务器 | P1 |
|
||||
|
||||
| `outputFormat` | OutputFormat | 结构化 JSON 输出 | P1 |
|
||||
|
||||
| `enableFileCheckpointing` | boolean | 文件检查点(支持回滚) | P1 |
|
||||
|
||||
| `sandbox` | SandboxConfig | 沙箱配置 | P1 |
|
||||
|
||||
| `canUseTool` | callback | 自定义权限处理 | P2 |
|
||||
|
||||
| `continue` | boolean | 继续最近会话 | P2 |
|
||||
|
||||
| `executableArgs` | string[] | 运行时参数 | P2 |
|
||||
|
||||
| `extraArgs` | string[] | CLI 透传参数 | P2 |
|
||||
|
||||
| `fallbackModel` | string | 备用模型 | P2 |
|
||||
|
||||
| `forkSession` | boolean | 恢复时 fork | P2 |
|
||||
|
||||
| `persistSession` | boolean | 会话持久化 | P2 |
|
||||
|
||||
| `pathToClaudeCodeExecutable` | string | 自定义可执行路径 | P2 |
|
||||
|
||||
| `permissionPromptToolName` | string | 权限提示路由 | P2 |
|
||||
|
||||
| `plugins` | string[] | 本地插件 | P2 |
|
||||
|
||||
| `resumeSessionAt` | string | 从指定消息恢复 | P2 |
|
||||
|
||||
| `strictMcpConfig` | boolean | 严格 MCP 校验 | P2 |
|
||||
|
||||
| `spawnClaudeCodeProcess` | callback | 自定义进程拉起 | P2 |
|
||||
|
||||
**### 3.4 无效字段(需移除)**
|
||||
|
||||
| 字段 | 问题 | 说明 |
|
||||
|
||||
|-----|------|------|
|
||||
|
||||
| `images` | SDK 0.1.69 不支持此字段 | 当前代码传了但会被 SDK 忽略,`claude.ts:400` |
|
||||
|
||||
**### 3.5 类型错误字段**
|
||||
|
||||
| 字段 | 当前传值 | SDK 期望值 | 说明 |
|
||||
|
||||
|-----|---------|----------|------|
|
||||
|
||||
| `executable` | `process.execPath` (路径) | `'node' \| 'bun' \| 'deno'` | 应传运行时名称而非路径 |
|
||||
|
||||
---
|
||||
|
||||
**## 4. SDK 消息类型处理对比**
|
||||
|
||||
Claude Agent SDK 定义了多种消息类型(`SDKMessage` 联合类型,`agentSdkTypes.d.ts:400-513`)。
|
||||
|
||||
**### 4.1 已处理消息类型**
|
||||
|
||||
| 消息类型 | 处理状态 | 代码位置 | 说明 |
|
||||
|
||||
|---------|---------|---------|------|
|
||||
|
||||
| `stream_event` | 部分处理 | `claude.ts:439-600` | 处理了 message*start/content_block*\*/message_stop |
|
||||
|
||||
| `assistant` | 已处理 | `claude.ts:601-670` | 作为 fallback |
|
||||
|
||||
| `result` | 部分处理 | `claude.ts:671-686` | 处理了 usage/error |
|
||||
|
||||
| `system:init` | 部分处理 | `claude.ts:687-704` | 仅提取 session_id |
|
||||
|
||||
**### 4.2 未处理消息类型**
|
||||
|
||||
| 消息类型 | SDK 定义 | 功能 | 优先级 |
|
||||
|
||||
|---------|---------|-----|-------|
|
||||
|
||||
| `auth_status` | SDKAuthStatusMessage | 认证状态(登录引导) | P0 |
|
||||
|
||||
| `tool_progress` | SDKToolProgressMessage | 工具执行进度 | P1 |
|
||||
|
||||
| `system:status` | SDKSystemMessage | 系统状态(如 compacting) | P2 |
|
||||
|
||||
| `system:compact_boundary` | SDKSystemMessage | 上下文压缩边界 | P2 |
|
||||
|
||||
| `system:hook_response` | SDKSystemMessage | Hook 执行结果 | P2 |
|
||||
|
||||
| `user` | SDKUserMessage | 用户消息回显 | P2 |
|
||||
|
||||
**### 4.3 result 消息未解析字段**
|
||||
|
||||
| 字段 | 功能 | 优先级 |
|
||||
|
||||
|-----|------|-------|
|
||||
|
||||
| `structured_output` | 结构化输出结果(配合 outputFormat) | P1 |
|
||||
|
||||
| `permission_denials` | 权限拒绝记录 | P1 |
|
||||
|
||||
---
|
||||
|
||||
**## 5. Query 管理方法实现状态**
|
||||
|
||||
Claude Agent SDK 的 `Query` 接口(`agentSdkTypes.d.ts:514-589`)提供了管理和控制方法。
|
||||
|
||||
**### 5.1 数据查询方法**
|
||||
|
||||
| 方法 | 功能 | AgentChat 状态 | 数据来源 | 优先级 |
|
||||
|
||||
|-----|-----|---------------|---------|-------|
|
||||
|
||||
| `supportedCommands()` | 获取 slash commands | 未实现 | initialize 缓存 | P0 |
|
||||
|
||||
| `supportedModels()` | 获取可用模型列表 | 未实现 | initialize 缓存 | P0 |
|
||||
|
||||
| `accountInfo()` | 获取账号信息 | 未实现 | initialize 缓存 | P0 |
|
||||
|
||||
| `mcpServerStatus()` | MCP 服务器状态 | 未实现 | 需活进程(控制请求) | P0 |
|
||||
|
||||
**### 5.2 控制方法**
|
||||
|
||||
| 方法 | 功能 | AgentChat 状态 | 优先级 |
|
||||
|
||||
|-----|-----|---------------|-------|
|
||||
|
||||
| `interrupt()` | 中断执行 | 未实现 | P0 |
|
||||
|
||||
| `rewindFiles(userMessageId)` | 文件回滚 | 未实现 | P1 |
|
||||
|
||||
| `setPermissionMode(mode)` | 运行时改权限 | 未实现 | P2 |
|
||||
|
||||
| `setModel(model)` | 运行时改模型 | 未实现 | P2 |
|
||||
|
||||
| `setMaxThinkingTokens(n)` | 运行时改思考上限 | 未实现 | P2 |
|
||||
|
||||
| `streamInput(stream)` | 流式输入 | 未实现 | P1 |
|
||||
|
||||
**### 5.3 数据来源说明**
|
||||
|
||||
SDK 的管理方法分为两类:
|
||||
|
||||
1. **读 initialize 缓存**(无额外开销):
|
||||
|
||||
- `supportedCommands()` → `sdk.mjs:8059-8061`
|
||||
|
||||
- `supportedModels()` → `sdk.mjs:8062-8064`
|
||||
|
||||
- `accountInfo()` → `sdk.mjs:8072-8074`
|
||||
|
||||
2. **发送控制请求**(需活进程):
|
||||
|
||||
- `mcpServerStatus()` → 发送 `mcp_status` 请求,`sdk.mjs:8065-8071`
|
||||
|
||||
---
|
||||
|
||||
**## 6. Claudable vs AgentChat 功能对比**
|
||||
|
||||
**### 6.1 功能覆盖对比**
|
||||
|
||||
| 功能 | Claudable | AgentChat | 差异说明 | 优先级 |
|
||||
|
||||
|-----|------|-----------|---------|-------|
|
||||
|
||||
| WebSocket + SSE 双通道 | ✓ | SSE only | Claudable 有 WS 优先 + SSE fallback | P1 |
|
||||
|
||||
| systemPrompt | ✓ | ✗ | Claudable 有预设的 Next.js 系统提示 | P1 |
|
||||
|
||||
| maxOutputTokens | ✓ | ✗ | Claudable 通过 env 设置 | P1 |
|
||||
|
||||
| 工具使用立即推送 | ✓ | ✗ | AgentChat 延迟到 content_block_stop | P1 |
|
||||
|
||||
| UserRequest 追踪模型 | ✓ | ✗ | Claudable 有 running/completed/failed 状态 | P2 |
|
||||
|
||||
| isOptimistic 乐观更新 | ✓ | ✗ | Claudable 支持乐观消息替换 | P2 |
|
||||
|
||||
| Thinking 标签渲染 | ✓ | ✗ | Claudable 解析 `<thinking>` 做折叠 | P2 |
|
||||
|
||||
| ready 状态事件 | ✓ | ✗ | Claudable 发送 ready 状态 | P2 |
|
||||
|
||||
**### 6.2 Claudable 关键实现参考**
|
||||
|
||||
| 功能 | Claudable 代码位置 |
|
||||
|
||||
|-----|-------------|
|
||||
|
||||
| Claude 执行入口 | `other/Claudable/lib/services/cli/claude.ts:563` |
|
||||
|
||||
| systemPrompt 设置 | `other/Claudable/lib/services/cli/claude.ts:726-736` |
|
||||
|
||||
| maxOutputTokens | `other/Claudable/lib/services/cli/claude.ts:582-586` |
|
||||
|
||||
| WebSocket + SSE | `other/Claudable/components/chat/ChatLog.tsx:1509-1600` |
|
||||
|
||||
| Thinking 渲染 | `other/Claudable/components/chat/ChatLog.tsx:2244-2290` |
|
||||
|
||||
| isOptimistic 类型 | `other/Claudable/types/realtime.ts:7-24` |
|
||||
|
||||
---
|
||||
|
||||
**## 7. 管理能力数据采集方案**
|
||||
|
||||
**### 7.1 推荐方案:act 内自动采集 + 缓存**
|
||||
|
||||
为避免每次管理查询都启动新进程,推荐在每次 act 的 query 过程中自动采集管理信息。
|
||||
|
||||
**#### 7.1.1 从 **`system:init`** 获取(零额外开销)**
|
||||
|
||||
`system:init` 消息包含丰富的管理信息(`agentSdkTypes.d.ts:421-456`):
|
||||
|
||||
```typescript
|
||||
interface SystemInitMessage {
|
||||
session_id: string; // 会话 ID
|
||||
|
||||
agents?: string[]; // 可用 agents 列表
|
||||
|
||||
tools: string[]; // 可用工具列表
|
||||
|
||||
mcp_servers: { name: string; status: string }[]; // MCP 服务器快照
|
||||
|
||||
slash_commands: string[]; // slash 命令名称列表
|
||||
|
||||
plugins?: string[]; // 已加载插件
|
||||
|
||||
skills?: string[]; // 已加载 skills
|
||||
|
||||
model: string; // 当前模型
|
||||
|
||||
permissionMode: string; // 权限模式
|
||||
|
||||
cwd: string; // 工作目录
|
||||
|
||||
output_style?: string; // 输出风格
|
||||
|
||||
betas?: string[]; // 启用的 beta 特性
|
||||
|
||||
claude_code_version?: string; // Claude Code 版本
|
||||
|
||||
apiKeySource?: string; // API Key 来源
|
||||
}
|
||||
```
|
||||
|
||||
**#### 7.1.2 从 Query 方法获取(无额外进程)**
|
||||
|
||||
这些方法读取 Query 构造时的 initialize 缓存,不发送额外请求:
|
||||
|
||||
```typescript
|
||||
// 完整模型列表(含 displayName/description)
|
||||
|
||||
const models = await query.supportedModels();
|
||||
|
||||
// 完整命令列表(含 description/argumentHint)
|
||||
|
||||
const commands = await query.supportedCommands();
|
||||
|
||||
// 账号信息(email/org/subscription)
|
||||
|
||||
const account = await query.accountInfo();
|
||||
```
|
||||
|
||||
**#### 7.1.3 mcpServerStatus 特殊处理**
|
||||
|
||||
`mcpServerStatus()` 需要发送控制请求,必须在活进程内调用:
|
||||
|
||||
- **默认**:展示 `system:init.mcp_servers` 快照
|
||||
|
||||
- **按需**:提供"刷新"按钮触发实时查询
|
||||
|
||||
**### 7.2 缓存策略**
|
||||
|
||||
| 配置项 | 建议值 | 说明 |
|
||||
|
||||
|-------|-------|------|
|
||||
|
||||
| 缓存级别 | project 或 session | 根据业务需求选择 |
|
||||
|
||||
| TTL | 5 分钟 | 可配置 |
|
||||
|
||||
| 失效条件 | 新 act 执行时自动刷新 | 保证数据新鲜 |
|
||||
|
||||
---
|
||||
|
||||
**## 8. 数据模型设计**
|
||||
|
||||
**### 8.1 当前问题**
|
||||
|
||||
当前 `resume` 绑定在 `project.activeClaudeSessionId` 单值上(`chat-service.ts:68-81`),导致:
|
||||
|
||||
- 同一项目的多个 session 会共享同一个 Claude 会话
|
||||
|
||||
- 会话串话,消息混乱
|
||||
|
||||
**### 8.2 建议方案:新增 sessions 表**
|
||||
|
||||
```sql
|
||||
|
||||
CREATE TABLE sessions (
|
||||
|
||||
id TEXT PRIMARY KEY, -- AgentChat session ID
|
||||
|
||||
project_id TEXT NOT NULL, -- 关联项目
|
||||
|
||||
engine_name TEXT NOT NULL, -- 引擎名称(claude/codex/...)
|
||||
|
||||
engine_session_id TEXT, -- 引擎会话 ID(如 claudeSessionId)
|
||||
|
||||
name TEXT, -- 会话名称
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
|
||||
updated_at TEXT NOT NULL,
|
||||
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id)
|
||||
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_project ON sessions(project_id);
|
||||
|
||||
CREATE INDEX idx_sessions_engine ON sessions(project_id, engine_name);
|
||||
|
||||
```
|
||||
|
||||
**### 8.3 类型定义**
|
||||
|
||||
```typescript
|
||||
interface Session {
|
||||
id: string;
|
||||
|
||||
projectId: string;
|
||||
|
||||
engineName: EngineName;
|
||||
|
||||
engineSessionId?: string; // Claude sessionId / Codex sessionId 等
|
||||
|
||||
name?: string;
|
||||
|
||||
createdAt: string;
|
||||
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ClaudeManagementInfo {
|
||||
sessionId: string;
|
||||
|
||||
models: ModelInfo[];
|
||||
|
||||
commands: SlashCommand[];
|
||||
|
||||
account: AccountInfo;
|
||||
|
||||
mcpServers: McpServerStatus[];
|
||||
|
||||
tools: string[];
|
||||
|
||||
agents: string[];
|
||||
|
||||
plugins: string[];
|
||||
|
||||
skills: string[];
|
||||
|
||||
betas: string[];
|
||||
|
||||
claudeCodeVersion?: string;
|
||||
|
||||
lastUpdated: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**## 9. API 设计**
|
||||
|
||||
**### 9.1 Session CRUD API**
|
||||
|
||||
| 方法 | 路径 | 功能 |
|
||||
|
||||
|-----|-----|------|
|
||||
|
||||
| POST | `/agent/projects/:projectId/sessions` | 创建会话 |
|
||||
|
||||
| GET | `/agent/projects/:projectId/sessions` | 列出会话 |
|
||||
|
||||
| GET | `/agent/sessions/:sessionId` | 获取会话详情 |
|
||||
|
||||
| DELETE | `/agent/sessions/:sessionId` | 删除会话 |
|
||||
|
||||
**### 9.2 管理能力 API**
|
||||
|
||||
| 方法 | 路径 | 功能 |
|
||||
|
||||
|-----|-----|------|
|
||||
|
||||
| GET | `/agent/projects/:projectId/claude-info` | 获取缓存的管理信息 |
|
||||
|
||||
| POST | `/agent/projects/:projectId/claude-info/refresh` | 刷新管理信息(可选) |
|
||||
|
||||
**### 9.3 消息 API 增强**
|
||||
|
||||
| 方法 | 路径 | 变更 |
|
||||
|
||||
|-----|-----|------|
|
||||
|
||||
| GET | `/agent/projects/:projectId/messages` | 增加 `sessionId` 过滤参数 |
|
||||
|
||||
---
|
||||
|
||||
**## 10. 实施计划**
|
||||
|
||||
**### 10.1 P0 - 必须实现(阻塞性/关键风险)**
|
||||
|
||||
| # | 任务 | 关键文件 |
|
||||
|
||||
|---|-----|---------|
|
||||
|
||||
| 1 | Session 数据模型重构 | `db/schema.ts`, `chat-service.ts`, `routes/agent.ts` |
|
||||
|
||||
| 2 | SDK Options 适配层 | `engines/claude.ts:390-416`, `engines/types.ts` |
|
||||
|
||||
| 3 | 取消执行可靠性(abortController) | `engines/claude.ts:424`, `chat-service.ts:230` |
|
||||
|
||||
| 4 | 认证状态可见性(auth_status) | `engines/claude.ts:439` |
|
||||
|
||||
| 5 | Claude 管理能力(act 内采集) | `engines/claude.ts:687`, `routes/agent.ts` |
|
||||
|
||||
| 6 | system:init 完整处理 | `engines/claude.ts:687-704` |
|
||||
|
||||
**### 10.2 P1 - 重要增强**
|
||||
|
||||
| # | 任务 | 说明 |
|
||||
|
||||
|---|-----|------|
|
||||
|
||||
| 7 | UI 可配置 Options | systemPrompt, env, betas, maxTokens, outputFormat, tools, mcpServers, sandbox |
|
||||
|
||||
| 8 | 事件处理增强 | tool_progress, system:status/compact_boundary/hook_response |
|
||||
|
||||
| 9 | agents/hooks 配置支持 | 声明式 subagents,预置 hooks 策略 |
|
||||
|
||||
| 10 | enableFileCheckpointing + rewindFiles | 文件检查点和回滚 |
|
||||
|
||||
| 11 | MCP 状态刷新 | init 快照 + 按需刷新 |
|
||||
|
||||
**### 10.3 P2 - 可延后**
|
||||
|
||||
| # | 任务 |
|
||||
|
||||
|---|-----|
|
||||
|
||||
| 12 | WebSocket 主通道(WS 优先 + SSE fallback) |
|
||||
|
||||
| 13 | Thinking 标签渲染(`<thinking>` 折叠) |
|
||||
|
||||
| 14 | 高级 Options(plugins, forkSession, resumeSessionAt, spawnClaudeCodeProcess, canUseTool) |
|
||||
|
||||
| 15 | 乐观消息(isOptimistic) |
|
||||
|
||||
**### 10.4 实施顺序建议**
|
||||
|
||||
```
|
||||
|
||||
阶段 1:数据模型基础
|
||||
|
||||
├── 新增 sessions 表和 API
|
||||
|
||||
├── 重构 claudeSessionId 到 session 维度
|
||||
|
||||
└── SDK Options 适配层
|
||||
|
||||
阶段 2:可靠性和管理
|
||||
|
||||
├── 接入 SDK abortController
|
||||
|
||||
├── 处理 auth_status
|
||||
|
||||
├── 完整处理 system:init
|
||||
|
||||
└── 新增管理 API
|
||||
|
||||
阶段 3:前端适配
|
||||
|
||||
├── Session 列表/切换 UI
|
||||
|
||||
├── 管理面板(models/commands/mcp/account)
|
||||
|
||||
└── 按 session 加载历史
|
||||
|
||||
阶段 4:P1 批量增强
|
||||
|
||||
├── UI 可配置 Options
|
||||
|
||||
├── 事件处理增强
|
||||
|
||||
└── 文件检查点
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**## 11. 风险点和注意事项**
|
||||
|
||||
**### 11.1 行为变更风险**
|
||||
|
||||
| 变更 | 影响 | 建议 |
|
||||
|
||||
|-----|------|------|
|
||||
|
||||
| `settingSources` 从 `[]` 改为含 `'project'` | 会开始加载 CLAUDE.md | 这是必要的对齐,需告知用户 |
|
||||
|
||||
| `permissionMode` 从 `bypassPermissions` 改为 `default` | 危险操作需确认 | 提供 UI 选项,默认安全 |
|
||||
|
||||
**### 11.2 兼容性风险**
|
||||
|
||||
| 问题 | 建议 |
|
||||
|
||||
|-----|------|
|
||||
|
||||
| 旧 `activeClaudeSessionId` 迁移 | 作为无 session 映射时的 fallback,逐步废弃 |
|
||||
|
||||
| `images` 字段移除 | 需寻找 SDK 支持的图片输入方式或移除功能 |
|
||||
|
||||
**### 11.3 性能风险**
|
||||
|
||||
| 场景 | 风险 | 建议 |
|
||||
|
||||
|-----|------|------|
|
||||
|
||||
| 管理信息频繁查询 | 频繁启动进程 | 使用 act 内采集 + 缓存方案 |
|
||||
|
||||
| mcpServerStatus 实时刷新 | 需要活进程 | 默认展示快照,按需刷新 |
|
||||
|
||||
---
|
||||
|
||||
**## 12. 验收标准**
|
||||
|
||||
**### 12.1 功能验收**
|
||||
|
||||
- [ ] 同一项目可以创建多个独立的 Claude 会话
|
||||
|
||||
- [ ] 每个会话有独立的 claudeSessionId 和消息历史
|
||||
|
||||
- [ ] 管理面板可以展示:支持的模型、slash commands、MCP servers 状态、账号信息
|
||||
|
||||
- [ ] 取消请求后 Claude Code 子进程确实退出
|
||||
|
||||
- [ ] `CLAUDE.md` 和 `.claude/settings.json` 能被正确加载
|
||||
|
||||
- [ ] 认证问题有明确的 UI 提示
|
||||
|
||||
**### 12.2 兼容性验收**
|
||||
|
||||
- [ ] 现有会话可以正常继续
|
||||
|
||||
- [ ] 旧数据迁移无损
|
||||
|
||||
---
|
||||
|
||||
**## 13. 附录**
|
||||
|
||||
**### 13.1 原始需求**
|
||||
|
||||
> 现在的AgentChat,对于ClaudeEngine,当前已经可以正常会话了,现在需要你和codex一起深入去检查一下,看other/Claudable里实现的claude相关的能力,在我们当前的AgentChat里是否已经全部100%覆盖了。我的目标是,我这里支持的ClaudeEngine的相关能力,必须>=other/Claudable里的,并且你和codex还要仔细查看claude agent sdk里面的所有特性,我希望都能在我的这个项目里能支持
|
||||
|
||||
**### 13.2 用户澄清**
|
||||
|
||||
1. **覆盖口径**:能力级对齐,以 SDK 0.1.69 为准
|
||||
|
||||
2. **SDK 特性**:可配置参数需支持,回调型特性通过配置启用
|
||||
|
||||
3. **管理能力**:需要支持 Claude 的管理命令(MCP、agents、models 等)
|
||||
|
||||
4. **路由粒度**:sessionId,支持一个项目多个 session
|
||||
|
||||
5. **权限模式**:可配置
|
||||
|
||||
**### 13.3 关键文件索引**
|
||||
|
||||
| 文件 | 说明 |
|
||||
|
||||
|-----|------|
|
||||
|
||||
| `app/native-server/src/agent/engines/claude.ts` | ClaudeEngine 核心实现 |
|
||||
|
||||
| `app/native-server/src/agent/engines/types.ts` | 引擎接口定义 |
|
||||
|
||||
| `app/native-server/src/agent/chat-service.ts` | 会话服务 |
|
||||
|
||||
| `app/native-server/src/agent/db/schema.ts` | 数据库 Schema |
|
||||
|
||||
| `app/native-server/src/server/routes/agent.ts` | API 路由 |
|
||||
|
||||
| `packages/shared/src/agent-types.ts` | 共享类型 |
|
||||
|
||||
| `other/Claudable/lib/services/cli/claude.ts` | Claudable Claude 实现参考 |
|
||||
|
||||
| `node_modules/.../agentSdkTypes.d.ts` | SDK 类型定义 |
|
||||
|
||||
---
|
||||
|
||||
_文档生成日期:2025-12-16_
|
||||
|
||||
_分析工具:Claude Opus 4.5 + Codex_
|
||||
Reference in New Issue
Block a user