From c1b23d009d1faf7ac995a49ae50dc43dbdb35f05 Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Sat, 14 Mar 2026 14:52:06 +0800 Subject: [PATCH 01/10] add file management apis to sdk --- src/app/auth/sign-in/page.tsx | 5 +- src/sdk/app/resource-cache.ts | 14 ++++ src/sdk/tui-client.ts | 142 ++++++++++++++++++++++++++++++++++ src/sdk/types | 2 +- 4 files changed, 160 insertions(+), 3 deletions(-) diff --git a/src/app/auth/sign-in/page.tsx b/src/app/auth/sign-in/page.tsx index fee8399..1af1e03 100644 --- a/src/app/auth/sign-in/page.tsx +++ b/src/app/auth/sign-in/page.tsx @@ -21,8 +21,9 @@ export default function SignIn() { if (!TUIClientSingleton.exists()) { TUIClientSingleton.create( - /** `${window.location.hostname}:12345`, */ - `${window.location.host}/api`, + `192.168.31.153:12345`, + // '192.168.31.178:12574/api', + // `${window.location.host}/api`, (error) => { /** @todo Handle disconnect properly */ console.error("TUIClient connection error: ", error); 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..b7c0fde 100644 --- a/src/sdk/tui-client.ts +++ b/src/sdk/tui-client.ts @@ -4,6 +4,24 @@ 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 binaryStr = atob(base64); + 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; @@ -702,4 +720,128 @@ 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<{ content: Uint8Array; }> { + 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 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..5159e72 160000 --- a/src/sdk/types +++ b/src/sdk/types @@ -1 +1 @@ -Subproject commit c983c33e7f7a21da0f4def1f7bfd50d02a010ddb +Subproject commit 5159e72aad05742119139e34f8a2df0769834177 From 9e27748b1e46700ef84365952a7150d0b3fc4505 Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Sat, 21 Mar 2026 12:21:26 +0800 Subject: [PATCH 02/10] update sdk --- src/sdk/tui-client.ts | 183 +++++++++++++++++++++++++++++++++--------- src/sdk/types | 2 +- 2 files changed, 144 insertions(+), 41 deletions(-) diff --git a/src/sdk/tui-client.ts b/src/sdk/tui-client.ts index b7c0fde..27a5436 100644 --- a/src/sdk/tui-client.ts +++ b/src/sdk/tui-client.ts @@ -194,13 +194,64 @@ export class TUIClient implements types.IServer { this.#cache.delete(['chat', id]); } + #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 { + 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(); @@ -208,16 +259,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; } @@ -225,63 +301,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; } diff --git a/src/sdk/types b/src/sdk/types index 5159e72..a5952b0 160000 --- a/src/sdk/types +++ b/src/sdk/types @@ -1 +1 @@ -Subproject commit 5159e72aad05742119139e34f8a2df0769834177 +Subproject commit a5952b04e39938031b2f769ffdccb6680fc3b9d0 From a438375fe64a509fe23f7debc9cfd480917caa83 Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Sat, 21 Mar 2026 12:50:18 +0800 Subject: [PATCH 03/10] migrate to new request format --- src/app/chat/chat.tsx | 73 ++++++++++++++++++++++++++++------------ src/app/chat/message.tsx | 2 +- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/app/chat/chat.tsx b/src/app/chat/chat.tsx index c847a02..14ad762 100644 --- a/src/app/chat/chat.tsx +++ b/src/app/chat/chat.tsx @@ -42,11 +42,11 @@ 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 [pendingAssistantMessage, setPendingAssistantMessage] = useState(undefined); 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 initialUserMessageHandled = useRef(false); @@ -90,20 +90,36 @@ export function Chat({ 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 +133,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) { @@ -166,7 +182,7 @@ export function Chat({ id: chatId, parent: tailNodeId, modelId: selectedModelId, - userMessage: message + messages: [message] }); while (true) { const result = await generator.next(); @@ -175,15 +191,19 @@ export function Chat({ return; } if (result.done) { + /** @todo: support tool call */ + if (result.value.messageIds.length !== 2) { + throw new Error(`Unexpected number of messages generated: ${result.value.messageIds.length}`); + } const userMessageNode: ServerTypes.MessageNode = { - id: result.value.userMessageId, + id: result.value.messageIds[0], message: message, parent: tailNodeId, - children: [result.value.assistantMessageId], + children: [result.value.messageIds[1]], timestamp: userMessageTimestamp, }; const assistantMessageNode: ServerTypes.MessageNode = { - id: result.value.assistantMessageId, + id: result.value.messageIds[1], message: assistantMessage, parent: userMessageNode.id, children: [], @@ -211,6 +231,9 @@ export function Chat({ setPendingAssistantMessage(undefined); break; } else { + if (typeof result.value !== 'string') { + throw new Error('Tool calling is not supported yet.'); + } assistantMessage.content[0].data = assistantMessage.content[0].data + result.value; setPendingAssistantMessage({ ...assistantMessage }); } @@ -316,10 +339,17 @@ export function Chat({ }, [tailNodeId, treeHistory]); 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(() => { @@ -403,12 +433,13 @@ export function Chat({ >
{getLinearHistory() - .filter(n => n.message.role !== 'developer') + /** @todo: render function calls */ + .filter(n => 'role' in n.message && n.message.role !== 'developer') .map(node => ( Date: Sat, 21 Mar 2026 13:33:10 +0800 Subject: [PATCH 04/10] add file tools --- package.json | 1 + src/tools/base.ts | 6 ++ src/tools/list-files.ts | 43 ++++++++ src/tools/quickjs.ts | 82 +++++++++++++++ test/auto/sdk/test-quickjs.ts | 190 ++++++++++++++++++++++++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 src/tools/base.ts create mode 100644 src/tools/list-files.ts create mode 100644 src/tools/quickjs.ts create mode 100644 test/auto/sdk/test-quickjs.ts diff --git a/package.json b/package.json index 0215cfc..b4a9ec2 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@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", 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..0ea22ae --- /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.'; + } + + 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..3d5b7ab --- /dev/null +++ b/src/tools/quickjs.ts @@ -0,0 +1,82 @@ +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. Use console.log() for 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. Use console.log() to print results.' + } + }, + 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}`; + } else { + const output = vm.dump(result.value); + result.value.dispose(); + vm.dispose(); + return output; + } + } +} diff --git a/test/auto/sdk/test-quickjs.ts b/test/auto/sdk/test-quickjs.ts new file mode 100644 index 0000000..b982b67 --- /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).toBeUndefined(); + }); + + 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]'); + }); +}); From d92f0da8bbca300ec6d780bcf7b244fb55d5da39 Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Sat, 21 Mar 2026 18:36:31 +0800 Subject: [PATCH 05/10] kinda working --- next.config.ts | 20 +- src/app/auth/sign-in/page.tsx | 4 +- src/app/chat/assistant-turn.tsx | 117 ++++++ src/app/chat/chat.tsx | 367 +++++++++++++----- src/app/chat/file-context-bar.tsx | 316 +++++++++++++++ src/app/chat/page.tsx | 6 +- src/app/chat/user-input.tsx | 4 +- src/sdk/tui-client.ts | 13 +- src/stubs/empty-module.ts | 3 + src/tools/quickjs.ts | 12 +- .../sdk/tui-client/test-basic-chat.ts | 10 +- 11 files changed, 763 insertions(+), 109 deletions(-) create mode 100644 src/app/chat/assistant-turn.tsx create mode 100644 src/app/chat/file-context-bar.tsx create mode 100644 src/stubs/empty-module.ts diff --git a/next.config.ts b/next.config.ts index 5a36aff..3423d66 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,7 +7,25 @@ const nextConfig: NextConfig = { images: { unoptimized: true, }, - /* other config options can go here */ + // 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/src/app/auth/sign-in/page.tsx b/src/app/auth/sign-in/page.tsx index 1af1e03..a882cba 100644 --- a/src/app/auth/sign-in/page.tsx +++ b/src/app/auth/sign-in/page.tsx @@ -21,9 +21,7 @@ export default function SignIn() { if (!TUIClientSingleton.exists()) { TUIClientSingleton.create( - `192.168.31.153:12345`, - // '192.168.31.178:12574/api', - // `${window.location.host}/api`, + `${window.location.host}/api`, (error) => { /** @todo Handle disconnect properly */ console.error("TUIClient connection error: ", error); diff --git a/src/app/chat/assistant-turn.tsx b/src/app/chat/assistant-turn.tsx new file mode 100644 index 0000000..3aadfc6 --- /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 ( +
+ + {expanded && ( +
+ {args && ( +
+
参数
+
+                {(() => {
+                  try { return JSON.stringify(JSON.parse(args), null, 2); } catch { return args; }
+                })()}
+              
+
+ )} + {result && ( +
+
结果
+
+                {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 14ad762..7ea8b78 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 } 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); @@ -43,12 +56,13 @@ export function Chat({ const [treeHistory, setTreeHistory] = useState({ nodes: {} }); const [tailNodeId, setTailNodeId] = useState(undefined); const [pendingUserMessage, setPendingUserMessage] = useState(undefined); - const [pendingAssistantMessage, setPendingAssistantMessage] = useState(undefined); + const [pendingTurnParts, setPendingTurnParts] = useState([]); const [editingBranch, setEditingBranch] = useState(false); const [previousTailNodeId, setPreviousTailNodeId] = 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,10 +98,10 @@ 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; @@ -159,100 +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: { contextFileIds: attachedFiles.filter(f => !f.deleted).map(f => f.fileId) }, + }); + } 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, - messages: [message] - }); + let pendingMessages: ServerTypes.Message[] = [message]; + let parentForNextCall = tailNodeId; + setPendingTurnParts([]); + while (true) { - const result = await generator.next(); - if (originalCounter !== generatingCounter.current) { - callMismatch = true; - return; - } - if (result.done) { - /** @todo: support tool call */ - if (result.value.messageIds.length !== 2) { - throw new Error(`Unexpected number of messages generated: ${result.value.messageIds.length}`); + 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; } - const userMessageNode: ServerTypes.MessageNode = { - id: result.value.messageIds[0], - message: message, - parent: tailNodeId, - children: [result.value.messageIds[1]], - timestamp: userMessageTimestamp, - }; - const assistantMessageNode: ServerTypes.MessageNode = { - id: result.value.messageIds[1], - 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] - }] + 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 { - return [i, n]; + 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", } - })), - [userMessageNode.id]: userMessageNode, - [assistantMessageNode.id]: assistantMessageNode, + ]); } - })); - setTailNodeId(assistantMessageNode.id); - setPendingUserMessage(undefined); - setPendingAssistantMessage(undefined); + } + } + + if (originalCounter !== generatingCounter.current) { + callMismatch = true; + return; + } + + if (pendingFunctionCalls.length === 0) { break; - } else { - if (typeof result.value !== 'string') { - throw new Error('Tool calling is not supported yet.'); + } + + /** 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)}`; } - assistantMessage.content[0].data = assistantMessage.content[0].data + result.value; - setPendingAssistantMessage({ ...assistantMessage }); + + setPendingTurnParts(parts => parts.map(part => { + return part.type === 'tool_call' && part.call.call_id === functionCall.call_id ? {...part, status: "done", result: resultText} : part + })); + + 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 () => { @@ -309,12 +399,37 @@ export function Chat({ } setTailNodeId(latestNode.id); } + /** Load attached files from chat metadata */ + try { + const meta = await TUIClientSingleton.get().getMetadataAsync({ + path: ['chat', activeChatId], + keys: ['contextFileIds'], + }); + const fileIds = Array.isArray(meta.contextFileIds) ? meta.contextFileIds as string[] : []; + if (fileIds.length > 0) { + const loadedFiles: AttachedFile[] = []; + for (const fileId of fileIds) { + try { + const fileMeta = await TUIClientSingleton.get().getFileMetaAsync({ fileId }); + const name = (fileMeta.fileMetadata as { name?: string })?.name ?? fileId; + loadedFiles.push({ fileId, name }); + } catch { + loadedFiles.push({ fileId, name: fileId, deleted: true }); + } + } + 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(() => { @@ -338,6 +453,62 @@ 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) { @@ -432,22 +603,28 @@ export function Chat({ onScroll={handleScroll} >
- {getLinearHistory() - /** @todo: render function calls */ - .filter(n => 'role' in n.message && 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 && (
正在编辑 @@ -463,8 +640,8 @@ export function Chat({ {pendingUserMessage && ( )} - {pendingAssistantMessage && ( - + {pendingTurnParts.length > 0 && ( + )} {generating && (
@@ -516,6 +693,14 @@ export function Chat({
+ { /** File context bar */ } + + { /** User input area */ } 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 pickerRef = useRef(null); + + /** Close picker when clicking outside */ + useEffect(() => { + if (!pickerOpen) return; + const handler = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + setPickerOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [pickerOpen]); + + 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: { contextFileIds: next.map((f) => f.fileId) }, + }); + } + }, + [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: { contextFileIds: next.map((f) => f.fileId) }, + }); + } + }, + [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 UTF-8 validation */ + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + + /** 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: file.name, uploadTime: Date.now() }; + const result = await TUIClientSingleton.get().putFileAsync({ + content: bytes, + metadata, + }); + + /** Attach to chat */ + const newFile: AttachedFile = { fileId: result.fileId, name: file.name }; + const next = [...attachedFiles, newFile]; + onAttachedFilesChange(next); + if (chatId) { + await TUIClientSingleton.get().setMetadataAsync({ + path: ["chat", chatId], + entries: { contextFileIds: next.map((f) => f.fileId) }, + }); + } + + /** 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 */} +
+
+ + + {attachedFiles.map((f) => ( + + {f.deleted && } + {f.name} + {!disabled && ( + + )} + + ))} + + {nonDeletedCount > 0 && ( + + {nonDeletedCount} 个文件 + + )} +
+
+ + {/* File picker popover */} + {pickerOpen && ( +
setPickerOpen(false)}> +
e.stopPropagation()} + > +
+ 选择文件 +
+ + +
+
+
+ {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 ( + + ); + }) + )} +
+
+
+ )} + + {/* Upload modal */} + { + if (!uploading) setUploadError(undefined); + }} + title="上传文件" + showCloseButton={!uploading} + > + {uploading && ( +
+
+
+
+
+ 正在上传并验证文件… +
+ )} + {uploadError && ( +
+
+ + {uploadError} +
+
+ +
+
+ )} + + + ); +} 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/sdk/tui-client.ts b/src/sdk/tui-client.ts index 27a5436..6131514 100644 --- a/src/sdk/tui-client.ts +++ b/src/sdk/tui-client.ts @@ -14,7 +14,9 @@ function Base64Encode(binary: Uint8Array): string { } function Base64Decode(base64: string): Uint8Array { - const binaryStr = atob(base64); + 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); @@ -245,7 +247,7 @@ export class TUIClient implements types.IServer { } } - async * #chatCompletionAsync(params: types.ChatCompletionParams): + async * #chatCompletionAsync(params: types.ChatCompletionParams): AsyncGenerator { if (this.#rpcClient === undefined) { throw new rpc.RequestError(-1, "client not connected"); @@ -886,7 +888,7 @@ export class TUIClient implements types.IServer { return await this.#cache.getConstAsync(this.#getFileMetaAsync.bind(this), ['fileMeta', params.fileId], params); } - async getFileContentAsync(params: types.GetFileContentParams): Promise<{ content: Uint8Array; }> { + async #getFileContentAsync(params: types.GetFileContentParams): Promise { if (this.#rpcClient === undefined) { throw new rpc.RequestError(-1, "client not connected"); } @@ -899,6 +901,11 @@ export class TUIClient implements types.IServer { 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 }; } 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/quickjs.ts b/src/tools/quickjs.ts index 3d5b7ab..a7b0fb4 100644 --- a/src/tools/quickjs.ts +++ b/src/tools/quickjs.ts @@ -16,7 +16,7 @@ export class QuickJSTool extends BaseTool { } 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. Use console.log() for output.'; + 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. The last expression\'s value will be returned as the output.'; } get paramSchema(): unknown { @@ -29,7 +29,7 @@ export class QuickJSTool extends BaseTool { }, script: { type: 'string', - description: 'The JavaScript code to execute. The file content is already available as the string variable fileContent. Use console.log() to print results.' + 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'], @@ -71,12 +71,16 @@ export class QuickJSTool extends BaseTool { const error = vm.dump(result.error); result.error.dispose(); vm.dispose(); - return `[QuickJS Error] ${error}`; + return `[QuickJS Error] ${error.name}: ${error.message}`; } else { const output = vm.dump(result.value); result.value.dispose(); vm.dispose(); - return output; + if (typeof output === 'object') { + return JSON.stringify(output); + } else { + return `${output}`; + } } } } 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]; } From 2a0e4ee1ce8b57024573c8f5867951c839928a14 Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Sun, 3 May 2026 11:11:41 +0800 Subject: [PATCH 06/10] visual adjustments Co-authored-by: Copilot --- src/app/chat/assistant-turn.tsx | 2 +- src/app/chat/file-context-bar.tsx | 129 ++++++++++++++---------------- src/app/chat/message.tsx | 4 +- 3 files changed, 63 insertions(+), 72 deletions(-) diff --git a/src/app/chat/assistant-turn.tsx b/src/app/chat/assistant-turn.tsx index 3aadfc6..b563921 100644 --- a/src/app/chat/assistant-turn.tsx +++ b/src/app/chat/assistant-turn.tsx @@ -96,7 +96,7 @@ function ToolCallSection({ export function AssistantTurn({ parts }: { parts: (PendingTurnPart | CommittedTurnPart)[] }) { return (
-
+
{parts.map((part, idx) => { if (part.type === "text") { if (!part.content) return null; diff --git a/src/app/chat/file-context-bar.tsx b/src/app/chat/file-context-bar.tsx index 780ecf4..284a707 100644 --- a/src/app/chat/file-context-bar.tsx +++ b/src/app/chat/file-context-bar.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Modal } from "@/components/ui/modal"; import { TUIClientSingleton } from "@/lib/tui-client-singleton"; @@ -34,19 +34,6 @@ export function FileContextBar({ const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(undefined); const fileInputRef = useRef(null); - const pickerRef = useRef(null); - - /** Close picker when clicking outside */ - useEffect(() => { - if (!pickerOpen) return; - const handler = (e: MouseEvent) => { - if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { - setPickerOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [pickerOpen]); const loadAllFiles = useCallback(async () => { setLoadingFiles(true); @@ -159,12 +146,12 @@ export function FileContextBar({ return ( <> {/* Context bar */} -
-
+
+
+ {nonDeletedCount > 0 && ( + + {nonDeletedCount} + + )} + {attachedFiles.map((f) => ( ))} - - {nonDeletedCount > 0 && ( - - {nonDeletedCount} 个文件 - - )}
- {/* File picker popover */} - {pickerOpen && ( -
setPickerOpen(false)}> -
e.stopPropagation()} - > -
- 选择文件 -
- - -
-
-
- {loadingFiles ? ( -
- 加载中… -
- ) : allFiles.length === 0 ? ( -
- 暂无文件,请上传新文件 + {/* File picker dialog */} + setPickerOpen(false)} + title="选择文件" + > +
+
+ + +
+
+ {loadingFiles ? ( +
+
+
+
- ) : ( - allFiles.map((f) => { + 加载中… +
+ ) : 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); @@ -251,7 +239,10 @@ export function FileContextBar({ ); - }) - )} -
+ })} +
+ )}
- )} + {/* Upload modal */}
{/* Thumbnails row(s) */} From 0639440065405006c2a8e6d6f88e7b9fd86630bb Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Sun, 3 May 2026 11:32:30 +0800 Subject: [PATCH 07/10] add file management --- src/app/settings/page.tsx | 3 + src/app/settings/tabs/file-settings.tsx | 162 ++++++++++++++++++ .../settings/tabs/user/delete-file-dialog.tsx | 68 ++++++++ 3 files changed, 233 insertions(+) create mode 100644 src/app/settings/tabs/file-settings.tsx create mode 100644 src/app/settings/tabs/user/delete-file-dialog.tsx 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)} + + )} +
+
+ + +
+ ))} +
+ )} + {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}

+ )} +
+ + +
+
+ )} + + ); +}; From 1ed54b0d50cf1344af793a3fbe493a6e63feb9c1 Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Sun, 3 May 2026 12:22:41 +0800 Subject: [PATCH 08/10] support pdf Co-authored-by: Copilot --- next.config.ts | 5 ++ package.json | 3 +- src/app/chat/chat.tsx | 25 ++++------ src/app/chat/file-context-bar.tsx | 80 +++++++++++++++++++++++++------ src/lib/pdf-extract.ts | 72 ++++++++++++++++++++++++++++ src/tools/list-files.ts | 2 +- src/tools/quickjs.ts | 2 +- tsconfig.json | 25 ++++++++-- 8 files changed, 175 insertions(+), 39 deletions(-) create mode 100644 src/lib/pdf-extract.ts diff --git a/next.config.ts b/next.config.ts index 3423d66..b82db2f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,11 @@ const nextConfig: NextConfig = { images: { unoptimized: true, }, + // 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: { diff --git a/package.json b/package.json index b4a9ec2..1830629 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,9 @@ "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/chat/chat.tsx b/src/app/chat/chat.tsx index 7ea8b78..370637e 100644 --- a/src/app/chat/chat.tsx +++ b/src/app/chat/chat.tsx @@ -7,7 +7,7 @@ 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 } from "./file-context-bar"; +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"; @@ -182,7 +182,7 @@ export function Chat({ if (attachedFiles.length > 0) { TUIClientSingleton.get().setMetadataAsync({ path: ['chat', chatId], - entries: { contextFileIds: attachedFiles.filter(f => !f.deleted).map(f => f.fileId) }, + entries: { contextFiles: serializeContextFiles(attachedFiles.filter(f => !f.deleted)) }, }); } return; @@ -399,24 +399,17 @@ export function Chat({ } setTailNodeId(latestNode.id); } - /** Load attached files from chat metadata */ + /** 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: ['contextFileIds'], + keys: ['contextFiles'], }); - const fileIds = Array.isArray(meta.contextFileIds) ? meta.contextFileIds as string[] : []; - if (fileIds.length > 0) { - const loadedFiles: AttachedFile[] = []; - for (const fileId of fileIds) { - try { - const fileMeta = await TUIClientSingleton.get().getFileMetaAsync({ fileId }); - const name = (fileMeta.fileMetadata as { name?: string })?.name ?? fileId; - loadedFiles.push({ fileId, name }); - } catch { - loadedFiles.push({ fileId, name: fileId, deleted: true }); - } - } + const loadedFiles = parseContextFiles(meta.contextFiles); + if (loadedFiles.length > 0) { setAttachedFiles(loadedFiles); onAttachedFilesChange?.(loadedFiles); } diff --git a/src/app/chat/file-context-bar.tsx b/src/app/chat/file-context-bar.tsx index 284a707..24bba3a 100644 --- a/src/app/chat/file-context-bar.tsx +++ b/src/app/chat/file-context-bar.tsx @@ -4,6 +4,7 @@ import React, { useState, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Modal } from "@/components/ui/modal"; import { TUIClientSingleton } from "@/lib/tui-client-singleton"; +import { extractPdfText, isPdfFile } from "@/lib/pdf-extract"; import { Paperclip, X, Upload, Check, AlertTriangle } from "lucide-react"; import { cn } from "@/lib/utils"; import * as ServerTypes from "@/sdk/types/IServer"; @@ -15,6 +16,37 @@ export type AttachedFile = { deleted?: boolean; }; +/** + * Shape stored in chat metadata under the `contextFiles` key. We persist the + * file name alongside the id so we can render attachments without making a + * server round trip — and so the user still sees a meaningful name even when + * the underlying file has since been deleted server-side. + */ +type ContextFileEntry = { fileId: string; name: string }; + +/** Serialize attached files for persistence (drops the transient `deleted` flag). */ +export function serializeContextFiles(files: AttachedFile[]): ContextFileEntry[] { + return files.map((f) => ({ 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[]; @@ -66,7 +98,7 @@ export function FileContextBar({ if (chatId) { await TUIClientSingleton.get().setMetadataAsync({ path: ["chat", chatId], - entries: { contextFileIds: next.map((f) => f.fileId) }, + entries: { contextFiles: serializeContextFiles(next) }, }); } }, @@ -81,7 +113,7 @@ export function FileContextBar({ if (chatId) { await TUIClientSingleton.get().setMetadataAsync({ path: ["chat", chatId], - entries: { contextFileIds: next.map((f) => f.fileId) }, + entries: { contextFiles: serializeContextFiles(next) }, }); } }, @@ -98,34 +130,52 @@ export function FileContextBar({ setUploading(true); setUploadError(undefined); try { - /** Read as ArrayBuffer for UTF-8 validation */ + /** Read as ArrayBuffer for either UTF-8 validation or PDF parsing */ const buffer = await file.arrayBuffer(); - const bytes = new Uint8Array(buffer); + let bytes = new Uint8Array(buffer); + let uploadName = file.name; - /** Validate full UTF-8 text */ - const decoder = new TextDecoder("utf-8", { fatal: true }); - try { - decoder.decode(bytes); - } catch { - setUploadError("文件不是有效的 UTF-8 文本文件。"); - return; + 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: file.name, uploadTime: Date.now() }; + 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: file.name }; + const newFile: AttachedFile = { fileId: result.fileId, name: uploadName }; const next = [...attachedFiles, newFile]; onAttachedFilesChange(next); if (chatId) { await TUIClientSingleton.get().setMetadataAsync({ path: ["chat", chatId], - entries: { contextFileIds: next.map((f) => f.fileId) }, + entries: { contextFiles: serializeContextFiles(next) }, }); } @@ -212,7 +262,7 @@ export function FileContextBar({ ref={fileInputRef} type="file" className="hidden" - accept=".txt,.md,.csv,.json,.xml,.yaml,.yml,.toml,.ini,.cfg,.conf,.log,.js,.ts,.jsx,.tsx,.py,.java,.c,.cpp,.h,.hpp,.cs,.go,.rs,.rb,.php,.sql,.sh,.bat,.ps1,.html,.css,.scss,.less,.svg" + accept=".txt,.md,.csv,.json,.xml,.yaml,.yml,.toml,.ini,.cfg,.conf,.log,.js,.ts,.jsx,.tsx,.py,.java,.c,.cpp,.h,.hpp,.cs,.go,.rs,.rb,.php,.sql,.sh,.bat,.ps1,.html,.css,.scss,.less,.svg,.pdf" onChange={handleUpload} />
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/tools/list-files.ts b/src/tools/list-files.ts index 0ea22ae..57887eb 100644 --- a/src/tools/list-files.ts +++ b/src/tools/list-files.ts @@ -13,7 +13,7 @@ export class ListFilesTool extends BaseTool { } 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.'; + 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 { diff --git a/src/tools/quickjs.ts b/src/tools/quickjs.ts index a7b0fb4..5d4647d 100644 --- a/src/tools/quickjs.ts +++ b/src/tools/quickjs.ts @@ -16,7 +16,7 @@ export class QuickJSTool extends BaseTool { } 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. The last expression\'s value will be returned as the output.'; + 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 { 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" + ] } From 41bb08dd608300d8099d074187550cf9b9eb340f Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Sun, 3 May 2026 14:00:25 +0800 Subject: [PATCH 09/10] update version and change log --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) 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/package.json b/package.json index 1830629..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": { From f2c3fc0333394bb51bf8b7bc19dbf28cc53057e6 Mon Sep 17 00:00:00 2001 From: Feng Wang Date: Sun, 3 May 2026 14:04:54 +0800 Subject: [PATCH 10/10] fix unit tests --- test/auto/sdk/test-quickjs.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/auto/sdk/test-quickjs.ts b/test/auto/sdk/test-quickjs.ts index b982b67..04d05da 100644 --- a/test/auto/sdk/test-quickjs.ts +++ b/test/auto/sdk/test-quickjs.ts @@ -116,7 +116,7 @@ describe('QuickJSTool script execution', () => { { file: 'data.txt', script: '2 + 3' }, ctx ); - expect(result).toBe(5); + expect(result).toBe('5'); }); test('script can process file content', async () => { @@ -124,7 +124,7 @@ describe('QuickJSTool script execution', () => { { file: 'nums.txt', script: 'fileContent.split("\\n").map(Number).reduce((a, b) => a + b, 0)' }, ctx ); - expect(result).toBe(6); + expect(result).toBe('6'); }); test('script returns undefined for statements without return value', async () => { @@ -132,7 +132,7 @@ describe('QuickJSTool script execution', () => { { file: 'data.txt', script: 'var x = 1;' }, ctx ); - expect(result).toBeUndefined(); + expect(result).toBe('undefined'); }); test('script can use string methods on fileContent', async () => { @@ -140,7 +140,7 @@ describe('QuickJSTool script execution', () => { { file: 'data.txt', script: 'fileContent.length' }, ctx ); - expect(result).toBe(13); + expect(result).toBe('13'); }); test('script can use JSON.parse and JSON.stringify', async () => {