cc工作前备份
This commit is contained in:
@@ -1,10 +1,26 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { getGatewayClient, AgentStreamDelta } from '../lib/gateway-client';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
streaming?: boolean;
|
||||
toolName?: string;
|
||||
toolInput?: string;
|
||||
toolOutput?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
sessionKey: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
@@ -18,25 +34,282 @@ export interface Agent {
|
||||
|
||||
interface ChatState {
|
||||
messages: Message[];
|
||||
conversations: Conversation[];
|
||||
currentConversationId: string | null;
|
||||
agents: Agent[];
|
||||
currentAgent: Agent | null;
|
||||
isStreaming: boolean;
|
||||
currentModel: string;
|
||||
sessionKey: string | null;
|
||||
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (id: string, updates: Partial<Message>) => void;
|
||||
setCurrentAgent: (agent: Agent) => void;
|
||||
setCurrentModel: (model: string) => void;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
initStreamListener: () => () => void;
|
||||
newConversation: () => void;
|
||||
switchConversation: (id: string) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set) => ({
|
||||
function generateConvId(): string {
|
||||
return `conv_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
function deriveTitle(messages: Message[]): string {
|
||||
const firstUser = messages.find(m => m.role === 'user');
|
||||
if (firstUser) {
|
||||
const text = firstUser.content.trim();
|
||||
return text.length > 30 ? text.slice(0, 30) + '...' : text;
|
||||
}
|
||||
return '新对话';
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
messages: [],
|
||||
conversations: [],
|
||||
currentConversationId: null,
|
||||
agents: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'ZCLAW',
|
||||
icon: '🦞',
|
||||
color: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
lastMessage: '好的!选项 A 确认...',
|
||||
time: '21:58',
|
||||
lastMessage: '发送消息开始对话',
|
||||
time: '',
|
||||
},
|
||||
],
|
||||
currentAgent: null,
|
||||
addMessage: (message) => set((state) => ({ messages: [...state.messages, message] })),
|
||||
isStreaming: false,
|
||||
currentModel: 'glm-5',
|
||||
sessionKey: null,
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({ messages: [...state.messages, message] })),
|
||||
|
||||
updateMessage: (id, updates) =>
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === id ? { ...m, ...updates } : m
|
||||
),
|
||||
})),
|
||||
|
||||
setCurrentAgent: (agent) => set({ currentAgent: agent }),
|
||||
}));
|
||||
|
||||
setCurrentModel: (model) => set({ currentModel: model }),
|
||||
|
||||
newConversation: () => {
|
||||
const state = get();
|
||||
let conversations = [...state.conversations];
|
||||
|
||||
// Save current conversation if it has messages
|
||||
if (state.messages.length > 0) {
|
||||
const currentId = state.currentConversationId || generateConvId();
|
||||
const existingIdx = conversations.findIndex(c => c.id === currentId);
|
||||
const conv: Conversation = {
|
||||
id: currentId,
|
||||
title: deriveTitle(state.messages),
|
||||
messages: [...state.messages],
|
||||
sessionKey: state.sessionKey,
|
||||
createdAt: existingIdx >= 0 ? conversations[existingIdx].createdAt : new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (existingIdx >= 0) {
|
||||
conversations[existingIdx] = conv;
|
||||
} else {
|
||||
conversations = [conv, ...conversations];
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
conversations,
|
||||
messages: [],
|
||||
sessionKey: null,
|
||||
isStreaming: false,
|
||||
currentConversationId: null,
|
||||
});
|
||||
},
|
||||
|
||||
switchConversation: (id: string) => {
|
||||
const state = get();
|
||||
let conversations = [...state.conversations];
|
||||
|
||||
// Save current conversation first
|
||||
if (state.messages.length > 0 && state.currentConversationId) {
|
||||
const existingIdx = conversations.findIndex(c => c.id === state.currentConversationId);
|
||||
if (existingIdx >= 0) {
|
||||
conversations[existingIdx] = {
|
||||
...conversations[existingIdx],
|
||||
messages: [...state.messages],
|
||||
sessionKey: state.sessionKey,
|
||||
updatedAt: new Date(),
|
||||
title: deriveTitle(state.messages),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const target = conversations.find(c => c.id === id);
|
||||
if (target) {
|
||||
set({
|
||||
conversations,
|
||||
messages: [...target.messages],
|
||||
sessionKey: target.sessionKey,
|
||||
currentConversationId: target.id,
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deleteConversation: (id: string) => {
|
||||
const state = get();
|
||||
const conversations = state.conversations.filter(c => c.id !== id);
|
||||
if (state.currentConversationId === id) {
|
||||
set({ conversations, messages: [], sessionKey: null, currentConversationId: null, isStreaming: false });
|
||||
} else {
|
||||
set({ conversations });
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (content: string) => {
|
||||
const { addMessage, currentModel, sessionKey } = get();
|
||||
|
||||
// Add user message
|
||||
const userMsg: Message = {
|
||||
id: `user_${Date.now()}`,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
addMessage(userMsg);
|
||||
|
||||
// Create placeholder assistant message for streaming
|
||||
const assistantId = `assistant_${Date.now()}`;
|
||||
const assistantMsg: Message = {
|
||||
id: assistantId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
streaming: true,
|
||||
};
|
||||
addMessage(assistantMsg);
|
||||
set({ isStreaming: true });
|
||||
|
||||
try {
|
||||
const client = getGatewayClient();
|
||||
const result = await client.chat(content, {
|
||||
sessionKey: sessionKey || undefined,
|
||||
model: currentModel,
|
||||
});
|
||||
|
||||
// Store session key for continuity
|
||||
if (!sessionKey) {
|
||||
set({ sessionKey: `session_${Date.now()}` });
|
||||
}
|
||||
|
||||
// The actual streaming content comes via the 'agent' event listener
|
||||
// set in initStreamListener(). The runId links events to this message.
|
||||
// Store runId on the message for correlation
|
||||
set((state) => ({
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId ? { ...m, toolInput: result.runId } : m
|
||||
),
|
||||
}));
|
||||
} catch (err: any) {
|
||||
// Gateway not connected — show error in the assistant bubble
|
||||
set((state) => ({
|
||||
isStreaming: false,
|
||||
messages: state.messages.map((m) =>
|
||||
m.id === assistantId
|
||||
? {
|
||||
...m,
|
||||
content: `⚠️ ${err.message || '无法连接 Gateway'}`,
|
||||
streaming: false,
|
||||
error: err.message,
|
||||
}
|
||||
: m
|
||||
),
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
initStreamListener: () => {
|
||||
const client = getGatewayClient();
|
||||
|
||||
const unsubscribe = client.onAgentStream((delta: AgentStreamDelta) => {
|
||||
const state = get();
|
||||
|
||||
// Find the currently streaming assistant message
|
||||
const streamingMsg = [...state.messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === 'assistant' && m.streaming);
|
||||
|
||||
if (!streamingMsg) return;
|
||||
|
||||
if (delta.stream === 'assistant' && delta.delta) {
|
||||
// Append text delta to the streaming message
|
||||
set((s) => ({
|
||||
messages: s.messages.map((m) =>
|
||||
m.id === streamingMsg.id
|
||||
? { ...m, content: m.content + delta.delta }
|
||||
: m
|
||||
),
|
||||
}));
|
||||
} else if (delta.stream === 'tool') {
|
||||
// Add a tool message
|
||||
const toolMsg: Message = {
|
||||
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: 'tool',
|
||||
content: delta.toolOutput || '',
|
||||
timestamp: new Date(),
|
||||
toolName: delta.tool,
|
||||
toolInput: delta.toolInput,
|
||||
toolOutput: delta.toolOutput,
|
||||
};
|
||||
set((s) => ({ messages: [...s.messages, toolMsg] }));
|
||||
} else if (delta.stream === 'lifecycle') {
|
||||
if (delta.phase === 'end' || delta.phase === 'error') {
|
||||
// Mark streaming complete
|
||||
set((s) => ({
|
||||
isStreaming: false,
|
||||
messages: s.messages.map((m) =>
|
||||
m.id === streamingMsg.id
|
||||
? {
|
||||
...m,
|
||||
streaming: false,
|
||||
error: delta.phase === 'error' ? delta.error : undefined,
|
||||
}
|
||||
: m
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'zclaw-chat-storage',
|
||||
partialize: (state) => ({
|
||||
conversations: state.conversations,
|
||||
currentModel: state.currentModel,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// Rehydrate Date objects from JSON strings
|
||||
if (state?.conversations) {
|
||||
for (const conv of state.conversations) {
|
||||
conv.createdAt = new Date(conv.createdAt);
|
||||
conv.updatedAt = new Date(conv.updatedAt);
|
||||
for (const msg of conv.messages) {
|
||||
msg.timestamp = new Date(msg.timestamp);
|
||||
msg.streaming = false; // Never restore streaming state
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
217
desktop/src/store/gatewayStore.ts
Normal file
217
desktop/src/store/gatewayStore.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { create } from 'zustand';
|
||||
import { GatewayClient, ConnectionState, getGatewayClient } from '../lib/gateway-client';
|
||||
|
||||
interface GatewayLog {
|
||||
timestamp: number;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface Clone {
|
||||
id: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
nickname?: string;
|
||||
scenarios?: string[];
|
||||
model?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UsageStats {
|
||||
totalSessions: number;
|
||||
totalMessages: number;
|
||||
totalTokens: number;
|
||||
byModel: Record<string, { messages: number; inputTokens: number; outputTokens: number }>;
|
||||
}
|
||||
|
||||
interface ChannelInfo {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
status: 'active' | 'inactive' | 'error';
|
||||
accounts?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ScheduledTask {
|
||||
id: string;
|
||||
name: string;
|
||||
schedule: string;
|
||||
status: 'active' | 'paused' | 'completed' | 'error';
|
||||
lastRun?: string;
|
||||
nextRun?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface GatewayStore {
|
||||
// Connection state
|
||||
connectionState: ConnectionState;
|
||||
gatewayVersion: string | null;
|
||||
error: string | null;
|
||||
logs: GatewayLog[];
|
||||
|
||||
// Data
|
||||
clones: Clone[];
|
||||
usageStats: UsageStats | null;
|
||||
pluginStatus: any[];
|
||||
channels: ChannelInfo[];
|
||||
scheduledTasks: ScheduledTask[];
|
||||
|
||||
// Client reference
|
||||
client: GatewayClient;
|
||||
|
||||
// Actions
|
||||
connect: (url?: string, token?: string) => Promise<void>;
|
||||
disconnect: () => void;
|
||||
sendMessage: (message: string, sessionKey?: string) => Promise<{ runId: string }>;
|
||||
loadClones: () => Promise<void>;
|
||||
createClone: (opts: { name: string; role?: string; scenarios?: string[] }) => Promise<void>;
|
||||
deleteClone: (id: string) => Promise<void>;
|
||||
loadUsageStats: () => Promise<void>;
|
||||
loadPluginStatus: () => Promise<void>;
|
||||
loadChannels: () => Promise<void>;
|
||||
loadScheduledTasks: () => Promise<void>;
|
||||
clearLogs: () => void;
|
||||
}
|
||||
|
||||
export const useGatewayStore = create<GatewayStore>((set, get) => {
|
||||
const client = getGatewayClient();
|
||||
|
||||
// Wire up state change callback
|
||||
client.onStateChange = (state) => {
|
||||
set({ connectionState: state });
|
||||
};
|
||||
|
||||
client.onLog = (level, message) => {
|
||||
set((s) => ({
|
||||
logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }],
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
connectionState: 'disconnected',
|
||||
gatewayVersion: null,
|
||||
error: null,
|
||||
logs: [],
|
||||
clones: [],
|
||||
usageStats: null,
|
||||
pluginStatus: [],
|
||||
channels: [],
|
||||
scheduledTasks: [],
|
||||
client,
|
||||
|
||||
connect: async (url?: string, token?: string) => {
|
||||
try {
|
||||
set({ error: null });
|
||||
const c = url ? getGatewayClient({ url, token }) : get().client;
|
||||
await c.connect();
|
||||
|
||||
// Fetch initial data after connection
|
||||
try {
|
||||
const health = await c.health();
|
||||
set({ gatewayVersion: health?.version });
|
||||
} catch { /* health may not return version */ }
|
||||
} catch (err: any) {
|
||||
set({ error: err.message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
disconnect: () => {
|
||||
get().client.disconnect();
|
||||
},
|
||||
|
||||
sendMessage: async (message: string, sessionKey?: string) => {
|
||||
const c = get().client;
|
||||
return c.chat(message, { sessionKey });
|
||||
},
|
||||
|
||||
loadClones: async () => {
|
||||
try {
|
||||
const result = await get().client.listClones();
|
||||
set({ clones: result?.clones || [] });
|
||||
} catch { /* ignore if method not available */ }
|
||||
},
|
||||
|
||||
createClone: async (opts) => {
|
||||
try {
|
||||
await get().client.createClone(opts);
|
||||
await get().loadClones();
|
||||
} catch (err: any) {
|
||||
set({ error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
deleteClone: async (id: string) => {
|
||||
try {
|
||||
await get().client.deleteClone(id);
|
||||
await get().loadClones();
|
||||
} catch (err: any) {
|
||||
set({ error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
loadUsageStats: async () => {
|
||||
try {
|
||||
const stats = await get().client.getUsageStats();
|
||||
set({ usageStats: stats });
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
loadPluginStatus: async () => {
|
||||
try {
|
||||
const result = await get().client.getPluginStatus();
|
||||
set({ pluginStatus: result?.plugins || [] });
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
loadChannels: async () => {
|
||||
const channels: { id: string; type: string; label: string; status: 'active' | 'inactive' | 'error'; accounts?: number; error?: string }[] = [];
|
||||
try {
|
||||
// Try listing channels from Gateway
|
||||
const result = await get().client.listChannels();
|
||||
if (result?.channels) {
|
||||
set({ channels: result.channels });
|
||||
return;
|
||||
}
|
||||
} catch { /* channels.list may not be available */ }
|
||||
|
||||
// Fallback: probe known channels individually
|
||||
try {
|
||||
const feishu = await get().client.getFeishuStatus();
|
||||
channels.push({
|
||||
id: 'feishu',
|
||||
type: 'feishu',
|
||||
label: '飞书 (Feishu)',
|
||||
status: feishu?.configured ? 'active' : 'inactive',
|
||||
accounts: feishu?.accounts || 0,
|
||||
});
|
||||
} catch {
|
||||
channels.push({ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'inactive' });
|
||||
}
|
||||
|
||||
// QQ channel (check if qqbot plugin is loaded)
|
||||
const plugins = get().pluginStatus;
|
||||
const qqPlugin = plugins.find((p: any) => (p.name || p.id || '').toLowerCase().includes('qqbot'));
|
||||
if (qqPlugin) {
|
||||
channels.push({
|
||||
id: 'qqbot',
|
||||
type: 'qqbot',
|
||||
label: 'QQ 机器人',
|
||||
status: qqPlugin.status === 'active' ? 'active' : 'inactive',
|
||||
});
|
||||
}
|
||||
|
||||
set({ channels });
|
||||
},
|
||||
|
||||
loadScheduledTasks: async () => {
|
||||
try {
|
||||
const result = await get().client.listScheduledTasks();
|
||||
set({ scheduledTasks: result?.tasks || [] });
|
||||
} catch { /* ignore if heartbeat.tasks not available */ }
|
||||
},
|
||||
|
||||
clearLogs: () => set({ logs: [] }),
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user