mirror of
https://github.com/coaidev/coai.git
synced 2025-05-22 22:40:14 +09:00
update scroll action and fix file style in mobile
This commit is contained in:
parent
76235ca796
commit
5a20c5282e
@ -22,6 +22,7 @@
|
||||
.file-wrapper {
|
||||
margin-top: 24px !important;
|
||||
padding: 8px 0;
|
||||
max-width: calc(90vw - 3rem);
|
||||
}
|
||||
|
||||
.drop-window {
|
||||
@ -103,3 +104,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-name {
|
||||
word-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
@ -13,6 +13,7 @@
|
||||
border: 1px solid hsl(var(--border-hover));
|
||||
background: hsl(var(--background-container)) !important;
|
||||
border-radius: var(--radius);
|
||||
white-space: pre-wrap;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
|
@ -14,10 +14,11 @@
|
||||
.scroll-action {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
bottom: 112px;
|
||||
right: 36px;
|
||||
opacity: 0;
|
||||
right: 36px;
|
||||
bottom: 12rem;
|
||||
transition: .25s;
|
||||
pointer-events: none;
|
||||
|
||||
button {
|
||||
border-color: rgba(0,0,0,0) !important;
|
||||
@ -25,6 +26,11 @@
|
||||
|
||||
&.active {
|
||||
opacity: 0.8;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
@media (max-width: 668px) {
|
||||
bottom: 8.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -318,6 +318,7 @@
|
||||
padding: 6px 24px;
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
@ -19,19 +19,11 @@ import { Button } from "./ui/button.tsx";
|
||||
type RichEditorProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
id?: string;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
function RichEditor({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
placeholder,
|
||||
maxLength,
|
||||
}: RichEditorProps) {
|
||||
function RichEditor({ value, onChange, maxLength }: RichEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const input = useRef(null);
|
||||
const [openPreview, setOpenPreview] = useState(!mobile);
|
||||
const [openInput, setOpenInput] = useState(true);
|
||||
@ -117,10 +109,10 @@ function RichEditor({
|
||||
>
|
||||
{openInput && (
|
||||
<Textarea
|
||||
placeholder={placeholder}
|
||||
placeholder={t("chat.placeholder")}
|
||||
value={value}
|
||||
className={`editor-input`}
|
||||
id={id}
|
||||
id={`editor`}
|
||||
maxLength={maxLength}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
ref={input}
|
||||
@ -142,7 +134,7 @@ function EditorProvider(props: RichEditorProps) {
|
||||
<>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<div className={`editor-action active ${props.className}`}>
|
||||
<div className={`editor-action active editor`}>
|
||||
<Maximize className={`h-3.5 w-3.5`} />
|
||||
</div>
|
||||
</DialogTrigger>
|
||||
|
@ -31,14 +31,11 @@ const MaxFileSize = 1024 * 1024 * 25; // 25MB File Size Limit
|
||||
const MaxPromptSize = 5000; // 5000 Prompt Size Limit (to avoid token overflow)
|
||||
|
||||
type FileProviderProps = {
|
||||
id: string;
|
||||
className?: string;
|
||||
|
||||
value: FileArray;
|
||||
onChange?: (value: FileArray) => void;
|
||||
};
|
||||
|
||||
function FileProvider({ id, className, value, onChange }: FileProviderProps) {
|
||||
function FileProvider({ value, onChange }: FileProviderProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const model = useSelector(selectModel);
|
||||
@ -82,7 +79,7 @@ function FileProvider({ id, className, value, onChange }: FileProviderProps) {
|
||||
<AlertTitle>{t("file.type")}</AlertTitle>
|
||||
</Alert>
|
||||
<FileList value={value} removeFile={removeFile} />
|
||||
<FileInput id={id} className={className} addFile={addFile} />
|
||||
<FileInput id={"file"} className={"file"} addFile={addFile} />
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@ -111,7 +108,7 @@ function FileList({ value, removeFile }: FileListProps) {
|
||||
<div className={`file-list`}>
|
||||
{value.length > 3 && full && (
|
||||
<div className={`file-item`}>
|
||||
<Paperclip className={`h-4 w-4 ml-2 mr-1.5`} />
|
||||
<Paperclip className={`flex-shrink-0 h-4 w-4 ml-2 mr-1.5`} />
|
||||
<div className={`file-name mr-1`}>
|
||||
{t("file.number", { number: value.length })}
|
||||
</div>
|
||||
@ -129,7 +126,7 @@ function FileList({ value, removeFile }: FileListProps) {
|
||||
{value.length <= 3 || full ? (
|
||||
value.map((file, index) => (
|
||||
<div className={`file-item`} key={index}>
|
||||
<File className={`h-4 w-4 ml-2 mr-1.5`} />
|
||||
<File className={`flex-shrink-0 h-4 w-4 ml-2 mr-1.5`} />
|
||||
<div className={`file-name mr-1`}>{file.name}</div>
|
||||
<div className={`grow`} />
|
||||
<div className={`file-size mr-2`}>
|
||||
@ -147,7 +144,7 @@ function FileList({ value, removeFile }: FileListProps) {
|
||||
))
|
||||
) : (
|
||||
<div className={`file-item`}>
|
||||
<Paperclip className={`h-4 w-4 ml-2 mr-1.5`} />
|
||||
<Paperclip className={`flex-shrink-0 h-4 w-4 ml-2 mr-1.5`} />
|
||||
<div className={`file-name mr-1`}>
|
||||
{t("file.zipper", {
|
||||
filename: file.name,
|
||||
|
@ -27,6 +27,7 @@ function ReloadPrompt() {
|
||||
|
||||
const before = getMemory("version");
|
||||
if (before.length > 0 && before !== version) {
|
||||
setMemory("version", version);
|
||||
toast({
|
||||
title: t("service.update-success"),
|
||||
description: t("service.update-success-prompt"),
|
||||
|
53
app/src/components/home/ChatFooter.tsx
Normal file
53
app/src/components/home/ChatFooter.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { openDialog } from "@/store/settings.ts";
|
||||
import { version } from "@/conf.ts";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
function ChatFooter() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<div className={`version`}>
|
||||
<svg
|
||||
className={`app`}
|
||||
onClick={() => {
|
||||
// triggerInstallApp();
|
||||
dispatch(openDialog());
|
||||
}}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M9 3h-4a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2z"
|
||||
strokeWidth="0"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9 13h-4a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2z"
|
||||
strokeWidth="0"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M19 13h-4a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2z"
|
||||
strokeWidth="0"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17 3a1 1 0 0 1 .993 .883l.007 .117v2h2a1 1 0 0 1 .117 1.993l-.117 .007h-2v2a1 1 0 0 1 -1.993 .117l-.007 -.117v-2h-2a1 1 0 0 1 -.117 -1.993l.117 -.007h2v-2a1 1 0 0 1 1 -1z"
|
||||
strokeWidth="0"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
chatnio v{version}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatFooter;
|
@ -1,61 +1,37 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { Message } from "@/conversation/types.ts";
|
||||
import { useSelector } from "react-redux";
|
||||
import { selectCurrent, selectMessages } from "@/store/chat.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import MessageSegment from "@/components/Message.tsx";
|
||||
import { connectionEvent } from "@/events/connection.ts";
|
||||
import { chatEvent } from "@/events/chat.ts";
|
||||
|
||||
function ChatInterface() {
|
||||
const ref = useRef(null);
|
||||
const [scroll, setScroll] = useState(false);
|
||||
type ChatInterfaceProps = {
|
||||
setTarget: (target: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
function ChatInterface({ setTarget }: ChatInterfaceProps) {
|
||||
const ref = React.useRef(null);
|
||||
const messages: Message[] = useSelector(selectMessages);
|
||||
const current: number = useSelector(selectCurrent);
|
||||
|
||||
function listenScrolling() {
|
||||
if (!ref.current) return;
|
||||
const el = ref.current as HTMLDivElement;
|
||||
const offset = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setScroll(offset > 100);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
function () {
|
||||
if (!ref.current) return;
|
||||
const el = ref.current as HTMLDivElement;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
listenScrolling();
|
||||
chatEvent.emit();
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const el = ref.current as HTMLDivElement;
|
||||
el.addEventListener("scroll", listenScrolling);
|
||||
setTarget(ref.current);
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`chat-content`} ref={ref}>
|
||||
<div className={`scroll-action ${scroll ? "active" : ""}`}>
|
||||
<Button
|
||||
variant={`outline`}
|
||||
size={`icon`}
|
||||
onClick={() => {
|
||||
if (!ref.current) return;
|
||||
const el = ref.current as HTMLDivElement;
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{messages.map((message, i) => (
|
||||
<MessageSegment
|
||||
message={message}
|
||||
|
84
app/src/components/home/ChatSpace.tsx
Normal file
84
app/src/components/home/ChatSpace.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { isSubscribedSelector } from "@/store/subscription.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
BookMarked,
|
||||
ChevronRight,
|
||||
FolderKanban,
|
||||
Newspaper,
|
||||
Users2,
|
||||
} from "lucide-react";
|
||||
import router from "@/router.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
|
||||
function ChatSpace() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const subscription = useSelector(isSubscribedSelector);
|
||||
return (
|
||||
<div className={`chat-product`}>
|
||||
<Button variant={`outline`} onClick={() => setOpen(true)}>
|
||||
<Users2 className={`h-4 w-4 mr-1.5`} />
|
||||
{t("contact.title")}
|
||||
<ChevronRight className={`h-4 w-4 ml-2`} />
|
||||
</Button>
|
||||
{subscription && (
|
||||
<Button variant={`outline`} onClick={() => router.navigate("/article")}>
|
||||
<Newspaper className={`h-4 w-4 mr-1.5`} />
|
||||
{t("article.title")}
|
||||
<ChevronRight className={`h-4 w-4 ml-2`} />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant={`outline`} onClick={() => router.navigate("/generate")}>
|
||||
<FolderKanban className={`h-4 w-4 mr-1.5`} />
|
||||
{t("generate.title")}
|
||||
<ChevronRight className={`h-4 w-4 ml-2`} />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("contact.title")}</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className={`grid pt-4`}>
|
||||
<Button
|
||||
className={`mx-auto`}
|
||||
variant={`outline`}
|
||||
onClick={() =>
|
||||
window.open("https://docs.chatnio.net", "_blank")
|
||||
}
|
||||
>
|
||||
<BookMarked className={`h-4 w-4 mr-1.5`} />
|
||||
{t("docs.title")}
|
||||
</Button>
|
||||
<a
|
||||
href={
|
||||
"http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=1oKfIbNVXmMNMVzW1NiFSTKDcT1qIEq5&authKey=uslxslIBZtLImf4BSxjDqfx4hiJA52YV7PFM38W%2BOArr%2BhE0jwVdQCRYs0%2FXKX7W&noverify=0&group_code=565902327"
|
||||
}
|
||||
target={"_blank"}
|
||||
className={`inline-flex mx-auto mt-1 mb-2`}
|
||||
>
|
||||
<img
|
||||
src={`/source/qq.jpg`}
|
||||
className={`contact-image`}
|
||||
alt={`QQ`}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatSpace;
|
@ -1,118 +1,26 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import FileProvider from "@/components/FileProvider.tsx";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { selectAuthenticated, selectInit } from "@/store/auth.ts";
|
||||
import {
|
||||
selectMessages,
|
||||
selectModel,
|
||||
selectWeb,
|
||||
setWeb,
|
||||
} from "@/store/chat.ts";
|
||||
import { selectMessages, selectModel, selectWeb } from "@/store/chat.ts";
|
||||
import { manager } from "@/conversation/manager.ts";
|
||||
import { formatMessage } from "@/utils/processor.ts";
|
||||
import ChatInterface from "@/components/home/ChatInterface.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import router from "@/router.tsx";
|
||||
import {
|
||||
BookMarked,
|
||||
ChevronRight,
|
||||
FolderKanban,
|
||||
Globe,
|
||||
Newspaper,
|
||||
Users2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip.tsx";
|
||||
import { Toggle } from "@/components/ui/toggle.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import EditorProvider from "@/components/EditorProvider.tsx";
|
||||
import ModelSelector from "./ModelSelector.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { version } from "@/conf.ts";
|
||||
import ModelFinder from "./ModelFinder.tsx";
|
||||
import { clearHistoryState, getQueryParam } from "@/utils/path.ts";
|
||||
import { forgetMemory, popMemory, setMemory } from "@/utils/memory.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,
|
||||
openDialog,
|
||||
} from "@/store/settings.ts";
|
||||
import { isSubscribedSelector } from "@/store/subscription.ts";
|
||||
import { alignSelector, contextSelector } from "@/store/settings.ts";
|
||||
import { FileArray } from "@/conversation/file.ts";
|
||||
|
||||
function ChatSpace() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const subscription = useSelector(isSubscribedSelector);
|
||||
return (
|
||||
<div className={`chat-product`}>
|
||||
<Button variant={`outline`} onClick={() => setOpen(true)}>
|
||||
<Users2 className={`h-4 w-4 mr-1.5`} />
|
||||
{t("contact.title")}
|
||||
<ChevronRight className={`h-4 w-4 ml-2`} />
|
||||
</Button>
|
||||
{subscription && (
|
||||
<Button variant={`outline`} onClick={() => router.navigate("/article")}>
|
||||
<Newspaper className={`h-4 w-4 mr-1.5`} />
|
||||
{t("article.title")}
|
||||
<ChevronRight className={`h-4 w-4 ml-2`} />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant={`outline`} onClick={() => router.navigate("/generate")}>
|
||||
<FolderKanban className={`h-4 w-4 mr-1.5`} />
|
||||
{t("generate.title")}
|
||||
<ChevronRight className={`h-4 w-4 ml-2`} />
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("contact.title")}</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className={`grid pt-4`}>
|
||||
<Button
|
||||
className={`mx-auto`}
|
||||
variant={`outline`}
|
||||
onClick={() =>
|
||||
window.open("https://docs.chatnio.net", "_blank")
|
||||
}
|
||||
>
|
||||
<BookMarked className={`h-4 w-4 mr-1.5`} />
|
||||
{t("docs.title")}
|
||||
</Button>
|
||||
<a
|
||||
href={
|
||||
"http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=1oKfIbNVXmMNMVzW1NiFSTKDcT1qIEq5&authKey=uslxslIBZtLImf4BSxjDqfx4hiJA52YV7PFM38W%2BOArr%2BhE0jwVdQCRYs0%2FXKX7W&noverify=0&group_code=565902327"
|
||||
}
|
||||
target={"_blank"}
|
||||
className={`inline-flex mx-auto mt-1 mb-2`}
|
||||
>
|
||||
<img
|
||||
src={`/source/qq.jpg`}
|
||||
className={`contact-image`}
|
||||
alt={`QQ`}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import WebToggle from "@/components/home/components/WebToggle.tsx";
|
||||
import ChatSpace from "@/components/home/ChatSpace.tsx";
|
||||
import ChatFooter from "@/components/home/ChatFooter.tsx";
|
||||
import SendButton from "@/components/home/components/SendButton.tsx";
|
||||
import ChatInput from "@/components/home/components/ChatInput.tsx";
|
||||
import ScrollAction from "@/components/home/components/ScrollAction.tsx";
|
||||
|
||||
function ChatWrapper() {
|
||||
const { t } = useTranslation();
|
||||
@ -129,6 +37,8 @@ function ChatWrapper() {
|
||||
const context = useSelector(contextSelector);
|
||||
const align = useSelector(alignSelector);
|
||||
|
||||
const [instance, setInstance] = useState<HTMLElement | null>(null);
|
||||
|
||||
manager.setDispatch(dispatch);
|
||||
|
||||
function clearFile() {
|
||||
@ -204,119 +114,36 @@ function ChatWrapper() {
|
||||
return (
|
||||
<div className={`chat-container`}>
|
||||
<div className={`chat-wrapper`}>
|
||||
{messages.length > 0 ? <ChatInterface /> : <ChatSpace />}
|
||||
{messages.length > 0 ? (
|
||||
<ChatInterface setTarget={setInstance} />
|
||||
) : (
|
||||
<ChatSpace />
|
||||
)}
|
||||
<ScrollAction target={instance} />
|
||||
<div className={`chat-input`}>
|
||||
<div className={`input-wrapper`}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Toggle
|
||||
aria-label={t("chat.web-aria")}
|
||||
defaultPressed={false}
|
||||
onPressedChange={(state: boolean) =>
|
||||
dispatch(setWeb(state))
|
||||
}
|
||||
variant={`outline`}
|
||||
>
|
||||
<Globe className={`h-4 w-4 web ${web ? "enable" : ""}`} />
|
||||
</Toggle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className={`tooltip`}>{t("chat.web")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<WebToggle />
|
||||
<div className={`chat-box`}>
|
||||
<FileProvider
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
id={`file`}
|
||||
className={`file`}
|
||||
/>
|
||||
<Input
|
||||
id={`input`}
|
||||
className={`input-box ${align && "align"}`}
|
||||
<FileProvider value={files} onChange={setFiles} />
|
||||
<ChatInput
|
||||
className={align ? "align" : ""}
|
||||
ref={target}
|
||||
value={input}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(e.target.value);
|
||||
setMemory("history", e.target.value);
|
||||
}}
|
||||
placeholder={t("chat.placeholder")}
|
||||
onKeyDown={async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") await handleSend(auth, model, web);
|
||||
}}
|
||||
onValueChange={setInput}
|
||||
onEnterPressed={async () => await handleSend(auth, model, web)}
|
||||
/>
|
||||
<EditorProvider
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
className={`editor`}
|
||||
id={`editor`}
|
||||
placeholder={t("chat.placeholder")}
|
||||
maxLength={8000}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size={`icon`}
|
||||
variant="outline"
|
||||
className={`send-button`}
|
||||
onClick={() => handleSend(auth, model, web)}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="m21.426 11.095-17-8A1 1 0 0 0 3.03 4.242l1.212 4.849L12 12l-7.758 2.909-1.212 4.849a.998.998 0 0 0 1.396 1.147l17-8a1 1 0 0 0 0-1.81z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
<SendButton onClick={() => handleSend(auth, model, web)} />
|
||||
</div>
|
||||
<div className={`input-options`}>
|
||||
<ModelSelector side={`bottom`} />
|
||||
</div>
|
||||
<div className={`version`}>
|
||||
<svg
|
||||
className={`app`}
|
||||
onClick={() => {
|
||||
// triggerInstallApp();
|
||||
dispatch(openDialog());
|
||||
}}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M9 3h-4a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2z"
|
||||
strokeWidth="0"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9 13h-4a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2z"
|
||||
strokeWidth="0"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M19 13h-4a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h4a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2z"
|
||||
strokeWidth="0"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17 3a1 1 0 0 1 .993 .883l.007 .117v2h2a1 1 0 0 1 .117 1.993l-.117 .007h-2v2a1 1 0 0 1 -1.993 .117l-.007 -.117v-2h-2a1 1 0 0 1 -.117 -1.993l.117 -.007h2v-2a1 1 0 0 1 1 -1z"
|
||||
strokeWidth="0"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
chatnio v{version}
|
||||
<ModelFinder side={`bottom`} />
|
||||
</div>
|
||||
<ChatFooter />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -19,7 +19,7 @@ type ModelSelectorProps = {
|
||||
side?: "left" | "right" | "top" | "bottom";
|
||||
};
|
||||
|
||||
function ModelSelector(props: ModelSelectorProps) {
|
||||
function ModelFinder(props: ModelSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const { toast } = useToast();
|
||||
@ -63,7 +63,7 @@ function ModelSelector(props: ModelSelectorProps) {
|
||||
<SelectGroup
|
||||
current={list.find((item) => item.name === model) as SelectItemProps}
|
||||
list={list}
|
||||
maxElements={6}
|
||||
maxElements={5}
|
||||
side={props.side}
|
||||
classNameMobile={`model-select-group`}
|
||||
onChange={(value: string) => {
|
||||
@ -87,4 +87,4 @@ function ModelSelector(props: ModelSelectorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelSelector;
|
||||
export default ModelFinder;
|
41
app/src/components/home/components/ChatInput.tsx
Normal file
41
app/src/components/home/components/ChatInput.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import React from "react";
|
||||
import { setMemory } from "@/utils/memory.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ChatInputProps = {
|
||||
className?: string;
|
||||
ref?: React.RefObject<HTMLInputElement>;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
onEnterPressed: () => void;
|
||||
};
|
||||
|
||||
function ChatInput({
|
||||
className,
|
||||
ref,
|
||||
value,
|
||||
onValueChange,
|
||||
onEnterPressed,
|
||||
}: ChatInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={`input`}
|
||||
className={`input-box ${className || ""}`}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onValueChange(e.target.value);
|
||||
setMemory("history", e.target.value);
|
||||
}}
|
||||
placeholder={t("chat.placeholder")}
|
||||
onKeyDown={async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") onEnterPressed();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatInput;
|
45
app/src/components/home/components/ScrollAction.tsx
Normal file
45
app/src/components/home/components/ScrollAction.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { chatEvent } from "@/events/chat.ts";
|
||||
|
||||
type ScrollActionProps = {
|
||||
target: HTMLElement | null;
|
||||
};
|
||||
|
||||
function ScrollAction({ target }: ScrollActionProps) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
target.addEventListener("scroll", listenScrollingAction);
|
||||
}, [target]);
|
||||
|
||||
function listenScrollingAction() {
|
||||
if (!target) return;
|
||||
const offset = target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||
setEnabled(offset > 100);
|
||||
}
|
||||
|
||||
chatEvent.addEventListener(listenScrollingAction);
|
||||
|
||||
return (
|
||||
<div className={`scroll-action ${enabled ? "active" : ""}`}>
|
||||
<Button
|
||||
variant={`outline`}
|
||||
size={`icon`}
|
||||
onClick={() => {
|
||||
if (!target) return;
|
||||
target.scrollTo({
|
||||
top: target.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4`} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScrollAction;
|
28
app/src/components/home/components/SendButton.tsx
Normal file
28
app/src/components/home/components/SendButton.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
|
||||
type SendButtonProps = {
|
||||
onClick: () => any;
|
||||
};
|
||||
|
||||
function SendButton({ onClick }: SendButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
size={`icon`}
|
||||
variant="outline"
|
||||
className={`send-button`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="m21.426 11.095-17-8A1 1 0 0 0 3.03 4.242l1.212 4.849L12 12l-7.758 2.909-1.212 4.849a.998.998 0 0 0 1.396 1.147l17-8a1 1 0 0 0 0-1.81z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default SendButton;
|
41
app/src/components/home/components/WebToggle.tsx
Normal file
41
app/src/components/home/components/WebToggle.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip.tsx";
|
||||
import { Toggle } from "@/components/ui/toggle.tsx";
|
||||
import { selectWeb, setWeb } from "@/store/chat.ts";
|
||||
import { Globe } from "lucide-react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function WebToggle() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const web = useSelector(selectWeb);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Toggle
|
||||
aria-label={t("chat.web-aria")}
|
||||
defaultPressed={false}
|
||||
onPressedChange={(state: boolean) => dispatch(setWeb(state))}
|
||||
variant={`outline`}
|
||||
>
|
||||
<Globe className={`h-4 w-4 web ${web ? "enable" : ""}`} />
|
||||
</Toggle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className={`tooltip`}>{t("chat.web")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebToggle;
|
@ -31,7 +31,7 @@ export const supportModels: Model[] = [
|
||||
// spark desk
|
||||
{ id: "spark-desk-v3", name: "讯飞星火 V3", free: true, auth: true },
|
||||
{ id: "spark-desk-v2", name: "讯飞星火 V2", free: true, auth: true },
|
||||
{ id: "spark-desk-v2", name: "讯飞星火 V1.5", free: true, auth: true },
|
||||
{ id: "spark-desk-v1.5", name: "讯飞星火 V1.5", free: true, auth: true },
|
||||
|
||||
// dashscope models
|
||||
{ id: "qwen-plus-net", name: "通义千问 Plus X", free: false, auth: true },
|
||||
|
5
app/src/events/chat.ts
Normal file
5
app/src/events/chat.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { EventCommitter } from "@/events/struct.ts";
|
||||
|
||||
export const chatEvent = new EventCommitter<void>({
|
||||
name: "chat",
|
||||
});
|
@ -13,7 +13,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card.tsx";
|
||||
import { useState } from "react";
|
||||
import ModelSelector from "@/components/home/ModelSelector.tsx";
|
||||
import ModelFinder from "@/components/home/ModelFinder.tsx";
|
||||
import { Toggle } from "@/components/ui/toggle.tsx";
|
||||
import { selectModel, selectWeb, setWeb } from "@/store/chat.ts";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
@ -175,7 +175,7 @@ function ArticleContent() {
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<ModelSelector side={`bottom`} />
|
||||
<ModelFinder side={`bottom`} />
|
||||
<Button
|
||||
variant={`default`}
|
||||
className={`mt-5 w-full mx-auto`}
|
||||
|
@ -11,7 +11,7 @@ import { manager } from "@/conversation/generation.ts";
|
||||
import { useToast } from "@/components/ui/use-toast.ts";
|
||||
import { handleGenerationData } from "@/utils/processor.ts";
|
||||
import { selectModel } from "@/store/chat.ts";
|
||||
import ModelSelector from "@/components/home/ModelSelector.tsx";
|
||||
import ModelFinder from "@/components/home/ModelFinder.tsx";
|
||||
|
||||
type WrapperProps = {
|
||||
onSend?: (value: string, model: string) => boolean;
|
||||
@ -147,7 +147,7 @@ function Wrapper({ onSend }: WrapperProps) {
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`model-box`}>
|
||||
<ModelSelector side={`bottom`} />
|
||||
<ModelFinder side={`bottom`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -27,7 +27,7 @@ const chatSlice = createSlice({
|
||||
history: [],
|
||||
messages: [],
|
||||
model: GetModel(getMemory("model")),
|
||||
web: false,
|
||||
web: getMemory("web") === "true",
|
||||
current: -1,
|
||||
} as initialStateType,
|
||||
reducers: {
|
||||
@ -57,6 +57,7 @@ const chatSlice = createSlice({
|
||||
state.model = action.payload as string;
|
||||
},
|
||||
setWeb: (state, action) => {
|
||||
setMemory("web", action.payload ? "true" : "false");
|
||||
state.web = action.payload as boolean;
|
||||
},
|
||||
setCurrent: (state, action) => {
|
||||
|
Loading…
Reference in New Issue
Block a user