diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba53f4d..c8e7ff6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,16 @@
+# 0.3.0
+
+This is a breaking change and requires the backend with version 0.3.x.
+
+## What's new
+
+* **[Breaking]** Support tool calling.
+* **[Breaking]** Support file operations.
+
+## Fixes
+
+## Changes
+
# 0.2.0
This is a breaking change and requires the backend with version 0.2.x.
diff --git a/next.config.ts b/next.config.ts
index 5a36aff..b82db2f 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -7,7 +7,30 @@ const nextConfig: NextConfig = {
images: {
unoptimized: true,
},
- /* other config options can go here */
+ // Allow loading the dev server from LAN IPs (HMR, RSC payloads, etc.).
+ // Without this, Next.js 15+ blocks dev resource requests when the page is
+ // accessed via anything other than localhost, causing hydration to silently
+ // fail and click handlers (e.g. the sign-in button) to do nothing.
+ allowedDevOrigins: ['192.168.31.153'],
+ // quickjs-emscripten's emscripten-generated code has require("fs") inside a
+ // Node.js-only branch that never executes in the browser. Stub it for both bundlers.
+ turbopack: {
+ resolveAlias: {
+ fs: './src/stubs/empty-module.ts',
+ },
+ },
+ webpack: (config) => {
+ config.resolve.fallback = {
+ ...config.resolve.fallback,
+ fs: false,
+ path: false,
+ };
+ config.experiments = {
+ ...config.experiments,
+ topLevelAwait: true,
+ };
+ return config;
+ },
};
export default nextConfig;
diff --git a/package.json b/package.json
index 0215cfc..0e19a14 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "tinywebui-webapp",
- "version": "0.2.0",
+ "version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
@@ -13,14 +13,16 @@
"@hpcc-js/wasm-zstd": "^1.11.0",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
+ "@tootallnate/quickjs-emscripten": "^0.23.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"github-markdown-css": "^5.8.1",
"katex": "^0.16.25",
"libsodium-wrappers-sumo": "0.7.15",
"lucide-react": "^0.536.0",
- "next": "^15.5.6",
+ "next": "^16.2.4",
"next-themes": "^0.4.6",
+ "pdfjs-dist": "^5.7.284",
"postcss": "^8.5.4",
"prism-themes": "^1.9.0",
"react": "^19.0.0",
diff --git a/src/app/auth/sign-in/page.tsx b/src/app/auth/sign-in/page.tsx
index fee8399..a882cba 100644
--- a/src/app/auth/sign-in/page.tsx
+++ b/src/app/auth/sign-in/page.tsx
@@ -21,7 +21,6 @@ export default function SignIn() {
if (!TUIClientSingleton.exists()) {
TUIClientSingleton.create(
- /** `${window.location.hostname}:12345`, */
`${window.location.host}/api`,
(error) => {
/** @todo Handle disconnect properly */
diff --git a/src/app/chat/assistant-turn.tsx b/src/app/chat/assistant-turn.tsx
new file mode 100644
index 0000000..b563921
--- /dev/null
+++ b/src/app/chat/assistant-turn.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import React, { useState } from "react";
+import MarkdownRenderer from "@/components/custom/markdown-renderer";
+import { ChevronRight } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { FunctionCallMessage } from "@/sdk/types/IServer";
+
+/** Parts that make up an assistant turn during generation. */
+export type PendingTurnPart =
+ | { type: "text"; content: string }
+ | { type: "tool_call"; call: FunctionCallMessage; status: "calling" | "executing" | "done"; result?: string };
+
+/**
+ * Parts for committed (already in history) assistant turns.
+ * Built from MessageNode groups between user messages.
+ */
+export type CommittedTurnPart =
+ | { type: "text"; content: string }
+ | { type: "tool_call"; call: FunctionCallMessage; result?: string };
+
+function ToolCallSection({
+ call,
+ status,
+ result
+}: {
+ call: FunctionCallMessage;
+ status?: "calling" | "executing" | "done";
+ result?: string;
+}) {
+ const { name, arguments: args } = call;
+ const [expanded, setExpanded] = useState(false);
+ const isDone = status === undefined || status === "done";
+ const canExpand = isDone && (args || result);
+
+ const statusText = (() => {
+ switch (status) {
+ case "calling": return "调用中…";
+ case "executing": return "执行中…";
+ default: return undefined;
+ }
+ })();
+
+ return (
+
+
canExpand && setExpanded(!expanded)}
+ disabled={!canExpand}
+ >
+
+ 🔧 {name}
+ {statusText && (
+ {statusText}
+ )}
+
+ {expanded && (
+
+ {args && (
+
+
参数
+
+ {(() => {
+ try { return JSON.stringify(JSON.parse(args), null, 2); } catch { return args; }
+ })()}
+
+
+ )}
+ {result && (
+
+ )}
+
+ )}
+
+ );
+}
+
+/**
+ * Renders an assistant turn consisting of text and tool call parts.
+ * Used for both committed history and pending (streaming) turns.
+ */
+export function AssistantTurn({ parts }: { parts: (PendingTurnPart | CommittedTurnPart)[] }) {
+ return (
+
+
+ {parts.map((part, idx) => {
+ if (part.type === "text") {
+ if (!part.content) return null;
+ return ;
+ }
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/chat/chat.tsx b/src/app/chat/chat.tsx
index c847a02..370637e 100644
--- a/src/app/chat/chat.tsx
+++ b/src/app/chat/chat.tsx
@@ -6,11 +6,20 @@ import * as ServerTypes from "@/sdk/types/IServer";
import { TUIClientSingleton } from "@/lib/tui-client-singleton";
import { UserInput } from "./user-input";
import { Message } from "./message";
+import { AssistantTurn, type PendingTurnPart, type CommittedTurnPart } from "./assistant-turn";
+import { FileContextBar, type AttachedFile, serializeContextFiles, parseContextFiles } from "./file-context-bar";
+import { ListFilesTool, type ListFilesToolContext } from "@/tools/list-files";
+import { QuickJSTool, type QuickJSToolContext } from "@/tools/quickjs";
import { RequestError } from "@/sdk/app/rpc";
import { ErrorCode } from "@/sdk/types/Rpc";
+const listFilesTool = new ListFilesTool();
+const quickJSTool = new QuickJSTool();
+
+type ToolContext = ListFilesToolContext & QuickJSToolContext;
+
interface ChatProps {
- onCreateChat: (chatId: string, message: ServerTypes.Message) => void;
+ onCreateChat: (chatId: string, message: ServerTypes.Message, attachedFiles: AttachedFile[]) => void;
onSetChatTitle: (chatId: string, title: string) => void;
requestChatListUpdateAsync?: () => Promise;
activeChatId?: string;
@@ -21,6 +30,8 @@ interface ChatProps {
onInputHeightChange: (height: number) => void;
initialScrollPosition?: number;
onScrollPositionChange?: (scrollTop: number) => void;
+ initialAttachedFiles?: AttachedFile[];
+ onAttachedFilesChange?: (files: AttachedFile[]) => void;
}
export function Chat({
@@ -35,6 +46,8 @@ export function Chat({
onInputHeightChange,
initialScrollPosition,
onScrollPositionChange,
+ initialAttachedFiles,
+ onAttachedFilesChange,
}: ChatProps) {
const [generating, setGenerating] = useState(false);
@@ -42,13 +55,14 @@ export function Chat({
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [treeHistory, setTreeHistory] = useState({ nodes: {} });
const [tailNodeId, setTailNodeId] = useState(undefined);
- const [pendingUserMessage, setPendingUserMessage] = useState(undefined);
- const [pendingAssistantMessage, setPendingAssistantMessage] = useState(undefined);
+ const [pendingUserMessage, setPendingUserMessage] = useState(undefined);
+ const [pendingTurnParts, setPendingTurnParts] = useState([]);
const [editingBranch, setEditingBranch] = useState(false);
const [previousTailNodeId, setPreviousTailNodeId] = useState(undefined);
- const [messageToEdit, setMessageToEdit] = useState(undefined);
+ const [messageToEdit, setMessageToEdit] = useState(undefined);
const [userDetachedFromBottom, setUserDetachedFromBottom] = useState(false);
const [generationError, setGenerationError] = useState(undefined);
+ const [attachedFiles, setAttachedFiles] = useState(initialAttachedFiles ?? []);
const initialUserMessageHandled = useRef(false);
const initializationCalled = useRef(false);
const generatingCounter = useRef(0);
@@ -57,6 +71,11 @@ export function Chat({
const scrollContainerRef = useRef(null);
const bottomRef = useRef(null);
+ const handleAttachedFilesChange = useCallback((files: AttachedFile[]) => {
+ setAttachedFiles(files);
+ onAttachedFilesChange?.(files);
+ }, [onAttachedFilesChange]);
+
const handleScroll = useCallback(() => {
const container = scrollContainerRef.current;
if (!container) {
@@ -71,8 +90,7 @@ export function Chat({
if (!generating || !bottomRef.current) {
return;
}
- const isInitialAssistantMessage = pendingAssistantMessage?.content.length === 1 && pendingAssistantMessage.content[0].data === '';
- if (!initialGenerationScrollDone.current && pendingAssistantMessage) {
+ if (!initialGenerationScrollDone.current && pendingTurnParts.length > 0) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
initialGenerationScrollDone.current = true;
return;
@@ -80,30 +98,46 @@ export function Chat({
if (userDetachedFromBottom) {
return;
}
- if (isInitialAssistantMessage || pendingAssistantMessage) {
+ if (pendingTurnParts.length > 0) {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
}
- }, [pendingAssistantMessage, generating, userDetachedFromBottom]);
+ }, [pendingTurnParts, generating, userDetachedFromBottom]);
const generateChatTitleAsync = useCallback(async (chatId: string, message: ServerTypes.Message) => {
const modelId = titleGenerationModelId ?? selectedModelId;
if (modelId === undefined) {
throw new Error("No model selected for title generation.");
}
+ if (!('role' in message) || message.role !== 'user') {
+ throw new Error("Title generation can only be triggered by a user message.");
+ }
/** Avoid messing with the referenced message */
- message = JSON.parse(JSON.stringify(message)) as ServerTypes.Message;
+ message = JSON.parse(JSON.stringify(message)) as ServerTypes.ChatMessage;
/**
* @todo Modify the server to take a multi message parameter for this.
* So we can use a developer prompt instead.
*/
- message.content.unshift({
- type: 'text',
- data: 'Generate a concise chat title for the following user message. The title needs to start with a emoji representing the topic, followed by a short text. Only reply the title without any other information. Following is the user message:\n\n'
- });
- const title = (await TUIClientSingleton.get().executeGenerationTaskAsync({
+ const messages: Array = [
+ {
+ role: 'developer',
+ content: [{
+ type: 'text',
+ data: 'Generate a concise chat title for the following user message. The title needs to start with a emoji representing the topic, followed by a short text. Only reply the title without any other information. Following is the user message:\n\n'
+ }]
+ },
+ message
+ ];
+ const response = (await TUIClientSingleton.get().executeGenerationTaskAsync({
modelId: modelId,
- message: message
- })).trim();
+ messages: messages
+ })).messages[0];
+ if (!('role' in response) || response.role !== 'assistant') {
+ throw new Error("Unexpected response message from title generation.");
+ }
+ const title = response.content[0]?.data.trim();
+ if (title === undefined) {
+ throw new Error("No content in title generation response.");
+ }
await TUIClientSingleton.get().setMetadataAsync({
path: ['chat', chatId],
entries: {
@@ -117,7 +151,7 @@ export function Chat({
if (loadingChat || generating) {
throw new Error("Cannot send message while loading or generating.");
}
- if (message.role !== 'user') {
+ if (!('role' in message) || message.role !== 'user') {
throw new Error("Only user role messages are allowed to be sent from the input area.");
}
if (selectedModelId === undefined) {
@@ -143,93 +177,172 @@ export function Chat({
await requestChatListUpdateAsync?.();
chatId = await TUIClientSingleton.get().newChatAsync();
}
- onCreateChat(chatId, message);
+ onCreateChat(chatId, message, attachedFiles);
+ /** Persist attached file IDs to the newly created chat */
+ if (attachedFiles.length > 0) {
+ TUIClientSingleton.get().setMetadataAsync({
+ path: ['chat', chatId],
+ entries: { contextFiles: serializeContextFiles(attachedFiles.filter(f => !f.deleted)) },
+ });
+ }
return;
}
- const assistantMessage: ServerTypes.Message = {
- role: 'assistant',
- content: [
- {
- type: 'text',
- data: ''
+
+ /** Resolve file context for tools */
+ const activeFiles = attachedFiles.filter(f => !f.deleted);
+ let toolContext: ToolContext | undefined;
+ let tools: ServerTypes.Tool[] | undefined;
+ if (activeFiles.length > 0) {
+ const files: ToolContext["files"] = [];
+ for (const af of activeFiles) {
+ try {
+ const meta = await TUIClientSingleton.get().getFileMetaAsync({ fileId: af.fileId });
+ const { content } = await TUIClientSingleton.get().getFileContentAsync({ contentId: meta.contentId });
+ const decoder = new TextDecoder("utf-8", { fatal: true });
+ files.push({ name: af.name, content: decoder.decode(content) });
+ } catch (err) {
+ if (err instanceof RequestError && err.code === ErrorCode.NOT_FOUND) {
+ /** File was deleted server-side; mark it */
+ handleAttachedFilesChange(
+ attachedFiles.map(f => f.fileId === af.fileId ? { ...f, deleted: true } : f)
+ );
+ } else {
+ throw err;
+ }
}
- ]
- };
- const userMessageTimestamp = Date.now();
+ }
+ if (files.length > 0) {
+ toolContext = { files };
+ tools = [
+ { name: listFilesTool.name, description: listFilesTool.description, parameters: listFilesTool.paramSchema },
+ { name: quickJSTool.name, description: quickJSTool.description, parameters: quickJSTool.paramSchema },
+ ];
+ }
+ }
+
initialGenerationScrollDone.current = false;
- setPendingAssistantMessage(assistantMessage);
+
/**
- * This step should start even on mismatch to ensure a concise chat history
- * @todo: This may throw CONFLICT. If so, we need to update the local history and notify the user about this.
+ * Tool call loop: the generation may request tool calls, we execute them and continue.
+ * The first round sends [userMessage], subsequent rounds send accumulated messages.
*/
- const generator = TUIClientSingleton.get().chatCompletionAsync({
- id: chatId,
- parent: tailNodeId,
- modelId: selectedModelId,
- userMessage: message
- });
+ let pendingMessages: ServerTypes.Message[] = [message];
+ let parentForNextCall = tailNodeId;
+ setPendingTurnParts([]);
+
while (true) {
- const result = await generator.next();
+ const pendingFunctionCalls: ServerTypes.FunctionCallMessage[] = [];
+
+ const generator = TUIClientSingleton.get().chatCompletionAsync({
+ id: chatId,
+ parent: parentForNextCall,
+ modelId: selectedModelId,
+ messages: pendingMessages,
+ tools,
+ });
+
+ while (true) {
+ const result = await generator.next();
+ if (originalCounter !== generatingCounter.current) {
+ callMismatch = true;
+ return;
+ }
+ if (result.done) {
+ parentForNextCall = result.value.messageIds[result.value.messageIds.length - 1];
+ break;
+ } else {
+ if (typeof result.value === "string") {
+ const valueString = result.value;
+ setPendingTurnParts(parts => {
+ const lastPart = parts[parts.length - 1];
+ if (lastPart?.type !== 'text') {
+ return [...parts, { type: "text", content: valueString }];
+ } else {
+ lastPart.content += result.value;
+ return [...parts];
+ }
+ });
+ } else if (result.value.event === "function_call_end") {
+ const fc = result.value.data;
+ pendingFunctionCalls.push(fc);
+ setPendingTurnParts(parts => [
+ ...parts,
+ {
+ type: "tool_call",
+ call: fc,
+ status: "calling",
+ }
+ ]);
+ }
+ }
+ }
+
if (originalCounter !== generatingCounter.current) {
callMismatch = true;
return;
}
- if (result.done) {
- const userMessageNode: ServerTypes.MessageNode = {
- id: result.value.userMessageId,
- message: message,
- parent: tailNodeId,
- children: [result.value.assistantMessageId],
- timestamp: userMessageTimestamp,
- };
- const assistantMessageNode: ServerTypes.MessageNode = {
- id: result.value.assistantMessageId,
- message: assistantMessage,
- parent: userMessageNode.id,
- children: [],
- timestamp: Date.now(),
- };
- setTreeHistory(prev => ({
- nodes: {
- ...Object.fromEntries(Object.entries(prev.nodes).map(([i, n]) => {
- /** React calls this twice.. */
- if (n.id === tailNodeId && n.children.indexOf(userMessageNode.id) < 0) {
- return [i, {
- ...n,
- children: [...n.children, userMessageNode.id]
- }]
- } else {
- return [i, n];
- }
- })),
- [userMessageNode.id]: userMessageNode,
- [assistantMessageNode.id]: assistantMessageNode,
+
+ if (pendingFunctionCalls.length === 0) {
+ break;
+ }
+
+ /** Execute tool calls and build output messages */
+ const toolOutputMessages: ServerTypes.FunctionCallOutputMessage[] = [];
+ for (const functionCall of pendingFunctionCalls) {
+ /** Update status to executing */
+ setPendingTurnParts(parts => parts.map(part => {
+ return part.type === 'tool_call' && part.call.call_id === functionCall.call_id ? {...part, status: "executing"} : part
+ }));
+
+ let resultText: string;
+ try {
+ if (functionCall.name === listFilesTool.name && toolContext) {
+ resultText = await listFilesTool.callAsync(JSON.parse(functionCall.arguments), toolContext);
+ } else if (functionCall.name === quickJSTool.name && toolContext) {
+ resultText = await quickJSTool.callAsync(JSON.parse(functionCall.arguments), toolContext);
+ } else {
+ resultText = `[Error] Unknown tool: ${functionCall.name}`;
}
+ } catch (err) {
+ resultText = `[Error] ${err instanceof Error ? err.message : String(err)}`;
+ }
+
+ setPendingTurnParts(parts => parts.map(part => {
+ return part.type === 'tool_call' && part.call.call_id === functionCall.call_id ? {...part, status: "done", result: resultText} : part
}));
- setTailNodeId(assistantMessageNode.id);
- setPendingUserMessage(undefined);
- setPendingAssistantMessage(undefined);
- break;
- } else {
- assistantMessage.content[0].data = assistantMessage.content[0].data + result.value;
- setPendingAssistantMessage({ ...assistantMessage });
+
+ toolOutputMessages.push({
+ type: "function_call_output",
+ call_id: functionCall.call_id,
+ output: [{ type: "text", data: resultText }],
+ });
}
+
+ /** Prepare next round: the parent is the last message from this round */
+ pendingMessages = toolOutputMessages;
}
+
+ /** All done — update tree history from cache and set tail */
+ const finalHistory = await TUIClientSingleton.get().getChatAsync(chatId);
+ setTreeHistory(finalHistory);
+ setTailNodeId(parentForNextCall);
+ setPendingUserMessage(undefined);
+ setPendingTurnParts([]);
} catch (error) {
if (!callMismatch) {
- setGenerationError(error)
+ setGenerationError(error);
}
} finally {
if (!callMismatch) {
setGenerating(false);
}
}
- }, [loadingChat, generating, selectedModelId, activeChatId, tailNodeId, onCreateChat, requestChatListUpdateAsync]);
+ }, [loadingChat, generating, selectedModelId, activeChatId, tailNodeId, attachedFiles, onCreateChat, requestChatListUpdateAsync, handleAttachedFilesChange]);
const cancelFailedGeneration = useCallback(() => {
setGenerationError(undefined);
setPendingUserMessage(undefined);
- setPendingAssistantMessage(undefined);
+ setPendingTurnParts([]);
}, []);
const retryFailedGenerationAsync = useCallback(async () => {
@@ -286,12 +399,30 @@ export function Chat({
}
setTailNodeId(latestNode.id);
}
+ /** Load attached files from chat metadata. We persist the file
+ * name alongside the id, so no per-file server lookup is needed —
+ * this also avoids spurious errors when a file has been deleted
+ * server-side. Deletion is detected lazily at message-send time. */
+ try {
+ const meta = await TUIClientSingleton.get().getMetadataAsync({
+ path: ['chat', activeChatId],
+ keys: ['contextFiles'],
+ });
+ const loadedFiles = parseContextFiles(meta.contextFiles);
+ if (loadedFiles.length > 0) {
+ setAttachedFiles(loadedFiles);
+ onAttachedFilesChange?.(loadedFiles);
+ }
+ } catch {
+ /* metadata may not exist yet */
+ }
} finally {
setLoadingChat(false);
setInitialLoadComplete(true);
}
})();
/** Only load once */
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -315,11 +446,74 @@ export function Chat({
return nodes;
}, [tailNodeId, treeHistory]);
+ /**
+ * Group the linear history into renderable turns.
+ * User messages render as Message. Everything between two user messages
+ * (assistant text, function calls, function call outputs) is grouped into an AssistantTurn.
+ */
+ type RenderItem =
+ | { kind: "user"; node: ServerTypes.MessageNode }
+ | { kind: "assistant-turn"; parts: CommittedTurnPart[]; firstNodeId: string };
+
+ const getRenderItems = useCallback((): RenderItem[] => {
+ const linear = getLinearHistory();
+ const items: RenderItem[] = [];
+ let currentTurnParts: CommittedTurnPart[] = [];
+ let turnFirstId: string | undefined;
+
+ const flushTurn = () => {
+ if (currentTurnParts.length > 0 && turnFirstId) {
+ items.push({ kind: "assistant-turn", parts: currentTurnParts, firstNodeId: turnFirstId });
+ currentTurnParts = [];
+ turnFirstId = undefined;
+ }
+ };
+
+ for (const node of linear) {
+ const msg = node.message;
+ if ("role" in msg) {
+ if (msg.role === "developer") continue;
+ if (msg.role === "user") {
+ flushTurn();
+ items.push({ kind: "user", node });
+ } else {
+ /** assistant */
+ if (!turnFirstId) turnFirstId = node.id;
+ const text = msg.content.filter(c => c.type === "text").map(c => c.data).join("\n");
+ if (text) {
+ currentTurnParts.push({ type: "text", content: text });
+ }
+ }
+ } else if (msg.type === "function_call") {
+ if (!turnFirstId) turnFirstId = node.id;
+ currentTurnParts.push({
+ type: "tool_call",
+ call: msg,
+ });
+ } else if (msg.type === "function_call_output") {
+ /** Attach result to the last tool_call part if possible */
+ const lastTc = [...currentTurnParts].reverse().find(p => p.type === "tool_call");
+ if (lastTc && lastTc.type === "tool_call" && lastTc.call.call_id === msg.call_id) {
+ lastTc.result = msg.output.filter(o => o.type === "text").map(o => o.data).join("\n");
+ }
+ }
+ }
+ flushTurn();
+ return items;
+ }, [getLinearHistory]);
+
const editUserMessage = useCallback((id: string) => {
+ const node = treeHistory.nodes[id];
+ if (node === undefined) {
+ throw new Error(`Inconsistent tree history state: node ${id} not found.`);
+ }
+ if (!('role' in node.message) || node.message.role !== 'user') {
+ throw new Error("Only user messages can be edited.");
+ }
setEditingBranch(true);
setPreviousTailNodeId(tailNodeId);
- setTailNodeId(treeHistory.nodes[id].parent);
- setMessageToEdit(treeHistory.nodes[id].message);
+ setTailNodeId(node.parent);
+ setMessageToEdit(node.message);
}, [tailNodeId, treeHistory]);
const cancelEditingUserMessage = useCallback(() => {
@@ -402,21 +596,28 @@ export function Chat({
onScroll={handleScroll}
>
- {getLinearHistory()
- .filter(n => n.message.role !== 'developer')
- .map(node => (
-
{editUserMessage(node.id)}}
- onPrevious={() => {gotoPreviousSibling(node.id)}}
- onNext={() => {gotoNextSibling(node.id)}}
- />
- ))}
+ {getRenderItems().map(item => {
+ if (item.kind === "user") {
+ const node = item.node;
+ return (
+ editUserMessage(node.id)}
+ onPrevious={() => gotoPreviousSibling(node.id)}
+ onNext={() => gotoNextSibling(node.id)}
+ />
+ );
+ } else {
+ return (
+
+ );
+ }
+ })}
{editingBranch && (
正在编辑
@@ -432,8 +633,8 @@ export function Chat({
{pendingUserMessage && (
)}
- {pendingAssistantMessage && (
-
+ {pendingTurnParts.length > 0 && (
+
)}
{generating && (
@@ -485,6 +686,14 @@ export function Chat({
+ { /** File context bar */ }
+
+
{ /** User input area */ }
({ fileId: f.fileId, name: f.name }));
+}
+
+/** Parse the `contextFiles` value loaded from chat metadata. */
+export function parseContextFiles(value: unknown): AttachedFile[] {
+ if (!Array.isArray(value)) return [];
+ const result: AttachedFile[] = [];
+ for (const entry of value) {
+ if (
+ entry &&
+ typeof entry === "object" &&
+ typeof (entry as ContextFileEntry).fileId === "string" &&
+ typeof (entry as ContextFileEntry).name === "string"
+ ) {
+ const e = entry as ContextFileEntry;
+ result.push({ fileId: e.fileId, name: e.name });
+ }
+ }
+ return result;
+}
+
+interface FileContextBarProps {
+ chatId: string | undefined;
+ attachedFiles: AttachedFile[];
+ onAttachedFilesChange: (files: AttachedFile[]) => void;
+ disabled?: boolean;
+}
+
+export function FileContextBar({
+ chatId,
+ attachedFiles,
+ onAttachedFilesChange,
+ disabled,
+}: FileContextBarProps) {
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const [allFiles, setAllFiles] = useState([]);
+ const [loadingFiles, setLoadingFiles] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [uploadError, setUploadError] = useState(undefined);
+ const fileInputRef = useRef(null);
+
+ const loadAllFiles = useCallback(async () => {
+ setLoadingFiles(true);
+ try {
+ const files = await TUIClientSingleton.get().listFileAsync();
+ setAllFiles(files);
+ } catch {
+ /* best effort */
+ } finally {
+ setLoadingFiles(false);
+ }
+ }, []);
+
+ const openPicker = useCallback(() => {
+ if (disabled) return;
+ setPickerOpen(true);
+ loadAllFiles();
+ }, [disabled, loadAllFiles]);
+
+ const toggleFile = useCallback(
+ async (fileId: string, fileName: string) => {
+ const isAttached = attachedFiles.some((f) => f.fileId === fileId);
+ let next: AttachedFile[];
+ if (isAttached) {
+ next = attachedFiles.filter((f) => f.fileId !== fileId);
+ } else {
+ next = [...attachedFiles, { fileId, name: fileName }];
+ }
+ onAttachedFilesChange(next);
+ if (chatId) {
+ await TUIClientSingleton.get().setMetadataAsync({
+ path: ["chat", chatId],
+ entries: { contextFiles: serializeContextFiles(next) },
+ });
+ }
+ },
+ [attachedFiles, chatId, onAttachedFilesChange]
+ );
+
+ const detachFile = useCallback(
+ async (fileId: string) => {
+ if (disabled) return;
+ const next = attachedFiles.filter((f) => f.fileId !== fileId);
+ onAttachedFilesChange(next);
+ if (chatId) {
+ await TUIClientSingleton.get().setMetadataAsync({
+ path: ["chat", chatId],
+ entries: { contextFiles: serializeContextFiles(next) },
+ });
+ }
+ },
+ [attachedFiles, chatId, disabled, onAttachedFilesChange]
+ );
+
+ const handleUpload = useCallback(
+ async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ /** Reset so the same file can be re-selected */
+ e.target.value = "";
+
+ setUploading(true);
+ setUploadError(undefined);
+ try {
+ /** Read as ArrayBuffer for either UTF-8 validation or PDF parsing */
+ const buffer = await file.arrayBuffer();
+ let bytes = new Uint8Array(buffer);
+ let uploadName = file.name;
+
+ if (isPdfFile(file)) {
+ /** Parse PDF to plain text on the client; server stores the text only. */
+ let text: string;
+ try {
+ text = await extractPdfText(bytes);
+ } catch (err) {
+ setUploadError(
+ err instanceof Error ? `PDF 解析失败: ${err.message}` : "PDF 解析失败"
+ );
+ return;
+ }
+ bytes = new TextEncoder().encode(text);
+ /** Preserve original name with a `.txt` suffix so downstream tools
+ * see a plain-text file. */
+ uploadName = `${file.name}.txt`;
+ } else {
+ /** Validate full UTF-8 text */
+ const decoder = new TextDecoder("utf-8", { fatal: true });
+ try {
+ decoder.decode(bytes);
+ } catch {
+ setUploadError("文件不是有效的 UTF-8 文本文件。");
+ return;
+ }
+ }
+
+ /** Upload */
+ const metadata = { name: uploadName, uploadTime: Date.now() };
+ const result = await TUIClientSingleton.get().putFileAsync({
+ content: bytes,
+ metadata,
+ });
+
+ /** Attach to chat */
+ const newFile: AttachedFile = { fileId: result.fileId, name: uploadName };
+ const next = [...attachedFiles, newFile];
+ onAttachedFilesChange(next);
+ if (chatId) {
+ await TUIClientSingleton.get().setMetadataAsync({
+ path: ["chat", chatId],
+ entries: { contextFiles: serializeContextFiles(next) },
+ });
+ }
+
+ /** Refresh picker list */
+ await loadAllFiles();
+ setUploading(false);
+ } catch (err) {
+ setUploadError(err instanceof Error ? err.message : "上传失败");
+ } finally {
+ setUploading(false);
+ }
+ },
+ [attachedFiles, chatId, loadAllFiles, onAttachedFilesChange]
+ );
+
+ const nonDeletedCount = attachedFiles.filter((f) => !f.deleted).length;
+
+ return (
+ <>
+ {/* Context bar */}
+
+
+
+
+ 附加文件
+
+
+ {nonDeletedCount > 0 && (
+
+ {nonDeletedCount}
+
+ )}
+
+ {attachedFiles.map((f) => (
+
+ {f.deleted && }
+ {f.name}
+ {!disabled && (
+ detachFile(f.fileId)}
+ >
+
+
+ )}
+
+ ))}
+
+
+
+ {/* File picker dialog */}
+ setPickerOpen(false)}
+ title="选择文件"
+ >
+
+
+ fileInputRef.current?.click()}
+ >
+
+ 上传新文件
+
+
+
+
+ {loadingFiles ? (
+
+ ) : allFiles.length === 0 ? (
+
+ 暂无文件,请上传新文件
+
+ ) : (
+
+ {allFiles.map((f) => {
+ const meta = f.fileMetadata as { name?: string; uploadTime?: number } | null;
+ const name = meta?.name ?? f.fileId;
+ const isAttached = attachedFiles.some((a) => a.fileId === f.fileId);
+ return (
+
toggleFile(f.fileId, name)}
+ >
+
+ {isAttached && }
+
+ {name}
+ {meta?.uploadTime && (
+
+ {new Date(meta.uploadTime).toLocaleDateString()}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+
+ {/* Upload modal */}
+ {
+ if (!uploading) setUploadError(undefined);
+ }}
+ title="上传文件"
+ showCloseButton={!uploading}
+ >
+ {uploading && (
+
+ )}
+ {uploadError && (
+
+
+
+ setUploadError(undefined)}>
+ 确定
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/src/app/chat/message.tsx b/src/app/chat/message.tsx
index da9a38f..8eaedaa 100644
--- a/src/app/chat/message.tsx
+++ b/src/app/chat/message.tsx
@@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
import { Pencil, ArrowLeft, ArrowRight } from "lucide-react";
interface MessageProps {
- message: ServerTypes.Message;
+ message: ServerTypes.ChatMessage;
showButtons?: boolean;
editable?: boolean;
hasPrevious?: boolean;
@@ -44,8 +44,8 @@ export function Message({
return (
{/* Thumbnails row(s) */}
diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx
index d596b53..b3d228f 100644
--- a/src/app/chat/page.tsx
+++ b/src/app/chat/page.tsx
@@ -21,6 +21,7 @@ export default function ChatPage() {
const [chatList, setChatList] = useState([]);
const [initialized, setInitialized] = useState(false);
const [newChatUserMessage, setNewChatUserMessage] = useState(undefined);
+ const [newChatAttachedFiles, setNewChatAttachedFiles] = useState([]);
const [inputHeight, setInputHeight] = useState(80);
/** The index of the last chat displayed. -1 if none is displayed */
const maxDisplayedChatIndex = useRef(-1);
@@ -30,19 +31,21 @@ export default function ChatPage() {
const onSwitchChat = useCallback((chatId: string | undefined) => {
setActiveChatId(chatId);
setNewChatUserMessage(undefined);
+ setNewChatAttachedFiles([]);
}, []);
function onChatDisplayRangeChange(max: number) {
maxDisplayedChatIndex.current = max;
}
- const onCreateChat = useCallback((chatId: string, message: ServerTypes.Message) => {
+ const onCreateChat = useCallback((chatId: string, message: ServerTypes.Message, attachedFiles: import('./file-context-bar').AttachedFile[]) => {
const chatInfo = {
id: chatId
};
setChatList(prev => [chatInfo, ...prev]);
setActiveChatId(chatId);
setNewChatUserMessage(message);
+ setNewChatAttachedFiles(attachedFiles);
}, []);
const onDeleteChat = useCallback((chatId: string) => {
@@ -199,6 +202,7 @@ export default function ChatPage() {
onInputHeightChange={setInputHeight}
initialScrollPosition={activeChatId ? scrollPositions.current[activeChatId] : undefined}
onScrollPositionChange={onScrollPositionChange}
+ initialAttachedFiles={newChatAttachedFiles}
/>
diff --git a/src/app/chat/user-input.tsx b/src/app/chat/user-input.tsx
index 329524b..93528c1 100644
--- a/src/app/chat/user-input.tsx
+++ b/src/app/chat/user-input.tsx
@@ -59,7 +59,7 @@ interface UserInputProps {
onUserMessage: (message: ServerTypes.Message) => void;
/** This controls the send button. Not the editor. */
inputEnabled: boolean;
- initialMessage?: ServerTypes.Message;
+ initialMessage?: ServerTypes.ChatMessage;
/** Optional controlled height for the editor. */
editorHeight?: number;
onEditorHeightChange?: (height: number) => void;
@@ -177,7 +177,7 @@ export function UserInput({ onUserMessage, inputEnabled, initialMessage, editorH
return;
}
- const content: ServerTypes.Message["content"] = [];
+ const content: ServerTypes.ChatMessage["content"] = [];
if (imageUrls.length > 0) {
for (const url of imageUrls) {
content.push({ type: "image_url", data: url });
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index f653887..4f164f8 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
import { Logo } from "@/components/custom/logo";
import { UiSettings } from "./tabs/ui-settings";
import { ChatSettings } from "./tabs/chat-settings";
+import { FileSettings } from "./tabs/file-settings";
import { ModelSettings } from "./tabs/model-settings";
import { GlobalSettings } from "./tabs/global-settings";
import { UserSettings } from "./tabs/user-settings";
@@ -20,6 +21,7 @@ const structure = [
children: [
{ id: "ui", label: "界面设置" },
{ id: "chat", label: "聊天设置" },
+ { id: "file", label: "文件管理" },
],
},
{
@@ -136,6 +138,7 @@ export default function SettingsPage() {
{currentTab === "ui" &&
}
{currentTab === "chat" &&
}
+ {currentTab === "file" &&
}
{currentTab === "model" && isAdmin &&
}
{currentTab === "global" && isAdmin &&
}
{currentTab === "user" && isAdmin &&
}
diff --git a/src/app/settings/tabs/file-settings.tsx b/src/app/settings/tabs/file-settings.tsx
new file mode 100644
index 0000000..fca0ade
--- /dev/null
+++ b/src/app/settings/tabs/file-settings.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { TUIClientSingleton } from "@/lib/tui-client-singleton";
+import { Button } from "@/components/ui/button";
+import { Download, Trash2, FileText } from "lucide-react";
+import { DeleteFileDialog } from "./user/delete-file-dialog";
+
+type FileInfo = {
+ fileId: string;
+ contentId: string;
+ name: string;
+ uploadTime?: number;
+ size?: number;
+};
+
+function formatFileSize(bytes: number | undefined): string {
+ if (bytes === undefined) return "";
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+}
+
+export function FileSettings() {
+ const [files, setFiles] = useState
([]);
+ const [loaded, setLoaded] = useState(false);
+ const [downloadingId, setDownloadingId] = useState(undefined);
+ const [fileToDelete, setFileToDelete] = useState(undefined);
+
+ const loadFiles = useCallback(async () => {
+ setLoaded(false);
+ try {
+ const list = await TUIClientSingleton.get().listFileAsync();
+ const infos: FileInfo[] = list.map(f => {
+ const meta = f.fileMetadata as { name?: string; uploadTime?: number; size?: number } | null;
+ return {
+ fileId: f.fileId,
+ contentId: f.contentId,
+ name: typeof meta?.name === "string" ? meta.name : f.fileId,
+ uploadTime: typeof meta?.uploadTime === "number" ? meta.uploadTime : undefined,
+ size: typeof meta?.size === "number" ? meta.size : undefined,
+ };
+ });
+ /** Newest first */
+ infos.sort((a, b) => (b.uploadTime ?? 0) - (a.uploadTime ?? 0));
+ setFiles(infos);
+ } finally {
+ setLoaded(true);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadFiles().catch(console.error);
+ }, [loadFiles]);
+
+ const handleDownload = useCallback(async (file: FileInfo) => {
+ setDownloadingId(file.fileId);
+ try {
+ const { content } = await TUIClientSingleton.get().getFileContentAsync({ contentId: file.contentId });
+ const blob = new Blob([new Uint8Array(content)]);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = file.name;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ } catch (err) {
+ console.error("Failed to download file", err);
+ } finally {
+ setDownloadingId(undefined);
+ }
+ }, []);
+
+ if (!loaded) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {files.length === 0 ? (
+
+
+
暂无已上传的文件
+
在聊天中附加文件后将显示在这里
+
+ ) : (
+
+ {files.map(file => (
+
+
+
+
+ {file.name}
+
+
+ {file.uploadTime && new Date(file.uploadTime).toLocaleString()}
+ {file.size !== undefined && (
+ <>
+ {file.uploadTime ? " · " : ""}
+ {formatFileSize(file.size)}
+ >
+ )}
+
+
+
handleDownload(file)}
+ disabled={downloadingId === file.fileId}
+ >
+ {downloadingId === file.fileId ? (
+
+ ) : (
+
+ )}
+
+
setFileToDelete(file)}
+ >
+
+
+
+ ))}
+
+ )}
+ {fileToDelete && (
+
{
+ setFileToDelete(undefined);
+ loadFiles().catch(console.error);
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/app/settings/tabs/user/delete-file-dialog.tsx b/src/app/settings/tabs/user/delete-file-dialog.tsx
new file mode 100644
index 0000000..3c5c267
--- /dev/null
+++ b/src/app/settings/tabs/user/delete-file-dialog.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { useCallback, useState } from 'react';
+import { Modal } from '@/components/ui/modal';
+import { Button } from '@/components/ui/button';
+import { TUIClientSingleton } from '@/lib/tui-client-singleton';
+
+export interface DeleteFileDialogProps {
+ fileId: string;
+ fileName: string;
+ onComplete: () => void;
+}
+
+export const DeleteFileDialog = ({ fileId, fileName, onComplete }: DeleteFileDialogProps) => {
+ const [deleting, setDeleting] = useState(false);
+ const [error, setError] = useState(undefined);
+
+ const handleDelete = useCallback(async () => {
+ setDeleting(true);
+ setError(undefined);
+ try {
+ await TUIClientSingleton.get().deleteFileAsync({ fileId });
+ onComplete();
+ } catch (err) {
+ console.error('Failed to delete file', err);
+ setError(err instanceof Error ? err.message : '删除失败');
+ setDeleting(false);
+ }
+ }, [fileId, onComplete]);
+
+ return (
+ {} : onComplete}
+ title="删除文件"
+ showCloseButton={!deleting}
+ >
+ {deleting && (
+
+ )}
+ {!deleting && (
+
+
+ 是否确认删除文件: “{fileName}” ?
+ 该文件将从所有引用它的聊天中移除。
+
+ {error && (
+
{error}
+ )}
+
+
+ 取消
+
+
+ 删除
+
+
+
+ )}
+
+ );
+};
diff --git a/src/lib/pdf-extract.ts b/src/lib/pdf-extract.ts
new file mode 100644
index 0000000..3692180
--- /dev/null
+++ b/src/lib/pdf-extract.ts
@@ -0,0 +1,72 @@
+/**
+ * Client-side PDF text extraction using PDF.js (pdfjs-dist).
+ *
+ * The PDF.js worker is bundled by Next.js (webpack/Turbopack) as a static asset
+ * via `new URL(..., import.meta.url)`. The bundler emits a content-hashed file
+ * under `/_next/static/media/pdf.worker..mjs`, so HTTP cache-busting works
+ * automatically when pdfjs-dist is upgraded.
+ *
+ * pdfjs-dist itself is loaded lazily so the ~1MB main bundle is not added to
+ * the initial page load — only when a user actually uploads a PDF.
+ */
+
+let workerConfigured = false;
+
+async function loadPdfJs() {
+ const pdfjs = await import("pdfjs-dist");
+ if (!workerConfigured) {
+ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
+ "pdfjs-dist/build/pdf.worker.min.mjs",
+ import.meta.url
+ ).toString();
+ workerConfigured = true;
+ }
+ return pdfjs;
+}
+
+/**
+ * Extract plain text from a PDF file's bytes.
+ *
+ * Pages are joined with form-feed (\f) characters; lines within a page are
+ * joined by newlines. The output is suitable for storing as a UTF-8 text file
+ * to be consumed by the chat tooling layer.
+ */
+export async function extractPdfText(bytes: Uint8Array): Promise {
+ const pdfjs = await loadPdfJs();
+ /** Pass a copy: PDF.js takes ownership of the buffer and detaches it. */
+ const data = bytes.slice();
+ const loadingTask = pdfjs.getDocument({ data });
+ const doc = await loadingTask.promise;
+ try {
+ const pageTexts: string[] = [];
+ for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
+ const page = await doc.getPage(pageNum);
+ try {
+ const textContent = await page.getTextContent();
+ /** items can be TextItem or TextMarkedContent; only TextItem has `str`. */
+ const lines: string[] = [];
+ let currentLine = "";
+ for (const item of textContent.items) {
+ if (!("str" in item)) continue;
+ currentLine += item.str;
+ if (item.hasEOL) {
+ lines.push(currentLine);
+ currentLine = "";
+ }
+ }
+ if (currentLine.length > 0) lines.push(currentLine);
+ pageTexts.push(lines.join("\n"));
+ } finally {
+ page.cleanup();
+ }
+ }
+ return pageTexts.join("\n\f\n");
+ } finally {
+ await doc.destroy();
+ }
+}
+
+export function isPdfFile(file: File): boolean {
+ if (file.type === "application/pdf") return true;
+ return file.name.toLowerCase().endsWith(".pdf");
+}
diff --git a/src/sdk/app/resource-cache.ts b/src/sdk/app/resource-cache.ts
index a7a6c0c..b45b7d1 100644
--- a/src/sdk/app/resource-cache.ts
+++ b/src/sdk/app/resource-cache.ts
@@ -28,6 +28,20 @@ export class ResourceCache {
}
}
+ async getConstAsync(
+ getter: (...args: Args) => Promise,
+ resourceKey: string[],
+ ...args: Args
+ ): Promise {
+ const key = this.#getKey(resourceKey);
+ if (this.#cache.has(key)) {
+ return this.#cache.get(key) as T;
+ }
+ const value = await getter(...args);
+ this.#cache.set(key, value);
+ return value;
+ }
+
update(
updater: (value: T | undefined) => T,
resourceKey: string[]
diff --git a/src/sdk/tui-client.ts b/src/sdk/tui-client.ts
index 116c850..6131514 100644
--- a/src/sdk/tui-client.ts
+++ b/src/sdk/tui-client.ts
@@ -4,6 +4,26 @@ import * as websocket from './session/websocket-client'
import * as types from './types/IServer';
import { PagedResourceCache, ResourceCache } from './app/resource-cache';
+function Base64Encode(binary: Uint8Array): string {
+ let binaryStr = '';
+ const chunkSize = 0x8000;
+ for (let i = 0; i < binary.length; i += chunkSize) {
+ binaryStr += String.fromCharCode(...binary.subarray(i, i + chunkSize));
+ }
+ return btoa(binaryStr);
+}
+
+function Base64Decode(base64: string): Uint8Array {
+ const padded = base64 + '='.repeat((4 - base64.length % 4) % 4);
+ const converted = padded.replace(/-/g, '+').replace(/_/g, '/');
+ const binaryStr = atob(converted);
+ const binary = new Uint8Array(binaryStr.length);
+ for (let i = 0; i < binaryStr.length; i++) {
+ binary[i] = binaryStr.charCodeAt(i);
+ }
+ return binary;
+}
+
export class TUIClient implements types.IServer {
#url: string;
#onDisconnected: (error: unknown | undefined) => void;
@@ -176,13 +196,64 @@ export class TUIClient implements types.IServer {
this.#cache.delete(['chat', id]);
}
- async * #chatCompletionAsync(params: types.ChatCompletionParams):
- AsyncGenerator {
+ #validateMessageContent(content: types.MessageContent): void {
+ if (typeof content !== 'object' || content === null) {
+ throw new rpc.RequestError(-1, "Invalid message content, should be an object");
+ }
+ if (content.type !== 'text' && content.type !== 'image_url' && content.type !== 'refusal') {
+ throw new rpc.RequestError(-1, "Invalid message content, invalid type");
+ }
+ if (typeof content.data !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid message content, data should be a string");
+ }
+ }
+
+ #validateMessage(message: types.Message): void {
+ if (typeof message !== 'object' || message === null) {
+ throw new rpc.RequestError(-1, "Invalid message, should be an object");
+ }
+ if ('role' in message) {
+ if (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'developer') {
+ throw new rpc.RequestError(-1, "Invalid message, invalid role");
+ }
+ if (!Array.isArray(message.content)) {
+ throw new rpc.RequestError(-1, "Invalid message, content should be an array");
+ }
+ for (const content of message.content) {
+ this.#validateMessageContent(content);
+ }
+ } else if (message.type === 'function_call') {
+ if (typeof message.call_id !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid message, call_id should be a string");
+ }
+ if (typeof message.name !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid message, name should be a string");
+ }
+ if (typeof message.arguments !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid message, arguments should be a string");
+ }
+ } else if (message.type === 'function_call_output') {
+ if (typeof message.call_id !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid message, call_id should be a string");
+ }
+ if (!Array.isArray(message.output)) {
+ throw new rpc.RequestError(-1, "Invalid message, output should be an array");
+ }
+ for (const content of message.output) {
+ this.#validateMessageContent(content);
+ }
+ } else {
+ throw new rpc.RequestError(-1, "Invalid message, unknown type");
+ }
+ }
+
+ async * #chatCompletionAsync(params: types.ChatCompletionParams):
+ AsyncGenerator {
if (this.#rpcClient === undefined) {
throw new rpc.RequestError(-1, "client not connected");
}
/** Incrase timeout to 2min for thinking models. */
- const generator = this.#rpcClient.makeStreamRequestAsync(
+ const generator = this.#rpcClient.makeStreamRequestAsync(
'chatCompletion', params, 120_000);
while (true) {
const it = await generator.next();
@@ -190,16 +261,41 @@ export class TUIClient implements types.IServer {
if (typeof it.value !== 'object' || it.value === null) {
throw new rpc.RequestError(-1, "Invalid final response, value should be an object");
}
- if (typeof it.value.userMessageId !== 'string') {
- throw new rpc.RequestError(-1, "Invalid final response, invalid userMessageId");
+ if (!Array.isArray(it.value.messageIds)) {
+ throw new rpc.RequestError(-1, "Invalid final response, messageIds should be an array");
}
- if (typeof it.value.assistantMessageId !== 'string') {
- throw new rpc.RequestError(-1, "Invalid final response, invalid assistantMessageId");
+ for (const messageId of it.value.messageIds) {
+ if (typeof messageId !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid final response, messageIds should be an array of strings");
+ }
}
return it.value;
} else {
if (typeof it.value !== 'string') {
- throw new rpc.RequestError(-1, "Invalid segment, value should be a string");
+ if (typeof it.value !== 'object' || it.value === null) {
+ throw new rpc.RequestError(-1, "Invalid segment response, value should be a string or an object");
+ }
+ if (it.value.event === 'function_call_start') {
+ /** Empty case */
+ } else if (it.value.event === 'function_call_end') {
+ if (typeof it.value.data !== 'object' || it.value.data === null) {
+ throw new rpc.RequestError(-1, "Invalid segment response, data should be an object");
+ }
+ if (it.value.data.type !== 'function_call') {
+ throw new rpc.RequestError(-1, "Invalid segment response, data.type should be 'function_call'");
+ }
+ if (typeof it.value.data.call_id !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid segment response, data.call_id should be a string");
+ }
+ if (typeof it.value.data.name !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid segment response, data.name should be a string");
+ }
+ if (typeof it.value.data.arguments !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid segment response, data.arguments should be a string");
+ }
+ } else {
+ throw new rpc.RequestError(-1, "Invalid segment response, unknown event type");
+ }
}
yield it.value;
}
@@ -207,63 +303,90 @@ export class TUIClient implements types.IServer {
}
async * chatCompletionAsync(params: types.ChatCompletionParams):
- AsyncGenerator {
- const userMessageTimestamp = Date.now();
+ AsyncGenerator {
+ const requestMessageTimestamp = Date.now();
+ const generatedMessages: Array = [];
let assistantMessageContent: string = '';
const generator = this.#chatCompletionAsync(params);
while (true) {
const it = await generator.next();
if (it.done === true) {
+ if (assistantMessageContent.length !== 0) {
+ generatedMessages.push({
+ role: 'assistant',
+ content: [{
+ type: 'text',
+ data: assistantMessageContent
+ }]
+ });
+ }
+ if (params.messages.length + generatedMessages.length !== it.value.messageIds.length) {
+ throw new rpc.RequestError(-1, "Invalid final response, messageIds length should be equal to the sum of input messages and generated messages");
+ }
+
this.#cache.update((history) => {
history = history ?? { nodes: {} } as types.TreeHistory;
if (params.parent !== undefined) {
const parent = history.nodes[params.parent];
- parent?.children.push(it.value.userMessageId);
+ parent?.children.push(it.value.messageIds[0]);
}
- history.nodes[it.value.userMessageId] = {
- id: it.value.userMessageId,
- message: params.userMessage,
- parent: params.parent,
- children: [it.value.assistantMessageId],
- /**
- * This will differ from the server side value.
- * But that won't be a significant problem.
- * DO NOT use this as a unique identifier.
- */
- timestamp: userMessageTimestamp
- };
- history.nodes[it.value.assistantMessageId] = {
- id: it.value.assistantMessageId,
- message: {
+ let i = 0;
+ const allMessages = [...params.messages, ...generatedMessages];
+ for (const message of allMessages) {
+ history.nodes[it.value.messageIds[i]] = {
+ id: it.value.messageIds[i],
+ message: message,
+ parent: i === 0 ? params.parent : it.value.messageIds[i - 1],
+ children: i === allMessages.length - 1 ? [] : [it.value.messageIds[i + 1]],
+ timestamp: i < params.messages.length ? requestMessageTimestamp : Date.now()
+ }
+ i++;
+ }
+ return history;
+ }, ['chat', params.id]);
+ return it.value;
+ } else {
+ if (typeof it.value === 'string') {
+ assistantMessageContent += it.value;
+ } else if (it.value.event === 'function_call_start') {
+ if (assistantMessageContent.length !== 0) {
+ generatedMessages.push({
role: 'assistant',
content: [{
type: 'text',
data: assistantMessageContent
}]
- },
- parent: it.value.userMessageId,
- children: [],
- /** This will also differ from the server side value */
- timestamp: Date.now()
- };
- return history;
- }, ['chat', params.id]);
- return it.value;
- } else {
- assistantMessageContent += it.value;
+ });
+ }
+ assistantMessageContent = '';
+ } else {
+ generatedMessages.push({
+ type: 'function_call',
+ call_id: it.value.data.call_id,
+ name: it.value.data.name,
+ arguments: it.value.data.arguments,
+ extra: it.value.data.extra
+ });
+ }
yield it.value;
}
}
}
- async executeGenerationTaskAsync(params: types.executeGenerationTaskParams): Promise {
+ async executeGenerationTaskAsync(params: types.executeGenerationTaskParams): Promise {
if (this.#rpcClient === undefined) {
throw new rpc.RequestError(-1, "client not connected");
}
- const result = await this.#rpcClient.makeRequestAsync(
+ const result = await this.#rpcClient.makeRequestAsync(
'executeGenerationTask', params);
- if (typeof result !== 'string') {
- throw new rpc.RequestError(-1, "Invalid response, result should be a string");
+ if (typeof result !== 'object' || result === null) {
+ throw new rpc.RequestError(-1, "Invalid response, result should be an object");
+ }
+ if (!Array.isArray(result.messages)) {
+ throw new rpc.RequestError(-1, "Invalid response, messages should be an array");
+ }
+ for (const message of result.messages) {
+ this.#validateMessage(message);
}
return result;
}
@@ -702,4 +825,133 @@ export class TUIClient implements types.IServer {
);
}
}
+
+ async putFileAsync(params: {
+ content: Uint8Array;
+ metadata: unknown;
+ }): Promise {
+ if (this.#rpcClient === undefined) {
+ throw new rpc.RequestError(-1, "client not connected");
+ }
+ const contentBase64 = Base64Encode(params.content);
+ const result = await this.#rpcClient.makeRequestAsync('putFile', {
+ fileMetadata: params.metadata,
+ contentBase64: contentBase64
+ });
+ if (typeof result.fileId !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid response, fileId should be a string");
+ }
+ if (typeof result.contentId !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid response, contentId should be a string");
+ }
+ this.#cache.update(() => {
+ return {
+ contentId: result.contentId,
+ fileMetadata: params.metadata,
+ }
+ }, ['fileMeta', result.fileId]);
+ this.#cache.update(value => {
+ if (value !== undefined) {
+ return value;
+ }
+ return params.content;
+ }, ['fileContent', result.contentId]);
+ this.#cache.update(list => {
+ list = list ?? [];
+ list.unshift({
+ fileId: result.fileId,
+ contentId: result.contentId,
+ fileMetadata: params.metadata,
+ });
+ return list;
+ }, ['fileList']);
+ return result;
+ }
+
+ async #getFileMetaAsync(params: types.GetFileMetaParams): Promise {
+ if (this.#rpcClient === undefined) {
+ throw new rpc.RequestError(-1, "client not connected");
+ }
+ const result = await this.#rpcClient.makeRequestAsync(
+ 'getFileMeta', params);
+ if (typeof result.contentId !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid response, contentId should be a string");
+ }
+ if (typeof result.fileMetadata !== 'object' || result.fileMetadata === null) {
+ throw new rpc.RequestError(-1, "Invalid response, fileMetadata should be an object");
+ }
+ return result;
+ }
+
+ async getFileMetaAsync(params: types.GetFileMetaParams): Promise {
+ /** The file list content will not change because of this. Since the elements are constants. */
+ return await this.#cache.getConstAsync(this.#getFileMetaAsync.bind(this), ['fileMeta', params.fileId], params);
+ }
+
+ async #getFileContentAsync(params: types.GetFileContentParams): Promise {
+ if (this.#rpcClient === undefined) {
+ throw new rpc.RequestError(-1, "client not connected");
+ }
+ const result = await this.#rpcClient.makeRequestAsync(
+ 'getFileContent', params);
+ if (typeof result.contentBase64 !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid response, contentBase64 should be a string");
+ }
+ const content = Base64Decode(result.contentBase64);
+ this.#cache.update(() => {
+ return content;
+ }, ['fileContent', params.contentId]);
+ return content;
+ }
+
+ async getFileContentAsync(params: types.GetFileContentParams): Promise<{ content: Uint8Array; }> {
+ const content = await this.#cache.getConstAsync(this.#getFileContentAsync.bind(this), ['fileContent', params.contentId], params);
+ return { content };
+ }
+
+ async deleteFileAsync(params: types.DeleteFileParams): Promise {
+ if (this.#rpcClient === undefined) {
+ throw new rpc.RequestError(-1, "client not connected");
+ }
+ const contentId = (await this.getFileMetaAsync({ fileId: params.fileId })).contentId;
+ await this.#rpcClient.makeRequestAsync(
+ 'deleteFile', params);
+ this.#cache.delete(['fileMeta', params.fileId]);
+ this.#cache.update(list => {
+ list = list ?? [];
+ return list.filter(file => file.fileId !== params.fileId);
+ }, ['fileList']);
+ /** Check with the latest list to decide if content cache should be cleared. */
+ const fileList = await this.#cache.getAsync(this.#listFileAsync.bind(this), ['fileList']);
+ if (fileList.find(file => file.contentId === contentId) === undefined) {
+ this.#cache.delete(['fileContent', contentId]);
+ }
+ }
+
+ async #listFileAsync(): Promise {
+ if (this.#rpcClient === undefined) {
+ throw new rpc.RequestError(-1, "client not connected");
+ }
+ const result = await this.#rpcClient.makeRequestAsync(
+ 'listFile', undefined);
+ if (!Array.isArray(result)) {
+ throw new rpc.RequestError(-1, "Invalid response, result should be an array");
+ }
+ for (const item of result) {
+ if (typeof item.fileId !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid response, fileId should be a string");
+ }
+ if (typeof item.contentId !== 'string') {
+ throw new rpc.RequestError(-1, "Invalid response, contentId should be a string");
+ }
+ if (typeof item.fileMetadata !== 'object' || item.fileMetadata === null) {
+ throw new rpc.RequestError(-1, "Invalid response, fileMetadata should be an object");
+ }
+ }
+ return result;
+ }
+
+ async listFileAsync(): Promise {
+ return await this.#cache.getAsync(this.#listFileAsync.bind(this), ['fileList']);
+ }
}
diff --git a/src/sdk/types b/src/sdk/types
index c983c33..a5952b0 160000
--- a/src/sdk/types
+++ b/src/sdk/types
@@ -1 +1 @@
-Subproject commit c983c33e7f7a21da0f4def1f7bfd50d02a010ddb
+Subproject commit a5952b04e39938031b2f769ffdccb6680fc3b9d0
diff --git a/src/stubs/empty-module.ts b/src/stubs/empty-module.ts
new file mode 100644
index 0000000..b4cc312
--- /dev/null
+++ b/src/stubs/empty-module.ts
@@ -0,0 +1,3 @@
+/** Empty stub for Node.js modules that are not available in the browser. */
+const emptyModule = {};
+export default emptyModule;
diff --git a/src/tools/base.ts b/src/tools/base.ts
new file mode 100644
index 0000000..9ccb954
--- /dev/null
+++ b/src/tools/base.ts
@@ -0,0 +1,6 @@
+export abstract class BaseTool {
+ abstract get name(): string;
+ abstract get description(): string;
+ abstract get paramSchema(): unknown;
+ abstract callAsync(params: unknown, context: unknown): Promise;
+};
diff --git a/src/tools/list-files.ts b/src/tools/list-files.ts
new file mode 100644
index 0000000..57887eb
--- /dev/null
+++ b/src/tools/list-files.ts
@@ -0,0 +1,43 @@
+import { BaseTool } from "./base";
+
+export interface ListFilesToolContext {
+ files: Array<{
+ name: string;
+ content: string;
+ }>;
+};
+
+export class ListFilesTool extends BaseTool {
+ get name(): string {
+ return 'list_files';
+ }
+
+ get description(): string {
+ return 'List all available context files. Return a JSON array of objects with name and length for each file the user provided. Non-text files were already converted to plain text.';
+ }
+
+ get paramSchema(): unknown {
+ return {
+ type: 'object',
+ properties: {},
+ required: [],
+ };
+ }
+
+ async callAsync(params: unknown, context: unknown): Promise {
+ const ctx = context as ListFilesToolContext;
+ if (!Array.isArray(ctx.files)) {
+ throw new Error('Invalid context: files array is required.');
+ }
+ for (const file of ctx.files) {
+ if (typeof file.name !== 'string' || typeof file.content !== 'string') {
+ throw new Error('Invalid context: each file must have a name and content string.');
+ }
+ }
+ const result = ctx.files.map(file => ({
+ name: file.name,
+ length: file.content.length,
+ }));
+ return JSON.stringify(result);
+ }
+};
diff --git a/src/tools/quickjs.ts b/src/tools/quickjs.ts
new file mode 100644
index 0000000..5d4647d
--- /dev/null
+++ b/src/tools/quickjs.ts
@@ -0,0 +1,86 @@
+import { getQuickJS } from "@tootallnate/quickjs-emscripten";
+import { BaseTool } from "./base";
+
+const qjs = await getQuickJS();
+
+export interface QuickJSToolContext {
+ files: Array<{
+ name: string;
+ content: string;
+ }>;
+};
+
+export class QuickJSTool extends BaseTool {
+ get name(): string {
+ return 'quickjs';
+ }
+
+ get description(): string {
+ return 'Execute a JavaScript script using QuickJS (ES2023 compatible, no Node.js APIs). The specified file content is pre-loaded into a const fileContent string variable. Non-text files were already converted to plain text. The last expression\'s value will be returned as the output.';
+ }
+
+ get paramSchema(): unknown {
+ return {
+ type: 'object',
+ properties: {
+ file: {
+ type: "string",
+ description: 'The name of the context file to process (as returned by list_files). Its content will be in the variable fileContent.'
+ },
+ script: {
+ type: 'string',
+ description: 'The JavaScript code to execute. The file content is already available as the string variable fileContent. The last expression\'s value will be returned as the output.'
+ }
+ },
+ required: ['file', 'script'],
+ }
+ }
+
+ async callAsync(params: unknown, context: unknown): Promise {
+ const ctx = context as QuickJSToolContext;
+ if (!Array.isArray(ctx.files)) {
+ throw new Error('Invalid context: files array is required.');
+ }
+ for (const file of ctx.files) {
+ if (typeof file.name !== 'string' || typeof file.content !== 'string') {
+ throw new Error('Invalid context: each file must have a name and content string.');
+ }
+ }
+
+ /** parameter errors should be returned to the caller as valid result */
+ if (typeof params !== 'object' || params === null) {
+ return '[Tool Error] Invalid parameters: expected an object.';
+ }
+ if (!('file' in params) || typeof params.file !== 'string') {
+ return '[Tool Error] Invalid parameters: file is required and must be a string.';
+ }
+ if (!('script' in params) || typeof params.script !== 'string') {
+ return '[Tool Error] Invalid parameters: script is required and must be a string.';
+ }
+ const file = ctx.files.find(f => f.name === params.file);
+ if (!file) {
+ return `[Tool Error] File not found: ${params.file}`;
+ }
+
+ const vm = qjs.newContext();
+ const fileContent = vm.newString(file.content);
+ vm.setProp(vm.global, 'fileContent', fileContent);
+ fileContent.dispose();
+ const result = vm.evalCode(params.script);
+ if (result.error) {
+ const error = vm.dump(result.error);
+ result.error.dispose();
+ vm.dispose();
+ return `[QuickJS Error] ${error.name}: ${error.message}`;
+ } else {
+ const output = vm.dump(result.value);
+ result.value.dispose();
+ vm.dispose();
+ if (typeof output === 'object') {
+ return JSON.stringify(output);
+ } else {
+ return `${output}`;
+ }
+ }
+ }
+}
diff --git a/test/auto/sdk/test-quickjs.ts b/test/auto/sdk/test-quickjs.ts
new file mode 100644
index 0000000..04d05da
--- /dev/null
+++ b/test/auto/sdk/test-quickjs.ts
@@ -0,0 +1,190 @@
+import { QuickJSTool, QuickJSToolContext } from "../../../src/tools/quickjs";
+
+let tool: QuickJSTool;
+
+beforeAll(() => {
+ tool = new QuickJSTool();
+});
+
+function makeContext(files: QuickJSToolContext['files']): QuickJSToolContext {
+ return { files };
+}
+
+describe('QuickJSTool metadata', () => {
+ test('name is quickjs', () => {
+ expect(tool.name).toBe('quickjs');
+ });
+
+ test('description is a non-empty string', () => {
+ expect(typeof tool.description).toBe('string');
+ expect(tool.description.length).toBeGreaterThan(0);
+ });
+
+ test('paramSchema requires file and script', () => {
+ const schema = tool.paramSchema as {
+ type: string;
+ properties: Record;
+ required: string[];
+ };
+ expect(schema.type).toBe('object');
+ expect(schema.properties).toHaveProperty('file');
+ expect(schema.properties).toHaveProperty('script');
+ expect(schema.required).toEqual(expect.arrayContaining(['file', 'script']));
+ });
+});
+
+describe('QuickJSTool context validation', () => {
+ test('throws when context.files is not an array', async () => {
+ await expect(
+ tool.callAsync({ file: 'a.txt', script: '1' }, { files: 'bad' })
+ ).rejects.toThrow('files array is required');
+ });
+
+ test('throws when a file entry is missing name', async () => {
+ await expect(
+ tool.callAsync({ file: 'a.txt', script: '1' }, { files: [{ content: 'x' }] })
+ ).rejects.toThrow('each file must have a name and content string');
+ });
+
+ test('throws when a file entry is missing content', async () => {
+ await expect(
+ tool.callAsync({ file: 'a.txt', script: '1' }, { files: [{ name: 'a.txt' }] })
+ ).rejects.toThrow('each file must have a name and content string');
+ });
+});
+
+describe('QuickJSTool parameter validation', () => {
+ const ctx = makeContext([{ name: 'a.txt', content: 'hello' }]);
+
+ test('returns error for null params', async () => {
+ const result = await tool.callAsync(null, ctx);
+ expect(result).toContain('[Tool Error]');
+ expect(result).toContain('Invalid parameters');
+ });
+
+ test('returns error for non-object params', async () => {
+ const result = await tool.callAsync('bad', ctx);
+ expect(result).toContain('[Tool Error]');
+ });
+
+ test('returns error when file param is missing', async () => {
+ const result = await tool.callAsync({ script: '1' }, ctx);
+ expect(result).toContain('[Tool Error]');
+ expect(result).toContain('file is required');
+ });
+
+ test('returns error when file param is not a string', async () => {
+ const result = await tool.callAsync({ file: 123, script: '1' }, ctx);
+ expect(result).toContain('[Tool Error]');
+ expect(result).toContain('file is required');
+ });
+
+ test('returns error when script param is missing', async () => {
+ const result = await tool.callAsync({ file: 'a.txt' }, ctx);
+ expect(result).toContain('[Tool Error]');
+ expect(result).toContain('script is required');
+ });
+
+ test('returns error when script param is not a string', async () => {
+ const result = await tool.callAsync({ file: 'a.txt', script: 42 }, ctx);
+ expect(result).toContain('[Tool Error]');
+ expect(result).toContain('script is required');
+ });
+
+ test('returns error when file is not found in context', async () => {
+ const result = await tool.callAsync({ file: 'missing.txt', script: '1' }, ctx);
+ expect(result).toContain('[Tool Error] File not found: missing.txt');
+ });
+});
+
+describe('QuickJSTool script execution', () => {
+ const ctx = makeContext([
+ { name: 'data.txt', content: 'Hello, world!' },
+ { name: 'nums.txt', content: '1\n2\n3' },
+ ]);
+
+ test('script can read fileContent variable', async () => {
+ const result = await tool.callAsync(
+ { file: 'data.txt', script: 'fileContent' },
+ ctx
+ );
+ expect(result).toBe('Hello, world!');
+ });
+
+ test('script returns numeric result', async () => {
+ const result = await tool.callAsync(
+ { file: 'data.txt', script: '2 + 3' },
+ ctx
+ );
+ expect(result).toBe('5');
+ });
+
+ test('script can process file content', async () => {
+ const result = await tool.callAsync(
+ { file: 'nums.txt', script: 'fileContent.split("\\n").map(Number).reduce((a, b) => a + b, 0)' },
+ ctx
+ );
+ expect(result).toBe('6');
+ });
+
+ test('script returns undefined for statements without return value', async () => {
+ const result = await tool.callAsync(
+ { file: 'data.txt', script: 'var x = 1;' },
+ ctx
+ );
+ expect(result).toBe('undefined');
+ });
+
+ test('script can use string methods on fileContent', async () => {
+ const result = await tool.callAsync(
+ { file: 'data.txt', script: 'fileContent.length' },
+ ctx
+ );
+ expect(result).toBe('13');
+ });
+
+ test('script can use JSON.parse and JSON.stringify', async () => {
+ const jsonCtx = makeContext([{ name: 'j.json', content: '{"a":1,"b":2}' }]);
+ const result = await tool.callAsync(
+ { file: 'j.json', script: 'JSON.stringify(JSON.parse(fileContent))' },
+ jsonCtx
+ );
+ expect(result).toBe('{"a":1,"b":2}');
+ });
+
+ test('selects correct file from multiple files', async () => {
+ const result = await tool.callAsync(
+ { file: 'nums.txt', script: 'fileContent' },
+ ctx
+ );
+ expect(result).toBe('1\n2\n3');
+ });
+});
+
+describe('QuickJSTool error handling', () => {
+ const ctx = makeContext([{ name: 'a.txt', content: '' }]);
+
+ test('returns QuickJS error for syntax errors', async () => {
+ const result = await tool.callAsync(
+ { file: 'a.txt', script: 'function(' },
+ ctx
+ );
+ expect(result).toContain('[QuickJS Error]');
+ });
+
+ test('returns QuickJS error for runtime exceptions', async () => {
+ const result = await tool.callAsync(
+ { file: 'a.txt', script: 'throw new Error("boom")' },
+ ctx
+ );
+ expect(result).toContain('[QuickJS Error]');
+ });
+
+ test('returns QuickJS error for reference errors', async () => {
+ const result = await tool.callAsync(
+ { file: 'a.txt', script: 'undefinedVar.prop' },
+ ctx
+ );
+ expect(result).toContain('[QuickJS Error]');
+ });
+});
diff --git a/test/interactive/sdk/tui-client/test-basic-chat.ts b/test/interactive/sdk/tui-client/test-basic-chat.ts
index aca5242..096020d 100644
--- a/test/interactive/sdk/tui-client/test-basic-chat.ts
+++ b/test/interactive/sdk/tui-client/test-basic-chat.ts
@@ -61,20 +61,22 @@ while(true)
id: chatId,
modelId: modelId,
parent: parentId,
- userMessage: {
+ messages: [{
role: 'user',
content:[{
type: 'text',
data: userMessage
}]
- }
+ }]
});
console.log("Assistant: ");
let result = undefined;
while(!(result = await stream.next()).done){
const chunk = result.value;
- process.stdout.write(chunk);
+ if (typeof chunk === 'string') {
+ process.stdout.write(chunk);
+ }
}
console.log("\n");
/** The chat info */
@@ -83,6 +85,6 @@ while(true)
exit(1);
}
const info = result.value;
- parentId = info.assistantMessageId;
+ parentId = info.messageIds[info.messageIds.length - 1];
}
diff --git a/tsconfig.json b/tsconfig.json
index 23f3743..fc4c58d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "esnext",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,20 @@
}
],
"paths": {
- "@/*": ["./src/*"]
+ "@/*": [
+ "./src/*"
+ ]
}
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "test/types/**/*.d.ts"],
- "exclude": ["node_modules"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ "test/types/**/*.d.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}