diff --git a/app/src/admin/api/market.ts b/app/src/admin/api/market.ts index 8b99ac1..3429b2c 100644 --- a/app/src/admin/api/market.ts +++ b/app/src/admin/api/market.ts @@ -1,4 +1,4 @@ -import { Model } from "@/api/types.ts"; +import { Model } from "@/api/types.tsx"; import { CommonResponse } from "@/api/common.ts"; import axios from "axios"; import { getErrorMessage } from "@/utils/base.ts"; diff --git a/app/src/admin/api/plan.ts b/app/src/admin/api/plan.ts index 14496e4..3718962 100644 --- a/app/src/admin/api/plan.ts +++ b/app/src/admin/api/plan.ts @@ -1,4 +1,4 @@ -import { Plan } from "@/api/types"; +import { Plan } from "@/api/types.tsx"; import axios from "axios"; import { CommonResponse } from "@/api/common.ts"; import { getErrorMessage } from "@/utils/base.ts"; diff --git a/app/src/admin/hook.tsx b/app/src/admin/hook.tsx index afc396a..d99fe37 100644 --- a/app/src/admin/hook.tsx +++ b/app/src/admin/hook.tsx @@ -3,7 +3,7 @@ import { getUniqueList } from "@/utils/base.ts"; import { defaultChannelModels } from "@/admin/channel.ts"; import { getApiMarket, getApiModels } from "@/api/v1.ts"; import { useEffectAsync } from "@/utils/hook.ts"; -import { Model } from "@/api/types.ts"; +import { Model } from "@/api/types.tsx"; export type onStateChange = (state: boolean, data?: T) => void; diff --git a/app/src/api/connection.ts b/app/src/api/connection.ts index dbf9bd8..cbe9777 100644 --- a/app/src/api/connection.ts +++ b/app/src/api/connection.ts @@ -5,6 +5,7 @@ import { Mask } from "@/masks/types.ts"; export const endpoint = `${websocketEndpoint}/chat`; export const maxRetry = 60; // 30s max websocket retry +export const maxConnection = 5; export type StreamMessage = { conversation?: number; @@ -37,14 +38,15 @@ type StreamCallback = (id: number, message: StreamMessage) => void; export class Connection { protected connection?: WebSocket; protected callback?: StreamCallback; - protected stack?: string; + protected stack?: Record; public id: number; public state: boolean; public constructor(id: number, callback?: StreamCallback) { this.state = false; this.id = id; - this.callback && this.setCallback(callback); + + callback && this.setCallback(callback); } public init(): void { @@ -57,8 +59,16 @@ export class Connection { id: this.id, }); }; - this.connection.onclose = () => { + this.connection.onclose = (event) => { this.state = false; + + this.stack = { + error: "websocket connection failed", + code: event.code, + reason: event.reason, + endpoint: endpoint, + }; + setTimeout(() => { console.debug(`[connection] reconnecting... (id: ${this.id})`); this.init(); @@ -68,10 +78,6 @@ export class Connection { const message = JSON.parse(event.data); this.triggerCallback(message as StreamMessage); }; - - this.connection.onclose = (event) => { - this.stack = `websocket connection failed (code: ${event.code}, reason: ${event.reason}, endpoint: ${endpoint})`; - }; } public reconnect(): void { @@ -105,16 +111,15 @@ export class Connection { ); } - const trace = - this.stack || - JSON.stringify( - { - message: data.message, - endpoint: endpoint, - }, - null, - 2, - ); + const trace = JSON.stringify( + this.stack ?? { + message: data.message, + endpoint: endpoint, + }, + null, + 2, + ); + this.stack = undefined; t && this.triggerCallback({ @@ -171,6 +176,16 @@ export class Connection { public setId(id: number): void { this.id = id; } + + public isReady(): boolean { + return this.state; + } + + public isRunning(): boolean { + if (!this.connection || !this.state) return false; + + return this.connection.readyState === WebSocket.OPEN; + } } export class ConnectionStack { @@ -186,12 +201,30 @@ export class ConnectionStack { return this.connections.find((conn) => conn.id === id); } - public addConnection(id: number): Connection { + public createConnection(id: number): Connection { const conn = new Connection(id, this.triggerCallback.bind(this)); this.connections.push(conn); + + // max connection garbage collection + if (this.connections.length > maxConnection) { + const garbage = this.connections.shift(); + garbage && garbage.close(); + } return conn; } + public send(id: number, t: any, props: ChatProps) { + const conn = this.getConnection(id); + if (!conn) return false; + + conn.sendWithRetry(t, props); + return true; + } + + public hasConnection(id: number): boolean { + return this.connections.some((conn) => conn.id === id); + } + public setCallback(callback?: StreamCallback): void { this.callback = callback; } @@ -216,9 +249,9 @@ export class ConnectionStack { conn && conn.sendMaskEvent(t, mask); } - public sendEditEvent(id: number, t: any, message: string) { + public sendEditEvent(id: number, t: any, messageId: number, message: string) { const conn = this.getConnection(id); - conn && conn.sendEditEvent(t, id, message); + conn && conn.sendEditEvent(t, messageId, message); } public sendRemoveEvent(id: number, t: any, messageId: number) { @@ -249,6 +282,13 @@ export class ConnectionStack { this.connections.forEach((conn) => conn.reconnect()); } + public raiseConnection(id: number): void { + const conn = this.getConnection(-1); + if (!conn) return; + + conn.setId(id); + } + public triggerCallback(id: number, message: StreamMessage): void { this.callback && this.callback(id, message); } diff --git a/app/src/api/conversation.ts b/app/src/api/conversation.ts deleted file mode 100644 index 501a7f5..0000000 --- a/app/src/api/conversation.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { ChatProps, Connection, StreamMessage } from "./connection.ts"; -import { Message } from "./types.ts"; -import { sharingEvent } from "@/events/sharing.ts"; -import { connectionEvent } from "@/events/connection.ts"; -import { AppDispatch } from "@/store"; -import { setMessages } from "@/store/chat.ts"; -import { modelEvent } from "@/events/model.ts"; -import { Mask } from "@/masks/types.ts"; - -type ConversationCallback = (idx: number, message: Message[]) => boolean; - -export type ConversationSerialized = { - model: string; - end: boolean; - mask: Mask | null; - messages: Message[]; -}; - -export class Conversation { - protected connection?: Connection; - protected callback?: ConversationCallback; - protected idx: number; - public id: number; - public model: string; - public data: Message[]; - public end: boolean; - public mask: Mask | null; - - public constructor(id: number, callback?: ConversationCallback) { - if (callback) this.setCallback(callback); - this.data = []; - this.idx = -1; - this.id = id; - this.model = ""; - this.end = true; - this.mask = null; - this.connection = new Connection(this.id); - - if (id === -1 && this.idx === -1) { - sharingEvent.bind(({ refer, data }) => { - console.debug( - `[conversation] load from sharing event (ref: ${refer}, length: ${data.length})`, - ); - - this.load(data); - this.sendShareEvent(refer); - }); - } - - connectionEvent.addEventListener((ev) => { - if (ev.id === this.id) { - console.debug( - `[conversation] connection event (id: ${this.id}, event: ${ev.event})`, - ); - - switch (ev.event) { - case "stop": - this.end = true; - this.data[this.data.length - 1].end = true; - this.sendStopEvent(); - this.triggerCallback(); - break; - - case "restart": - this.end = false; - delete this.data[this.data.length - 1]; - this.connection?.setCallback(this.useMessage()); - this.sendRestartEvent(); - break; - - case "edit": - const index = ev.index ?? -1; - const message = ev.message ?? ""; - - if (this.isValidIndex(index)) { - this.data[index].content = message; - this.sendEditEvent(index, message); - this.triggerCallback(); - } - break; - - case "remove": - const idx = ev.index ?? -1; - - if (this.isValidIndex(idx)) { - this.data.splice(idx, 1); - - this.sendRemoveEvent(idx); - this.triggerCallback(); - } - break; - - default: - console.debug( - `[conversation] unknown event: ${ev.event} (from: ${ev.id})`, - ); - } - } - }); - } - - protected sendEvent(event: string, data?: string) { - this.connection?.sendWithRetry(null, { - type: event, - message: data || "", - model: "event", - }); - } - - public isValidIndex(idx: number): boolean { - return idx >= 0 && idx < this.data.length; - } - - public sendStopEvent() { - this.sendEvent("stop"); - } - - public sendRestartEvent() { - this.sendEvent("restart"); - } - - public sendMaskEvent(mask: Mask) { - this.sendEvent("mask", JSON.stringify(mask.context)); - } - - public sendEditEvent(id: number, message: string) { - this.sendEvent("edit", `${id}:${message}`); - } - - public sendRemoveEvent(id: number) { - this.sendEvent("remove", id.toString()); - } - - public sendShareEvent(refer: string) { - this.sendEvent("share", refer); - } - - public preflightMask(mask: Mask) { - this.mask = mask; - } - - public presentMask() { - if (this.mask) { - this.sendMaskEvent(this.mask); - this.mask = null; - } - } - - public setId(id: number): void { - this.id = id; - } - - public copyMessages(): Message[] { - // deep copy: cannot use return [...this.data]; - return this.data.map((item) => { - return { - ...item, - }; - }); - } - - public load(data: Message[]): void { - this.data = data; - this.idx = data.length - 1; - this.triggerCallback(); - } - - public getLength(): number { - return this.data.length; - } - - public getIndex(): number { - return this.idx; - } - - public getMessage(idx: number): Message { - if (idx < 0 || idx >= this.getLength()) { - throw new Error("Index out of range"); - } - return this.data[idx]; - } - - public setCallback(callback: ConversationCallback) { - this.callback = callback; - } - - public setModel(model?: string) { - if (!model) return; - this.model = model; - } - - public getModel(): string { - return this.model; - } - - public isEmpty(): boolean { - return this.getLength() === 0; - } - - public toggle(dispatch: AppDispatch): void { - dispatch(setMessages(this.copyMessages())); - modelEvent.emit(this.getModel()); - } - - public triggerCallback() { - if (!this.callback) return; - const interval = setInterval(() => { - const state = - this.callback && this.callback(this.id, this.copyMessages()); - if (state) clearInterval(interval); - }, 100); - } - - public addMessage(message: Message): number { - this.idx++; - this.data.push(message); - this.triggerCallback(); - return this.idx; - } - - public setMessage(idx: number, message: Message) { - this.data[idx] = message; - this.triggerCallback(); - } - - public updateMessage( - idx: number, - message: string, - keyword?: string, - quota?: number, - end?: boolean, - plan?: boolean, - ) { - this.data[idx].content += message; - if (keyword) this.data[idx].keyword = keyword; - if (quota) this.data[idx].quota = quota; - this.data[idx].end = end; - this.data[idx].plan = plan; - this.triggerCallback(); - } - - public useMessage(): (message: StreamMessage) => void { - const cursor = this.addMessage({ - content: "", - role: "assistant", - }); - - return (message: StreamMessage) => { - this.updateMessage( - cursor, - message.message, - message.keyword, - message.quota, - message.end, - message.plan, - ); - if (message.end) { - this.end = true; - } - }; - } - - public send(t: any, props: ChatProps) { - if (!this.connection) { - this.connection = new Connection(this.id); - } - this.end = false; - this.connection.setCallback(this.useMessage()); // hook - this.connection.sendWithRetry(t, props); - } - - public sendMessage(t: any, props: ChatProps): boolean { - if (!this.end) return false; - - this.presentMask(); - this.addMessage({ - content: props.message, - role: "user", - }); - - this.send(t, props); - - return true; - } - - public sendMessageWithRaise(t: any, id: number, props: ChatProps): boolean { - if (!this.end) return false; - - this.presentMask(); - this.addMessage({ - content: props.message, - role: "user", - }); - - this.send(t, props); - this.setId(id); - - return true; - } -} diff --git a/app/src/api/history.ts b/app/src/api/history.ts index dbe4948..279457e 100644 --- a/app/src/api/history.ts +++ b/app/src/api/history.ts @@ -1,7 +1,6 @@ import axios from "axios"; -import type { ConversationInstance } from "./types.ts"; +import type { ConversationInstance } from "./types.tsx"; import { setHistory } from "@/store/chat.ts"; -import { manager } from "./manager.ts"; import { AppDispatch } from "@/store"; export async function getConversationList(): Promise { @@ -37,38 +36,22 @@ export async function loadConversation( } } -export async function deleteConversation( - dispatch: AppDispatch, - id: number, -): Promise { +export async function deleteConversation(id: number): Promise { try { const resp = await axios.get(`/conversation/delete?id=${id}`); - if (!resp.data.status) return false; - await manager.delete(dispatch, id); - return true; + return resp.data.status; } catch (e) { console.warn(e); return false; } } -export async function deleteAllConversations( - dispatch: AppDispatch, -): Promise { +export async function deleteAllConversations(): Promise { try { const resp = await axios.get("/conversation/clean"); - if (!resp.data.status) return false; - await manager.deleteAll(dispatch); - return true; + return resp.data.status; } catch (e) { console.warn(e); return false; } } - -export async function toggleConversation( - dispatch: AppDispatch, - id: number, -): Promise { - return manager.toggle(dispatch, id); -} diff --git a/app/src/api/manager.ts b/app/src/api/manager.ts deleted file mode 100644 index ad8f0f2..0000000 --- a/app/src/api/manager.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Conversation } from "@/api/conversation.ts"; -import { ConversationMapper, Message } from "@/api/types.ts"; -import { loadConversation } from "@/api/history.ts"; -import { - addHistory, - removeHistory, - setCurrent, - setMessages, -} from "@/store/chat.ts"; -import { useShared } from "@/utils/hook.ts"; -import { ChatProps } from "@/api/connection.ts"; -import { AppDispatch } from "@/store"; -import { sharingEvent } from "@/events/sharing.ts"; -import { maskEvent } from "@/events/mask.ts"; -import { Mask } from "@/masks/types.ts"; - -export class Manager { - conversations: Record; - current: number; - dispatch?: AppDispatch; - - public constructor() { - this.conversations = {}; - this.conversations[-1] = this.createConversation(-1); - this.current = -1; - - const _this = this; - sharingEvent.addEventListener(async (data) => { - console.debug(`[manager] accept sharing event (refer: ${data.refer})`); - await _this.newPage(); - }); - - maskEvent.addEventListener(async (mask) => { - console.debug(`[manager] accept mask event (name: ${mask.name})`); - await _this.newMaskPage(mask); - }); - } - - public setDispatch(dispatch: AppDispatch): void { - this.dispatch = dispatch; - } - - public async newPage(): Promise { - const interval = setInterval(() => { - if (this.dispatch) { - this.toggle(this.dispatch, -1); - clearInterval(interval); - } - }, 100); - } - - public async newMaskPage(mask: Mask): Promise { - const interval = setInterval(() => { - if (this.dispatch) { - this.toggle(this.dispatch, -1); - - const instance = this.get(-1); - if (!instance) return; - - instance.load([...mask.context]); // deep copy - instance.preflightMask(mask); - clearInterval(interval); - } - }, 100); - } - - public callback(idx: number, message: Message[]): boolean { - console.debug( - `[manager] conversation receive message (id: ${idx}, length: ${message.length})`, - ); - if (idx === this.current) this.dispatch?.(setMessages(message)); - return !!this.dispatch; - } - - public getCurrent(): number { - return this.current; - } - - public getConversations(): ConversationMapper { - return this.conversations; - } - - public createConversation(id: number): Conversation { - console.debug(`[manager] create conversation instance (id: ${id})`); - const _this = this; - return new Conversation(id, function ( - idx: number, - message: Message[], - ): boolean { - return _this.callback(idx, message); - }); - } - - public async add(id: number): Promise { - if (this.conversations[id]) return; - const instance = this.createConversation(id); - this.conversations[id] = instance; - - const res = await loadConversation(id); - instance.load(res.message); - instance.setModel(res.model); - } - - public async load(id: number, data: Message[]): Promise { - if (!this.conversations[id]) await this.add(id); - this.conversations[id].load(data); - } - - public async toggle(dispatch: AppDispatch, id: number): Promise { - if (!this.conversations[id]) await this.add(id); - - this.current = id; - dispatch(setCurrent(id)); - this.get(id)!.toggle(dispatch); - } - - public async delete(dispatch: AppDispatch, id: number): Promise { - if (this.getCurrent() === id) await this.toggle(dispatch, -1); - dispatch(removeHistory(id)); - if (this.conversations[id]) delete this.conversations[id]; - } - - public async deleteAll(dispatch: AppDispatch): Promise { - const ids = Object.keys(this.conversations).map((v) => parseInt(v)); - for (const id of ids) await this.delete(dispatch, id); - - await this.toggle(dispatch, -1); - } - - public async reset(dispatch: AppDispatch): Promise { - await this.deleteAll(dispatch); - await this.toggle(dispatch, -1); - } - - public async send(t: any, auth: boolean, props: ChatProps): Promise { - const id = this.getCurrent(); - if (!this.conversations[id]) return false; - console.debug( - `[chat] trigger send event: ${props.message} (type: ${ - auth ? "user" : "anonymous" - }, id: ${id})`, - ); - if (id === -1 && auth) { - // check for raise conversation - console.debug( - `[manager] raise new conversation (name: ${props.message})`, - ); - const { hook, useHook } = useShared(); - this.dispatch?.( - addHistory({ - message: props.message, - hook, - }), - ); - const target = await useHook(); - this.conversations[target] = this.conversations[-1]; - delete this.conversations[-1]; // fix pointer - this.conversations[-1] = this.createConversation(-1); - this.current = target; - return this.get(target)!.sendMessageWithRaise(t, target, props); - } - const conversation = this.get(id); - if (!conversation) return false; - return conversation.sendMessage(t, props); - } - - public get(id: number): Conversation | undefined { - if (!this.conversations[id]) return undefined; - return this.conversations[id]; - } -} - -export const manager = new Manager(); diff --git a/app/src/api/sharing.ts b/app/src/api/sharing.ts index 0cdfbf6..0707b23 100644 --- a/app/src/api/sharing.ts +++ b/app/src/api/sharing.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { Message } from "./types.ts"; +import { Message } from "./types.tsx"; export type SharingForm = { status: boolean; diff --git a/app/src/api/types.ts b/app/src/api/types.tsx similarity index 71% rename from app/src/api/types.ts rename to app/src/api/types.tsx index d4b9479..11f4364 100644 --- a/app/src/api/types.ts +++ b/app/src/api/types.tsx @@ -1,5 +1,6 @@ -import { Conversation } from "./conversation.ts"; import { ChargeBaseProps } from "@/admin/charge.ts"; +import { useMemo } from "react"; +import { BotIcon, ServerIcon, UserIcon } from "lucide-react"; export const UserRole = "user"; export const AssistantRole = "assistant"; @@ -7,6 +8,21 @@ export const SystemRole = "system"; export type Role = typeof UserRole | typeof AssistantRole | typeof SystemRole; export const Roles = [UserRole, AssistantRole, SystemRole]; +export const getRoleIcon = (role: string) => { + return useMemo(() => { + switch (role) { + case UserRole: + return ; + case AssistantRole: + return ; + case SystemRole: + return ; + default: + return ; + } + }, [role]); +}; + export type Message = { role: string; content: string; @@ -40,8 +56,6 @@ export type ConversationInstance = { shared?: boolean; }; -export type ConversationMapper = Record; - export type PlanItem = { id: string; name: string; diff --git a/app/src/api/v1.ts b/app/src/api/v1.ts index 5d0c64d..17148c0 100644 --- a/app/src/api/v1.ts +++ b/app/src/api/v1.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { Model, Plan } from "@/api/types.ts"; +import { Model, Plan } from "@/api/types.tsx"; import { ChargeProps, nonBilling } from "@/admin/charge.ts"; import { getErrorMessage } from "@/utils/base.ts"; diff --git a/app/src/assets/pages/chat.less b/app/src/assets/pages/chat.less index 8bb91ac..0adfb7f 100644 --- a/app/src/assets/pages/chat.less +++ b/app/src/assets/pages/chat.less @@ -139,6 +139,24 @@ } } + .message-avatar-wrapper { + .message-avatar { + display: flex; + width: 2.5rem; + height: 2.5rem; + border-radius: var(--radius); + text-align: center; + font-size: 0.875rem; + } + + flex-shrink: 0; + margin-left: 0.5rem; + border-radius: var(--radius); + border: 1px solid hsl(var(--border)); + width: 2.5rem; + height: 2.5rem; + } + &.user { align-items: flex-end; } @@ -154,8 +172,7 @@ } } - &.assistant, - &.system { + &.assistant, &.system { align-items: flex-start; .message-content { @@ -167,6 +184,11 @@ border-color: var(--assistant-border-hover); } } + + .message-avatar-wrapper { + margin-right: 0.5rem; + margin-left: 0; + } } } diff --git a/app/src/components/Message.tsx b/app/src/components/Message.tsx index 0f2e7fa..f6532e2 100644 --- a/app/src/components/Message.tsx +++ b/app/src/components/Message.tsx @@ -1,4 +1,4 @@ -import { Message } from "@/api/types.ts"; +import { getRoleIcon, Message } from "@/api/types.tsx"; import Markdown from "@/components/Markdown.tsx"; import { CalendarCheck2, @@ -28,6 +28,11 @@ import { import { cn } from "@/components/ui/lib/utils.ts"; import Tips from "@/components/Tips.tsx"; import EditorProvider from "@/components/EditorProvider.tsx"; +import Avatar from "@/components/Avatar.tsx"; +import { useSelector } from "react-redux"; +import { selectUsername } from "@/store/auth.ts"; +import { appLogo } from "@/conf/env.ts"; +import Icon from "@/components/utils/Icon.tsx"; type MessageProps = { index: number; @@ -92,15 +97,20 @@ function MessageQuota({ message }: MessageQuotaProps) { function MessageContent({ message, end, index, onEvent }: MessageProps) { const { t } = useTranslation(); const isAssistant = message.role === "assistant"; + const isUser = message.role === "user"; + + const username = useSelector(selectUsername); + const icon = getRoleIcon(message.role); const [open, setOpen] = useState(false); + const [dropdown, setDropdown] = useState(false); const [editedMessage, setEditedMessage] = useState(""); return (
+
+ + ) : ( + {``} + ) + } + > + + {message.role} + +
{message.content.length ? ( @@ -121,16 +146,18 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) { )}
- + {isAssistant && end && ( - onEvent && onEvent(message.end !== false ? "restart" : "stop") - } + onClick={() => { + onEvent && + onEvent(message.end !== false ? "restart" : "stop"); + setDropdown(false); + }} > {message.end !== false ? ( <> diff --git a/app/src/components/ProjectLink.tsx b/app/src/components/ProjectLink.tsx index 044a0fe..1c4adc6 100644 --- a/app/src/components/ProjectLink.tsx +++ b/app/src/components/ProjectLink.tsx @@ -1,19 +1,18 @@ import { Button } from "./ui/button.tsx"; -import { selectMessages } from "@/store/chat.ts"; -import { useDispatch, useSelector } from "react-redux"; +import { useConversationActions, useMessages } from "@/store/chat.ts"; import { MessageSquarePlus } from "lucide-react"; -import { toggleConversation } from "@/api/history.ts"; import Github from "@/components/ui/icons/Github.tsx"; function ProjectLink() { - const dispatch = useDispatch(); - const messages = useSelector(selectMessages); + const messages = useMessages(); + + const { toggle } = useConversationActions(); return messages.length > 0 ? ( diff --git a/app/src/components/app/AppProvider.tsx b/app/src/components/app/AppProvider.tsx index 8f392d2..71cdd55 100644 --- a/app/src/components/app/AppProvider.tsx +++ b/app/src/components/app/AppProvider.tsx @@ -5,7 +5,12 @@ import Broadcast from "@/components/Broadcast.tsx"; import { useEffectAsync } from "@/utils/hook.ts"; import { bindMarket, getApiPlans } from "@/api/v1.ts"; import { useDispatch } from "react-redux"; -import { updateMasks, updateSupportModels } from "@/store/chat.ts"; +import { + stack, + updateMasks, + updateSupportModels, + useMessageActions, +} from "@/store/chat.ts"; import { dispatchSubscriptionData, setTheme } from "@/store/globals.ts"; import { infoEvent } from "@/events/info.ts"; import { setForm } from "@/store/info.ts"; @@ -14,10 +19,15 @@ import { useEffect } from "react"; function AppProvider() { const dispatch = useDispatch(); + const { receive } = useMessageActions(); useEffect(() => { infoEvent.bind((data) => dispatch(setForm(data))); themeEvent.bind((theme) => dispatch(setTheme(theme))); + + stack.setCallback(async (id, message) => { + await receive(id, message); + }); }, []); useEffectAsync(async () => { diff --git a/app/src/components/home/ChatInterface.tsx b/app/src/components/home/ChatInterface.tsx index 6b8b9fa..d983d7b 100644 --- a/app/src/components/home/ChatInterface.tsx +++ b/app/src/components/home/ChatInterface.tsx @@ -1,9 +1,12 @@ import React, { useEffect } from "react"; -import { Message } from "@/api/types.ts"; +import { Message } from "@/api/types.tsx"; import { useSelector } from "react-redux"; -import { selectCurrent, selectMessages } from "@/store/chat.ts"; +import { + listenMessageEvent, + selectCurrent, + useMessages, +} from "@/store/chat.ts"; import MessageSegment from "@/components/Message.tsx"; -import { connectionEvent } from "@/events/connection.ts"; import { chatEvent } from "@/events/chat.ts"; import { addEventListeners } from "@/utils/dom.ts"; @@ -13,7 +16,8 @@ type ChatInterfaceProps = { function ChatInterface({ setTarget }: ChatInterfaceProps) { const ref = React.useRef(null); - const messages: Message[] = useSelector(selectMessages); + const messages: Message[] = useMessages(); + const process = listenMessageEvent(); const current: number = useSelector(selectCurrent); const [scrollable, setScrollable] = React.useState(true); const [position, setPosition] = React.useState(0); @@ -57,13 +61,8 @@ function ChatInterface({ setTarget }: ChatInterfaceProps) { { - connectionEvent.emit({ - id: current, - event: e, - index, - message, - }); + onEvent={(event: string, index?: number, message?: string) => { + process({ id: current, event, index, message }); }} key={i} index={i} diff --git a/app/src/components/home/ChatWrapper.tsx b/app/src/components/home/ChatWrapper.tsx index f048129..0ac2ea5 100644 --- a/app/src/components/home/ChatWrapper.tsx +++ b/app/src/components/home/ChatWrapper.tsx @@ -1,16 +1,17 @@ import { useTranslation } from "react-i18next"; import { useEffect, useMemo, useRef, useState } from "react"; import FileAction from "@/components/FileProvider.tsx"; -import { useDispatch, useSelector } from "react-redux"; +import { useSelector } from "react-redux"; import { selectAuthenticated, selectInit } from "@/store/auth.ts"; import { + listenMessageEvent, selectCurrent, - selectMessages, selectModel, selectSupportModels, - selectWeb, + useMessageActions, + useMessages, + useWorking, } from "@/store/chat.ts"; -import { manager } from "@/api/manager.ts"; import { formatMessage } from "@/utils/processor.ts"; import ChatInterface from "@/components/home/ChatInterface.tsx"; import EditorAction from "@/components/EditorProvider.tsx"; @@ -19,18 +20,7 @@ import { clearHistoryState, getQueryParam } from "@/utils/path.ts"; import { forgetMemory, popMemory } from "@/utils/memory.ts"; import { useToast } from "@/components/ui/use-toast.ts"; import { ToastAction } from "@/components/ui/toast.tsx"; -import { - alignSelector, - contextSelector, - frequencyPenaltySelector, - historySelector, - maxTokensSelector, - presencePenaltySelector, - repetitionPenaltySelector, - temperatureSelector, - topKSelector, - topPSelector, -} from "@/store/settings.ts"; +import { alignSelector } from "@/store/settings.ts"; import { FileArray } from "@/api/file.ts"; import { MarketAction, @@ -42,57 +32,36 @@ import ChatSpace from "@/components/home/ChatSpace.tsx"; import ActionButton from "@/components/home/assemblies/ActionButton.tsx"; import ChatInput from "@/components/home/assemblies/ChatInput.tsx"; import ScrollAction from "@/components/home/assemblies/ScrollAction.tsx"; -import { connectionEvent } from "@/events/connection.ts"; -import { chatEvent } from "@/events/chat.ts"; import { cn } from "@/components/ui/lib/utils.ts"; import { goAuth } from "@/utils/app.ts"; import { getModelFromId } from "@/conf/model.ts"; import { posterEvent } from "@/events/poster.ts"; type InterfaceProps = { - setWorking: (working: boolean) => void; setTarget: (instance: HTMLElement | null) => void; }; function Interface(props: InterfaceProps) { - const messages = useSelector(selectMessages); - - useEffect(() => { - const end = - messages.length > 0 && (messages[messages.length - 1].end ?? true); - const working = messages.length > 0 && !end; - props.setWorking?.(working); - }, [messages]); - + const messages = useMessages(); return messages.length > 0 ? : ; } function ChatWrapper() { const { t } = useTranslation(); const { toast } = useToast(); + const { send: sendAction } = useMessageActions(); + const process = listenMessageEvent(); const [files, setFiles] = useState([]); const [input, setInput] = useState(""); - const [working, setWorking] = useState(false); const [visible, setVisibility] = useState(false); - const dispatch = useDispatch(); const init = useSelector(selectInit); const current = useSelector(selectCurrent); const auth = useSelector(selectAuthenticated); const model = useSelector(selectModel); - const web = useSelector(selectWeb); - const history = useSelector(historySelector); const target = useRef(null); - const context = useSelector(contextSelector); const align = useSelector(alignSelector); - const max_tokens = useSelector(maxTokensSelector); - const temperature = useSelector(temperatureSelector); - const top_p = useSelector(topPSelector); - const top_k = useSelector(topKSelector); - const presence_penalty = useSelector(presencePenaltySelector); - const frequency_penalty = useSelector(frequencyPenaltySelector); - const repetition_penalty = useSelector(repetitionPenaltySelector); - + const working = useWorking(); const supportModels = useSelector(selectSupportModels); const requireAuth = useMemo( @@ -102,9 +71,6 @@ function ChatWrapper() { const [instance, setInstance] = useState(null); - manager.setDispatch(dispatch); - chatEvent.addEventListener(() => setWorking(false)); - function clearFile() { setFiles([]); } @@ -127,23 +93,7 @@ function ChatWrapper() { const message: string = formatMessage(files, data); if (message.length > 0 && data.trim().length > 0) { - if ( - await manager.send(t, auth, { - type: "chat", - message, - web, - model, - context: history, - ignore_context: !context, - max_tokens, - temperature, - top_p, - top_k, - presence_penalty, - frequency_penalty, - repetition_penalty, - }) - ) { + if (await sendAction(message)) { forgetMemory("history"); clearFile(); return true; @@ -160,10 +110,7 @@ function ChatWrapper() { } async function handleCancel() { - connectionEvent.emit({ - id: current, - event: "stop", - }); + process({ id: current, event: "stop" }); } useEffect(() => { @@ -208,7 +155,7 @@ function ChatWrapper() { return (
- +
(false); async function handleDeleteAll(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); - if (await deleteAllConversations(dispatch)) - toast({ - title: t("conversation.delete-success"), - description: t("conversation.delete-success-prompt"), - }); - else - toast({ - title: t("conversation.delete-failed"), - description: t("conversation.delete-failed-prompt"), - }); + (await removeAllAction()) + ? toast({ + title: t("conversation.delete-success"), + description: t("conversation.delete-success-prompt"), + }) + : toast({ + title: t("conversation.delete-failed"), + description: t("conversation.delete-failed-prompt"), + }); - await updateConversationList(dispatch); + await refreshAction(); setOperateConversation({ target: null, type: "" }); setRemoveAll(false); } @@ -92,7 +95,7 @@ function SidebarAction({ setOperateConversation }: SidebarActionProps) { variant={`ghost`} size={`icon`} onClick={async () => { - await toggleConversation(dispatch, -1); + await toggle(-1); if (mobile) dispatch(setMenu(false)); dispatch(closeMarket()); }} @@ -128,17 +131,10 @@ function SidebarAction({ setOperateConversation }: SidebarActionProps) { variant={`ghost`} size={`icon`} id={`refresh`} - ref={refresh} + ref={refreshRef} onClick={() => { - const hook = useAnimation(refresh, "active", 500); - updateConversationList(dispatch) - .catch(() => - toast({ - title: t("conversation.refresh-failed"), - description: t("conversation.refresh-failed-prompt"), - }), - ) - .finally(hook); + const hook = useAnimation(refreshRef, "active", 500); + refreshAction().finally(hook); }} > @@ -152,8 +148,8 @@ function SidebarConversationList({ setOperateConversation, }: ConversationListProps) { const { t } = useTranslation(); - const dispatch = useDispatch(); const { toast } = useToast(); + const { remove } = useConversationActions(); const history: ConversationInstance[] = useSelector(selectHistory); const [shared, setShared] = useState(""); const current = useSelector(selectCurrent); @@ -162,9 +158,7 @@ function SidebarConversationList({ e.preventDefault(); e.stopPropagation(); - if ( - await deleteConversation(dispatch, operateConversation?.target?.id || -1) - ) + if (await remove(operateConversation?.target?.id || -1)) toast({ title: t("conversation.delete-success"), description: t("conversation.delete-success-prompt"), @@ -336,16 +330,14 @@ function SidebarMenu() { } function SideBar() { const { t } = useTranslation(); - const dispatch = useDispatch(); + const { refresh } = useConversationActions(); const open = useSelector(selectMenu); const auth = useSelector(selectAuthenticated); const [operateConversation, setOperateConversation] = useState({ target: null, type: "", }); - useEffectAsync(async () => { - await updateConversationList(dispatch); - }, []); + useEffectAsync(async () => await refresh(), []); return (
diff --git a/app/src/components/home/assemblies/ScrollAction.tsx b/app/src/components/home/assemblies/ScrollAction.tsx index 1ea6c5e..abeb9ce 100644 --- a/app/src/components/home/assemblies/ScrollAction.tsx +++ b/app/src/components/home/assemblies/ScrollAction.tsx @@ -4,9 +4,8 @@ import { chatEvent } from "@/events/chat.ts"; import { addEventListeners, scrollDown } from "@/utils/dom.ts"; import { ChatAction } from "@/components/home/assemblies/ChatAction.tsx"; import { useTranslation } from "react-i18next"; -import { Message } from "@/api/types.ts"; -import { useSelector } from "react-redux"; -import { selectMessages } from "@/store/chat.ts"; +import { Message } from "@/api/types.tsx"; +import { useMessages } from "@/store/chat.ts"; type ScrollActionProps = { visible: boolean; @@ -16,7 +15,7 @@ type ScrollActionProps = { function ScrollAction({ visible, target, setVisibility }: ScrollActionProps) { const { t } = useTranslation(); - const messages: Message[] = useSelector(selectMessages); + const messages: Message[] = useMessages(); useEffect(() => { if (messages.length === 0) return setVisibility(false); diff --git a/app/src/components/home/subscription/BuyDialog.tsx b/app/src/components/home/subscription/BuyDialog.tsx index 8340908..97c1132 100644 --- a/app/src/components/home/subscription/BuyDialog.tsx +++ b/app/src/components/home/subscription/BuyDialog.tsx @@ -28,7 +28,7 @@ import { deeptrainEndpoint, useDeeptrain } from "@/conf/env.ts"; import { AppDispatch } from "@/store"; import { openDialog } from "@/store/quota.ts"; import { getPlanPrice } from "@/conf/subscription.tsx"; -import { Plans } from "@/api/types.ts"; +import { Plans } from "@/api/types.tsx"; import { subscriptionDataSelector } from "@/store/globals.ts"; function countPrice(data: Plans, base: number, month: number): number { diff --git a/app/src/conf/model.ts b/app/src/conf/model.ts index 37ddba1..5e28cf7 100644 --- a/app/src/conf/model.ts +++ b/app/src/conf/model.ts @@ -1,4 +1,4 @@ -import { Model } from "@/api/types.ts"; +import { Model } from "@/api/types.tsx"; export function getModelFromId(market: Model[], id: string): Model | undefined { return market.find((model) => model.id === id); diff --git a/app/src/conf/storage.ts b/app/src/conf/storage.ts index dfb25cd..8aa5c01 100644 --- a/app/src/conf/storage.ts +++ b/app/src/conf/storage.ts @@ -1,5 +1,5 @@ import { getMemory, setMemory } from "@/utils/memory.ts"; -import { Model, Plan } from "@/api/types.ts"; +import { Model, Plan } from "@/api/types.tsx"; export function savePreferenceModels(models: Model[]): void { setMemory("model_preference", models.map((item) => item.id).join(",")); diff --git a/app/src/conf/subscription.tsx b/app/src/conf/subscription.tsx index 93c98ef..c222ea6 100644 --- a/app/src/conf/subscription.tsx +++ b/app/src/conf/subscription.tsx @@ -10,7 +10,7 @@ import { Flame, } from "lucide-react"; import React, { useMemo } from "react"; -import { Plan, Plans } from "@/api/types.ts"; +import { Plan, Plans } from "@/api/types.tsx"; import Icon from "@/components/utils/Icon.tsx"; export const subscriptionIcons: Record = { diff --git a/app/src/dialogs/MaskDialog.tsx b/app/src/dialogs/MaskDialog.tsx index f3b63b4..258c2f9 100644 --- a/app/src/dialogs/MaskDialog.tsx +++ b/app/src/dialogs/MaskDialog.tsx @@ -14,16 +14,15 @@ import { selectMask, setMask, updateMasks, + useConversationActions, } from "@/store/chat.ts"; import { MASKS } from "@/masks/prompts.ts"; import { CustomMask, initialCustomMask, Mask } from "@/masks/types.ts"; import { Input } from "@/components/ui/input.tsx"; import React, { useMemo, useReducer, useState } from "react"; import { splitList } from "@/utils/base.ts"; -import { maskEvent } from "@/events/mask.ts"; import { Button } from "@/components/ui/button.tsx"; import { - Bot, ChevronDown, ChevronUp, FolderInput, @@ -32,15 +31,13 @@ import { Pencil, Plus, Search, - Server, Trash, - User, } from "lucide-react"; import EmojiPicker, { Theme } from "emoji-picker-react"; import { themeSelector } from "@/store/globals.ts"; import { cn } from "@/components/ui/lib/utils.ts"; import Tips from "@/components/Tips.tsx"; -import { AssistantRole, Roles, SystemRole, UserRole } from "@/api/types.ts"; +import { getRoleIcon, Roles, UserRole } from "@/api/types.tsx"; import { Drawer, DrawerClose, @@ -90,6 +87,8 @@ type RoleActionProps = { }; function RoleAction({ role, onClick }: RoleActionProps) { + const icon = getRoleIcon(role); + const toggle = () => { const index = Roles.indexOf(role); const next = (index + 1) % Roles.length; @@ -97,19 +96,6 @@ function RoleAction({ role, onClick }: RoleActionProps) { onClick(Roles[next]); }; - const icon = useMemo(() => { - switch (role) { - case UserRole: - return ; - case AssistantRole: - return ; - case SystemRole: - return ; - default: - return ; - } - }, [role]); - return (