Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
# 0.4.0

This is a breaking change and requires the backend with version 0.4.x.

## What's new

* **[breaking]** Pin and unpin chats from the chat actions menu. Pinned chats appear above the normal list.
* Order chat list by the latest successful update.
* Support "duplicate to create" for model configs.

# 0.3.0

This is a breaking change and requires the backend with version 0.3.x.
Expand Down
82 changes: 14 additions & 68 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,79 +1,25 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# TinyWebUI

## Getting Started
A lightweight chat interface built with Next.js and React. Requires the matching TinyWebUI server.

First, run the development server:
## How to Use

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.

## Interactive Dev Demos

Development pages for manually exploring the components without a backend.

Run the dev server and open:

```
npm run dev
```

Entry points:

* Side bar

http://localhost:3000/dev/side-demo
Open your deployment, sign in, select a model, and start chatting. Administrators can configure model providers in Settings.

* User input
Use each chat's actions menu to rename, pin, unpin, or delete it. Pinned chats appear first; both lists are ordered by the latest successful message write.

http://localhost:3000/dev/user-input-demo
## How to Build

* Message (single message preview)
Use a current Node.js LTS release and npm. From the repository root:

http://localhost:3000/dev/message-demo

These pages are purely for local manual testing and are not intended for production deployment.

### Static Export Without Dev Pages

When generating a static bundle (e.g. deploying to a plain static host), use:

```
npm run build:static
```bash
git submodule update --init --recursive
npm install
npm run build
```

This script will:
1. Temporarily move `src/app/dev` to `.dev-pages.stash` before `next build`.
2. Run `next build` (which emits a fully static site to `out/` because of `output: 'export'` in the config).
3. Restore the dev pages directory afterward so local development can continue.

Artifacts `.dev-pages.stash` and marker `.dev-pages-removed` are git‑ignored.

Result: No `/dev/*` HTML or assets are included in the exported static site.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!

## Deploy on Vercel
The build generates static files in `out/`. Serve them with a reverse proxy that routes `/api` WebSocket traffic to the backend on the same origin.

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
For local development, run `npm run dev` (port 3000). Keep the same `/api` proxy arrangement, routing other requests to the dev server.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Run `npm test` for the app and SDK tests.
22 changes: 20 additions & 2 deletions src/app/chat/chat-title.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
"use client";

import React from "react";
import { MoreHorizontal, Pin } from "lucide-react";

export interface ChatTitleProps {
id?: string;
active: boolean;
title: string;
pinned?: boolean;
onChatSelected: (id: string | undefined) => void;
onContextMenu?: (event: React.MouseEvent<HTMLDivElement>) => void;
onMenuClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
}

export const ChatTitle = React.forwardRef<HTMLDivElement, ChatTitleProps>(
function ChatTitleInner({ id, active, title, onChatSelected, onContextMenu }, ref) {
function ChatTitleInner({ id, active, title, pinned, onChatSelected, onContextMenu, onMenuClick }, ref) {
return (
<div
ref={ref}
Expand All @@ -22,13 +25,28 @@ export const ChatTitle = React.forwardRef<HTMLDivElement, ChatTitleProps>(
onContextMenu(event);
}
}}
className={`flex items-center px-3 py-2 rounded-md cursor-pointer text-sm transition-colors ${
className={`flex min-w-0 items-center px-3 py-2 rounded-md cursor-pointer text-sm transition-colors ${
active
? "bg-primary text-primary-foreground"
: "hover:bg-accent hover:text-accent-foreground"
}`}
>
{pinned && <Pin className="mr-2 size-3.5 shrink-0" aria-label="Pinned chat" />}
<span className="flex-1 truncate">{title}</span>
{onMenuClick && (
<button
type="button"
aria-label="Chat actions"
title="Chat actions"
className="ml-1 flex size-6 shrink-0 items-center justify-center rounded hover:bg-foreground/10"
onClick={event => {
event.stopPropagation();
onMenuClick(event);
}}
>
<MoreHorizontal className="size-4" />
</button>
)}
</div>
);
}
Expand Down
5 changes: 3 additions & 2 deletions src/app/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type ToolContext = ListFilesToolContext & QuickJSToolContext;
interface ChatProps {
onCreateChat: (chatId: string, message: ServerTypes.Message, attachedFiles: AttachedFile[]) => void;
onSetChatTitle: (chatId: string, title: string) => void;
requestChatListUpdateAsync?: () => Promise<void>;
requestChatListUpdateAsync?: (fromStart?: boolean) => Promise<void>;
activeChatId?: string;
selectedModelId?: string;
titleGenerationModelId?: string;
Expand Down Expand Up @@ -174,7 +174,7 @@ export function Chat({
if (!(error instanceof RequestError) || error.code !== ErrorCode.CONFLICT) {
throw error;
}
await requestChatListUpdateAsync?.();
await requestChatListUpdateAsync?.(true);
chatId = await TUIClientSingleton.get().newChatAsync();
}
onCreateChat(chatId, message, attachedFiles);
Expand Down Expand Up @@ -249,6 +249,7 @@ export function Chat({
}
if (result.done) {
parentForNextCall = result.value.messageIds[result.value.messageIds.length - 1];
await requestChatListUpdateAsync?.(true).catch(console.error);
break;
} else {
if (typeof result.value === "string") {
Expand Down
105 changes: 75 additions & 30 deletions src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,33 @@ import { Logo } from "@/components/custom/logo";

const CHAT_LIST_MARGIN = 50;

type ChatLists = {
chats: ServerTypes.GetChatListResult;
pinned: ServerTypes.GetPinnedChatListResult;
};

export default function ChatPage() {
const [activeChatId, setActiveChatId] = useState<string|undefined>(undefined);
const [isSidebarVisible, setIsSidebarVisible] = useState(true);
const [selectedModelId, setSelectedModelId] = useState<string|undefined>(undefined);
const [titleGenerationModelId, setTitleGenerationModelId] = useState<string|undefined>(undefined);
const [chatList, setChatList] = useState<ServerTypes.GetChatListResult>([]);
const chatListsRef = useRef<ChatLists>({ chats: [], pinned: [] });
const [chatLists, setChatLists] = useState(chatListsRef.current);
const [initialized, setInitialized] = useState(false);
const [newChatUserMessage, setNewChatUserMessage] = useState<ServerTypes.Message|undefined>(undefined);
const [newChatAttachedFiles, setNewChatAttachedFiles] = useState<import('./file-context-bar').AttachedFile[]>([]);
const [inputHeight, setInputHeight] = useState<number>(80);
/** The index of the last chat displayed. -1 if none is displayed */
const maxDisplayedChatIndex = useRef<number>(-1);
const updateChatListPromise = useRef<Promise<void>|undefined>(undefined);
const refreshChatListRequested = useRef(false);
const scrollPositions = useRef<Record<string, number>>({});

const updateChatLists = useCallback((updater: (current: ChatLists) => ChatLists) => {
chatListsRef.current = updater(chatListsRef.current);
setChatLists(chatListsRef.current);
}, []);

const onSwitchChat = useCallback((chatId: string | undefined) => {
setActiveChatId(chatId);
setNewChatUserMessage(undefined);
Expand All @@ -42,24 +54,27 @@ export default function ChatPage() {
const chatInfo = {
id: chatId
};
setChatList(prev => [chatInfo, ...prev]);
updateChatLists(current => ({ ...current, chats: [chatInfo, ...current.chats] }));
refreshChatListRequested.current = true;
setActiveChatId(chatId);
setNewChatUserMessage(message);
setNewChatAttachedFiles(attachedFiles);
}, []);
}, [updateChatLists]);

const onDeleteChat = useCallback((chatId: string) => {
setChatList(prevList => {
return prevList.filter(chat => chat.id !== chatId);
});
updateChatLists(current => ({
chats: current.chats.filter(chat => chat.id !== chatId),
pinned: current.pinned.filter(chat => chat.id !== chatId),
}));
refreshChatListRequested.current = true;
if (activeChatId === chatId) {
setActiveChatId(undefined);
}
}, [activeChatId]);
}, [activeChatId, updateChatLists]);

const onSetChatTitle = useCallback((chatId: string, title: string) => {
setChatList(prevList => {
return prevList.map(chat => {
const updateTitle = (list: ServerTypes.GetChatListResult) => {
return list.map(chat => {
if (chat.id === chatId) {
return {
...chat,
Expand All @@ -72,8 +87,13 @@ export default function ChatPage() {
return chat;
}
});
});
}, []);
};
updateChatLists(current => ({
chats: updateTitle(current.chats),
pinned: updateTitle(current.pinned),
}));
refreshChatListRequested.current = true;
}, [updateChatLists]);

const onScrollPositionChange = useCallback((scrollTop: number) => {
if (activeChatId) {
Expand All @@ -85,37 +105,58 @@ export default function ChatPage() {
/** Allow two trials in case of resource conflict */
for (let trial = 0; trial < 2; trial++) {
try {
const oldList = JSON.parse(JSON.stringify(chatList)) as ServerTypes.GetChatListResult;
const start = fromStart ? 0 : chatList.length;
const segment = await TUIClientSingleton.get().getChatListAsync({
start: start,
quantity: fromStart ? maxDisplayedChatIndex.current + 1 + CHAT_LIST_MARGIN : CHAT_LIST_MARGIN,
metaDataKeys: ["title"],
});
const newList = fromStart ? segment : [...oldList, ...segment];
setChatList(newList);
if (newList.find(c => c.id === activeChatId) === undefined) {
/** The active chat was deleted */
onSwitchChat(undefined);
const client = TUIClientSingleton.get();
const pinned = await client.getPinnedChatListAsync({ metaDataKeys: ["title"] });
const pinnedIds = new Set(pinned.map(chat => chat.id));
const chats = fromStart ? [] : [...chatListsRef.current.chats];
let quantity = fromStart
? Math.max(chatListsRef.current.chats.length, maxDisplayedChatIndex.current + 1 + CHAT_LIST_MARGIN)
: CHAT_LIST_MARGIN;
while (true) {
const segment = await client.getChatListAsync({
start: chats.length,
quantity,
metaDataKeys: ["title"],
});
chats.push(...segment);
if (segment.length < quantity || segment.some(chat => !pinnedIds.has(chat.id))) {
break;
}
quantity = CHAT_LIST_MARGIN;
}
updateChatLists(() => ({ chats, pinned }));
return;
} catch (error) {
if (error instanceof RequestError && (error.code === ErrorCode.CONFLICT)) {
if (trial === 0 && error instanceof RequestError && error.code === ErrorCode.CONFLICT) {
/** Retry from the start */
fromStart = true;
} else {
throw error;
}
}
}
}, [chatList, activeChatId, onSwitchChat]);
}, [updateChatLists]);

const updateChatListDedupAsync = useCallback(async () => {
if (updateChatListPromise.current === undefined) {
updateChatListPromise.current = updateChatListAsync();
const updateChatListDedupAsync = useCallback(async (fromStart = false) => {
if (fromStart) {
refreshChatListRequested.current = true;
}
if (updateChatListPromise.current !== undefined) {
await updateChatListPromise.current;
return;
}
updateChatListPromise.current = (async () => {
do {
const refresh = refreshChatListRequested.current;
refreshChatListRequested.current = false;
await updateChatListAsync(refresh);
} while (refreshChatListRequested.current);
})();
try {
await updateChatListPromise.current;
} finally {
updateChatListPromise.current = undefined;
}
await updateChatListPromise.current;
updateChatListPromise.current = undefined;
}, [updateChatListAsync]);

useEffect(() => {
Expand Down Expand Up @@ -151,6 +192,9 @@ export default function ChatPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const pinnedChatIds = new Set(chatLists.pinned.map(chat => chat.id));
const chatList = [...chatLists.pinned, ...chatLists.chats.filter(chat => !pinnedChatIds.has(chat.id))];

if (!initialized) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background">
Expand All @@ -177,6 +221,7 @@ export default function ChatPage() {
onSetChatTitle={onSetChatTitle}
onDeleteChat={onDeleteChat}
chatList={chatList}
pinnedChatIds={pinnedChatIds}
activeChatId={activeChatId}
onHideSidebar={() => setIsSidebarVisible(false)}
/>
Expand Down
Loading
Loading