Merge permanent conversation deletion and simplified project configuration
This commit is contained in:
commit
c77d8cf3a2
21 files changed
+1104
-326
No files matched your search
@@ -5,14 +5,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import {
|
||||
Archive,
|
||||
Bot,
|
||||
BotMessageSquare,
|
||||
Cpu,
|
||||
FolderKanban,
|
||||
Gamepad2,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Settings as SettingsIcon,
|
||||
@@ -131,7 +129,6 @@ export function Sidebar({
|
||||
} = useCurrentUserProfile();
|
||||
const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot);
|
||||
const [projectsOpen, setProjectsOpen] = useState(true);
|
||||
const [archivedProjectId, setArchivedProjectId] = useState<string | null>(null);
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||
@@ -357,17 +354,13 @@ export function Sidebar({
|
||||
await readAndEnterProject(project);
|
||||
}, [readAndEnterProject]);
|
||||
|
||||
const openProjectAction = async (project: CodingProjectSummary, action: 'settings' | 'archived' | 'recent') => {
|
||||
const openProjectSettings = async (project: CodingProjectSummary) => {
|
||||
const opening = openProject(project);
|
||||
const intent = projectEntryIntentRef.current;
|
||||
await opening;
|
||||
if (intent !== projectEntryIntentRef.current) return;
|
||||
if (codingWorkspaceStore.getState().activeProjectId !== project.id) return;
|
||||
if (action === 'settings') {
|
||||
navigate('/project-config');
|
||||
} else {
|
||||
setArchivedProjectId(action === 'archived' ? project.id : null);
|
||||
}
|
||||
navigate('/project-config');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -508,7 +501,6 @@ export function Sidebar({
|
||||
|
||||
{visibleProjects.map((project) => {
|
||||
const active = activeProject?.id === project.id;
|
||||
const archived = active && archivedProjectId === project.id;
|
||||
return (
|
||||
<div key={project.id}>
|
||||
<div className="relative">
|
||||
@@ -548,14 +540,7 @@ export function Sidebar({
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center rounded p-2 outline-none focus:bg-accent"
|
||||
onSelect={() => void openProjectAction(project, archived ? 'recent' : 'archived')}
|
||||
>
|
||||
{archived ? <MessageSquare aria-hidden="true" className="mr-2 h-3.5 w-3.5" /> : <Archive aria-hidden="true" className="mr-2 h-3.5 w-3.5" />}
|
||||
{archived ? '最近对话' : '已归档对话'}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center rounded p-2 outline-none focus:bg-accent"
|
||||
onSelect={() => void openProjectAction(project, 'settings')}
|
||||
onSelect={() => void openProjectSettings(project)}
|
||||
>
|
||||
<SettingsIcon aria-hidden="true" className="mr-2 h-3.5 w-3.5" />
|
||||
项目设置
|
||||
@@ -569,8 +554,7 @@ export function Sidebar({
|
||||
key={project.id}
|
||||
projectId={project.id}
|
||||
compact={workspaceLayout}
|
||||
archived={archived}
|
||||
onArchivedChange={(value) => setArchivedProjectId(value ? project.id : null)}
|
||||
onMenuOpenChange={setProjectMenuOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,10 @@ type PendingUpload = {
|
||||
|
||||
const pendingUploads = new Map<string, PendingUpload>();
|
||||
const uploadTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const inFlightUploads = new Map<string, Promise<void>>();
|
||||
const inFlightUploads = new Map<string, { sessionId: string; task: Promise<void> }>();
|
||||
// Code conversation IDs are stable local UUIDs across account changes. Keep
|
||||
// these tombstones for the Renderer lifetime so late snapshots cannot requeue.
|
||||
const forgottenCodingConversationIds = new Set<string>();
|
||||
let pendingUploadsHydrated = false;
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g');
|
||||
@@ -233,7 +236,8 @@ function hydratePendingUploads(): void {
|
||||
// Queue entries are account-bound. Do not guess an owner for records
|
||||
// written by an older build, otherwise a different login could receive
|
||||
// another account's local conversation data.
|
||||
if (!userId || !isAgentSessionData(data)) continue;
|
||||
if (!userId || !isAgentSessionData(data)
|
||||
|| forgottenCodingConversationIds.has(data.session_id)) continue;
|
||||
const retryCount = typeof record?.retryCount === 'number' && Number.isFinite(record.retryCount)
|
||||
? Math.max(0, Math.floor(record.retryCount))
|
||||
: 0;
|
||||
@@ -279,10 +283,12 @@ function getRetryDelay(retryCount: number): number {
|
||||
}
|
||||
|
||||
async function uploadWithCurrentProfile(userId: string, data: AgentSessionData): Promise<void> {
|
||||
if (forgottenCodingConversationIds.has(data.session_id)) return;
|
||||
if (getCurrentAccountKey() !== userId) {
|
||||
throw new Error('Authenticated account changed during Agent session sync');
|
||||
}
|
||||
const accessToken = await useAuthStore.getState().getValidAccessToken();
|
||||
if (forgottenCodingConversationIds.has(data.session_id)) return;
|
||||
if (!accessToken) throw new Error('No authenticated session for Agent session sync');
|
||||
|
||||
if (getCurrentAccountKey() !== userId) {
|
||||
@@ -302,19 +308,20 @@ async function uploadWithCurrentProfile(userId: string, data: AgentSessionData):
|
||||
|
||||
async function flushUpload(key: string): Promise<void> {
|
||||
const existing = inFlightUploads.get(key);
|
||||
if (existing) return existing;
|
||||
if (existing) return existing.task;
|
||||
|
||||
hydratePendingUploads();
|
||||
const pending = pendingUploads.get(key);
|
||||
if (!pending || forgottenCodingConversationIds.has(pending.data.session_id)) return;
|
||||
if (getCurrentAccountKey() !== pending.userId) return;
|
||||
|
||||
const task = (async () => {
|
||||
hydratePendingUploads();
|
||||
const pending = pendingUploads.get(key);
|
||||
if (!pending) return;
|
||||
if (getCurrentAccountKey() !== pending.userId) return;
|
||||
|
||||
pendingUploads.delete(key);
|
||||
persistPendingUploads();
|
||||
try {
|
||||
await uploadWithCurrentProfile(pending.userId, pending.data);
|
||||
} catch {
|
||||
if (forgottenCodingConversationIds.has(pending.data.session_id)) return;
|
||||
if (!pendingUploads.has(key)) {
|
||||
const retryCount = pending.retryCount + 1;
|
||||
pendingUploads.set(key, { userId: pending.userId, data: pending.data, retryCount });
|
||||
@@ -326,20 +333,21 @@ async function flushUpload(key: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingUploads.has(key)) {
|
||||
if (!forgottenCodingConversationIds.has(pending.data.session_id) && pendingUploads.has(key)) {
|
||||
scheduleUpload(key, SESSION_UPLOAD_DEBOUNCE_MS);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightUploads.set(key, task);
|
||||
inFlightUploads.set(key, { sessionId: pending.data.session_id, task });
|
||||
try {
|
||||
await task;
|
||||
} finally {
|
||||
if (inFlightUploads.get(key) === task) inFlightUploads.delete(key);
|
||||
if (inFlightUploads.get(key)?.task === task) inFlightUploads.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function queueAgentSessionData(data: AgentSessionData): void {
|
||||
if (forgottenCodingConversationIds.has(data.session_id)) return;
|
||||
const userId = getCurrentAccountKey();
|
||||
if (!userId) return;
|
||||
|
||||
@@ -363,6 +371,7 @@ export function queueAgentSessionSync(
|
||||
|
||||
/** Queue the final product Conversation snapshot after a completed prompt turn. */
|
||||
export function queueCodingConversationSessionSync(snapshot: ConversationSnapshot): void {
|
||||
if (forgottenCodingConversationIds.has(snapshot.conversation.id.trim())) return;
|
||||
const messageNodes = snapshot.nodes.filter((node) => node.kind === 'message');
|
||||
let lastUserIndex = -1;
|
||||
for (let index = messageNodes.length - 1; index >= 0; index -= 1) {
|
||||
@@ -406,6 +415,26 @@ export function queueCodingConversationSessionSync(snapshot: ConversationSnapsho
|
||||
queueAgentSessionData(data);
|
||||
}
|
||||
|
||||
/** Forget local retry data after deletion; existing shared observations stay intact. */
|
||||
export function forgetCodingConversationSessionSync(conversationId: string): void {
|
||||
const sessionId = conversationId.trim();
|
||||
if (!sessionId) return;
|
||||
|
||||
forgottenCodingConversationIds.add(sessionId);
|
||||
hydratePendingUploads();
|
||||
for (const [key, pending] of pendingUploads) {
|
||||
if (pending.data.session_id !== sessionId) continue;
|
||||
clearUploadTimer(key);
|
||||
pendingUploads.delete(key);
|
||||
}
|
||||
// The profile upload API cannot abort a request already sent. Its completion
|
||||
// may update the shared profile version, but must never restore this queue.
|
||||
for (const [key, upload] of inFlightUploads) {
|
||||
if (upload.sessionId === sessionId) clearUploadTimer(key);
|
||||
}
|
||||
persistPendingUploads();
|
||||
}
|
||||
|
||||
/** Flush persisted session snapshots after login or profile bootstrap. */
|
||||
export async function flushPendingAgentSessionSync(): Promise<void> {
|
||||
hydratePendingUploads();
|
||||
@@ -426,5 +455,6 @@ export function resetAgentSessionSyncForTests(): void {
|
||||
uploadTimers.clear();
|
||||
pendingUploads.clear();
|
||||
inFlightUploads.clear();
|
||||
forgottenCodingConversationIds.clear();
|
||||
pendingUploadsHydrated = false;
|
||||
}
|
||||
@@ -159,11 +159,13 @@ export function CodingChatPanel({
|
||||
(state) => state.conversationErrorsByProjectId,
|
||||
);
|
||||
const creatingAgentIds = useCodingWorkspaceStore((state) => state.creatingAgentIds);
|
||||
const deletingConversationProjectIds = useCodingWorkspaceStore((state) => state.deletingConversationProjectIds);
|
||||
const loadWorkspace = useCodingWorkspaceStore((state) => state.load);
|
||||
const patchConversation = useCodingWorkspaceStore((state) => state.patchConversation);
|
||||
const upsertConversation = useCodingWorkspaceStore((state) => state.upsertConversation);
|
||||
|
||||
const selectedConversationId = useCodingConversationStore((state) => state.selectedConversationId);
|
||||
const selectionClearedByDeletionProjectId = useCodingConversationStore((state) => state.selectionClearedByDeletionProjectId);
|
||||
const connectionError = useCodingConversationStore((state) => state.globalError);
|
||||
const primeConversation = useCodingConversationStore((state) => state.primeConversation);
|
||||
const selectConversation = useCodingConversationStore((state) => state.selectConversation);
|
||||
@@ -376,6 +378,8 @@ export function CodingChatPanel({
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (selectionClearedByDeletionProjectId === activeProject.id
|
||||
|| (selectedConversationId && deletingConversationProjectIds[selectedConversationId] === activeProject.id)) return;
|
||||
const next = newestConversation(conversations);
|
||||
if (next) {
|
||||
markConversationUnread(next.id, false);
|
||||
@@ -386,6 +390,9 @@ export function CodingChatPanel({
|
||||
clearConversationSelection();
|
||||
}, [
|
||||
activeProject,
|
||||
selectionClearedByDeletionProjectId,
|
||||
selectedConversationId,
|
||||
deletingConversationProjectIds,
|
||||
clearConversationSelection,
|
||||
conversations,
|
||||
markConversationUnread,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
LoaderCircle,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Settings2,
|
||||
Trash2,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
@@ -21,13 +19,11 @@ import { createLocalConversationSnapshot } from './coding-chat-snapshot';
|
||||
export function CodingProjectConversations({
|
||||
projectId,
|
||||
compact = false,
|
||||
archived = false,
|
||||
onArchivedChange,
|
||||
onMenuOpenChange,
|
||||
}: {
|
||||
projectId: string;
|
||||
compact?: boolean;
|
||||
archived?: boolean;
|
||||
onArchivedChange?: (archived: boolean) => void;
|
||||
onMenuOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const conversations = useCodingWorkspaceStore((state) => state.conversations);
|
||||
@@ -36,18 +32,6 @@ export function CodingProjectConversations({
|
||||
const summaries = useCodingConversationStore((state) => state.summariesByConversationId);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [rename, setRename] = useState<CodingConversationMetadata | null>(null);
|
||||
const selectedConversation = conversations.find((item) => item.id === selected);
|
||||
const previousSelection = useRef(selectedConversation);
|
||||
useEffect(() => {
|
||||
const previous = previousSelection.current;
|
||||
if (
|
||||
previous?.id === selectedConversation?.id &&
|
||||
previous?.archivedAt &&
|
||||
!selectedConversation?.archivedAt
|
||||
)
|
||||
onArchivedChange?.(false);
|
||||
previousSelection.current = selectedConversation;
|
||||
}, [selectedConversation, onArchivedChange]);
|
||||
if (activeProjectId !== projectId) return null;
|
||||
const act = async (key: string, operation: () => Promise<unknown>) => {
|
||||
setBusy(key);
|
||||
@@ -61,8 +45,12 @@ export function CodingProjectConversations({
|
||||
};
|
||||
const select = async (conversation: CodingConversationMetadata) => {
|
||||
if (codingWorkspaceStore.getState().activeProjectId !== projectId) return;
|
||||
const opened = conversation.archivedAt
|
||||
? await codingWorkspaceStore.getState().patchConversation(conversation.id, { archived: false })
|
||||
: conversation;
|
||||
if (codingWorkspaceStore.getState().activeProjectId !== projectId) return;
|
||||
const state = codingConversationStore.getState();
|
||||
state.primeConversation(createLocalConversationSnapshot(projectId, conversation));
|
||||
state.primeConversation(createLocalConversationSnapshot(projectId, opened));
|
||||
state.markUnread(conversation.id, false);
|
||||
if (conversation.unread)
|
||||
void codingWorkspaceStore
|
||||
@@ -91,7 +79,6 @@ export function CodingProjectConversations({
|
||||
const workspace = codingWorkspaceStore.getState();
|
||||
const conversation = await workspace.createConversation();
|
||||
if (codingWorkspaceStore.getState().activeProjectId === projectId) {
|
||||
onArchivedChange?.(false);
|
||||
await select(conversation);
|
||||
}
|
||||
})
|
||||
@@ -101,25 +88,9 @@ export function CodingProjectConversations({
|
||||
新对话
|
||||
</button>
|
||||
</div>
|
||||
{archived && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1 text-xs text-muted-foreground">
|
||||
<span>已归档对话</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchivedChange?.(false)}
|
||||
className="rounded py-1 text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35"
|
||||
>
|
||||
返回最近对话
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{conversations
|
||||
.filter((item) => Boolean(item.archivedAt) === archived)
|
||||
.sort((a, b) =>
|
||||
(archived ? b.archivedAt! : b.updatedAt).localeCompare(
|
||||
archived ? a.archivedAt! : a.updatedAt
|
||||
)
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
.map((item) => {
|
||||
const summary = summaries[item.id];
|
||||
const running =
|
||||
@@ -146,11 +117,11 @@ export function CodingProjectConversations({
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-brand" aria-label="未读" />
|
||||
)}
|
||||
</button>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Root onOpenChange={onMenuOpenChange}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
aria-label={`对话操作:${item.title}`}
|
||||
disabled={busy === item.id}
|
||||
disabled={!!busy}
|
||||
className="rounded p-1 text-muted-foreground opacity-0 focus-visible:opacity-100 group-hover/session:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
@@ -158,18 +129,9 @@ export function CodingProjectConversations({
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="z-[100] min-w-40 rounded-lg border bg-popover p-1 text-sm shadow-md"
|
||||
className="z-[400] min-w-40 rounded-lg border bg-popover p-1 text-sm shadow-md"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center rounded p-2 outline-none focus:bg-accent"
|
||||
onSelect={() =>
|
||||
navigate('/project-config?agent=' + encodeURIComponent(item.agentId))
|
||||
}
|
||||
>
|
||||
<Settings2 className="mr-2 h-3.5 w-3.5" />
|
||||
会话编程设置
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center rounded p-2 outline-none focus:bg-accent"
|
||||
onSelect={() => setRename(item)}
|
||||
@@ -178,35 +140,19 @@ export function CodingProjectConversations({
|
||||
重命名
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center rounded p-2 outline-none focus:bg-accent"
|
||||
className="flex items-center rounded p-2 text-destructive outline-none focus:bg-accent"
|
||||
onSelect={() =>
|
||||
void act(item.id, async () => {
|
||||
await codingWorkspaceStore
|
||||
.getState()
|
||||
.patchConversation(item.id, { archived: !item.archivedAt });
|
||||
if (
|
||||
item.archivedAt &&
|
||||
codingWorkspaceStore.getState().activeProjectId === projectId
|
||||
)
|
||||
onArchivedChange?.(false);
|
||||
if (
|
||||
!item.archivedAt &&
|
||||
codingConversationStore.getState().selectedConversationId === item.id
|
||||
)
|
||||
codingConversationStore.getState().clearConversationSelection();
|
||||
if (codingWorkspaceStore.getState().activeProjectId !== projectId)
|
||||
throw new Error('项目已切换');
|
||||
await codingWorkspaceStore.getState().removeConversation(item.id, () => {
|
||||
codingConversationStore.getState().forgetConversation(item.id);
|
||||
});
|
||||
})
|
||||
}
|
||||
>
|
||||
{item.archivedAt ? (
|
||||
<ArchiveRestore className="mr-2 h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
{item.archivedAt
|
||||
? '恢复到最近对话'
|
||||
: running
|
||||
? '归档(任务继续运行)'
|
||||
: '归档'}
|
||||
<Trash2 aria-hidden="true" className="mr-2 h-3.5 w-3.5" />
|
||||
删除对话
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Check, Cpu, Files, Puzzle, Trash2, Upload, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -10,7 +10,6 @@ import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
|
||||
import { Plugins } from '@/pages/Plugins';
|
||||
import { PROJECT_SETTINGS_PLUGINS_PATH } from '@/pages/Plugins/legacy-plugin-redirect';
|
||||
import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
|
||||
import { getCodingSkills } from '@/lib/coding-product-tools';
|
||||
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
@@ -18,13 +17,6 @@ import type { CodingProjectAgent, CodingProjectConfig } from '@/types/coding-pro
|
||||
|
||||
const EMPTY_FILES: string[] = [];
|
||||
type DrawerMode = 'models' | 'knowledge' | null;
|
||||
type SkillInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
available: boolean;
|
||||
effective: boolean;
|
||||
};
|
||||
|
||||
const THINKING_LEVEL_LABELS = {
|
||||
off: '关闭',
|
||||
@@ -122,24 +114,11 @@ export function ProjectConfiguration() {
|
||||
const [draft, setDraft] = useState<CodingProjectConfig | null>(null);
|
||||
const [drawerMode, setDrawerMode] = useState<DrawerMode>(null);
|
||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const knowledgeInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const loadSkills = useCallback(() => {
|
||||
void getCodingSkills()
|
||||
.then((result) => setSkills(result.skills.map((skill) => ({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
available: skill.available,
|
||||
effective: skill.effective,
|
||||
}))))
|
||||
.catch(() => setSkills([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (activeProject) void load(activeProject.id); }, [activeProject, load]);
|
||||
useEffect(() => { if (stored) setDraft(structuredClone(stored)); }, [stored]);
|
||||
useEffect(() => {
|
||||
@@ -147,7 +126,6 @@ export function ProjectConfiguration() {
|
||||
if (!requestedAgentId || !draft?.agents.some((agent) => agent.id === requestedAgentId)) return;
|
||||
setActiveAgentId(requestedAgentId);
|
||||
}, [draft, location.search]);
|
||||
useEffect(() => { loadSkills(); }, [loadSkills]);
|
||||
useEffect(() => { void refreshProviderSnapshot(); }, [refreshProviderSnapshot]);
|
||||
const modelOptions = useMemo(
|
||||
() => buildCodingModelOptions(providerAccounts, providerVendors),
|
||||
@@ -162,11 +140,10 @@ export function ProjectConfiguration() {
|
||||
search: '?scope=all&source=all&state=available',
|
||||
});
|
||||
const closePluginWorkspace = () => {
|
||||
loadSkills();
|
||||
navigate('/project-config', { replace: true });
|
||||
};
|
||||
|
||||
if (!activeProject) return <><main data-testid="project-configuration-empty-state" className="min-h-full bg-background p-8 text-foreground"><div className="mx-auto w-full max-w-7xl"><h1 className="text-xl font-semibold">项目配置</h1><p className="mt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p><p className="mt-2 text-sm font-medium text-muted-foreground">新建或打开一个项目后即可配置模型、提示词、知识与项目能力;插件目录仍可在这里查看。</p><div className="mt-6 max-w-sm"><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="查看可选与已安装能力" onClick={openPluginWorkspace} /></div></div></main><PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} /></>;
|
||||
if (!activeProject) return <><main data-testid="project-configuration-empty-state" className="min-h-full bg-background p-8 text-foreground"><div className="mx-auto w-full max-w-7xl"><h1 className="text-xl font-semibold">项目配置</h1><p className="mt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p><p className="mt-2 text-sm font-medium text-muted-foreground">新建或打开一个项目后即可配置模型、知识与项目能力;插件目录仍可在这里查看。</p><div className="mt-6 max-w-sm"><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="查看可选与已安装能力" onClick={openPluginWorkspace} /></div></div></main><PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} /></>;
|
||||
if (!draft) return <div className="p-8 text-center text-sm font-medium">正在读取项目配置…</div>;
|
||||
|
||||
const updateAgent = (next: CodingProjectAgent) => setDraft((current) => current ? { ...current, agents: current.agents.map((item) => item.id === next.id ? { ...next, updatedAt: new Date().toISOString() } : item) } : current);
|
||||
@@ -202,19 +179,12 @@ export function ProjectConfiguration() {
|
||||
<section data-testid="project-resource-row" className="grid grid-cols-3 gap-3"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="管理云端与本机能力" onClick={openPluginWorkspace} /></section>
|
||||
{activeAgent && <section className="surface-card rounded-2xl border border-border/70 p-5" data-testid="project-default-settings">
|
||||
<h2 className="text-lg font-semibold">{activeAgent.id === draft.defaultAgentId ? '默认编程配置' : '历史会话编程配置'}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">新会话使用项目默认配置;历史会话保留原来的配置与记录。</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">默认模型用于新对话;已有对话的模型和消息记录保持不变。</p>
|
||||
<label className="mt-4 block text-sm">默认模型<select className="mt-2 block w-full rounded-lg border bg-background p-2"
|
||||
value={activeAgent.model ? JSON.stringify([activeAgent.model.accountId, activeAgent.model.modelId]) : ''}
|
||||
onChange={event => { const model = parseCodingModelKey(event.target.value); updateAgent({...activeAgent, model: model ? {...model, thinkingLevel: 'off', reasoningChoice: {mode: 'default'}} : null, modelResolution: model ? 'resolved' : 'required'}); }}>
|
||||
<option value="">发送前选择模型</option>{modelOptions.map(model => <option key={model.key} value={model.key}>{model.modelId}</option>)}
|
||||
</select></label>
|
||||
<label className="mt-4 block text-sm">项目提示词<textarea className="mt-2 min-h-36 w-full rounded-lg border bg-background p-3" value={activeAgent.prompt} onChange={event => updateAgent({...activeAgent,prompt:event.target.value})} /></label>
|
||||
<h3 className="mt-4 text-sm font-medium">项目 Skills</h3>
|
||||
<div className="mt-2 space-y-2">{skills.map(skill => <label key={skill.id} className="flex items-start gap-2 text-sm">
|
||||
<input type="checkbox" checked={activeAgent.skillIds.includes(skill.id)} disabled={!skill.available}
|
||||
onChange={event => updateAgent({...activeAgent,skillIds:event.target.checked ? [...activeAgent.skillIds,skill.id] : activeAgent.skillIds.filter(id=>id!==skill.id)})} />
|
||||
<span>{skill.name}<span className="block text-xs text-muted-foreground">{skill.description}</span></span>
|
||||
</label>)}</div>
|
||||
</section>}
|
||||
<div data-testid="project-configuration-actions" className="glass-surface config-motion-actions sticky bottom-3 z-10 mt-auto flex shrink-0 flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float sm:flex-row sm:items-start sm:justify-between">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setDeleteDialogOpen(true)} className="h-9 w-full border-brand/25 bg-background px-3 font-semibold text-foreground shadow-none hover:border-brand/40 hover:bg-brand-soft sm:w-auto"><Trash2 className="mr-2 h-4 w-4" />删除项目</Button>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type ConversationReducerState,
|
||||
} from '../../shared/coding-conversation-reducer';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import { queueCodingConversationSessionSync } from '@/lib/agent-session-sync';
|
||||
import { forgetCodingConversationSessionSync, queueCodingConversationSessionSync } from '@/lib/agent-session-sync';
|
||||
import { codingWorkspaceStore } from './coding-workspace';
|
||||
import {
|
||||
getCodingConversationSnapshot,
|
||||
@@ -62,12 +62,14 @@ interface CodingConversationStoreDependencies {
|
||||
submitPrompt(input: SubmitCodingConversationPromptInput): Promise<PromptAcceptance>;
|
||||
recover(conversationId: string): Promise<void>;
|
||||
queueSettledSessionSync(snapshot: ConversationSnapshot): void;
|
||||
forgetSettledSessionSync(conversationId: string): void;
|
||||
createId(kind: 'request' | 'node'): string;
|
||||
preparationTimeoutMs: number;
|
||||
}
|
||||
|
||||
export interface CodingConversationStoreState {
|
||||
selectedConversationId: string | null;
|
||||
selectionClearedByDeletionProjectId: string | null;
|
||||
entriesByConversationId: Record<string, CodingConversationEntry>;
|
||||
summariesByConversationId: Record<string, CodingConversationSummary>;
|
||||
draftsByConversationId: Record<string, CodingConversationDraft>;
|
||||
@@ -76,6 +78,7 @@ export interface CodingConversationStoreState {
|
||||
globalError: string | null;
|
||||
primeConversation(snapshot: ConversationSnapshot): void;
|
||||
clearConversationSelection(): void;
|
||||
forgetConversation(conversationId: string): void;
|
||||
selectConversation(conversationId: string): Promise<void>;
|
||||
loadSnapshot(
|
||||
conversationId: string,
|
||||
@@ -313,6 +316,7 @@ function defaultDependencies(): CodingConversationStoreDependencies {
|
||||
submitPrompt: submitCodingConversationPrompt,
|
||||
recover: recoverCodingConversation,
|
||||
queueSettledSessionSync: queueCodingConversationSessionSync,
|
||||
forgetSettledSessionSync: forgetCodingConversationSessionSync,
|
||||
createId: () => crypto.randomUUID(),
|
||||
preparationTimeoutMs: 10_000,
|
||||
};
|
||||
@@ -422,12 +426,20 @@ export function createCodingConversationStore(
|
||||
const snapshotLoads = new Map<string, Promise<ConversationSnapshot>>();
|
||||
const recoveryBatches = new Map<string, CodingConversationPatchBatchEvent[]>();
|
||||
const recoveryRefreshKeys = new Map<string, string>();
|
||||
// Conversation ids are permanent. Late requests and stream events must not
|
||||
// recreate local state after the authoritative deletion has completed.
|
||||
const forgottenConversations = new Set<string>();
|
||||
let eventSource: EventSource | null = null;
|
||||
let connectFlight: Promise<void> | null = null;
|
||||
let connectionGeneration = 0;
|
||||
let store!: StoreApi<CodingConversationStoreState>;
|
||||
|
||||
const assertConversationExists = (conversationId: string) => {
|
||||
if (forgottenConversations.has(conversationId)) throw new Error('对话已删除。');
|
||||
};
|
||||
|
||||
const bufferRecoveryBatch = (event: CodingConversationPatchBatchEvent) => {
|
||||
if (forgottenConversations.has(event.conversationId)) return;
|
||||
const buffered = recoveryBatches.get(event.conversationId) ?? [];
|
||||
if (buffered.some((candidate) => (
|
||||
candidate.workerGeneration === event.workerGeneration
|
||||
@@ -443,6 +455,7 @@ export function createCodingConversationStore(
|
||||
};
|
||||
|
||||
const replayRecoveryBatches = (conversationId: string) => {
|
||||
if (forgottenConversations.has(conversationId)) return;
|
||||
const buffered = recoveryBatches.get(conversationId);
|
||||
if (!buffered?.length) return;
|
||||
const snapshot = selectCodingConversationSnapshot(conversationId)(store.getState());
|
||||
@@ -489,7 +502,7 @@ export function createCodingConversationStore(
|
||||
if (willRefresh) {
|
||||
recoveryRefreshKeys.set(conversationId, refreshKey);
|
||||
queueMicrotask(() => {
|
||||
if (!snapshotLoads.has(conversationId)) {
|
||||
if (!forgottenConversations.has(conversationId) && !snapshotLoads.has(conversationId)) {
|
||||
void store.getState().loadSnapshot(conversationId, 'recovering').catch(() => undefined);
|
||||
}
|
||||
});
|
||||
@@ -510,6 +523,7 @@ export function createCodingConversationStore(
|
||||
|
||||
store = createStore<CodingConversationStoreState>((set, get) => ({
|
||||
selectedConversationId: null,
|
||||
selectionClearedByDeletionProjectId: null,
|
||||
entriesByConversationId: {},
|
||||
summariesByConversationId: {},
|
||||
draftsByConversationId: {},
|
||||
@@ -519,6 +533,7 @@ export function createCodingConversationStore(
|
||||
|
||||
primeConversation(snapshot) {
|
||||
const conversationId = snapshot.conversation.id;
|
||||
if (forgottenConversations.has(conversationId)) return;
|
||||
set((state) => {
|
||||
const current = state.entriesByConversationId[conversationId];
|
||||
if (current?.loadState === 'live' || current?.reducer.invalidation) return state;
|
||||
@@ -548,12 +563,43 @@ export function createCodingConversationStore(
|
||||
set({ selectedConversationId: null });
|
||||
},
|
||||
|
||||
forgetConversation(conversationId) {
|
||||
forgottenConversations.add(conversationId);
|
||||
snapshotLoads.delete(conversationId);
|
||||
recoveryBatches.delete(conversationId);
|
||||
recoveryRefreshKeys.delete(conversationId);
|
||||
deps.forgetSettledSessionSync(conversationId);
|
||||
set((state) => {
|
||||
const entriesByConversationId = { ...state.entriesByConversationId };
|
||||
const summariesByConversationId = { ...state.summariesByConversationId };
|
||||
const draftsByConversationId = { ...state.draftsByConversationId };
|
||||
const requestsByConversationId = { ...state.requestsByConversationId };
|
||||
delete entriesByConversationId[conversationId];
|
||||
delete summariesByConversationId[conversationId];
|
||||
delete draftsByConversationId[conversationId];
|
||||
delete requestsByConversationId[conversationId];
|
||||
return {
|
||||
selectedConversationId: state.selectedConversationId === conversationId ? null : state.selectedConversationId,
|
||||
selectionClearedByDeletionProjectId: state.selectedConversationId === conversationId
|
||||
? state.entriesByConversationId[conversationId]?.reducer.snapshot?.conversation.projectId
|
||||
?? codingWorkspaceStore.getState().activeProjectId
|
||||
: state.selectionClearedByDeletionProjectId,
|
||||
entriesByConversationId,
|
||||
summariesByConversationId,
|
||||
draftsByConversationId,
|
||||
requestsByConversationId,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async selectConversation(conversationId) {
|
||||
if (forgottenConversations.has(conversationId)) return;
|
||||
set((state) => {
|
||||
const current = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
||||
const entry = current.unread ? { ...current, unread: false } : current;
|
||||
return {
|
||||
selectedConversationId: conversationId,
|
||||
selectionClearedByDeletionProjectId: null,
|
||||
entriesByConversationId: {
|
||||
...state.entriesByConversationId,
|
||||
[conversationId]: entry,
|
||||
@@ -572,6 +618,7 @@ export function createCodingConversationStore(
|
||||
},
|
||||
|
||||
loadSnapshot(conversationId, mode = 'loading') {
|
||||
if (forgottenConversations.has(conversationId)) return Promise.reject(new Error('对话已删除。'));
|
||||
const prior = snapshotLoads.get(conversationId);
|
||||
if (prior) return prior;
|
||||
if (mode !== 'silent') {
|
||||
@@ -594,6 +641,7 @@ export function createCodingConversationStore(
|
||||
deps.preparationTimeoutMs,
|
||||
)
|
||||
.then((snapshot) => {
|
||||
assertConversationExists(conversationId);
|
||||
get().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId,
|
||||
@@ -604,7 +652,7 @@ export function createCodingConversationStore(
|
||||
return snapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (mode !== 'silent') {
|
||||
if (!forgottenConversations.has(conversationId) && mode !== 'silent') {
|
||||
const failure = errorDetails(error);
|
||||
set((state) => {
|
||||
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
||||
@@ -628,6 +676,7 @@ export function createCodingConversationStore(
|
||||
},
|
||||
|
||||
async recoverConversation(conversationId) {
|
||||
assertConversationExists(conversationId);
|
||||
recoveryRefreshKeys.delete(conversationId);
|
||||
set((state) => {
|
||||
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
||||
@@ -640,8 +689,10 @@ export function createCodingConversationStore(
|
||||
});
|
||||
try {
|
||||
await deps.recover(conversationId);
|
||||
assertConversationExists(conversationId);
|
||||
await get().loadSnapshot(conversationId, 'recovering');
|
||||
} catch (error) {
|
||||
if (forgottenConversations.has(conversationId)) throw error;
|
||||
const failure = errorDetails(error);
|
||||
set((state) => {
|
||||
const entry = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
||||
@@ -729,6 +780,7 @@ export function createCodingConversationStore(
|
||||
},
|
||||
|
||||
setDraft(conversationId, text, attachments = []) {
|
||||
if (forgottenConversations.has(conversationId)) return;
|
||||
set((state) => {
|
||||
const current = state.draftsByConversationId[conversationId] ?? DEFAULT_DRAFT;
|
||||
return {
|
||||
@@ -745,6 +797,7 @@ export function createCodingConversationStore(
|
||||
},
|
||||
|
||||
markUnread(conversationId, unread) {
|
||||
if (forgottenConversations.has(conversationId)) return;
|
||||
set((state) => {
|
||||
const current = state.entriesByConversationId[conversationId] ?? emptyEntry();
|
||||
if (current.unread === unread) return state;
|
||||
@@ -760,12 +813,14 @@ export function createCodingConversationStore(
|
||||
},
|
||||
|
||||
async submitPrompt(input) {
|
||||
assertConversationExists(input.conversationId);
|
||||
let entry = get().entriesByConversationId[input.conversationId];
|
||||
if (!entry?.reducer.snapshot || entry.reducer.invalidation) {
|
||||
await get().loadSnapshot(
|
||||
input.conversationId,
|
||||
entry?.reducer.invalidation ? 'recovering' : 'loading',
|
||||
);
|
||||
assertConversationExists(input.conversationId);
|
||||
entry = get().entriesByConversationId[input.conversationId];
|
||||
}
|
||||
if (!entry?.reducer.snapshot) throw new Error('Conversation snapshot is unavailable');
|
||||
@@ -823,6 +878,7 @@ export function createCodingConversationStore(
|
||||
if (input.prepareAttachments) {
|
||||
submittedDraft.attachments = (await input.prepareAttachments())
|
||||
.map((attachment) => ({ ...attachment }));
|
||||
assertConversationExists(input.conversationId);
|
||||
set((state) => {
|
||||
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
|
||||
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
||||
@@ -857,6 +913,7 @@ export function createCodingConversationStore(
|
||||
};
|
||||
});
|
||||
}
|
||||
assertConversationExists(input.conversationId);
|
||||
const acceptance = await deps.submitPrompt({
|
||||
conversationId: input.conversationId,
|
||||
clientRequestId,
|
||||
@@ -864,6 +921,7 @@ export function createCodingConversationStore(
|
||||
text: submittedDraft.text,
|
||||
attachments: submittedDraft.attachments.map(({ attachmentId }) => ({ attachmentId })),
|
||||
});
|
||||
assertConversationExists(input.conversationId);
|
||||
set((state) => {
|
||||
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
||||
const current = requests[clientRequestId];
|
||||
@@ -899,6 +957,7 @@ export function createCodingConversationStore(
|
||||
});
|
||||
return acceptance;
|
||||
} catch (error) {
|
||||
if (forgottenConversations.has(input.conversationId)) throw error;
|
||||
const failure = errorDetails(error);
|
||||
const uncertain = failure.code === 'CODING_REQUEST_UNCERTAIN';
|
||||
set((state) => {
|
||||
@@ -953,6 +1012,7 @@ export function createCodingConversationStore(
|
||||
},
|
||||
|
||||
applySnapshotEvent(event) {
|
||||
if (forgottenConversations.has(event.conversationId)) return;
|
||||
const snapshot = event.snapshot;
|
||||
if (event.type !== 'snapshot'
|
||||
|| event.conversationId !== snapshot.conversation.id
|
||||
@@ -1005,6 +1065,7 @@ export function createCodingConversationStore(
|
||||
},
|
||||
|
||||
applyPatchBatchEvent(event) {
|
||||
if (forgottenConversations.has(event.conversationId)) return;
|
||||
const currentBeforeBatch = get().entriesByConversationId[event.conversationId];
|
||||
const currentSnapshot = currentBeforeBatch?.reducer.snapshot;
|
||||
if (currentSnapshot && event.workerGeneration < currentSnapshot.cursor.workerGeneration) return;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useStore } from 'zustand';
|
||||
import { createStore, type StoreApi } from 'zustand/vanilla';
|
||||
import {
|
||||
createCodingProjectConversation,
|
||||
deleteCodingProjectConversation,
|
||||
createCodingProject,
|
||||
getCodingProjectConfig,
|
||||
listCodingProjectConversations,
|
||||
@@ -37,6 +38,7 @@ interface CodingWorkspaceDependencies {
|
||||
conversationId: string,
|
||||
patch: { title?: string; archived?: boolean; unread?: boolean },
|
||||
): Promise<CodingConversationMetadata>;
|
||||
removeConversation(conversationId: string): Promise<void>;
|
||||
openProject(projectPath: string): Promise<CodingProjectSummary>;
|
||||
createProject(input: {
|
||||
projectPath?: string;
|
||||
@@ -60,6 +62,7 @@ export interface CodingWorkspaceState {
|
||||
error: string | null;
|
||||
conversationErrorsByProjectId: Record<string, Record<string, string>>;
|
||||
creatingAgentIds: Record<string, true>;
|
||||
deletingConversationProjectIds: Record<string, string>;
|
||||
load(): Promise<void>;
|
||||
refreshConversations(projectId: string): Promise<void>;
|
||||
openProject(projectPath: string): Promise<CodingProjectSummary>;
|
||||
@@ -79,6 +82,7 @@ export interface CodingWorkspaceState {
|
||||
conversationId: string,
|
||||
patch: { title?: string; archived?: boolean; unread?: boolean },
|
||||
): Promise<CodingConversationMetadata>;
|
||||
removeConversation(conversationId: string, onDeleted?: () => void): Promise<void>;
|
||||
upsertConversation(projectId: string, conversation: CodingConversationMetadata): void;
|
||||
}
|
||||
|
||||
@@ -127,6 +131,7 @@ function defaultDependencies(): CodingWorkspaceDependencies {
|
||||
listConversations: listCodingProjectConversations,
|
||||
createConversation: createCodingProjectConversation,
|
||||
patchConversation: patchCodingProjectConversation,
|
||||
removeConversation: deleteCodingProjectConversation,
|
||||
openProject: openCodingProject,
|
||||
createProject: createCodingProject,
|
||||
setActiveProject: setActiveCodingProject,
|
||||
@@ -139,6 +144,7 @@ export function createCodingWorkspaceStore(
|
||||
): StoreApi<CodingWorkspaceState> {
|
||||
const deps = { ...defaultDependencies(), ...dependencies };
|
||||
const conversationFlights = new Map<string, Promise<CodingConversationMetadata>>();
|
||||
const deletedConversationIds = new Set<string>();
|
||||
let loadFlight: Promise<void> | null = null;
|
||||
let loadGeneration = 0;
|
||||
let metadataRevision = 0;
|
||||
@@ -158,6 +164,7 @@ export function createCodingWorkspaceStore(
|
||||
error: null,
|
||||
conversationErrorsByProjectId: {},
|
||||
creatingAgentIds: {},
|
||||
deletingConversationProjectIds: {},
|
||||
|
||||
async refreshConversations(projectId) {
|
||||
if (get().activeProjectId !== projectId) return;
|
||||
@@ -176,7 +183,7 @@ export function createCodingWorkspaceStore(
|
||||
const conversations = await deps.listConversations(projectId);
|
||||
if (get().activeProjectId !== projectId || generation !== loadGeneration) return;
|
||||
if (revision !== metadataRevision) { refresh.dirty = true; continue; }
|
||||
if (!refresh.dirty) set({ conversations });
|
||||
if (!refresh.dirty) set({ conversations: conversations.filter((item) => !deletedConversationIds.has(item.id)) });
|
||||
}
|
||||
})().finally(() => { refreshFlights.delete(projectId); });
|
||||
refreshFlights.set(projectId, refresh);
|
||||
@@ -220,7 +227,7 @@ export function createCodingWorkspaceStore(
|
||||
activeProject: snapshot.project,
|
||||
config: snapshot.config,
|
||||
conversations: revision === metadataRevision || get().activeProjectId !== activeProject.id
|
||||
? conversations : get().conversations,
|
||||
? conversations.filter((item) => !deletedConversationIds.has(item.id)) : get().conversations,
|
||||
selectedAgentId: currentAgentId ?? firstEnabledAgent(snapshot.config)?.id ?? null,
|
||||
loadState: 'ready',
|
||||
error: null,
|
||||
@@ -358,8 +365,49 @@ export function createCodingWorkspaceStore(
|
||||
}
|
||||
},
|
||||
|
||||
async removeConversation(conversationId, onDeleted) {
|
||||
const sourceProjectId = get().activeProjectId;
|
||||
if (!sourceProjectId) throw new Error('当前没有可用的项目。');
|
||||
set((current) => ({
|
||||
deletingConversationProjectIds: { ...current.deletingConversationProjectIds, [conversationId]: sourceProjectId },
|
||||
}));
|
||||
metadataRevision += 1;
|
||||
const flight = metadataTail.then(() => deps.removeConversation(conversationId));
|
||||
metadataTail = flight.then(() => undefined, () => undefined);
|
||||
try {
|
||||
await flight;
|
||||
deletedConversationIds.add(conversationId);
|
||||
metadataRevision += 1;
|
||||
onDeleted?.();
|
||||
set((current) => ({
|
||||
...(current.activeProjectId === sourceProjectId
|
||||
? { conversations: current.conversations.filter((item) => item.id !== conversationId) }
|
||||
: {}),
|
||||
conversationErrorsByProjectId: withConversationError(
|
||||
current.conversationErrorsByProjectId, sourceProjectId, conversationId, null,
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
metadataRevision += 1;
|
||||
set((current) => ({
|
||||
conversationErrorsByProjectId: withConversationError(
|
||||
current.conversationErrorsByProjectId, sourceProjectId, conversationId,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
),
|
||||
}));
|
||||
throw error;
|
||||
} finally {
|
||||
set((current) => {
|
||||
const pending = { ...current.deletingConversationProjectIds };
|
||||
delete pending[conversationId];
|
||||
return { deletingConversationProjectIds: pending };
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
upsertConversation(projectId, conversation) {
|
||||
metadataRevision += 1;
|
||||
if (deletedConversationIds.has(conversation.id)) return;
|
||||
if (get().activeProjectId !== projectId) return;
|
||||
set((current) => ({
|
||||
conversations: [
|
||||
|
||||
Reference in new issue
Block a user