hangwin--mcp-chrome
574 行
18 KiB
TypeScript
574 行
18 KiB
TypeScript
/**
|
|
* Composable for managing Agent Projects.
|
|
* Handles project CRUD, selection, and persistence.
|
|
*/
|
|
import { ref, computed, watch } from 'vue';
|
|
import type { AgentProject, AgentStoredMessage } from 'chrome-mcp-shared';
|
|
|
|
const STORAGE_KEY_SELECTED_PROJECT = 'agent-selected-project-id';
|
|
|
|
interface PathValidationResult {
|
|
valid: boolean;
|
|
absolute: string;
|
|
exists: boolean;
|
|
needsCreation: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Normalize path for comparison (handle trailing slashes and separators).
|
|
*/
|
|
function normalizePathForComparison(path: string): string {
|
|
// Remove trailing slashes and normalize separators
|
|
return path
|
|
.trim()
|
|
.replace(/[/\\]+$/, '')
|
|
.replace(/\\/g, '/')
|
|
.toLowerCase();
|
|
}
|
|
|
|
export interface UseAgentProjectsOptions {
|
|
getServerPort: () => number | null;
|
|
ensureServer: () => Promise<boolean>;
|
|
onHistoryLoaded?: (messages: AgentStoredMessage[]) => void;
|
|
}
|
|
|
|
export function useAgentProjects(options: UseAgentProjectsOptions) {
|
|
// State
|
|
const projects = ref<AgentProject[]>([]);
|
|
const selectedProjectId = ref<string>('');
|
|
const isLoadingProjects = ref(false);
|
|
const showCreateProject = ref(false);
|
|
const newProjectName = ref('');
|
|
const newProjectRootPath = ref('');
|
|
const isCreatingProject = ref(false);
|
|
const projectError = ref<string | null>(null);
|
|
|
|
// Computed
|
|
const selectedProject = computed(() => {
|
|
return projects.value.find((p) => p.id === selectedProjectId.value) || null;
|
|
});
|
|
|
|
const canCreateProject = computed(() => {
|
|
return newProjectName.value.trim().length > 0 && newProjectRootPath.value.trim().length > 0;
|
|
});
|
|
|
|
// Load selected project from storage
|
|
async function loadSelectedProjectId(): Promise<void> {
|
|
try {
|
|
const result = await chrome.storage.local.get(STORAGE_KEY_SELECTED_PROJECT);
|
|
if (result[STORAGE_KEY_SELECTED_PROJECT]) {
|
|
selectedProjectId.value = result[STORAGE_KEY_SELECTED_PROJECT];
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load selected project ID:', error);
|
|
}
|
|
}
|
|
|
|
// Save selected project to storage
|
|
async function saveSelectedProjectId(): Promise<void> {
|
|
try {
|
|
await chrome.storage.local.set({
|
|
[STORAGE_KEY_SELECTED_PROJECT]: selectedProjectId.value,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to save selected project ID:', error);
|
|
}
|
|
}
|
|
|
|
// Fetch projects from server
|
|
async function fetchProjects(): Promise<void> {
|
|
const serverPort = options.getServerPort();
|
|
if (!serverPort) return;
|
|
|
|
isLoadingProjects.value = true;
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/projects`;
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
projects.value = data.projects || [];
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch projects:', error);
|
|
} finally {
|
|
isLoadingProjects.value = false;
|
|
}
|
|
}
|
|
|
|
// Refresh projects
|
|
async function refreshProjects(): Promise<void> {
|
|
const ready = await options.ensureServer();
|
|
if (!ready) return;
|
|
await fetchProjects();
|
|
}
|
|
|
|
// Track pending history load with nonce to prevent A→B→A race conditions
|
|
let historyLoadNonce = 0;
|
|
|
|
/**
|
|
* Load chat history for a project with race-condition protection.
|
|
* Uses a nonce to handle A→B→A scenarios.
|
|
*/
|
|
async function loadChatHistory(projectId: string): Promise<void> {
|
|
const serverPort = options.getServerPort();
|
|
if (!serverPort || !projectId) return;
|
|
|
|
// Increment nonce - any subsequent load will invalidate this one
|
|
const myNonce = ++historyLoadNonce;
|
|
|
|
const isStillValid = (): boolean => {
|
|
return myNonce === historyLoadNonce && selectedProjectId.value === projectId;
|
|
};
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/chat/${encodeURIComponent(projectId)}/messages?limit=100`;
|
|
const response = await fetch(url);
|
|
|
|
if (!isStillValid()) return;
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
|
|
if (!isStillValid()) return;
|
|
|
|
// Server returns { success, data: messages[], totalCount, pagination }
|
|
const stored = result.data || [];
|
|
options.onHistoryLoaded?.(stored);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load chat history:', error);
|
|
}
|
|
}
|
|
|
|
// Validate path before creating project
|
|
async function validatePath(rootPath: string): Promise<PathValidationResult | null> {
|
|
const serverPort = options.getServerPort();
|
|
if (!serverPort) return null;
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/projects/validate-path`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ rootPath }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(text || `Validation failed: HTTP ${response.status}`);
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Failed to validate path:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Create project
|
|
async function createProject(): Promise<AgentProject | null> {
|
|
const name = newProjectName.value.trim();
|
|
const rootPath = newProjectRootPath.value.trim();
|
|
if (!name || !rootPath) return null;
|
|
|
|
const ready = await options.ensureServer();
|
|
const serverPort = options.getServerPort();
|
|
if (!ready || !serverPort) {
|
|
projectError.value = 'Agent server is not available.';
|
|
return null;
|
|
}
|
|
|
|
isCreatingProject.value = true;
|
|
projectError.value = null;
|
|
|
|
try {
|
|
// Step 1: Validate the path
|
|
const validation = await validatePath(rootPath);
|
|
if (!validation) {
|
|
projectError.value = 'Failed to validate path';
|
|
return null;
|
|
}
|
|
|
|
if (!validation.valid) {
|
|
projectError.value = validation.error || 'Invalid path';
|
|
return null;
|
|
}
|
|
|
|
// Step 2: If directory doesn't exist, ask user for confirmation
|
|
let allowCreate = false;
|
|
if (validation.needsCreation) {
|
|
const confirmed = confirm(
|
|
`目录 "${validation.absolute}" 不存在,是否创建?\n\nThe directory "${validation.absolute}" does not exist. Create it?`,
|
|
);
|
|
if (!confirmed) {
|
|
return null;
|
|
}
|
|
allowCreate = true;
|
|
}
|
|
|
|
// Step 3: Create the project
|
|
const url = `http://127.0.0.1:${serverPort}/agent/projects`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, rootPath, allowCreate }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(text || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const project = payload?.project as AgentProject | undefined;
|
|
|
|
if (project?.id) {
|
|
// Update local state
|
|
const others = projects.value.filter((p) => p.id !== project.id);
|
|
projects.value = [...others, project];
|
|
selectedProjectId.value = project.id;
|
|
await saveSelectedProjectId();
|
|
await loadChatHistory(project.id);
|
|
|
|
// Clear form
|
|
newProjectName.value = '';
|
|
newProjectRootPath.value = '';
|
|
showCreateProject.value = false;
|
|
|
|
return project;
|
|
} else {
|
|
projectError.value = 'Project created but response is invalid.';
|
|
return null;
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error('Failed to create project:', error);
|
|
projectError.value = error instanceof Error ? error.message : 'Failed to create project.';
|
|
return null;
|
|
} finally {
|
|
isCreatingProject.value = false;
|
|
}
|
|
}
|
|
|
|
// Toggle create project form
|
|
function toggleCreateProject(): void {
|
|
showCreateProject.value = !showCreateProject.value;
|
|
if (!showCreateProject.value) {
|
|
newProjectName.value = '';
|
|
newProjectRootPath.value = '';
|
|
projectError.value = null;
|
|
}
|
|
}
|
|
|
|
// Get default project root path for a project name
|
|
async function getDefaultProjectRoot(projectName: string): Promise<string | null> {
|
|
const serverPort = options.getServerPort();
|
|
if (!serverPort || !projectName.trim()) return null;
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/projects/default-root`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ projectName: projectName.trim() }),
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
return data.path || null;
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Failed to get default project root:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Open directory picker dialog
|
|
async function pickDirectory(): Promise<string | null> {
|
|
const ready = await options.ensureServer();
|
|
const serverPort = options.getServerPort();
|
|
if (!ready || !serverPort) {
|
|
projectError.value = 'Server not available';
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/projects/pick-directory`;
|
|
const response = await fetch(url, { method: 'POST' });
|
|
|
|
// Handle HTTP errors (e.g., 404 means server version mismatch)
|
|
if (!response.ok) {
|
|
if (response.status === 404) {
|
|
projectError.value =
|
|
'Directory picker not available. Please rebuild and restart the native server.';
|
|
} else {
|
|
projectError.value = `Server error: HTTP ${response.status}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success && data.path) {
|
|
return data.path;
|
|
} else if (data.cancelled) {
|
|
return null; // User cancelled, not an error
|
|
} else {
|
|
projectError.value = data.error || 'Failed to open directory picker';
|
|
return null;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to open directory picker:', error);
|
|
projectError.value = 'Failed to open directory picker';
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Ensure default project exists (auto-create if no projects)
|
|
async function ensureDefaultProject(): Promise<AgentProject | null> {
|
|
const ready = await options.ensureServer();
|
|
const serverPort = options.getServerPort();
|
|
if (!ready || !serverPort) return null;
|
|
|
|
try {
|
|
// First fetch current projects
|
|
await fetchProjects();
|
|
|
|
// If there are already projects, no need to create default
|
|
if (projects.value.length > 0) {
|
|
return null;
|
|
}
|
|
|
|
// Get default workspace directory from server
|
|
const defaultRootUrl = `http://127.0.0.1:${serverPort}/agent/projects/default-root`;
|
|
const defaultRootResponse = await fetch(defaultRootUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ projectName: 'default' }),
|
|
});
|
|
const defaultRootData = await defaultRootResponse.json();
|
|
const defaultRoot = defaultRootData.path;
|
|
|
|
if (!defaultRoot) {
|
|
console.error('Failed to get default project root');
|
|
return null;
|
|
}
|
|
|
|
// Create default project
|
|
const createUrl = `http://127.0.0.1:${serverPort}/agent/projects`;
|
|
const createResponse = await fetch(createUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: 'Default',
|
|
rootPath: defaultRoot,
|
|
allowCreate: true,
|
|
}),
|
|
});
|
|
|
|
if (!createResponse.ok) {
|
|
const text = await createResponse.text().catch(() => '');
|
|
console.error('Failed to create default project:', text);
|
|
return null;
|
|
}
|
|
|
|
const payload = await createResponse.json();
|
|
const project = payload?.project as AgentProject | undefined;
|
|
|
|
if (project?.id) {
|
|
projects.value = [project];
|
|
selectedProjectId.value = project.id;
|
|
await saveSelectedProjectId();
|
|
return project;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Failed to ensure default project:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Create project from a directory path (used when user picks a directory)
|
|
async function createProjectFromPath(
|
|
rootPath: string,
|
|
name: string,
|
|
): Promise<AgentProject | null> {
|
|
const ready = await options.ensureServer();
|
|
const serverPort = options.getServerPort();
|
|
if (!ready || !serverPort) {
|
|
projectError.value = 'Agent server is not available.';
|
|
return null;
|
|
}
|
|
|
|
projectError.value = null;
|
|
|
|
try {
|
|
// Validate the path first
|
|
const validation = await validatePath(rootPath);
|
|
if (!validation) {
|
|
projectError.value = 'Failed to validate path';
|
|
return null;
|
|
}
|
|
|
|
if (!validation.valid) {
|
|
projectError.value = validation.error || 'Invalid path';
|
|
return null;
|
|
}
|
|
|
|
// Check if project with same path already exists
|
|
const normalizedPath = normalizePathForComparison(validation.absolute);
|
|
const existingProject = projects.value.find(
|
|
(p) => normalizePathForComparison(p.rootPath) === normalizedPath,
|
|
);
|
|
|
|
if (existingProject) {
|
|
// Project already exists - select it instead of creating a new one
|
|
const shouldSwitch = confirm(
|
|
`目录 "${validation.absolute}" 已存在对应的项目:${existingProject.name}\n\n` +
|
|
`是否切换到该项目?\n\n` +
|
|
`A project already exists for "${validation.absolute}": ${existingProject.name}\n` +
|
|
`Switch to that project?`,
|
|
);
|
|
if (shouldSwitch) {
|
|
selectedProjectId.value = existingProject.id;
|
|
await saveSelectedProjectId();
|
|
await loadChatHistory(existingProject.id);
|
|
return existingProject;
|
|
}
|
|
// User declined to switch, return null to indicate no action taken
|
|
return null;
|
|
}
|
|
|
|
// If directory doesn't exist, ask user for confirmation
|
|
let allowCreate = false;
|
|
if (validation.needsCreation) {
|
|
const confirmed = confirm(
|
|
`目录 "${validation.absolute}" 不存在,是否创建?\n\nThe directory "${validation.absolute}" does not exist. Create it?`,
|
|
);
|
|
if (!confirmed) {
|
|
return null;
|
|
}
|
|
allowCreate = true;
|
|
}
|
|
|
|
// Create the project
|
|
const url = `http://127.0.0.1:${serverPort}/agent/projects`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, rootPath, allowCreate }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(text || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const project = payload?.project as AgentProject | undefined;
|
|
|
|
if (project?.id) {
|
|
// Update local state
|
|
const others = projects.value.filter((p) => p.id !== project.id);
|
|
projects.value = [...others, project];
|
|
selectedProjectId.value = project.id;
|
|
await saveSelectedProjectId();
|
|
await loadChatHistory(project.id);
|
|
|
|
return project;
|
|
} else {
|
|
projectError.value = 'Project created but response is invalid.';
|
|
return null;
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error('Failed to create project from path:', error);
|
|
projectError.value = error instanceof Error ? error.message : 'Failed to create project.';
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Handle project change
|
|
async function handleProjectChanged(): Promise<void> {
|
|
await saveSelectedProjectId();
|
|
if (selectedProjectId.value) {
|
|
await loadChatHistory(selectedProjectId.value);
|
|
}
|
|
}
|
|
|
|
// Save project preference (CLI, model, useCcr, enableChromeMcp)
|
|
async function saveProjectPreference(
|
|
cli?: string,
|
|
model?: string,
|
|
useCcr?: boolean,
|
|
enableChromeMcp?: boolean,
|
|
): Promise<void> {
|
|
const project = selectedProject.value;
|
|
const serverPort = options.getServerPort();
|
|
if (!project || !serverPort) return;
|
|
|
|
try {
|
|
const url = `http://127.0.0.1:${serverPort}/agent/projects`;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: project.id,
|
|
name: project.name,
|
|
rootPath: project.rootPath,
|
|
// Normalize and allow empty string (means "Auto/Default")
|
|
preferredCli: cli?.trim() ?? project.preferredCli,
|
|
selectedModel: model?.trim() ?? project.selectedModel,
|
|
useCcr: useCcr ?? project.useCcr,
|
|
enableChromeMcp: enableChromeMcp ?? project.enableChromeMcp,
|
|
}),
|
|
});
|
|
|
|
// Update local project state if successful
|
|
if (response.ok) {
|
|
const payload = await response.json();
|
|
const updatedProject = payload?.project as AgentProject | undefined;
|
|
if (updatedProject?.id) {
|
|
const index = projects.value.findIndex((p) => p.id === updatedProject.id);
|
|
if (index !== -1) {
|
|
projects.value[index] = updatedProject;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save project preference:', error);
|
|
}
|
|
}
|
|
|
|
return {
|
|
// State
|
|
projects,
|
|
selectedProjectId,
|
|
isLoadingProjects,
|
|
showCreateProject,
|
|
newProjectName,
|
|
newProjectRootPath,
|
|
isCreatingProject,
|
|
projectError,
|
|
|
|
// Computed
|
|
selectedProject,
|
|
canCreateProject,
|
|
|
|
// Methods
|
|
loadSelectedProjectId,
|
|
saveSelectedProjectId,
|
|
fetchProjects,
|
|
refreshProjects,
|
|
loadChatHistory,
|
|
createProject,
|
|
toggleCreateProject,
|
|
handleProjectChanged,
|
|
saveProjectPreference,
|
|
getDefaultProjectRoot,
|
|
pickDirectory,
|
|
ensureDefaultProject,
|
|
createProjectFromPath,
|
|
};
|
|
}
|