restruct: restruct conversation and history runtime and dispatch

This commit is contained in:
Zhang Minghan 2024-03-05 15:25:37 +08:00
parent 67cb512eb4
commit 43a2cbfe1e
38 changed files with 672 additions and 765 deletions

View File

@ -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";

View File

@ -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";

View File

@ -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<T> = (state: boolean, data?: T) => void;

View File

@ -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<string, any>;
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);
}

View File

@ -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;
}
}

View File

@ -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<ConversationInstance[]> {
@ -37,38 +36,22 @@ export async function loadConversation(
}
}
export async function deleteConversation(
dispatch: AppDispatch,
id: number,
): Promise<boolean> {
export async function deleteConversation(id: number): Promise<boolean> {
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<boolean> {
export async function deleteAllConversations(): Promise<boolean> {
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<void> {
return manager.toggle(dispatch, id);
}

View File

@ -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<number, Conversation>;
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<void> {
const interval = setInterval(() => {
if (this.dispatch) {
this.toggle(this.dispatch, -1);
clearInterval(interval);
}
}, 100);
}
public async newMaskPage(mask: Mask): Promise<void> {
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<void> {
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<void> {
if (!this.conversations[id]) await this.add(id);
this.conversations[id].load(data);
}
public async toggle(dispatch: AppDispatch, id: number): Promise<void> {
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<void> {
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<void> {
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<void> {
await this.deleteAll(dispatch);
await this.toggle(dispatch, -1);
}
public async send(t: any, auth: boolean, props: ChatProps): Promise<boolean> {
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<number>();
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();

View File

@ -1,5 +1,5 @@
import axios from "axios";
import { Message } from "./types.ts";
import { Message } from "./types.tsx";
export type SharingForm = {
status: boolean;

View File

@ -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 <UserIcon />;
case AssistantRole:
return <BotIcon />;
case SystemRole:
return <ServerIcon />;
default:
return <UserIcon />;
}
}, [role]);
};
export type Message = {
role: string;
content: string;
@ -40,8 +56,6 @@ export type ConversationInstance = {
shared?: boolean;
};
export type ConversationMapper = Record<Id, Conversation>;
export type PlanItem = {
id: string;
name: string;

View File

@ -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";

View File

@ -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;
}
}
}

View File

@ -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<string | undefined>("");
return (
<div
className={cn(
"content-wrapper",
isAssistant ? "flex-row" : "flex-row-reverse",
!isUser ? "flex-row" : "flex-row-reverse",
)}
>
<EditorProvider
@ -111,6 +121,21 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) {
value={editedMessage ?? ""}
onChange={setEditedMessage}
/>
<div className={`message-avatar-wrapper`}>
<Tips
classNamePopup={`flex flex-row items-center`}
trigger={
isUser ? (
<Avatar className={`message-avatar`} username={username} />
) : (
<img src={appLogo} alt={``} className={`message-avatar p-1`} />
)
}
>
<Icon icon={icon} className={`h-4 w-4 mr-1`} />
{message.role}
</Tips>
</div>
<div className={`message-content`}>
{message.content.length ? (
<Markdown children={message.content} />
@ -121,16 +146,18 @@ function MessageContent({ message, end, index, onEvent }: MessageProps) {
)}
</div>
<div className={`message-toolbar`}>
<DropdownMenu>
<DropdownMenu open={dropdown} onOpenChange={setDropdown}>
<DropdownMenuTrigger className={`outline-none`}>
<MoreVertical className={`h-4 w-4 m-0.5`} />
</DropdownMenuTrigger>
<DropdownMenuContent align={`end`}>
{isAssistant && end && (
<DropdownMenuItem
onClick={() =>
onEvent && onEvent(message.end !== false ? "restart" : "stop")
}
onClick={() => {
onEvent &&
onEvent(message.end !== false ? "restart" : "stop");
setDropdown(false);
}}
>
{message.end !== false ? (
<>

View File

@ -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 ? (
<Button
variant="outline"
size="icon"
onClick={async () => await toggleConversation(dispatch, -1)}
onClick={async () => await toggle(-1)}
>
<MessageSquarePlus className={`h-4 w-4`} />
</Button>

View File

@ -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 () => {

View File

@ -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) {
<MessageSegment
message={message}
end={i === messages.length - 1}
onEvent={(e: string, index?: number, message?: string) => {
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}

View File

@ -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 ? <ChatInterface {...props} /> : <ChatSpace />;
}
function ChatWrapper() {
const { t } = useTranslation();
const { toast } = useToast();
const { send: sendAction } = useMessageActions();
const process = listenMessageEvent();
const [files, setFiles] = useState<FileArray>([]);
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<HTMLElement | null>(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 (
<div className={`chat-container`}>
<div className={`chat-wrapper`}>
<Interface setTarget={setInstance} setWorking={setWorking} />
<Interface setTarget={setInstance} />
<div className={`chat-input`}>
<div className={`input-action`}>
<ScrollAction

View File

@ -1,4 +1,3 @@
import { toggleConversation } from "@/api/history.ts";
import { mobile } from "@/utils/device.ts";
import { filterMessage } from "@/utils/processor.ts";
import { setMenu } from "@/store/menu.ts";
@ -17,9 +16,9 @@ import {
} from "@/components/ui/dropdown-menu.tsx";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { ConversationInstance } from "@/api/types.ts";
import { ConversationInstance } from "@/api/types.tsx";
import { useState } from "react";
import { closeMarket } from "@/store/chat.ts";
import { closeMarket, useConversationActions } from "@/store/chat.ts";
import { cn } from "@/components/ui/lib/utils.ts";
type ConversationSegmentProps = {
@ -36,6 +35,7 @@ function ConversationSegment({
operate,
}: ConversationSegmentProps) {
const dispatch = useDispatch();
const { toggle } = useConversationActions();
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [offset, setOffset] = useState(0);
@ -52,7 +52,7 @@ function ConversationSegment({
target.parentElement?.classList.contains("delete")
)
return;
await toggleConversation(dispatch, conversation.id);
await toggle(conversation.id);
if (mobile) dispatch(setMenu(false));
dispatch(closeMarket());
}}

View File

@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { selectAuthenticated } from "@/store/auth.ts";
import { useToast } from "@/components/ui/use-toast.ts";
import { Model, Plans } from "@/api/types.ts";
import { Model, Plans } from "@/api/types.tsx";
import { modelEvent } from "@/events/model.ts";
import { levelSelector } from "@/store/subscription.ts";
import { teenagerSelector } from "@/store/package.ts";

View File

@ -15,7 +15,7 @@ import {
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { isUrl, splitList } from "@/utils/base.ts";
import { Model } from "@/api/types.ts";
import { Model } from "@/api/types.tsx";
import { useDispatch, useSelector } from "react-redux";
import {
addModelList,

View File

@ -1,20 +1,19 @@
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { selectAuthenticated, selectUsername } from "@/store/auth.ts";
import { closeMarket, selectCurrent, selectHistory } from "@/store/chat.ts";
import {
closeMarket,
selectCurrent,
selectHistory,
useConversationActions,
} from "@/store/chat.ts";
import React, { useRef, useState } from "react";
import { ConversationInstance } from "@/api/types.ts";
import { ConversationInstance } from "@/api/types.tsx";
import { useToast } from "@/components/ui/use-toast.ts";
import { extractMessage, filterMessage } from "@/utils/processor.ts";
import { copyClipboard } from "@/utils/dom.ts";
import { useEffectAsync, useAnimation } from "@/utils/hook.ts";
import { mobile } from "@/utils/device.ts";
import {
deleteAllConversations,
deleteConversation,
toggleConversation,
updateConversationList,
} from "@/api/history.ts";
import { Button } from "@/components/ui/button.tsx";
import { selectMenu, setMenu } from "@/store/menu.ts";
import {
@ -63,25 +62,29 @@ function SidebarAction({ setOperateConversation }: SidebarActionProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const { toast } = useToast();
const refresh = useRef(null);
const {
toggle,
refresh: refreshAction,
removeAll: removeAllAction,
} = useConversationActions();
const refreshRef = useRef(null);
const [removeAll, setRemoveAll] = useState<boolean>(false);
async function handleDeleteAll(e: React.MouseEvent<HTMLButtonElement>) {
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);
}}
>
<RotateCw className={`h-4 w-4`} />
@ -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<string>("");
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<Operation>({
target: null,
type: "",
});
useEffectAsync(async () => {
await updateConversationList(dispatch);
}, []);
useEffectAsync(async () => await refresh(), []);
return (
<div className={cn("sidebar", open && "open")}>

View File

@ -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);

View File

@ -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 {

View File

@ -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);

View File

@ -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(","));

View File

@ -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<string, React.ReactElement> = {

View File

@ -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 <User />;
case AssistantRole:
return <Bot />;
case SystemRole:
return <Server />;
default:
return <User />;
}
}, [role]);
return (
<Button
variant={`outline`}
@ -133,6 +119,8 @@ function MaskItem({ mask, event, custom }: MaskItemProps) {
const dispatch = useDispatch();
const { toast } = useToast();
const { mask: setMask } = useConversationActions();
const [open, setOpen] = useState(false);
const prevent = (e: React.MouseEvent) => {
@ -146,7 +134,7 @@ function MaskItem({ mask, event, custom }: MaskItemProps) {
onClick={(e) => {
e.preventDefault();
maskEvent.emit(mask);
setMask(mask);
dispatch(closeMask());
}}
>
@ -174,7 +162,7 @@ function MaskItem({ mask, event, custom }: MaskItemProps) {
onClick={(e) => {
prevent(e);
maskEvent.emit(mask);
setMask(mask);
dispatch(closeMask());
setOpen(false);

View File

@ -1,12 +0,0 @@
import { EventCommitter } from "./struct.ts";
export type ConnectionEvent = {
id: number;
event: string;
index?: number;
message?: string;
};
export const connectionEvent = new EventCommitter<ConnectionEvent>({
name: "connection",
});

View File

@ -1,7 +0,0 @@
import { Mask } from "@/masks/types.ts";
import { EventCommitter } from "@/events/struct.ts";
export const maskEvent = new EventCommitter<Mask>({
name: "mask",
destroyedAfterTrigger: true,
});

View File

@ -1,5 +1,5 @@
import { EventCommitter } from "./struct.ts";
import { Message } from "@/api/types.ts";
import { Message } from "@/api/types.tsx";
export type SharingEvent = {
refer: string;

View File

@ -1,4 +1,4 @@
import { UserRole } from "@/api/types.ts";
import { UserRole } from "@/api/types.tsx";
export type MaskMessage = {
role: string;

View File

@ -20,7 +20,7 @@ import { Button } from "@/components/ui/button.tsx";
import router from "@/router.tsx";
import { useToast } from "@/components/ui/use-toast.ts";
import { sharingEvent } from "@/events/sharing.ts";
import { Message } from "@/api/types.ts";
import { Message } from "@/api/types.tsx";
import Avatar from "@/components/Avatar.tsx";
import { toJpeg } from "html-to-image";
import { appLogo } from "@/conf/env.ts";

View File

@ -6,7 +6,7 @@ import {
} from "@/components/ui/card.tsx";
import { useTranslation } from "react-i18next";
import { Dispatch, useMemo, useReducer, useState } from "react";
import { Model as RawModel } from "@/api/types.ts";
import { Model as RawModel } from "@/api/types.tsx";
import { Input } from "@/components/ui/input.tsx";
import {
Activity,

View File

@ -31,7 +31,7 @@ import {
SubscriptionIcon,
subscriptionIconsList,
} from "@/conf/subscription.tsx";
import { Plan, PlanItem } from "@/api/types.ts";
import { Plan, PlanItem } from "@/api/types.tsx";
import Tips from "@/components/Tips.tsx";
import { NumberInput } from "@/components/ui/number-input.tsx";
import { Input } from "@/components/ui/input.tsx";

View File

@ -1,7 +1,11 @@
import { createSlice } from "@reduxjs/toolkit";
import { ConversationInstance, Model } from "@/api/types.ts";
import { Message } from "@/api/types.ts";
import { insertStart } from "@/utils/base.ts";
import {
AssistantRole,
ConversationInstance,
Model,
UserRole,
} from "@/api/types.tsx";
import { Message } from "@/api/types.tsx";
import { AppDispatch, RootState } from "./index.ts";
import {
getArrayMemory,
@ -15,16 +19,44 @@ import {
loadPreferenceModels,
setOfflineModels,
} from "@/conf/storage.ts";
import { CustomMask } from "@/masks/types.ts";
import {
deleteConversation as doDeleteConversation,
deleteAllConversations as doDeleteAllConversations,
loadConversation,
getConversationList,
} from "@/api/history.ts";
import { CustomMask, Mask } from "@/masks/types.ts";
import { listMasks } from "@/api/mask.ts";
import { ConversationSerialized } from "@/api/conversation.ts";
import { useSelector } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { useMemo } from "react";
import { ConnectionStack } from "@/api/connection.ts";
import { ConnectionStack, StreamMessage } from "@/api/connection.ts";
import { useTranslation } from "react-i18next";
import {
contextSelector,
frequencyPenaltySelector,
historySelector,
maxTokensSelector,
presencePenaltySelector,
repetitionPenaltySelector,
temperatureSelector,
topKSelector,
topPSelector,
} from "@/store/settings.ts";
export type ConversationSerialized = {
model?: string;
messages: Message[];
};
export type ConnectionEvent = {
id: number;
event: string;
index?: number;
message?: string;
};
type initialStateType = {
history: ConversationInstance[];
messages: Message[];
conversations: Record<number, ConversationSerialized>;
model: string;
web: boolean;
@ -32,10 +64,13 @@ type initialStateType = {
model_list: string[];
market: boolean;
mask: boolean;
mask_item: Mask | null;
custom_masks: CustomMask[];
support_models: Model[];
};
const defaultConversation: ConversationSerialized = { messages: [] };
export function inModel(supportModels: Model[], model: string): boolean {
return (
model.length > 0 &&
@ -72,7 +107,9 @@ const chatSlice = createSlice({
initialState: {
history: [],
messages: [],
conversations: {},
conversations: {
[-1]: { ...defaultConversation },
},
web: getBooleanMemory("web", false),
current: -1,
model: getModel(offline, getMemory("model")),
@ -83,30 +120,142 @@ const chatSlice = createSlice({
),
market: false,
mask: false,
mask_item: null,
custom_masks: [],
support_models: offline,
} as initialStateType,
reducers: {
createMessage: (state, action) => {
const { id, role, content } = action.payload as {
id: number;
role: string;
content?: string;
};
const conversation = state.conversations[id];
if (!conversation) return;
if (id === -1 && state.mask_item && conversation.messages.length === 0) {
conversation.messages = [...state.mask_item.context];
state.mask_item = null;
}
conversation.messages.push({
role: role ?? AssistantRole,
content: content ?? "",
end: role === AssistantRole ? false : undefined,
});
},
updateMessage: (state, action) => {
const { id, message } = action.payload as {
id: number;
message: StreamMessage;
};
const conversation = state.conversations[id];
if (!conversation) return;
if (conversation.messages.length === 0)
conversation.messages.push({
role: AssistantRole,
content: message.message,
keyword: message.keyword,
quota: message.quota,
end: message.end,
plan: message.plan,
});
const instance = conversation.messages[conversation.messages.length - 1];
instance.content += message.message;
if (message.keyword) instance.keyword = message.keyword;
if (message.quota) instance.quota = message.quota;
if (message.end) instance.end = message.end;
instance.plan = message.plan;
},
removeMessage: (state, action) => {
const { id, idx } = action.payload as { id: number; idx: number };
const conversation = state.conversations[id];
if (!conversation) return;
conversation.messages.splice(idx, 1);
},
restartMessage: (state, action) => {
const id = action.payload as number;
const conversation = state.conversations[id];
if (!conversation || conversation.messages.length === 0) return;
const last = conversation.messages[conversation.messages.length - 1];
if (last.role !== AssistantRole) return;
conversation.messages.pop();
conversation.messages.push({
role: AssistantRole,
content: "",
end: false,
});
},
editMessage: (state, action) => {
const { id, idx, message } = action.payload as {
id: number;
idx: number;
message: string;
};
const conversation = state.conversations[id];
if (!conversation || conversation.messages.length <= idx) return;
conversation.messages[idx].content = message;
},
stopMessage: (state, action) => {
const { id } = action.payload as { id: number };
const conversation = state.conversations[id];
if (!conversation || conversation.messages.length === 0) return;
conversation.messages[conversation.messages.length - 1].end = true;
},
raiseConversation: (state, action) => {
// raise conversation `-1` to target id
const id = action.payload as number;
const conversation = state.conversations[-1];
if (!conversation || id === -1) return;
state.conversations[id] = conversation;
if (state.current === -1) state.current = id;
state.conversations[-1] = { ...defaultConversation };
},
importConversation: (state, action) => {
const { conversation, id } = action.payload as {
conversation: ConversationSerialized;
id: number;
};
if (state.conversations[id]) return;
state.conversations[id] = conversation;
},
deleteConversation: (state, action) => {
const id = action.payload as number;
if (id === -1) return;
if (!state.conversations[id]) return;
if (state.current === id) state.current = -1;
delete state.conversations[id];
state.history = state.history.filter((item) => item.id !== id);
},
deleteAllConversation: (state) => {
state.history = [];
state.conversations = { [-1]: { ...defaultConversation } };
state.current = -1;
},
setHistory: (state, action) => {
state.history = action.payload as ConversationInstance[];
},
removeHistory: (state, action) => {
state.history = state.history.filter(
(item) => item.id !== (action.payload as number),
);
},
addHistory: (state, action) => {
const name = action.payload.message as string;
const id = state.history.length
? Math.max(...state.history.map((item) => item.id)) + 1
: 1;
preflightHistory: (state, action) => {
const name = action.payload as string;
state.history = insertStart(state.history, { id, name, message: [] });
state.current = id;
action.payload.hook(id);
},
setMessages: (state, action) => {
state.messages = action.payload as Message[];
// add a new history at the beginning
state.history = [{ id: -1, name, message: [] }, ...state.history];
},
setModel: (state, action) => {
setMemory("model", action.payload as string);
@ -122,13 +271,17 @@ const chatSlice = createSlice({
state.web = web;
},
setCurrent: (state, action) => {
state.current = action.payload as number;
},
addMessage: (state, action) => {
state.messages.push(action.payload as Message);
},
setMessage: (state, action) => {
state.messages[state.messages.length - 1] = action.payload as Message;
const current = action.payload as number;
state.current = current;
const conversation = state.conversations[current];
if (!conversation) return;
if (
conversation.model &&
inModel(state.support_models, conversation.model)
) {
state.model = conversation.model;
}
},
setModelList: (state, action) => {
const models = action.payload as string[];
@ -175,6 +328,12 @@ const chatSlice = createSlice({
closeMask: (state) => {
state.mask = false;
},
setMaskItem: (state, action) => {
state.mask_item = action.payload as Mask;
},
clearMaskItem: (state) => {
state.mask_item = null;
},
setCustomMasks: (state, action) => {
state.custom_masks = action.payload as CustomMask[];
},
@ -196,15 +355,10 @@ const chatSlice = createSlice({
export const {
setHistory,
removeHistory,
addHistory,
setCurrent,
setMessages,
setModel,
setWeb,
toggleWeb,
addMessage,
setMessage,
setModelList,
addModelList,
removeModelList,
@ -216,11 +370,22 @@ export const {
closeMask,
setCustomMasks,
setSupportModels,
setMaskItem,
clearMaskItem,
createMessage,
updateMessage,
removeMessage,
restartMessage,
editMessage,
stopMessage,
raiseConversation,
importConversation,
deleteConversation,
deleteAllConversation,
preflightHistory,
} = chatSlice.actions;
export const selectHistory = (state: RootState): ConversationInstance[] =>
state.chat.history;
export const selectMessages = (state: RootState): Message[] =>
state.chat.messages;
export const selectConversations = (
state: RootState,
): Record<number, ConversationSerialized> => state.chat.conversations;
@ -235,6 +400,8 @@ export const selectCustomMasks = (state: RootState): CustomMask[] =>
state.chat.custom_masks;
export const selectSupportModels = (state: RootState): Model[] =>
state.chat.support_models;
export const selectMaskItem = (state: RootState): Mask | null =>
state.chat.mask_item;
export function useConversation(): ConversationSerialized | undefined {
const conversations = useSelector(selectConversations);
@ -243,14 +410,211 @@ export function useConversation(): ConversationSerialized | undefined {
return useMemo(() => conversations[current], [conversations, current]);
}
export function useConversationActions() {
const dispatch = useDispatch();
const conversations = useSelector(selectConversations);
const current = useSelector(selectCurrent);
const mask = useSelector(selectMaskItem);
return {
toggle: async (id: number) => {
const conversation = conversations[id];
if (!conversation) {
const data = await loadConversation(id);
const props: ConversationSerialized = {
model: data.model,
messages: data.message,
};
dispatch(
importConversation({
conversation: props,
id,
}),
);
}
if (current === -1 && conversations[-1].messages.length === 0) {
// current is mask, clear mask
mask && dispatch(clearMaskItem());
}
dispatch(setCurrent(id));
},
remove: async (id: number) => {
const state = await doDeleteConversation(id);
state && dispatch(deleteConversation(id));
return state;
},
removeAll: async () => {
const state = await doDeleteAllConversations();
state && dispatch(deleteAllConversation());
return state;
},
refresh: async () => {
const resp = await getConversationList();
dispatch(setHistory(resp));
},
mask: (mask: Mask) => {
dispatch(setMaskItem(mask));
if (current !== -1) {
dispatch(setCurrent(-1));
}
},
};
}
export function useMessageActions() {
const { t } = useTranslation();
const dispatch = useDispatch();
const { refresh } = useConversationActions();
const current = useSelector(selectCurrent);
const conversations = useSelector(selectConversations);
const mask = useSelector(selectMaskItem);
const model = useSelector(selectModel);
const web = useSelector(selectWeb);
const history = useSelector(historySelector);
const context = useSelector(contextSelector);
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);
return {
send: async (message: string) => {
if (current === -1 && conversations[-1].messages.length === 0) {
// preflight history if it's a new conversation
dispatch(preflightHistory(message));
}
if (!stack.hasConnection(current)) {
const conn = stack.createConnection(current);
if (current === -1 && mask && mask.context.length > 0) {
conn.sendMaskEvent(t, mask);
dispatch(clearMaskItem());
}
}
const state = stack.send(current, t, {
type: "chat",
message,
web,
model,
context: history,
ignore_context: !context,
max_tokens,
temperature,
top_p,
top_k,
presence_penalty,
frequency_penalty,
repetition_penalty,
});
if (!state) return false;
dispatch(
createMessage({ id: current, role: UserRole, content: message }),
);
dispatch(createMessage({ id: current, role: AssistantRole }));
return true;
},
stop: () => {
if (!stack.hasConnection(current)) return;
stack.sendStopEvent(current, t);
dispatch(stopMessage(current));
},
restart: () => {
if (!stack.hasConnection(current)) {
stack.createConnection(current);
}
stack.sendRestartEvent(current, t);
// remove the last message if it's from assistant and create a new message
dispatch(restartMessage(current));
},
remove: (idx: number) => {
if (idx < 0 || idx >= conversations[current].messages.length) return;
dispatch(removeMessage({ id: current, idx }));
if (!stack.hasConnection(current)) stack.createConnection(current);
stack.sendRemoveEvent(current, t, idx);
},
edit: (idx: number, message: string) => {
if (idx < 0 || idx >= conversations[current].messages.length) return;
dispatch(editMessage({ id: current, idx, message }));
if (!stack.hasConnection(current)) stack.createConnection(current);
stack.sendEditEvent(current, t, idx, message);
},
receive: async (id: number, message: StreamMessage) => {
dispatch(updateMessage({ id, message }));
// raise conversation if it is -1
if (id === -1 && message.conversation) {
const target: number = message.conversation;
dispatch(raiseConversation(target));
stack.raiseConnection(target);
await refresh();
}
},
};
}
export function listenMessageEvent() {
const actions = useMessageActions();
return (e: ConnectionEvent) => {
console.debug(`[conversation] receive event: ${e.event} (id: ${e.id})`);
switch (e.event) {
case "stop":
actions.stop();
break;
case "restart":
actions.restart();
break;
case "remove":
actions.remove(e.index ?? -1);
break;
case "edit":
actions.edit(e.index ?? -1, e.message ?? "");
break;
}
};
}
export function useMessages(): Message[] {
const conversations = useSelector(selectConversations);
const current = useSelector(selectCurrent);
const mask = useSelector(selectMaskItem);
return useMemo(
() => conversations[current]?.messages || [],
[conversations, current],
);
return useMemo(() => {
const messages = conversations[current]?.messages || [];
const showMask = current === -1 && mask && messages.length === 0;
return !showMask ? messages : mask?.context;
}, [conversations, current, mask]);
}
export function useWorking(): boolean {
const messages = useMessages();
return useMemo(() => {
if (messages.length === 0) return false;
const last = messages[messages.length - 1];
if (last.role !== AssistantRole || last.end === undefined) return false;
return !last.end;
}, [messages]);
}
export const updateMasks = async (dispatch: AppDispatch) => {

View File

@ -1,5 +1,5 @@
import { createSlice } from "@reduxjs/toolkit";
import { Plans } from "@/api/types.ts";
import { Plans } from "@/api/types.tsx";
import { AppDispatch, RootState } from "@/store/index.ts";
import { getOfflinePlans, setOfflinePlans } from "@/conf/storage.ts";
import { getTheme, Theme } from "@/components/ThemeProvider.tsx";

View File

@ -28,6 +28,11 @@ func NewChatRequest(group string, props *adapter.ChatProps, hook globals.Hook) e
}
globals.Info(fmt.Sprintf("[channel] channels are exhausted for model %s", props.Model))
if err == nil {
err = fmt.Errorf("channels are exhausted for model %s", props.Model)
}
return err
}
@ -53,7 +58,7 @@ func PreflightCache(cache *redis.Client, hash string, buffer *utils.Buffer, hook
buffer.SetInputTokens(buf.CountInputToken())
buffer.SetToolCalls(buf.GetToolCalls())
buffer.SetFunctionCall(buf.GetFunctionCall())
return idx, true, hook(&globals.Chunk{
Content: data,
FunctionCall: buf.GetFunctionCall(),

View File

@ -109,6 +109,10 @@ func (c *Conversation) IsEnableWeb() bool {
}
func (c *Conversation) GetContextLength() int {
if c.Context <= 0 {
return defaultConversationContext
}
return c.Context
}
@ -376,6 +380,19 @@ func (c *Conversation) RemoveLatestMessage() globals.Message {
return c.RemoveMessage(len(c.Message) - 1)
}
func (c *Conversation) RemoveLatestMessageWithRole(role string) globals.Message {
if len(c.Message) == 0 {
return globals.Message{}
}
message := c.Message[len(c.Message)-1]
if message.Role == role {
return c.RemoveLatestMessage()
}
return globals.Message{}
}
func (c *Conversation) EditMessage(index int, message string) {
if index < 0 || index >= len(c.Message) {
return

View File

@ -92,14 +92,7 @@ func ChatAPI(c *gin.Context) {
case ShareType:
instance.LoadSharing(db, form.Message)
case RestartType:
if message := instance.RemoveLatestMessage(); message.Role != globals.Assistant {
conn.Send(globals.ChatSegmentResponse{
Message: "Hello, How can I assist you?",
End: true,
})
return fmt.Errorf("message type error")
}
instance.RemoveLatestMessageWithRole(globals.Assistant)
response := ChatHandler(buf, user, instance)
instance.SaveResponse(db, response)
case MaskType: