Merge branch 'next' into develop

This commit is contained in:
nocobase[bot]
2026-06-08 08:21:47 +00:00
15 changed files with 1646 additions and 640 deletions
@@ -0,0 +1,235 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useCallback } from 'react';
import { useApp } from '@nocobase/client-v2';
import { randomId } from '@nocobase/flow-engine';
import type { Attachment, AIEmployee, ClearOptions, Message, Task, TriggerTaskOptions } from '../../types';
import { useChatBoxStore } from '../stores/chat-box';
import { useChatConversationsStore } from '../stores/chat-conversations';
import { CHAT_DEFAULT_SESSION_KEY, useChatMessagesStore } from '../stores/chat-messages';
const getFilenameAttachments = (attachments?: Attachment[]) => {
const result: Attachment[] = [];
for (const attachment of attachments ?? []) {
if (!attachment) {
continue;
}
if (Array.isArray(attachment)) {
for (const item of attachment) {
if (item?.filename) {
result.push(item);
}
}
continue;
}
if (attachment.filename) {
result.push(attachment);
}
}
return result;
};
const parseTask = (task: Task) => {
const message = task.message;
return {
userMessage: message?.user ? { type: 'text' as const, content: message.user } : undefined,
systemMessage: message?.system,
attachments: getFilenameAttachments(message?.attachments),
workContext: message?.workContext,
skillSettings: task.skillSettings,
webSearch: task.webSearch,
model: task.model,
};
};
export const useChatBoxActions = () => {
const app = useApp();
const open = useChatBoxStore.use.open();
const setOpen = useChatBoxStore.use.setOpen();
const setReadonly = useChatBoxStore.use.setReadonly();
const setSenderValue = useChatBoxStore.use.setSenderValue();
const setTaskVariables = useChatBoxStore.use.setTaskVariables();
const setCurrentEmployee = useChatBoxStore.use.setCurrentEmployee();
const setModel = useChatBoxStore.use.setModel();
const senderRef = useChatBoxStore.use.senderRef();
const setCurrentConversation = useChatConversationsStore.use.setCurrentConversation();
const setWebSearch = useChatConversationsStore.use.setWebSearch();
const clear = useCallback(
(options?: ClearOptions) => {
const { sender, systemMessage, attachments, contextItems, taskVariables, skillSettings } = options ?? {};
if (sender !== false) {
setSenderValue('');
}
if (systemMessage !== false) {
useChatMessagesStore.getState().setSessionSystemMessage(CHAT_DEFAULT_SESSION_KEY, '');
}
if (attachments !== false) {
useChatMessagesStore.getState().setSessionAttachments(CHAT_DEFAULT_SESSION_KEY, []);
}
if (contextItems !== false) {
useChatMessagesStore.getState().setSessionContextItems(CHAT_DEFAULT_SESSION_KEY, []);
}
if (taskVariables !== false) {
setTaskVariables({});
}
if (skillSettings !== false) {
useChatMessagesStore.getState().setSessionSkillSettings(CHAT_DEFAULT_SESSION_KEY, undefined);
}
},
[setSenderValue, setTaskVariables],
);
const getDefaultGreeting = useCallback(
(aiEmployee: AIEmployee) => {
const fallback = `Hello, I am ${aiEmployee.nickname || aiEmployee.username}. How can I help you?`;
return (
aiEmployee.greeting ||
app.i18n?.t?.('Default greeting message', {
ns: ['@nocobase/plugin-ai', 'client'],
nickname: aiEmployee.nickname,
defaultValue: fallback,
}) ||
fallback
);
},
[app],
);
const startNewConversation = useCallback(() => {
const currentEmployee = useChatBoxStore.getState().currentEmployee;
setCurrentConversation(undefined);
clear(undefined);
if (currentEmployee) {
useChatMessagesStore.getState().setSessionMessages(CHAT_DEFAULT_SESSION_KEY, [
{
key: randomId(),
role: currentEmployee.username,
content: {
type: 'greeting',
content: getDefaultGreeting(currentEmployee),
},
},
]);
}
senderRef.current?.focus();
}, [clear, getDefaultGreeting, senderRef, setCurrentConversation]);
const switchAIEmployee = useCallback(
(aiEmployee: AIEmployee, options?: { clear?: ClearOptions }) => {
setCurrentEmployee(aiEmployee);
setCurrentConversation(undefined);
clear(options?.clear);
setModel(null);
if (aiEmployee) {
useChatMessagesStore.getState().setSessionMessages(CHAT_DEFAULT_SESSION_KEY, [
{
key: randomId(),
role: aiEmployee.username,
content: {
type: 'greeting',
content: getDefaultGreeting(aiEmployee),
},
},
]);
senderRef.current?.focus();
} else {
useChatMessagesStore.getState().setSessionMessages(CHAT_DEFAULT_SESSION_KEY, []);
}
},
[clear, getDefaultGreeting, senderRef, setCurrentConversation, setCurrentEmployee, setModel],
);
const triggerTask = useCallback(
async (options: TriggerTaskOptions) => {
const { aiEmployee, tasks } = options;
clear(undefined);
setReadonly(false);
useChatMessagesStore.getState().setSessionResponseLoading(CHAT_DEFAULT_SESSION_KEY, false);
if (!open) {
setOpen(true);
}
setCurrentConversation(undefined);
setCurrentEmployee(aiEmployee);
senderRef.current?.focus();
const messages: Message[] = aiEmployee
? [
{
key: randomId(),
role: aiEmployee.username,
content: {
type: 'greeting',
content: getDefaultGreeting(aiEmployee),
},
},
]
: [];
if (!tasks?.length) {
useChatMessagesStore.getState().setSessionMessages(CHAT_DEFAULT_SESSION_KEY, messages);
return;
}
if (tasks.length === 1 && options.auto !== false) {
const task = tasks[0];
const { userMessage, systemMessage, attachments, workContext, skillSettings, webSearch, model } =
parseTask(task);
useChatMessagesStore.getState().setSessionMessages(CHAT_DEFAULT_SESSION_KEY, messages);
setWebSearch(typeof webSearch === 'boolean' ? webSearch : false);
setModel(model ?? null);
setSenderValue(userMessage?.content ?? '');
if (attachments?.length) {
useChatMessagesStore.getState().setSessionAttachments(CHAT_DEFAULT_SESSION_KEY, attachments);
}
if (workContext) {
useChatMessagesStore.getState().setSessionContextItems(CHAT_DEFAULT_SESSION_KEY, workContext);
}
if (systemMessage) {
useChatMessagesStore.getState().setSessionSystemMessage(CHAT_DEFAULT_SESSION_KEY, systemMessage);
}
if (skillSettings) {
useChatMessagesStore.getState().setSessionSkillSettings(CHAT_DEFAULT_SESSION_KEY, skillSettings);
}
return;
}
messages.push({
key: randomId(),
role: 'task',
content: {
content: tasks,
},
});
useChatMessagesStore.getState().setSessionMessages(CHAT_DEFAULT_SESSION_KEY, messages);
},
[
clear,
getDefaultGreeting,
open,
senderRef,
setCurrentConversation,
setCurrentEmployee,
setModel,
setOpen,
setReadonly,
setSenderValue,
setWebSearch,
],
);
return {
clear,
startNewConversation,
switchAIEmployee,
triggerTask,
};
};
@@ -0,0 +1,144 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import type { Bubble, Sender } from '@ant-design/x';
import type { GetProp, GetRef } from 'antd';
import { create } from 'zustand';
import type { AIEmployee } from '../../types';
import { createSelectors } from './create-selectors';
import { getOrCreateGlobalStore } from './global-store';
type RolesType = GetProp<typeof Bubble.List, 'roles'>;
export interface ModelRef {
llmService: string;
model: string;
}
interface ChatBoxState {
open: boolean;
expanded: boolean;
collapsed: boolean;
showConversations: boolean;
minimize: boolean;
currentEmployee?: AIEmployee;
senderValue: string;
senderPlaceholder: string;
roles: GetProp<typeof Bubble.List, 'roles'>;
taskVariables: {
variables?: Record<string, unknown>;
localVariables?: Record<string, unknown>;
};
isEditingMessage: boolean;
editingMessageId?: string;
chatBoxRef: React.MutableRefObject<HTMLDivElement> | null;
senderRef: React.MutableRefObject<GetRef<typeof Sender>> | null;
showCodeHistory: boolean;
model?: ModelRef | null;
showDebugPanel: boolean;
readonly: boolean;
isShowSenderHint: boolean;
}
interface ChatBoxActions {
setOpen: (open: boolean) => void;
setExpanded: (expanded: boolean) => void;
setCollapsed: (collapsed: boolean) => void;
setShowConversations: (show: boolean) => void;
setMinimize: (minus: boolean) => void;
setCurrentEmployee: (aiEmployee?: AIEmployee | ((prev: AIEmployee) => AIEmployee)) => void;
setSenderValue: (value: string) => void;
setSenderPlaceholder: (placeholder: string) => void;
setTaskVariables: (variables: ChatBoxState['taskVariables']) => void;
setRoles: (roles: RolesType | ((prev: RolesType) => RolesType)) => void;
addRole: (name: string, role: unknown) => void;
setIsEditingMessage: (isEditing: boolean) => void;
setEditingMessageId: (id?: string) => void;
setChatBoxRef: (ref: React.MutableRefObject<HTMLDivElement> | null) => void;
setSenderRef: (ref: React.MutableRefObject<GetRef<typeof Sender>> | null) => void;
setShowCodeHistory: (show: boolean) => void;
setModel: (model: ModelRef | null) => void;
setShowDebugPanel: (show: boolean) => void;
setReadonly: (readonly: boolean) => void;
setShowSenderHint: (show: boolean) => void;
}
const store = getOrCreateGlobalStore('@nocobase/plugin-ai/chat-box-store', () =>
create<ChatBoxState & ChatBoxActions>()((set) => ({
open: false,
expanded: false,
collapsed: false,
showConversations: false,
minimize: false,
currentEmployee: null,
senderValue: '',
senderPlaceholder: '',
taskVariables: {},
roles: {},
isEditingMessage: false,
editingMessageId: null,
chatBoxRef: {
current: null,
},
senderRef: {
current: null,
},
showCodeHistory: false,
model: null,
showDebugPanel: false,
readonly: false,
isShowSenderHint: false,
setOpen: (open) => set({ open, ...(open ? {} : { collapsed: false }) }),
setExpanded: (expanded) => set({ expanded, ...(expanded ? { collapsed: false } : {}) }),
setCollapsed: (collapsed) => set({ collapsed }),
setShowConversations: (show) => set({ showConversations: show }),
setMinimize: (minus) => set({ minimize: minus }),
setCurrentEmployee: (employee: AIEmployee | ((prev: AIEmployee) => AIEmployee)) =>
set((state) => ({
currentEmployee: typeof employee === 'function' ? employee(state.currentEmployee) : employee,
})),
setSenderValue: (val) => set({ senderValue: val }),
setSenderPlaceholder: (val) => set({ senderPlaceholder: val }),
setTaskVariables: (vars) => set({ taskVariables: vars }),
setRoles: (roles: RolesType | ((prev: RolesType) => RolesType)) =>
set((state) => ({
roles: typeof roles === 'function' ? (roles as (prev: RolesType) => RolesType)(state.roles) : roles,
})),
addRole: (name, role) => set((state) => ({ roles: { ...state.roles, [name]: role } })),
setIsEditingMessage: (isEditing) => set({ isEditingMessage: isEditing }),
setEditingMessageId: (id) => set({ editingMessageId: id }),
setChatBoxRef: (ref) => set({ chatBoxRef: ref }),
setSenderRef: (ref) => set({ senderRef: ref }),
setShowCodeHistory: (show) => set({ showCodeHistory: show }),
setModel: (model) => set({ model }),
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
setReadonly: (readonly) => set({ readonly }),
setShowSenderHint: (isShowSenderHint) => set({ isShowSenderHint }),
})),
);
export const useChatBoxStore = createSelectors(store);
@@ -0,0 +1,57 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { create } from 'zustand';
import type { Conversation } from '../../types';
import { createSelectors } from './create-selectors';
import { getOrCreateGlobalStore } from './global-store';
interface ChatConversationsState {
currentConversation?: string;
conversations: Conversation[];
keyword: string;
webSearch: boolean;
conversationSegmented: string;
unreadCount: number;
}
interface ChatConversationsActions {
setCurrentConversation: (id: string | undefined) => void;
setKeyword: (keyword: string) => void;
setConversations: (conversations: Conversation[] | ((prev: Conversation[]) => Conversation[])) => void;
setWebSearch: (webSearch: boolean) => void;
setConversationSegmented: (conversationSegmented: string) => void;
setUnreadCount: (unreadCount: number | ((prev: number) => number)) => void;
}
const store = getOrCreateGlobalStore('@nocobase/plugin-ai/chat-conversations-store', () =>
create<ChatConversationsState & ChatConversationsActions>((set) => ({
currentConversation: undefined,
conversations: [],
keyword: '',
webSearch: false,
conversationSegmented: 'conversations',
unreadCount: 0,
setCurrentConversation: (id) => set({ currentConversation: id }),
setKeyword: (keyword) => set({ keyword }),
setConversations: (conversations) =>
set((state) => ({
conversations: typeof conversations === 'function' ? conversations(state.conversations) : conversations,
})),
setWebSearch: (webSearch) => set({ webSearch }),
setConversationSegmented: (conversationSegmented) => set({ conversationSegmented }),
setUnreadCount: (unreadCount) =>
set((state) => ({
unreadCount: typeof unreadCount === 'function' ? unreadCount(state.unreadCount) : unreadCount,
})),
})),
);
export const useChatConversationsStore = createSelectors(store);
@@ -0,0 +1,473 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { randomId } from '@nocobase/flow-engine';
import { create } from 'zustand';
import type { Attachment, ChatEditorRef, ContextItem, Message, SkillSettings, WebSearching } from '../../types';
import { createSelectors } from './create-selectors';
import { getOrCreateGlobalStore } from './global-store';
export const CHAT_DEFAULT_SESSION_KEY = '__draft__';
export const getChatSessionKey = (sessionId?: string) => sessionId || CHAT_DEFAULT_SESSION_KEY;
export type ChatSessionState = {
messages: Message[];
messagesLoading: boolean;
messagesError?: unknown;
messagesMeta: {
cursor?: string;
hasMore?: boolean;
};
attachments: Attachment[];
contextItems: ContextItem[];
systemMessage: string;
responseLoading: boolean;
abortController?: AbortController;
skillSettings?: SkillSettings;
webSearching?: WebSearching;
backgroundWorking: boolean;
resumeStreamFailed: boolean;
};
export const CHAT_EMPTY_SESSION_STATE: ChatSessionState = {
messages: [],
messagesLoading: false,
messagesError: null,
messagesMeta: {},
attachments: [],
contextItems: [],
systemMessage: '',
responseLoading: false,
abortController: null,
skillSettings: null,
webSearching: null,
backgroundWorking: false,
resumeStreamFailed: false,
};
type ChatMessagesState = {
sessions: Record<string, ChatSessionState>;
editorRef?: Record<string, ChatEditorRef | null>;
currentEditorRefUid?: string;
flowContext?: unknown;
};
type SessionStateUpdater<T> = T | ((prev: T) => T);
const createInitialSessionState = (): ChatSessionState => ({
...CHAT_EMPTY_SESSION_STATE,
});
const cloneSessionState = (session: ChatSessionState): ChatSessionState => ({
...session,
messages: [...session.messages],
messagesMeta: { ...session.messagesMeta },
attachments: [...session.attachments],
contextItems: [...session.contextItems],
});
const resolveSessionState = (state: { sessions: Record<string, ChatSessionState> }, sessionId?: string) =>
state.sessions[getChatSessionKey(sessionId)] ?? createInitialSessionState();
const updateSessionState = (
state: ChatMessagesState,
sessionId: string | undefined,
updater: (session: ChatSessionState) => ChatSessionState,
) => {
const key = getChatSessionKey(sessionId);
const nextSession = updater(resolveSessionState(state, key));
return {
sessions: {
...state.sessions,
[key]: nextSession,
},
};
};
export interface ChatMessagesActions {
setEditorRef: (uid: string, editorRef: ChatEditorRef | null) => void;
setCurrentEditorRefUid: (uid: string) => void;
setFlowContext: (ctx: unknown) => void;
getSessionState: (sessionId?: string) => ChatSessionState;
resetSessionState: (sessionId?: string, patch?: Partial<ChatSessionState>) => void;
migrateSessionState: (fromSessionId: string | undefined, toSessionId: string) => void;
setSessionMessages: (sessionId: string | undefined, messages: SessionStateUpdater<Message[]>) => void;
setSessionMessagesLoading: (sessionId: string | undefined, loading: boolean) => void;
setSessionMessagesError: (sessionId: string | undefined, error: unknown) => void;
setSessionMessagesMeta: (
sessionId: string | undefined,
meta:
| ChatSessionState['messagesMeta']
| ((prev: ChatSessionState['messagesMeta']) => ChatSessionState['messagesMeta']),
) => void;
setSessionAttachments: (sessionId: string | undefined, attachments: SessionStateUpdater<Attachment[]>) => void;
setSessionContextItems: (sessionId: string | undefined, items: SessionStateUpdater<ContextItem[]>) => void;
setSessionSystemMessage: (sessionId: string | undefined, msg: string | ((prev: string) => string)) => void;
setSessionResponseLoading: (sessionId: string | undefined, loading: boolean) => void;
setSessionBackgroundWorking: (sessionId: string | undefined, backgroundWorking: boolean) => void;
setSessionResumeStreamFailed: (sessionId: string | undefined, resumeStreamFailed: boolean) => void;
addSessionMessage: (sessionId: string | undefined, msg: Message) => void;
addSessionMessages: (sessionId: string | undefined, msgs: Message[]) => void;
updateSessionLastMessage: (sessionId: string | undefined, updater: (msg: Message) => Message) => void;
removeSessionMessage: (sessionId: string | undefined, key: string) => void;
addSessionAttachments: (sessionId: string | undefined, attachments: Attachment | Attachment[]) => void;
removeSessionAttachment: (sessionId: string | undefined, filename: string) => void;
addSessionContextItems: (sessionId: string | undefined, items: ContextItem | ContextItem[]) => void;
addContextItems: (items: ContextItem | ContextItem[]) => void;
removeSessionContextItem: (sessionId: string | undefined, type: string, uid: string) => void;
setSessionAbortController: (sessionId: string | undefined, controller: AbortController | undefined) => void;
setSessionSkillSettings: (sessionId: string | undefined, settings: SkillSettings | undefined) => void;
setSessionWebSearching: (sessionId: string | undefined, webSearching: WebSearching) => void;
addSessionSubAgentMessage: (sessionId: string | undefined, subSessionId: string, msg: Message) => void;
addSessionSubAgentMessages: (sessionId: string | undefined, subSessionId: string, msgs: Message[]) => void;
updateSessionLastSubAgentMessage: (
sessionId: string | undefined,
subSessionId: string,
username: string,
updater: (msg: Message) => Message,
) => void;
updateSessionSubAgentConversationStatus: (
sessionId: string | undefined,
subSessionId: string,
status: 'pending' | 'completed',
) => void;
}
const store = getOrCreateGlobalStore('@nocobase/plugin-ai/chat-messages-store', () =>
create<ChatMessagesState & ChatMessagesActions>((set, get) => {
const defaultSession = createInitialSessionState();
return {
sessions: {
[CHAT_DEFAULT_SESSION_KEY]: defaultSession,
},
editorRef: {},
currentEditorRefUid: null,
flowContext: null,
getSessionState: (sessionId) => cloneSessionState(resolveSessionState(get(), sessionId)),
resetSessionState: (sessionId, patch) =>
set((state) =>
updateSessionState(state, sessionId, () => ({
...createInitialSessionState(),
...(patch ?? {}),
})),
),
migrateSessionState: (fromSessionId, toSessionId) => {
const fromKey = getChatSessionKey(fromSessionId);
const toKey = getChatSessionKey(toSessionId);
if (fromKey === toKey) {
return;
}
set((state) => {
const sourceSession = resolveSessionState(state, fromKey);
const nextSessions = { ...state.sessions, [toKey]: cloneSessionState(sourceSession) };
if (fromKey === CHAT_DEFAULT_SESSION_KEY) {
nextSessions[CHAT_DEFAULT_SESSION_KEY] = createInitialSessionState();
return { sessions: nextSessions };
}
delete nextSessions[fromKey];
return {
sessions: nextSessions,
};
});
},
setSessionMessages: (sessionId, messages) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messages: typeof messages === 'function' ? messages(session.messages) : messages,
})),
),
setSessionMessagesLoading: (sessionId, loading) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messagesLoading: loading,
})),
),
setSessionMessagesError: (sessionId, error) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messagesError: error,
})),
),
setSessionMessagesMeta: (sessionId, meta) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messagesMeta: typeof meta === 'function' ? meta(session.messagesMeta) : meta,
})),
),
setSessionAttachments: (sessionId, attachments) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
attachments: typeof attachments === 'function' ? attachments(session.attachments) : attachments,
})),
),
setSessionContextItems: (sessionId, items) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
contextItems: typeof items === 'function' ? items(session.contextItems) : items,
})),
),
setSessionSystemMessage: (sessionId, msg) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
systemMessage: typeof msg === 'function' ? msg(session.systemMessage) : msg,
})),
),
setSessionResponseLoading: (sessionId, loading) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
responseLoading: loading,
})),
),
setSessionBackgroundWorking: (sessionId, backgroundWorking) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
backgroundWorking,
})),
),
setSessionResumeStreamFailed: (sessionId, resumeStreamFailed) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
resumeStreamFailed,
})),
),
addSessionMessage: (sessionId, message) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messages: [...session.messages, message],
})),
),
addSessionMessages: (sessionId, msgs) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messages: [...session.messages, ...msgs],
})),
),
updateSessionLastMessage: (sessionId, updater) =>
set((state) =>
updateSessionState(state, sessionId, (session) => {
const messages = [...session.messages];
const index = messages.length - 1;
if (index >= 0) {
messages[index] = updater(messages[index]);
}
return {
...session,
messages,
};
}),
),
removeSessionMessage: (sessionId, key) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messages: session.messages.filter((msg) => msg.key !== key),
})),
),
addSessionAttachments: (sessionId, attachments) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
attachments: Array.isArray(attachments)
? [...session.attachments, ...attachments]
: [...session.attachments, attachments],
})),
),
removeSessionAttachment: (sessionId, filename) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
attachments: session.attachments.filter((attachment) => attachment.filename !== filename),
})),
),
addSessionContextItems: (sessionId, items) => {
const nextItems = Array.isArray(items) ? items : [items];
set((state) =>
updateSessionState(state, sessionId, (session) => {
const map = new Map<string, ContextItem>();
for (const item of session.contextItems) {
map.set(`${item.type}:${item.uid}`, item);
}
for (const item of nextItems) {
map.set(`${item.type}:${item.uid}`, item);
}
return {
...session,
contextItems: Array.from(map.values()),
};
}),
);
},
addContextItems: (items) => {
get().addSessionContextItems(undefined, items);
},
removeSessionContextItem: (sessionId, type, uid) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
contextItems: session.contextItems.filter((item) => !(item.type === type && item.uid === uid)),
})),
),
setSessionAbortController: (sessionId, controller) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
abortController: controller,
})),
),
setSessionSkillSettings: (sessionId, settings) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
skillSettings: settings,
})),
),
setEditorRef: (uid, editorRef) => set((state) => ({ editorRef: { ...state.editorRef, [uid]: editorRef } })),
setCurrentEditorRefUid: (uid) => set({ currentEditorRefUid: uid }),
setSessionWebSearching: (sessionId, webSearching) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
webSearching,
})),
),
setFlowContext: (flowContext) => set({ flowContext }),
addSessionSubAgentMessage: (sessionId, subSessionId, msg) => {
get().addSessionSubAgentMessages(sessionId, subSessionId, [msg]);
},
addSessionSubAgentMessages: (sessionId, subSessionId, msgs) => {
get().updateSessionLastMessage(sessionId, (last) => ({
...last,
content: {
...last.content,
subAgentConversations: last.content.subAgentConversations?.map((conversation) => {
if (conversation.sessionId !== subSessionId) {
return conversation;
}
return {
...conversation,
messages: [...conversation.messages, ...msgs],
};
}) ?? [
{
sessionId: subSessionId,
messages: msgs,
},
],
},
loading: false,
}));
},
updateSessionLastSubAgentMessage: (sessionId, subSessionId, username, updater) => {
get().updateSessionLastMessage(sessionId, (last) => ({
...last,
content: {
...last.content,
subAgentConversations: last.content.subAgentConversations?.map((conversation) => {
if (conversation.sessionId !== subSessionId) {
return conversation;
}
const messages = [...conversation.messages];
const index = messages.length - 1;
if (index >= 0) {
messages[index] = updater(messages[index]);
}
return {
...conversation,
messages,
};
}) ?? [
{
sessionId: subSessionId,
messages: [
updater({
key: randomId(),
role: username,
createdAt: new Date().toISOString(),
content: { type: 'text', content: '' },
loading: true,
}),
],
},
],
},
loading: false,
}));
},
updateSessionSubAgentConversationStatus: (sessionId, subSessionId, status) => {
get().updateSessionLastMessage(sessionId, (last) => ({
...last,
content: {
...last.content,
subAgentConversations: last.content.subAgentConversations?.map((conversation) => {
if (conversation.sessionId !== subSessionId) {
return conversation;
}
return {
...conversation,
status,
};
}),
},
loading: false,
}));
},
};
}),
);
export const useChatMessagesStore = createSelectors(store);
@@ -0,0 +1,22 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import type { StoreApi, UseBoundStore } from 'zustand';
type WithSelectors<S> = S extends { getState: () => infer T } ? S & { use: { [K in keyof T]: () => T[K] } } : never;
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(_store: S) => {
const store = _store as WithSelectors<typeof _store>;
store.use = {};
for (const k of Object.keys(store.getState())) {
(store.use as Record<string, () => unknown>)[k] = () => store((s) => s[k as keyof typeof s]);
}
return store;
};
@@ -0,0 +1,27 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
type GlobalWithAIChatStores = typeof globalThis & {
__nocobasePluginAIChatStores?: Record<string, unknown>;
};
export const getOrCreateGlobalStore = <T>(key: string, createStore: () => T): T => {
const global = globalThis as GlobalWithAIChatStores;
const stores = global.__nocobasePluginAIChatStores ?? {};
global.__nocobasePluginAIChatStores = stores;
const existingStore = stores[key];
if (existingStore) {
return existingStore as T;
}
const store = createStore();
stores[key] = store;
return store;
};
@@ -7,6 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import type { BubbleProps } from '@ant-design/x';
export type AIEmployee = {
username: string;
nickname?: string;
@@ -14,9 +16,164 @@ export type AIEmployee = {
avatar?: string;
bio?: string;
greeting?: string;
userConfig?: {
prompt?: string;
};
skillSettings?: {
tools?: { name: string; autoCall?: boolean }[];
skills?: string[];
};
chatSettings?: {
systemPromptMode?: 'default' | 'raw' | 'none';
enableSkills?: boolean;
enableTools?: boolean;
[key: string]: unknown;
};
builtIn?: boolean;
webSearch?: boolean;
toolsConflict?: boolean;
category?: string;
deprecated?: boolean;
modelSettings?: {
enabled?: boolean;
llmService?: string;
model?: string;
models?: {
llmService?: string;
model?: string;
}[];
};
};
export type SkillSettings = {
toolsVersion?: number;
skillsVersion?: number;
tools?: string[];
skills?: string[];
};
export type Conversation = {
sessionId: string;
title: string;
updatedAt: string;
aiEmployee: AIEmployee;
read: boolean;
options?: {
modelSettings?: {
llmService?: string;
model?: string;
};
[key: string]: any;
};
};
export type ContextItem = {
type: string;
uid: string;
title?: string;
content?: unknown;
};
export type ToolCall<T = unknown> = {
id: string;
type: string;
name: string;
status?: 'success' | 'error';
invokeStatus: 'init' | 'interrupted' | 'waiting' | 'pending' | 'done' | 'confirmed';
auto: boolean;
args: T;
[key: string]: any;
};
export type Attachment = any;
export type MessageType = 'text' | 'greeting';
export type Message = Omit<BubbleProps, 'content'> & {
key?: string | number;
role?: string;
createdAt?: string | Date;
content: {
content: any;
ref?: React.MutableRefObject<any>;
type?: MessageType;
messageId?: string;
attachments?: Attachment[];
workContext?: ContextItem[];
tool_calls?: ToolCall<unknown>[];
metadata?: {
model: string;
provider: string;
llmService?: string;
usage_metadata?: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
autoCallTools?: string[];
};
reference?: {
title: string;
url: string;
}[];
reasoning?: { status: string; content: string };
subAgentConversations?: {
sessionId: string;
toolCallId?: string;
status?: 'pending' | 'completed';
messages: Message[];
}[];
from?: 'main-agent' | 'sub-agent';
};
};
export type TaskMessage = {
user?: string;
system?: string;
attachments?: Attachment[];
workContext?: ContextItem[];
};
export type Task = {
title?: string;
message?: TaskMessage;
autoSend?: boolean;
skillSettings?: SkillSettings;
webSearch?: boolean;
model?: {
llmService: string;
model: string;
} | null;
};
export type TriggerTaskOptions = {
aiEmployee?: AIEmployee;
tasks?: Task[];
auto?: boolean;
};
export type ClearOptions = {
sender?: boolean;
systemMessage?: boolean;
attachments?: boolean;
contextItems?: boolean;
taskVariables?: boolean;
toolModal?: boolean;
activeTool?: boolean;
activeMessageId?: boolean;
skillSettings?: boolean;
};
export type WebSearching = {
type: string;
query: string;
};
export interface ChatEditorRef {
write(document: string): void;
read(): string;
run?(): Promise<unknown>;
buttonGroupHeight?: number;
snippetEntries: unknown[];
logs: unknown[];
}
@@ -8,13 +8,45 @@
*/
import { Plugin } from '@nocobase/client-v2';
import { AIConfigRepository } from './repositories/AIConfigRepository';
export class PluginAIClientV2 extends Plugin {}
type AIFlowContext = {
aiConfigRepository?: AIConfigRepository;
defineProperty: (name: string, descriptor: { value: unknown }) => void;
};
export class PluginAIClientV2 extends Plugin {
async load() {
const context = this.app.flowEngine.context as AIFlowContext;
if (!context.aiConfigRepository) {
context.defineProperty('aiConfigRepository', {
value: new AIConfigRepository(this.app.apiClient),
});
}
}
}
export default PluginAIClientV2;
export { AIEmployeeProfileCard } from './ai-employees/ProfileCard';
export { AIEmployeeShortcut } from './ai-employees/AIEmployeeShortcut';
export { avatars, avatarsMap } from './ai-employees/avatars';
export type { AIEmployee, Task } from './ai-employees/types';
export type {
AIEmployee,
Attachment,
ChatEditorRef,
ContextItem,
Conversation,
Message,
SkillSettings,
Task,
TriggerTaskOptions,
WebSearching,
} from './ai-employees/types';
export { formatModelLabel } from './llm-services/model-label';
export { AIConfigRepository } from './repositories/AIConfigRepository';
export { useAIConfigRepository } from './repositories/hooks/useAIConfigRepository';
export { useChatMessagesStore } from './ai-employees/chatbox/stores/chat-messages';
export { useChatBoxStore } from './ai-employees/chatbox/stores/chat-box';
export { useChatConversationsStore } from './ai-employees/chatbox/stores/chat-conversations';
export { useChatBoxActions } from './ai-employees/chatbox/hooks/useChatBoxActions';
@@ -0,0 +1,273 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { define, observable } from '@nocobase/flow-engine';
import type { SkillsEntry, ToolsEntry, ToolsManager } from '@nocobase/client-v2';
import type { AIEmployee } from '../ai-employees/types';
type APIResource = {
list?: (params?: Record<string, unknown>) => Promise<{ data?: { data?: unknown } }>;
listAllEnabledModels?: () => Promise<{ data?: { data?: unknown } }>;
listByUser?: () => Promise<{ data?: { data?: unknown } }>;
};
type APIClient = {
resource: (name: string) => APIResource;
};
export interface LLMServiceItem {
llmService: string;
llmServiceTitle: string;
provider?: string;
providerTitle?: string;
enabledModels: { label: string; value: string }[];
supportWebSearch?: boolean;
isToolConflict?: boolean;
}
export class AIConfigRepository {
llmServices = observable.shallow<LLMServiceItem[]>([]);
llmServicesLoading = false;
aiEmployees = observable.shallow<AIEmployee[]>([]);
aiEmployeesLoading = false;
aiTools = observable.shallow<ToolsEntry[]>([]);
aiToolsLoading = false;
aiSkills = observable.shallow<SkillsEntry[]>([]);
aiSkillsLoading = false;
private llmServicesLoaded = false;
private aiEmployeesLoaded = false;
private aiToolsLoaded = false;
private aiToolsBySessionId: string | undefined = null;
private aiSkillsLoaded = false;
private llmServicesInFlight: Promise<LLMServiceItem[]> | null = null;
private aiEmployeesInFlight: Promise<AIEmployee[]> | null = null;
private aiToolsInFlight: Promise<ToolsEntry[]> | null = null;
private aiSkillsInFlight: Promise<SkillsEntry[]> | null = null;
constructor(
private readonly apiClient: APIClient,
private readonly options?: { toolsManager?: Pick<ToolsManager, 'listTools'> },
) {
define(this, {
llmServices: observable.shallow,
llmServicesLoading: observable.ref,
aiEmployees: observable.shallow,
aiEmployeesLoading: observable.ref,
aiTools: observable.shallow,
aiToolsLoading: observable.ref,
aiSkills: observable.shallow,
aiSkillsLoading: observable.ref,
});
}
async getLLMServices(): Promise<LLMServiceItem[]> {
if (this.llmServicesInFlight) {
return this.llmServicesInFlight;
}
if (this.llmServicesLoaded) {
return this.llmServices;
}
return this.startRefresh(
this.llmServicesInFlight,
(promise) => {
this.llmServicesInFlight = promise;
},
() => this.doRefreshLLMServices(),
() => this.llmServices,
);
}
async refreshLLMServices(): Promise<LLMServiceItem[]> {
return this.startRefresh(
this.llmServicesInFlight,
(promise) => {
this.llmServicesInFlight = promise;
},
() => this.doRefreshLLMServices(),
() => this.llmServices,
);
}
async getAIEmployees(): Promise<AIEmployee[]> {
if (this.aiEmployeesInFlight) {
return this.aiEmployeesInFlight;
}
if (this.aiEmployeesLoaded) {
return this.aiEmployees;
}
return this.startRefresh(
this.aiEmployeesInFlight,
(promise) => {
this.aiEmployeesInFlight = promise;
},
() => this.doRefreshAIEmployees(),
() => this.aiEmployees,
);
}
async refreshAIEmployees(): Promise<AIEmployee[]> {
return this.startRefresh(
this.aiEmployeesInFlight,
(promise) => {
this.aiEmployeesInFlight = promise;
},
() => this.doRefreshAIEmployees(),
() => this.aiEmployees,
);
}
getAIEmployeesMap(): Record<string, AIEmployee> {
return this.aiEmployees.reduce<Record<string, AIEmployee>>((acc, aiEmployee) => {
acc[aiEmployee.username] = aiEmployee;
return acc;
}, {});
}
async getAITools(sessionId?: string): Promise<ToolsEntry[]> {
if (this.aiToolsInFlight) {
return this.aiToolsInFlight;
}
if (this.aiToolsLoaded && this.aiToolsBySessionId === sessionId) {
return this.aiTools;
}
return this.startRefresh(
this.aiToolsInFlight,
(promise) => {
this.aiToolsInFlight = promise;
},
() => this.doRefreshAITools(sessionId),
() => this.aiTools,
);
}
async refreshAITools(sessionId?: string): Promise<ToolsEntry[]> {
return this.startRefresh(
this.aiToolsInFlight,
(promise) => {
this.aiToolsInFlight = promise;
},
() => this.doRefreshAITools(sessionId),
() => this.aiTools,
);
}
async getAISkills(): Promise<SkillsEntry[]> {
if (this.aiSkillsInFlight) {
return this.aiSkillsInFlight;
}
if (this.aiSkillsLoaded) {
return this.aiSkills;
}
return this.startRefresh(
this.aiSkillsInFlight,
(promise) => {
this.aiSkillsInFlight = promise;
},
() => this.doRefreshAISkills(),
() => this.aiSkills,
);
}
async refreshAISkills(): Promise<SkillsEntry[]> {
return this.startRefresh(
this.aiSkillsInFlight,
(promise) => {
this.aiSkillsInFlight = promise;
},
() => this.doRefreshAISkills(),
() => this.aiSkills,
);
}
private startRefresh<T>(
inFlight: Promise<T> | null,
setInFlight: (promise: Promise<T> | null) => void,
refresh: () => Promise<void>,
getData: () => T,
): Promise<T> {
if (inFlight) {
return inFlight;
}
const promise = refresh()
.then(() => getData())
.finally(() => {
setInFlight(null);
});
setInFlight(promise);
return promise;
}
private async doRefreshLLMServices() {
this.llmServicesLoading = true;
try {
const res = await this.apiClient.resource('ai').listAllEnabledModels();
const data = Array.isArray(res?.data?.data) ? (res.data.data as LLMServiceItem[]) : [];
this.llmServices = data;
this.llmServicesLoaded = true;
} catch {
this.llmServices = [];
this.llmServicesLoaded = false;
} finally {
this.llmServicesLoading = false;
}
}
private async doRefreshAIEmployees() {
this.aiEmployeesLoading = true;
try {
const res = await this.apiClient.resource('aiEmployees').listByUser();
const aiEmployees = Array.isArray(res?.data?.data) ? (res.data.data as AIEmployee[]) : [];
this.aiEmployees = aiEmployees;
this.aiEmployeesLoaded = true;
} catch {
this.aiEmployees = [];
this.aiEmployeesLoaded = false;
} finally {
this.aiEmployeesLoading = false;
}
}
private async doRefreshAITools(sessionId?: string) {
this.aiToolsLoading = true;
try {
let tools: ToolsEntry[] = [];
if (this.options?.toolsManager) {
tools = await this.options.toolsManager.listTools({ sessionId });
} else {
const res = await this.apiClient.resource('aiTools').list({ filter: { sessionId } });
tools = Array.isArray(res?.data?.data) ? (res.data.data as ToolsEntry[]) : [];
}
this.aiTools = tools;
this.aiToolsLoaded = true;
this.aiToolsBySessionId = sessionId;
} catch {
this.aiTools = [];
this.aiToolsLoaded = false;
this.aiToolsBySessionId = null;
} finally {
this.aiToolsLoading = false;
}
}
private async doRefreshAISkills() {
this.aiSkillsLoading = true;
try {
const res = await this.apiClient.resource('aiSkills').list({});
const data = Array.isArray(res?.data?.data) ? (res.data.data as SkillsEntry[]) : [];
this.aiSkills = data;
this.aiSkillsLoaded = true;
} catch {
this.aiSkills = [];
this.aiSkillsLoaded = false;
} finally {
this.aiSkillsLoading = false;
}
}
}
@@ -0,0 +1,15 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useFlowEngine } from '@nocobase/flow-engine';
import type { AIConfigRepository } from '../AIConfigRepository';
export const useAIConfigRepository = (): AIConfigRepository => {
return useFlowEngine().context.aiConfigRepository;
};
@@ -7,139 +7,4 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { create } from 'zustand';
import { Bubble, Sender } from '@ant-design/x';
import { GetProp, GetRef } from 'antd';
import { AIEmployee } from '../../types';
import { createSelectors } from './create-selectors';
type RolesType = GetProp<typeof Bubble.List, 'roles'>;
export interface ModelRef {
llmService: string;
model: string;
}
interface ChatBoxState {
open: boolean;
expanded: boolean;
collapsed: boolean;
showConversations: boolean;
minimize: boolean;
currentEmployee?: AIEmployee;
senderValue: string;
senderPlaceholder: string;
roles: GetProp<typeof Bubble.List, 'roles'>;
taskVariables: {
variables?: Record<string, any>;
localVariables?: Record<string, any>;
};
isEditingMessage: boolean;
editingMessageId?: string;
chatBoxRef: React.MutableRefObject<HTMLDivElement> | null;
senderRef: React.MutableRefObject<GetRef<typeof Sender>> | null;
showCodeHistory: boolean;
model?: ModelRef | null;
// [AI_DEBUG]
showDebugPanel: boolean;
readonly: boolean;
isShowSenderHint: boolean;
}
interface ChatBoxActions {
setOpen: (open: boolean) => void;
setExpanded: (expanded: boolean) => void;
setCollapsed: (collapsed: boolean) => void;
setShowConversations: (show: boolean) => void;
setMinimize: (minus: boolean) => void;
setCurrentEmployee: (aiEmployee?: AIEmployee | ((prev: AIEmployee) => AIEmployee)) => void;
setSenderValue: (value: string) => void;
setSenderPlaceholder: (placeholder: string) => void;
setTaskVariables: (variables: ChatBoxState['taskVariables']) => void;
setRoles: (roles: RolesType | ((prev: RolesType) => RolesType)) => void;
addRole: (name: string, role: any) => void;
setIsEditingMessage: (isEditing: boolean) => void;
setEditingMessageId: (id?: string) => void;
setChatBoxRef: (ref: React.MutableRefObject<HTMLDivElement> | null) => void;
setSenderRef: (ref: React.MutableRefObject<GetRef<typeof Sender>> | null) => void;
setShowCodeHistory: (show: boolean) => void;
setModel: (model: ModelRef | null) => void;
// [AI_DEBUG]
setShowDebugPanel: (show: boolean) => void;
setReadonly: (readonly: boolean) => void;
setShowSenderHint: (show: boolean) => void;
}
const store = create<ChatBoxState & ChatBoxActions>()((set) => ({
open: false,
expanded: false,
collapsed: false,
showConversations: false,
minimize: false,
currentEmployee: null,
senderValue: '',
senderPlaceholder: '',
taskVariables: {},
roles: {},
isEditingMessage: false,
editingMessageId: null,
chatBoxRef: {
current: null,
},
senderRef: {
current: null,
},
showCodeHistory: false,
model: null,
// [AI_DEBUG]
showDebugPanel: false,
readonly: false,
isShowSenderHint: false,
setOpen: (open) => set({ open, ...(open ? {} : { collapsed: false }) }),
setExpanded: (expanded) => set({ expanded, ...(expanded ? { collapsed: false } : {}) }),
setCollapsed: (collapsed) => set({ collapsed }),
setShowConversations: (show) => set({ showConversations: show }),
setMinimize: (minus) => set({ minimize: minus }),
setCurrentEmployee: (employee: AIEmployee | ((prev: AIEmployee) => AIEmployee)) =>
set((state) => ({
currentEmployee: typeof employee === 'function' ? employee(state.currentEmployee) : employee,
})),
setSenderValue: (val) => set({ senderValue: val }),
setSenderPlaceholder: (val) => set({ senderPlaceholder: val }),
setTaskVariables: (vars) => set({ taskVariables: vars }),
setRoles: (roles: RolesType | ((prev: RolesType) => RolesType)) =>
set((state) => ({
roles: typeof roles === 'function' ? (roles as (prev: RolesType) => RolesType)(state.roles) : roles,
})),
addRole: (name, role) => set((state) => ({ roles: { ...state.roles, [name]: role } })),
setIsEditingMessage: (isEditing) => set({ isEditingMessage: isEditing }),
setEditingMessageId: (id) => set({ editingMessageId: id }),
setChatBoxRef: (ref) => set({ chatBoxRef: ref }),
setSenderRef: (ref) => set({ senderRef: ref }),
setShowCodeHistory: (show) => set({ showCodeHistory: show }),
setModel: (model) => set({ model }),
// [AI_DEBUG]
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
setReadonly: (readonly) => set({ readonly }),
setShowSenderHint: (isShowSenderHint) => set({ isShowSenderHint }),
}));
export const useChatBoxStore = createSelectors(store);
export * from '../../../../client-v2/ai-employees/chatbox/stores/chat-box';
@@ -7,48 +7,4 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { create } from 'zustand';
import { Conversation } from '../../types';
import { createSelectors } from './create-selectors';
interface ChatConversationsState {
currentConversation?: string;
conversations: Conversation[];
keyword: string;
webSearch: boolean;
conversationSegmented: string;
unreadCount: number;
}
interface ChatConversationsActions {
setCurrentConversation: (id: string | undefined) => void;
setKeyword: (keyword: string) => void;
setConversations: (conversations: Conversation[] | ((prev: Conversation[]) => Conversation[])) => void;
setWebSearch: (webSearch: boolean) => void;
setConversationSegmented: (conversationSegmented: string) => void;
setUnreadCount: (unreadCount: number | ((prev: number) => number)) => void;
}
const store = create<ChatConversationsState & ChatConversationsActions>((set) => ({
currentConversation: undefined,
conversations: [],
keyword: '',
webSearch: false,
conversationSegmented: 'conversations',
unreadCount: 0,
setCurrentConversation: (id) => set({ currentConversation: id }),
setKeyword: (keyword) => set({ keyword }),
setConversations: (conversations) =>
set((state) => ({
conversations: typeof conversations === 'function' ? conversations(state.conversations) : conversations,
})),
setWebSearch: (webSearch) => set({ webSearch }),
setConversationSegmented: (conversationSegmented) => set({ conversationSegmented }),
setUnreadCount: (unreadCount) =>
set((state) => ({
unreadCount: typeof unreadCount === 'function' ? unreadCount(state.unreadCount) : unreadCount,
})),
}));
export const useChatConversationsStore = createSelectors(store);
export * from '../../../../client-v2/ai-employees/chatbox/stores/chat-conversations';
@@ -7,460 +7,4 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { create } from 'zustand';
import { Message, Attachment, ContextItem, SkillSettings, WebSearching } from '../../types';
import { createSelectors } from './create-selectors';
import { EditorRef } from '@nocobase/client';
import { uid } from '@formily/shared';
export const CHAT_DEFAULT_SESSION_KEY = '__draft__';
export const getChatSessionKey = (sessionId?: string) => sessionId || CHAT_DEFAULT_SESSION_KEY;
export type ChatSessionState = {
messages: Message[];
messagesLoading: boolean;
messagesError?: any;
messagesMeta: {
cursor?: string;
hasMore?: boolean;
};
attachments: Attachment[];
contextItems: ContextItem[];
systemMessage: string;
responseLoading: boolean;
abortController?: AbortController;
skillSettings?: SkillSettings;
webSearching?: WebSearching;
backgroundWorking: boolean;
resumeStreamFailed: boolean;
};
export const CHAT_EMPTY_SESSION_STATE: ChatSessionState = {
messages: [],
messagesLoading: false,
messagesError: null,
messagesMeta: {},
attachments: [],
contextItems: [],
systemMessage: '',
responseLoading: false,
abortController: null,
skillSettings: null,
webSearching: null,
backgroundWorking: false,
resumeStreamFailed: false,
};
type ChatMessagesState = {
sessions: Record<string, ChatSessionState>;
editorRef?: Record<string, EditorRef>;
currentEditorRefUid?: string;
flowContext?: any;
};
type SessionStateUpdater<T> = T | ((prev: T) => T);
const createInitialSessionState = (): ChatSessionState => ({
...CHAT_EMPTY_SESSION_STATE,
});
const cloneSessionState = (session: ChatSessionState): ChatSessionState => ({
...session,
messages: [...session.messages],
messagesMeta: { ...session.messagesMeta },
attachments: [...session.attachments],
contextItems: [...session.contextItems],
});
const resolveSessionState = (state: { sessions: Record<string, ChatSessionState> }, sessionId?: string) =>
state.sessions[getChatSessionKey(sessionId)] ?? createInitialSessionState();
const updateSessionState = (
state: ChatMessagesState,
sessionId: string | undefined,
updater: (session: ChatSessionState) => ChatSessionState,
) => {
const key = getChatSessionKey(sessionId);
const nextSession = updater(resolveSessionState(state, key));
return {
sessions: {
...state.sessions,
[key]: nextSession,
},
};
};
export interface ChatMessagesActions {
setEditorRef: (uid: string, editorRef: EditorRef) => void;
setCurrentEditorRefUid: (uid: string) => void;
setFlowContext: (ctx: any) => void;
getSessionState: (sessionId?: string) => ChatSessionState;
resetSessionState: (sessionId?: string, patch?: Partial<ChatSessionState>) => void;
migrateSessionState: (fromSessionId: string | undefined, toSessionId: string) => void;
setSessionMessages: (sessionId: string | undefined, messages: SessionStateUpdater<Message[]>) => void;
setSessionMessagesLoading: (sessionId: string | undefined, loading: boolean) => void;
setSessionMessagesError: (sessionId: string | undefined, error: any) => void;
setSessionMessagesMeta: (
sessionId: string | undefined,
meta:
| ChatSessionState['messagesMeta']
| ((prev: ChatSessionState['messagesMeta']) => ChatSessionState['messagesMeta']),
) => void;
setSessionAttachments: (sessionId: string | undefined, attachments: SessionStateUpdater<Attachment[]>) => void;
setSessionContextItems: (sessionId: string | undefined, items: SessionStateUpdater<ContextItem[]>) => void;
setSessionSystemMessage: (sessionId: string | undefined, msg: string | ((prev: string) => string)) => void;
setSessionResponseLoading: (sessionId: string | undefined, loading: boolean) => void;
setSessionBackgroundWorking: (sessionId: string | undefined, backgroundWorking: boolean) => void;
setSessionResumeStreamFailed: (sessionId: string | undefined, resumeStreamFailed: boolean) => void;
addSessionMessage: (sessionId: string | undefined, msg: Message) => void;
addSessionMessages: (sessionId: string | undefined, msgs: Message[]) => void;
updateSessionLastMessage: (sessionId: string | undefined, updater: (msg: Message) => Message) => void;
removeSessionMessage: (sessionId: string | undefined, key: string) => void;
addSessionAttachments: (sessionId: string | undefined, attachments: Attachment | Attachment[]) => void;
removeSessionAttachment: (sessionId: string | undefined, filename: string) => void;
addSessionContextItems: (sessionId: string | undefined, items: ContextItem | ContextItem[]) => void;
removeSessionContextItem: (sessionId: string | undefined, type: string, uid: string) => void;
setSessionAbortController: (sessionId: string | undefined, controller: AbortController | undefined) => void;
setSessionSkillSettings: (sessionId: string | undefined, settings: SkillSettings | undefined) => void;
setSessionWebSearching: (sessionId: string | undefined, webSearching: WebSearching) => void;
addSessionSubAgentMessage: (sessionId: string | undefined, subSessionId: string, msg: Message) => void;
addSessionSubAgentMessages: (sessionId: string | undefined, subSessionId: string, msgs: Message[]) => void;
updateSessionLastSubAgentMessage: (
sessionId: string | undefined,
subSessionId: string,
username: string,
updater: (msg: Message) => Message,
) => void;
updateSessionSubAgentConversationStatus: (
sessionId: string | undefined,
subSessionId: string,
status: 'pending' | 'completed',
) => void;
}
const store = create<ChatMessagesState & ChatMessagesActions>((set, get) => {
const defaultSession = createInitialSessionState();
return {
sessions: {
[CHAT_DEFAULT_SESSION_KEY]: defaultSession,
},
editorRef: {},
currentEditorRefUid: null,
flowContext: null,
getSessionState: (sessionId) => cloneSessionState(resolveSessionState(get(), sessionId)),
resetSessionState: (sessionId, patch) =>
set((state) =>
updateSessionState(state, sessionId, () => ({
...createInitialSessionState(),
...(patch ?? {}),
})),
),
migrateSessionState: (fromSessionId, toSessionId) => {
const fromKey = getChatSessionKey(fromSessionId);
const toKey = getChatSessionKey(toSessionId);
if (fromKey === toKey) {
return;
}
set((state) => {
const sourceSession = resolveSessionState(state, fromKey);
const nextSessions = { ...state.sessions, [toKey]: cloneSessionState(sourceSession) };
if (fromKey === CHAT_DEFAULT_SESSION_KEY) {
nextSessions[CHAT_DEFAULT_SESSION_KEY] = createInitialSessionState();
return { sessions: nextSessions };
}
delete nextSessions[fromKey];
return {
sessions: nextSessions,
};
});
},
setSessionMessages: (sessionId, messages) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messages: typeof messages === 'function' ? messages(session.messages) : messages,
})),
),
setSessionMessagesLoading: (sessionId, loading) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messagesLoading: loading,
})),
),
setSessionMessagesError: (sessionId, error) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messagesError: error,
})),
),
setSessionMessagesMeta: (sessionId, meta) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messagesMeta: typeof meta === 'function' ? meta(session.messagesMeta) : meta,
})),
),
setSessionAttachments: (sessionId, attachments) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
attachments: typeof attachments === 'function' ? attachments(session.attachments) : attachments,
})),
),
setSessionContextItems: (sessionId, items) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
contextItems: typeof items === 'function' ? items(session.contextItems) : items,
})),
),
setSessionSystemMessage: (sessionId, msg) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
systemMessage: typeof msg === 'function' ? msg(session.systemMessage) : msg,
})),
),
setSessionResponseLoading: (sessionId, loading) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
responseLoading: loading,
})),
),
setSessionBackgroundWorking: (sessionId, backgroundWorking) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
backgroundWorking,
})),
),
setSessionResumeStreamFailed: (sessionId, resumeStreamFailed) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
resumeStreamFailed,
})),
),
addSessionMessage: (sessionId, message) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messages: [...session.messages, message],
})),
),
addSessionMessages: (sessionId, msgs) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messages: [...session.messages, ...msgs],
})),
),
updateSessionLastMessage: (sessionId, updater) =>
set((state) =>
updateSessionState(state, sessionId, (session) => {
const messages = [...session.messages];
const index = messages.length - 1;
if (index >= 0) {
messages[index] = updater(messages[index]);
}
return {
...session,
messages,
};
}),
),
removeSessionMessage: (sessionId, key) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
messages: session.messages.filter((msg) => msg.key !== key),
})),
),
addSessionAttachments: (sessionId, attachments) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
attachments: Array.isArray(attachments)
? [...session.attachments, ...attachments]
: [...session.attachments, attachments],
})),
),
removeSessionAttachment: (sessionId, filename) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
attachments: session.attachments.filter((attachment) => attachment.filename !== filename),
})),
),
addSessionContextItems: (sessionId, items) => {
const nextItems = Array.isArray(items) ? items : [items];
set((state) =>
updateSessionState(state, sessionId, (session) => {
const map = new Map<string, ContextItem>();
for (const item of session.contextItems) {
map.set(`${item.type}:${item.uid}`, item);
}
for (const item of nextItems) {
map.set(`${item.type}:${item.uid}`, item);
}
return {
...session,
contextItems: Array.from(map.values()),
};
}),
);
},
removeSessionContextItem: (sessionId, type, uid) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
contextItems: session.contextItems.filter((item) => !(item.type === type && item.uid === uid)),
})),
),
setSessionAbortController: (sessionId, controller) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
abortController: controller,
})),
),
setSessionSkillSettings: (sessionId, settings) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
skillSettings: settings,
})),
),
setEditorRef: (uid, editorRef) => set((state) => ({ editorRef: { ...state.editorRef, [uid]: editorRef } })),
setCurrentEditorRefUid: (uid) => set({ currentEditorRefUid: uid }),
setSessionWebSearching: (sessionId, webSearching) =>
set((state) =>
updateSessionState(state, sessionId, (session) => ({
...session,
webSearching,
})),
),
setFlowContext: (flowContext) => set({ flowContext }),
addSessionSubAgentMessage: (sessionId, subSessionId, msg) => {
get().addSessionSubAgentMessages(sessionId, subSessionId, [msg]);
},
addSessionSubAgentMessages: (sessionId, subSessionId, msgs) => {
get().updateSessionLastMessage(sessionId, (last) => ({
...last,
content: {
...last.content,
subAgentConversations: last.content.subAgentConversations?.map((conversation) => {
if (conversation.sessionId !== subSessionId) {
return conversation;
}
return {
...conversation,
messages: [...conversation.messages, ...msgs],
};
}) ?? [
{
sessionId: subSessionId,
messages: msgs,
},
],
},
loading: false,
}));
},
updateSessionLastSubAgentMessage: (sessionId, subSessionId, username, updater) => {
get().updateSessionLastMessage(sessionId, (last) => ({
...last,
content: {
...last.content,
subAgentConversations: last.content.subAgentConversations?.map((conversation) => {
if (conversation.sessionId !== subSessionId) {
return conversation;
}
const messages = [...conversation.messages];
const index = messages.length - 1;
if (index >= 0) {
messages[index] = updater(messages[index]);
}
return {
...conversation,
messages,
};
}) ?? [
{
sessionId: subSessionId,
messages: [
updater({
key: uid(),
role: username,
createdAt: new Date().toISOString(),
content: { type: 'text', content: '' },
loading: true,
}),
],
},
],
},
loading: false,
}));
},
updateSessionSubAgentConversationStatus: (sessionId, subSessionId, status) => {
get().updateSessionLastMessage(sessionId, (last) => ({
...last,
content: {
...last.content,
subAgentConversations: last.content.subAgentConversations?.map((conversation) => {
if (conversation.sessionId !== subSessionId) {
return conversation;
}
return {
...conversation,
status,
};
}),
},
loading: false,
}));
},
};
});
export const useChatMessagesStore = createSelectors(store);
export * from '../../../../client-v2/ai-employees/chatbox/stores/chat-messages';
@@ -0,0 +1,191 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { useT } from '../../locale';
import { Avatar, Popover, theme } from 'antd';
import {
useChatMessagesStore,
useAIConfigRepository,
useChatBoxStore,
useChatBoxActions,
AIEmployeeProfileCard,
avatars,
type ChatEditorRef,
type Task,
} from '@nocobase/plugin-ai/client-v2';
import { observer } from '@nocobase/flow-engine';
import type { FlowSettingsContext } from '@nocobase/flow-engine';
export const DaraButton: React.FC<{ ctx: FlowSettingsContext<any> }> = observer(({ ctx }) => {
const t = useT();
const { token } = theme.useToken();
const aiConfigRepository = useAIConfigRepository();
const aiEmployees = aiConfigRepository.aiEmployees;
const aiEmployee = aiEmployees?.find((e) => e.username === 'dara');
const setEditorRef = useChatMessagesStore.use.setEditorRef();
const setCurrentEditorRefUid = useChatMessagesStore.use.setCurrentEditorRefUid();
const addContextItems = useChatMessagesStore.use.addContextItems();
const open = useChatBoxStore.use.open();
const currentEmployee = useChatBoxStore.use.currentEmployee();
const { triggerTask } = useChatBoxActions();
const uid = ctx.model.uid;
const panelRef = React.useMemo<ChatEditorRef>(
() => ({
read() {
const values = ctx.getStepFormValues('chartSettings', 'configure') || {};
const { query, chart } = values || {};
const payload = {
uid,
query: {
mode: query?.mode,
sql: query?.sql,
sqlDatasource: query?.sqlDatasource,
},
chart: {
option: {
mode: chart?.option?.mode,
raw: chart?.option?.raw,
},
events: {
mode: chart?.events?.mode,
raw: chart?.events?.raw,
},
},
};
return JSON.stringify(payload, null, 2);
},
write(text: string) {
try {
const content = (text || '').trim();
const isSql =
/\bselect\b|\bwith\b|\binsert\b|\bupdate\b|\bdelete\b|\bfrom\b|\bwhere\b|\bgroup\s+by\b|\border\s+by\b/i.test(
content,
) && !/return\s*\{/.test(content);
const isEvents = /chart\.(on|off)\s*\(|ctx\.\w+\s*\(/.test(content) && !/\breturn\s*\{/.test(content);
if (isSql) {
// 从注释中提取数据源
const dsMatch = content.match(/^--\s*dataSource:\s*(\S+)/i);
const dataSource = dsMatch ? dsMatch[1] : undefined;
return ctx.writeSql(content, dataSource);
}
if (isEvents) return ctx.writeChartEvents(content);
return ctx.writeChartConfig(content);
} catch (e) {
console.error('DaraButton panelRef.write error:', e);
}
},
buttonGroupHeight: 0,
snippetEntries: [],
logs: [],
}),
[ctx, uid],
);
React.useEffect(() => {
aiConfigRepository.getAIEmployees();
}, [aiConfigRepository]);
React.useEffect(() => {
setEditorRef(uid, panelRef);
setCurrentEditorRefUid(uid);
return () => setEditorRef(uid, null);
}, [uid, panelRef, setEditorRef, setCurrentEditorRefUid]);
const systemPrompt =
'If you are not in SQL/Custom mode, first call the tool switchModes; after editing SQL, if you need field samples, call the tool runQuery. Use query.sqlDatasource as the current data source key when executing SQL. Do not render chart previews directly in the chat window.';
type TaskPrototype = Partial<Task> & { user?: string };
const TaskTemplate = (prototype: TaskPrototype): Task => {
const { message, user, ...rest } = prototype;
return {
message: {
user: message?.user ?? user ?? '',
system: systemPrompt,
workContext: [
{
type: 'chart-config',
uid,
title: t('Chart config'),
content: panelRef.read(),
},
],
},
autoSend: false,
...rest,
};
};
const tasks = [
TaskTemplate({
title: t('Choose the appropriate chart'),
user: t('Recommend chart type by data structure and explain reasons'),
}),
TaskTemplate({
title: t('Auto map by data columns'),
user: t('Map X/Y/category/value by columns and generate ECharts options'),
}),
TaskTemplate({
title: t('Optimize visual encoding'),
user: t('Optimize color, ordering and aggregation, output ECharts options'),
}),
TaskTemplate({
title: t('Fix preview errors or field mismatch'),
user: t('Fix errors or field mismatch, output SQL or ECharts options'),
}),
];
const onClick = async () => {
if (aiEmployee && (!open || currentEmployee?.username !== aiEmployee.username)) {
await triggerTask({ aiEmployee, tasks });
}
setCurrentEditorRefUid(uid);
addContextItems({
type: 'chart-config',
uid,
title: t('Chart config'),
content: panelRef.read(),
});
};
if (!aiEmployee) return null;
return (
<Popover
content={
<AIEmployeeProfileCard
aiEmployee={aiEmployee}
tasks={tasks}
onTaskClick={(task) => {
triggerTask({
aiEmployee,
tasks: [task],
});
}}
/>
}
>
<Avatar
src={avatars(aiEmployee.avatar)}
size={32}
shape="circle"
style={{
cursor: 'pointer',
border: `${token.lineWidth}px ${token.lineType} ${token.colorBorderSecondary}`,
}}
onClick={onClick}
/>
</Popover>
);
});
export default DaraButton;
@@ -26,6 +26,8 @@ import { ChartResource } from '../resources/ChartResource';
import { genRawByBuilder } from './ChartOptionsBuilder.service';
import { configStore } from './config-store';
import PluginDataVisualizationClient from '../../plugin';
import { DaraButton } from '../components/DaraButton';
import { useChatBoxStore, useChatMessagesStore } from '@nocobase/plugin-ai/client-v2';
const NO_PREVIEW_SNAPSHOT = Symbol('NO_PREVIEW_SNAPSHOT');
@@ -529,6 +531,7 @@ const CancelButton = () => {
// 回滚 未保存的 stepParams 并刷新图表
ctx.model.cancelPreview();
closeAssociatedAIChatBox(ctx);
ctx.view.close();
}}
>
@@ -537,6 +540,14 @@ const CancelButton = () => {
);
};
const closeAssociatedAIChatBox = (ctx: any) => {
const aiOpen = useChatBoxStore.getState().open;
const associatedUid = useChatMessagesStore.getState().currentEditorRefUid;
if (aiOpen && associatedUid === ctx.model.uid) {
useChatBoxStore.getState().setOpen(false);
}
};
ChartBlockModel.define({
label: tExpr('Charts'),
});
@@ -550,6 +561,10 @@ ChartBlockModel.registerFlow({
uiMode: (ctx) => ({
type: 'embed',
props: {
onClose: () => {
closeAssociatedAIChatBox(ctx);
},
header: { extra: <DaraButton ctx={ctx} /> },
footer: (originNode, { OkBtn }) => (
<Space>
<CancelButton />