mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
feat(ui): add Chat UI — ChatGPT-like interface with MCP tools and streaming (#22937)
* feat(ui): add chat message and conversation types * feat(ui): add useChatHistory hook for localStorage-backed conversations * feat(ui): add ConversationList sidebar component * feat(ui): add MCPConnectPicker for attaching MCP servers to chat * feat(ui): add ModelSelector dropdown for chat * feat(ui): add ChatInputBar with MCP tool attachment support * feat(ui): add MCPAppsPanel with list/detail view for MCP servers * feat(ui): add ChatMessages component; remove auto-scrollIntoView that caused scroll-lock bypass * feat(ui): add ChatPage — ChatGPT-like UI with scroll lock, MCP tools, streaming * feat(ui): add /chat route wired to ChatPage * feat(ui): remove chat from leftnav — chat accessible via navbar button * feat(ui): add Chat button to top navbar * feat(ui): add dismissible Chat UI announcement banner to Playground page * feat(proxy): add Chat UI link to Swagger description * feat(ui): add react-markdown and syntax-highlighter deps for chat UI * fix(ui): replace missing BorderOutlined import with inline stop icon div * fix(ui): apply remark-gfm plugin to ReactMarkdown for GFM support * fix(ui): remove unused isEvenRow variable in MCPAppsPanel * fix(ui): add ellipsis when truncating conversation title * fix(ui): wire search button to chats view; remove non-functional keyboard hint * fix(ui): use serverRootPath in navbar chat link for sub-path deployments * fix(ui): remove unused ChatInputBar and ModelSelector files * fix(ui): correct grid bottom-border condition for odd server count * fix(chat): move localStorage writes out of setConversations updater (React purity) * fix(chat): fix stale closure in handleEditAndResend - compute history before async state update * fix(chat): fix 4 issues in ChatMessages - array redaction, clipboard error, inline detection, remove unused ref
This commit is contained in:
parent
bc23c08d03
commit
ec600aa70a
14 changed files with 2988 additions and 7 deletions
|
|
@ -372,6 +372,9 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import (
|
|||
from litellm.proxy.management_endpoints.internal_user_endpoints import (
|
||||
user_update,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import (
|
||||
router as jwt_key_mapping_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
delete_verification_tokens,
|
||||
duration_in_seconds,
|
||||
|
|
@ -380,9 +383,6 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
|
|||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
router as key_management_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import (
|
||||
router as jwt_key_mapping_router,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
|
||||
router as mcp_management_router,
|
||||
)
|
||||
|
|
@ -661,6 +661,9 @@ ui_message += "\n\n💸 [```LiteLLM Model Cost Map```](https://models.litellm.ai
|
|||
|
||||
ui_message += f"\n\n🔎 [```LiteLLM Model Hub```]({model_hub_link}). See available models on the proxy. [**Docs**](https://docs.litellm.ai/docs/proxy/ai_hub)"
|
||||
|
||||
chat_link = f"{server_root_path}/ui/chat"
|
||||
ui_message += f"\n\n💬 [```LiteLLM Chat UI```]({chat_link}). ChatGPT-like interface for your users to chat with AI models and MCP tools."
|
||||
|
||||
custom_swagger_message = "[**Customize Swagger Docs**](https://docs.litellm.ai/docs/proxy/enterprise#swagger-docs---custom-routes--branding)"
|
||||
|
||||
### CUSTOM BRANDING [ENTERPRISE FEATURE] ###
|
||||
|
|
|
|||
295
ui/litellm-dashboard/package-lock.json
generated
295
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
|||
"@types/papaparse": "^5.3.15",
|
||||
"antd": "^5.13.2",
|
||||
"cva": "^1.0.0-beta.3",
|
||||
"dayjs": "^1.11.19",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.513.0",
|
||||
"moment": "^2.30.1",
|
||||
|
|
@ -31,6 +32,7 @@
|
|||
"react-json-view-lite": "^2.5.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
|
|
@ -8281,6 +8283,16 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -8290,6 +8302,34 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
||||
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"unist-util-is": "^6.0.0",
|
||||
"unist-util-visit-parents": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-from-markdown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
|
||||
|
|
@ -8314,6 +8354,107 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
|
||||
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-gfm-autolink-literal": "^2.0.0",
|
||||
"mdast-util-gfm-footnote": "^2.0.0",
|
||||
"mdast-util-gfm-strikethrough": "^2.0.0",
|
||||
"mdast-util-gfm-table": "^2.0.0",
|
||||
"mdast-util-gfm-task-list-item": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-autolink-literal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
|
||||
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"ccount": "^2.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"mdast-util-find-and-replace": "^3.0.0",
|
||||
"micromark-util-character": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-footnote": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
|
||||
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.1.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-strikethrough": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
|
||||
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-table": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
|
||||
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"markdown-table": "^3.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-gfm-task-list-item": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
|
||||
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"devlop": "^1.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-mdx-expression": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
||||
|
|
@ -8528,6 +8669,127 @@
|
|||
"micromark-util-types": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
|
||||
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-extension-gfm-autolink-literal": "^2.0.0",
|
||||
"micromark-extension-gfm-footnote": "^2.0.0",
|
||||
"micromark-extension-gfm-strikethrough": "^2.0.0",
|
||||
"micromark-extension-gfm-table": "^2.0.0",
|
||||
"micromark-extension-gfm-tagfilter": "^2.0.0",
|
||||
"micromark-extension-gfm-task-list-item": "^2.0.0",
|
||||
"micromark-util-combine-extensions": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-autolink-literal": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
|
||||
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-footnote": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
|
||||
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-core-commonmark": "^2.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-normalize-identifier": "^2.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-strikethrough": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
|
||||
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-util-chunked": "^2.0.0",
|
||||
"micromark-util-classify-character": "^2.0.0",
|
||||
"micromark-util-resolve-all": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-table": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
|
||||
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-tagfilter": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
|
||||
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-extension-gfm-task-list-item": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
|
||||
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"devlop": "^1.0.0",
|
||||
"micromark-factory-space": "^2.0.0",
|
||||
"micromark-util-character": "^2.0.0",
|
||||
"micromark-util-symbol": "^2.0.0",
|
||||
"micromark-util-types": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark-factory-destination": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||
|
|
@ -11006,6 +11268,24 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-gfm": "^3.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-parse": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||
|
|
@ -11039,6 +11319,21 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-stringify": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
|
||||
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-to-markdown": "^2.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"@types/papaparse": "^5.3.15",
|
||||
"antd": "^5.13.2",
|
||||
"cva": "^1.0.0-beta.3",
|
||||
"dayjs": "^1.11.19",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.513.0",
|
||||
"moment": "^2.30.1",
|
||||
|
|
@ -43,6 +44,7 @@
|
|||
"react-json-view-lite": "^2.5.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
|
|
@ -107,4 +109,4 @@
|
|||
"node": ">=18.17.0",
|
||||
"npm": ">=8.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import ComplianceUI from "@/components/playground/complianceUI/ComplianceUI";
|
|||
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { fetchProxySettings } from "@/utils/proxyUtils";
|
||||
import { MessageOutlined, CloseOutlined } from "@ant-design/icons";
|
||||
|
||||
interface ProxySettings {
|
||||
PROXY_BASE_URL?: string;
|
||||
|
|
@ -17,6 +18,7 @@ interface ProxySettings {
|
|||
export default function PlaygroundPage() {
|
||||
const { accessToken, userRole, userId, disabledPersonalKeyCreation, token } = useAuthorized();
|
||||
const [proxySettings, setProxySettings] = useState<ProxySettings | undefined>(undefined);
|
||||
const [chatBannerDismissed, setChatBannerDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeProxySettings = async () => {
|
||||
|
|
@ -35,7 +37,66 @@ export default function PlaygroundPage() {
|
|||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<TabGroup className="h-full w-full">
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{!chatBannerDismissed && (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 16,
|
||||
padding: "10px 20px",
|
||||
background: "#f0f9ff",
|
||||
borderBottom: "1px solid #bae6fd",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "#fff",
|
||||
background: "#0ea5e9",
|
||||
borderRadius: 4,
|
||||
padding: "2px 7px",
|
||||
letterSpacing: "0.08em",
|
||||
textTransform: "uppercase",
|
||||
flexShrink: 0,
|
||||
lineHeight: "18px",
|
||||
}}>
|
||||
New
|
||||
</span>
|
||||
<span style={{ flex: 1, color: "#0c4a6e", fontSize: 13.5, lineHeight: 1.5 }}>
|
||||
<strong>Chat UI</strong>
|
||||
{" "}— a ChatGPT-like interface for your users to chat with AI models and MCP tools. Share it with your team.
|
||||
</span>
|
||||
<a
|
||||
href="/chat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "5px 14px",
|
||||
borderRadius: 6,
|
||||
background: "#0ea5e9",
|
||||
color: "#fff",
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Open Chat UI →
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setChatBannerDismissed(true)}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "#64748b", padding: 4, flexShrink: 0, lineHeight: 1 }}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<CloseOutlined style={{ fontSize: 13 }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<TabGroup className="w-full" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<TabList className="mb-0">
|
||||
<Tab>Chat</Tab>
|
||||
<Tab>Compare</Tab>
|
||||
|
|
@ -72,5 +133,6 @@ export default function PlaygroundPage() {
|
|||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
19
ui/litellm-dashboard/src/app/chat/page.tsx
Normal file
19
ui/litellm-dashboard/src/app/chat/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import ChatPage from "@/components/chat/ChatPage";
|
||||
|
||||
const ChatPageRoute = () => {
|
||||
const { accessToken, userRole, userId, userEmail } = useAuthorized();
|
||||
|
||||
return (
|
||||
<ChatPage
|
||||
accessToken={accessToken ?? ""}
|
||||
userRole={userRole ?? ""}
|
||||
userId={userId ?? ""}
|
||||
userEmail={userEmail ?? ""}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPageRoute;
|
||||
577
ui/litellm-dashboard/src/components/chat/ChatMessages.tsx
Normal file
577
ui/litellm-dashboard/src/components/chat/ChatMessages.tsx
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
"use client";
|
||||
|
||||
import { ToolOutlined, CopyOutlined, CheckOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import { Collapse, Tooltip } from "antd";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import ReasoningContent from "../playground/chat_ui/ReasoningContent";
|
||||
import { ChatMessage } from "./types";
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
// Keys whose values must be redacted in tool args display
|
||||
const REDACTED_KEY_PATTERNS = /token|key|secret|password|auth/i;
|
||||
|
||||
function redactSensitiveValues(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (REDACTED_KEY_PATTERNS.test(k)) {
|
||||
result[k] = "[redacted]";
|
||||
} else if (Array.isArray(v)) {
|
||||
result[k] = v.map((item) =>
|
||||
item !== null && typeof item === "object" && !Array.isArray(item)
|
||||
? redactSensitiveValues(item as Record<string, unknown>)
|
||||
: item,
|
||||
);
|
||||
} else if (v !== null && typeof v === "object") {
|
||||
result[k] = redactSensitiveValues(v as Record<string, unknown>);
|
||||
} else {
|
||||
result[k] = v;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
// Shared markdown code renderer matching ReasoningContent style.
|
||||
// react-markdown v9 removed the `inline` prop; detect fenced blocks via language className.
|
||||
function MarkdownCodeRenderer({
|
||||
node,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"code"> & { node?: unknown }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
style={coy as Record<string, React.CSSProperties>}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded-md my-2"
|
||||
{...(props as Record<string, unknown>)}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className={`${className ?? ""} px-1.5 py-0.5 rounded bg-gray-100 text-sm font-mono`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// ------- Sub-components -------
|
||||
|
||||
interface UserBubbleProps {
|
||||
message: ChatMessage;
|
||||
onEdit?: (messageId: string, newContent: string) => void;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
function UserBubble({ message, onEdit, isStreaming }: UserBubbleProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(message.content);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.selectionStart = textareaRef.current.value.length;
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
}, [editValue, editing]);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== message.content && onEdit) {
|
||||
onEdit(message.id, trimmed);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditValue(message.content);
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end" }}>
|
||||
<div style={{
|
||||
width: "72%",
|
||||
background: "#fff",
|
||||
border: "1.5px solid #1677ff",
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 0 0 3px rgba(22,119,255,0.1)",
|
||||
}}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px 14px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
fontSize: 14,
|
||||
lineHeight: "1.6",
|
||||
color: "#111827",
|
||||
fontFamily: "inherit",
|
||||
background: "transparent",
|
||||
boxSizing: "border-box",
|
||||
minHeight: 40,
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
padding: "6px 10px 8px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => { setEditValue(message.content); setEditing(false); }}
|
||||
style={{
|
||||
padding: "4px 12px", borderRadius: 6, border: "1px solid #d1d5db",
|
||||
background: "#fff", color: "#374151", fontSize: 13, cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!editValue.trim()}
|
||||
style={{
|
||||
padding: "4px 12px", borderRadius: 6, border: "none",
|
||||
background: editValue.trim() ? "#1677ff" : "#f3f4f6",
|
||||
color: editValue.trim() ? "#fff" : "#9ca3af",
|
||||
fontSize: 13, fontWeight: 500, cursor: editValue.trim() ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
Save & Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", width: "100%" }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, maxWidth: "72%" }}>
|
||||
{/* Edit button — appears on hover, to the left of the bubble */}
|
||||
{hovered && !isStreaming && onEdit && (
|
||||
<Tooltip title="Edit message">
|
||||
<button
|
||||
onClick={() => { setEditValue(message.content); setEditing(true); }}
|
||||
style={{
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
padding: "4px 6px", borderRadius: 5,
|
||||
color: "#9ca3af", fontSize: 13, flexShrink: 0,
|
||||
display: "flex", alignItems: "center",
|
||||
transition: "color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#6b7280"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af"; }}
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#f0f2f5",
|
||||
borderRadius: 16,
|
||||
padding: "10px 14px",
|
||||
fontSize: 14,
|
||||
lineHeight: "1.6",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
color: "#111827",
|
||||
}}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AssistantBubbleProps {
|
||||
message: ChatMessage;
|
||||
isLastMessage: boolean;
|
||||
isStreaming: boolean;
|
||||
isTypingIndicator: boolean;
|
||||
}
|
||||
|
||||
function AssistantBubble({
|
||||
message,
|
||||
isLastMessage,
|
||||
isStreaming,
|
||||
isTypingIndicator,
|
||||
}: AssistantBubbleProps) {
|
||||
// Ref to control ReasoningContent collapse on streaming end.
|
||||
// ReasoningContent manages its own expanded state; we use a key to
|
||||
// remount it (collapsed by default) when streaming finishes.
|
||||
const reasoningKeyRef = useRef<number>(0);
|
||||
const prevStreamingRef = useRef<boolean>(isStreaming);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevStreamingRef.current && !isStreaming) {
|
||||
// Streaming just stopped — bump the key to remount ReasoningContent
|
||||
// with isExpanded default (false won't work since it starts expanded).
|
||||
// ReasoningContent always starts expanded on mount; we accept that
|
||||
// behaviour and leave collapse-on-finish as a best-effort remount.
|
||||
reasoningKeyRef.current += 1;
|
||||
}
|
||||
prevStreamingRef.current = isStreaming;
|
||||
}, [isStreaming]);
|
||||
|
||||
const showReasoningPlaceholder =
|
||||
isLastMessage && isStreaming && !message.reasoningContent;
|
||||
|
||||
const showReasoning =
|
||||
!!message.reasoningContent || showReasoningPlaceholder;
|
||||
|
||||
if (isTypingIndicator) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4, padding: "10px 4px" }}>
|
||||
<TypingDots />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split content at trailing "[stopped]"
|
||||
let mainContent = message.content;
|
||||
let stoppedSuffix = false;
|
||||
if (mainContent.endsWith("[stopped]")) {
|
||||
mainContent = mainContent.slice(0, -"[stopped]".length);
|
||||
stoppedSuffix = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", maxWidth: "80%" }}>
|
||||
{showReasoning && (
|
||||
showReasoningPlaceholder ? (
|
||||
<ThinkingPlaceholder />
|
||||
) : (
|
||||
<ReasoningContent
|
||||
key={reasoningKeyRef.current}
|
||||
reasoningContent={message.reasoningContent!}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
lineHeight: "1.7",
|
||||
color: "#111827",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code: MarkdownCodeRenderer as React.ComponentType<React.ComponentPropsWithoutRef<"code">>,
|
||||
}}
|
||||
>
|
||||
{mainContent}
|
||||
</ReactMarkdown>
|
||||
{stoppedSuffix && (
|
||||
<span style={{ color: "#9ca3af", fontStyle: "italic" }}> [stopped]</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CopyButton text={mainContent} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}).catch(() => {
|
||||
message.error("Failed to copy to clipboard");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 6 }}>
|
||||
<Tooltip title={copied ? "Copied!" : "Copy"}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "4px 6px",
|
||||
borderRadius: 5,
|
||||
color: copied ? "#52c41a" : "#9ca3af",
|
||||
fontSize: 13,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
transition: "color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!copied) (e.currentTarget as HTMLButtonElement).style.color = "#6b7280";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!copied) (e.currentTarget as HTMLButtonElement).style.color = "#9ca3af";
|
||||
}}
|
||||
>
|
||||
{copied ? <CheckOutlined /> : <CopyOutlined />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingPlaceholder() {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes thinking-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.chat-thinking-text {
|
||||
animation: thinking-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 10px",
|
||||
marginBottom: 8,
|
||||
backgroundColor: "#f9fafb",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
<span className="chat-thinking-text">Thinking...</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TypingDots() {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes chat-typing-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
30% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
.chat-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background-color: #9ca3af;
|
||||
animation: chat-typing-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
.chat-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.chat-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
`}</style>
|
||||
<div className="chat-dot" />
|
||||
<div className="chat-dot" />
|
||||
<div className="chat-dot" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCardProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
function ToolCard({ message }: ToolCardProps) {
|
||||
const redactedArgs =
|
||||
message.toolArgs ? redactSensitiveValues(message.toolArgs) : undefined;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: "80%" }}>
|
||||
<Collapse
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: "#fafafa",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Panel
|
||||
header={
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13 }}>
|
||||
<ToolOutlined style={{ color: "#6b7280" }} />
|
||||
<span style={{ color: "#374151", fontWeight: 500 }}>
|
||||
{message.toolName ?? "Tool call"}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
key="tool"
|
||||
>
|
||||
{redactedArgs !== undefined && (
|
||||
<div style={{ marginBottom: message.toolResult ? 12 : 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "#9ca3af",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Arguments
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "8px 10px",
|
||||
backgroundColor: "#f3f4f6",
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(redactedArgs, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.toolResult && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "#9ca3af",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Result
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "#374151",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
}}
|
||||
>
|
||||
{message.toolResult}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</Collapse>
|
||||
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------- Main component -------
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
onEditMessage?: (messageId: string, newContent: string) => void;
|
||||
}
|
||||
|
||||
const ChatMessages: React.FC<Props> = ({ messages, isStreaming, onEditMessage }) => {
|
||||
// Scrolling is managed by ChatPage.tsx (scroll lock during streaming,
|
||||
// scroll-to-bottom on new message). No auto-scroll here.
|
||||
|
||||
const lastIndex = messages.length - 1;
|
||||
const lastMsg = messages[lastIndex] ?? null;
|
||||
const isTypingIndicator =
|
||||
isStreaming &&
|
||||
lastMsg !== null &&
|
||||
lastMsg.role === "assistant" &&
|
||||
lastMsg.content === "";
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{messages.map((msg, idx) => {
|
||||
const isLastMessage = idx === lastIndex;
|
||||
|
||||
if (msg.role === "user") {
|
||||
return <UserBubble key={msg.id} message={msg} onEdit={onEditMessage} isStreaming={isStreaming} />;
|
||||
}
|
||||
|
||||
if (msg.role === "tool") {
|
||||
return <ToolCard key={msg.id} message={msg} />;
|
||||
}
|
||||
|
||||
// assistant
|
||||
return (
|
||||
<AssistantBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isLastMessage={isLastMessage}
|
||||
isStreaming={isStreaming}
|
||||
isTypingIndicator={isLastMessage && isTypingIndicator}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessages;
|
||||
823
ui/litellm-dashboard/src/components/chat/ChatPage.tsx
Normal file
823
ui/litellm-dashboard/src/components/chat/ChatPage.tsx
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState, useLayoutEffect } from "react";
|
||||
import { Select, Tooltip, Skeleton, Popover, message } from "antd";
|
||||
import {
|
||||
SettingOutlined,
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
SearchOutlined,
|
||||
MessageOutlined,
|
||||
AppstoreOutlined,
|
||||
ArrowLeftOutlined,
|
||||
DownOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useChatHistory } from "./useChatHistory";
|
||||
import ConversationList from "./ConversationList";
|
||||
import ChatMessages from "./ChatMessages";
|
||||
import MCPConnectPicker from "./MCPConnectPicker";
|
||||
import MCPAppsPanel from "./MCPAppsPanel";
|
||||
import { fetchAvailableModels } from "../playground/llm_calls/fetch_models";
|
||||
import { makeOpenAIChatCompletionRequest } from "../playground/llm_calls/chat_completion";
|
||||
import { serverRootPath } from "@/components/networking";
|
||||
|
||||
interface ChatPageProps {
|
||||
accessToken: string;
|
||||
userRole: string;
|
||||
userId: string;
|
||||
userEmail?: string;
|
||||
}
|
||||
|
||||
const SUGGESTIONS = ["Write", "Learn", "Code", "Brainstorm"];
|
||||
|
||||
function getGreeting(): string {
|
||||
const h = new Date().getHours();
|
||||
if (h >= 5 && h < 12) return "Good morning";
|
||||
if (h >= 12 && h < 17) return "Good afternoon";
|
||||
return "Good evening";
|
||||
}
|
||||
|
||||
const LOCALSTORAGE_MODEL_KEY = "litellm_chat_selected_model";
|
||||
|
||||
// Build the dashboard root URL
|
||||
function getDashboardUrl(): string {
|
||||
const base = process.env.NEXT_PUBLIC_BASE_URL ?? "";
|
||||
const trimmed = base.replace(/^\/+|\/+$/g, "");
|
||||
const uiPath = trimmed ? `/${trimmed}/` : "/";
|
||||
if (serverRootPath && serverRootPath !== "/") {
|
||||
const cleanRoot = serverRootPath.replace(/\/+$/, "");
|
||||
const cleanUi = uiPath.replace(/^\/+/, "");
|
||||
return `${cleanRoot}/${cleanUi}`;
|
||||
}
|
||||
return uiPath;
|
||||
}
|
||||
|
||||
const ChatPage: React.FC<ChatPageProps> = ({ accessToken, userRole, userId, userEmail }) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const activeConversationId = searchParams.get("id");
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(true);
|
||||
const [selectedMCPServers, setSelectedMCPServers] = useState<string[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [sidebarView, setSidebarView] = useState<"chats" | "apps">("chats");
|
||||
const [storageBannerDismissed, setStorageBannerDismissed] = useState(false);
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messagesScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const streamScrollLock = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
conversations,
|
||||
activeConversation,
|
||||
storageUnavailable,
|
||||
staleId,
|
||||
createConversation,
|
||||
appendMessage,
|
||||
updateLastAssistantMessage,
|
||||
truncateAfterMessage,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
} = useChatHistory(activeConversationId);
|
||||
|
||||
// Load models
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
setIsLoadingModels(true);
|
||||
fetchAvailableModels(accessToken)
|
||||
.then((data) => {
|
||||
const names = (data || []).map((m: { model_group?: string }) => m.model_group ?? "").filter(Boolean);
|
||||
setModels(names);
|
||||
const saved = localStorage.getItem(LOCALSTORAGE_MODEL_KEY);
|
||||
if (saved && names.includes(saved)) {
|
||||
setSelectedModel(saved);
|
||||
} else if (names.length > 0) {
|
||||
setSelectedModel(names[0]);
|
||||
localStorage.setItem(LOCALSTORAGE_MODEL_KEY, names[0]);
|
||||
}
|
||||
})
|
||||
.catch(() => message.error("Could not load models"))
|
||||
.finally(() => setIsLoadingModels(false));
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (staleId) router.replace("/chat");
|
||||
}, [staleId, router]);
|
||||
|
||||
|
||||
const handleModelChange = (val: string) => {
|
||||
setSelectedModel(val);
|
||||
localStorage.setItem(LOCALSTORAGE_MODEL_KEY, val);
|
||||
};
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string, historyOverride?: Array<{ role: "user" | "assistant"; content: string }>) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || !selectedModel || isStreaming) return;
|
||||
setInputText("");
|
||||
|
||||
let convId = activeConversationId;
|
||||
if (!convId) {
|
||||
convId = createConversation(selectedModel);
|
||||
router.push(`/chat?id=${convId}`);
|
||||
}
|
||||
|
||||
appendMessage(convId, { role: "user", content: trimmed });
|
||||
appendMessage(convId, { role: "assistant", content: "" });
|
||||
|
||||
setIsStreaming(true);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const history = [
|
||||
...(historyOverride ?? (activeConversation?.messages ?? [])
|
||||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||
.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
content: m.content,
|
||||
}))),
|
||||
{ role: "user" as const, content: trimmed },
|
||||
];
|
||||
|
||||
let accumulatedContent = "";
|
||||
let accumulatedReasoning = "";
|
||||
|
||||
try {
|
||||
await makeOpenAIChatCompletionRequest(
|
||||
history,
|
||||
(chunk: string) => {
|
||||
accumulatedContent += chunk;
|
||||
updateLastAssistantMessage(convId!, { content: accumulatedContent });
|
||||
},
|
||||
selectedModel,
|
||||
accessToken,
|
||||
undefined,
|
||||
abortControllerRef.current.signal,
|
||||
(rc: string) => {
|
||||
accumulatedReasoning += rc;
|
||||
updateLastAssistantMessage(convId!, { reasoningContent: accumulatedReasoning });
|
||||
},
|
||||
undefined, undefined, undefined, undefined, undefined, undefined,
|
||||
selectedMCPServers.length > 0 ? selectedMCPServers : undefined,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
updateLastAssistantMessage(convId!, {
|
||||
content: accumulatedContent + " [stopped]",
|
||||
});
|
||||
} else {
|
||||
updateLastAssistantMessage(convId!, {
|
||||
content: "[Something went wrong. The partial response has been saved.]",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[activeConversationId, activeConversation, selectedModel, selectedMCPServers, accessToken,
|
||||
createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
const handleEditAndResend = useCallback(
|
||||
(messageId: string, newContent: string) => {
|
||||
if (!activeConversationId || isStreaming) return;
|
||||
// Compute the truncated history synchronously before the async state update lands,
|
||||
// so handleSend receives the correct pre-edit context rather than the stale closure value.
|
||||
const msgs = activeConversation?.messages ?? [];
|
||||
const idx = msgs.findIndex((m) => m.id === messageId);
|
||||
const priorMessages = (idx === -1 ? msgs : msgs.slice(0, idx))
|
||||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||
.map((m) => ({ role: m.role as "user" | "assistant", content: m.content }));
|
||||
truncateAfterMessage(activeConversationId, messageId);
|
||||
handleSend(newContent, priorMessages);
|
||||
},
|
||||
[activeConversationId, isStreaming, activeConversation, truncateAfterMessage, handleSend],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend(inputText);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = `${Math.min(ta.scrollHeight, 180)}px`;
|
||||
}, [inputText]);
|
||||
|
||||
// Track scroll position to show/hide scroll-to-bottom button
|
||||
useEffect(() => {
|
||||
const el = messagesScrollRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
setShowScrollButton(distFromBottom > 120);
|
||||
if (streamScrollLock.current !== null) {
|
||||
streamScrollLock.current = el.scrollTop; // track user-initiated scroll
|
||||
}
|
||||
};
|
||||
el.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", onScroll);
|
||||
}, [activeConversation]);
|
||||
|
||||
// Start/stop the scroll lock when streaming begins/ends
|
||||
useEffect(() => {
|
||||
const el = messagesScrollRef.current;
|
||||
if (isStreaming) {
|
||||
streamScrollLock.current = el?.scrollTop ?? 0;
|
||||
} else {
|
||||
streamScrollLock.current = null;
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
// After every render during streaming, restore the locked scroll position
|
||||
useLayoutEffect(() => {
|
||||
if (streamScrollLock.current === null) return;
|
||||
const el = messagesScrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = streamScrollLock.current;
|
||||
});
|
||||
|
||||
// Scroll to bottom only when message COUNT increases (new message added)
|
||||
const prevMsgCountRef = useRef(0);
|
||||
useLayoutEffect(() => {
|
||||
const count = activeConversation?.messages?.length ?? 0;
|
||||
const prev = prevMsgCountRef.current;
|
||||
prevMsgCountRef.current = count;
|
||||
if (count > prev) {
|
||||
const el = messagesScrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [activeConversation?.messages]);
|
||||
|
||||
const showBlankState = !activeConversation || activeConversation.messages.length === 0;
|
||||
const displayName = userEmail?.split("@")[0] ?? userId ?? "";
|
||||
const greeting = displayName ? `${getGreeting()}, ${displayName}` : getGreeting();
|
||||
const dashboardUrl = getDashboardUrl();
|
||||
|
||||
// ---- Sidebar nav item renderer (inline, not a function-in-function) ----
|
||||
const sidebarNavItem = (
|
||||
icon: React.ReactNode,
|
||||
label: string,
|
||||
onClick: () => void,
|
||||
active = false,
|
||||
kbd?: string,
|
||||
) => (
|
||||
<Tooltip title={sidebarCollapsed ? label : undefined} placement="right" key={label}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
width: "100%",
|
||||
borderRadius: 7,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: active ? "#e8f4ff" : "transparent",
|
||||
color: active ? "#1677ff" : "#374151",
|
||||
textAlign: "left",
|
||||
fontSize: 14,
|
||||
justifyContent: sidebarCollapsed ? "center" : "flex-start",
|
||||
transition: "background 0.12s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) (e.currentTarget as HTMLButtonElement).style.background = "#f5f5f5";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = active ? "#e8f4ff" : "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16, flexShrink: 0 }}>{icon}</span>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<span style={{ flex: 1 }}>{label}</span>
|
||||
{kbd && <span style={{ fontSize: 11, color: "#9ca3af" }}>{kbd}</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
height: "100vh",
|
||||
width: "100vw",
|
||||
background: "#ffffff",
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
|
||||
{/* ===== LEFT SIDEBAR ===== */}
|
||||
<div style={{
|
||||
width: sidebarCollapsed ? 56 : 260,
|
||||
flexShrink: 0,
|
||||
background: "#f9fafb",
|
||||
borderRight: "1px solid #e5e7eb",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
transition: "width 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}>
|
||||
|
||||
{/* Sidebar header: logo + collapse button */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "12px 10px",
|
||||
justifyContent: sidebarCollapsed ? "center" : "space-between",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{!sidebarCollapsed && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<img
|
||||
src="/assets/logos/litellm_logo.jpg"
|
||||
alt="LiteLLM"
|
||||
width={26}
|
||||
height={26}
|
||||
style={{ borderRadius: 6, objectFit: "cover", flexShrink: 0 }}
|
||||
/>
|
||||
<span style={{ fontWeight: 700, fontSize: 15, color: "#111827", letterSpacing: "-0.01em" }}>
|
||||
LiteLLM
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} placement="right">
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed((v) => !v)}
|
||||
style={{
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
padding: 6, borderRadius: 7, color: "#6b7280", fontSize: 16,
|
||||
display: "flex", alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Sidebar nav buttons */}
|
||||
<div style={{ padding: "0 8px 4px", flexShrink: 0 }}>
|
||||
{sidebarNavItem(<EditOutlined />, "New chat", () => router.push("/chat"))}
|
||||
{sidebarNavItem(<SearchOutlined />, "Search chats", () => setSidebarView("chats"))}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: "#e5e7eb", margin: "4px 8px", flexShrink: 0 }} />
|
||||
|
||||
{/* Chats / Apps tabs + Back to console */}
|
||||
<div style={{ padding: "4px 8px", flexShrink: 0 }}>
|
||||
{sidebarNavItem(<MessageOutlined />, "Chats", () => setSidebarView("chats"), sidebarView === "chats")}
|
||||
{sidebarNavItem(<AppstoreOutlined />, "Apps", () => setSidebarView("apps"), sidebarView === "apps")}
|
||||
<Tooltip title={sidebarCollapsed ? "Back to Developer Console UI" : undefined} placement="right">
|
||||
<a
|
||||
href={dashboardUrl}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 10px",
|
||||
width: "100%",
|
||||
borderRadius: 7,
|
||||
color: "#6b7280",
|
||||
textDecoration: "none",
|
||||
fontSize: 14,
|
||||
justifyContent: sidebarCollapsed ? "center" : "flex-start",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = "#f5f5f5";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<ArrowLeftOutlined style={{ fontSize: 16, flexShrink: 0 }} />
|
||||
{!sidebarCollapsed && (
|
||||
<span>Back to Developer Console UI</span>
|
||||
)}
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: "#e5e7eb", margin: "4px 8px", flexShrink: 0 }} />
|
||||
|
||||
{/* Sidebar content — only conversation list, only when in chats view and expanded */}
|
||||
{!sidebarCollapsed && sidebarView === "chats" && (
|
||||
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onSelect={(id) => router.push(`/chat?id=${id}`)}
|
||||
onDelete={deleteConversation}
|
||||
onNewChat={() => router.push("/chat")}
|
||||
onRename={renameConversation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* ===== MAIN AREA ===== */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
|
||||
|
||||
{/* Top bar — clean, minimal like ChatGPT */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "8px 16px",
|
||||
flexShrink: 0,
|
||||
borderBottom: "1px solid #f0f0f0",
|
||||
background: "#fff",
|
||||
height: 48,
|
||||
}}>
|
||||
{/* Left: model selector */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{isLoadingModels ? (
|
||||
<Skeleton.Input active style={{ width: 160, height: 28 }} />
|
||||
) : (
|
||||
<Select
|
||||
value={selectedModel || undefined}
|
||||
onChange={handleModelChange}
|
||||
showSearch
|
||||
placeholder="Select model"
|
||||
style={{ width: 220 }}
|
||||
size="middle"
|
||||
variant="borderless"
|
||||
options={models.map((m) => ({
|
||||
value: m,
|
||||
label: m.length > 35 ? m.slice(0, 35) + "…" : m,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: settings */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<Tooltip title="Settings">
|
||||
<button style={{
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
padding: 7, borderRadius: 7, color: "#6b7280", fontSize: 16,
|
||||
display: "flex", alignItems: "center",
|
||||
}}>
|
||||
<SettingOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage warning banner */}
|
||||
{storageUnavailable && !storageBannerDismissed && (
|
||||
<div style={{
|
||||
background: "#fffbe6", borderBottom: "1px solid #ffe58f",
|
||||
padding: "6px 20px", fontSize: 13, color: "#874d00",
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
}}>
|
||||
<span>Chat history won't be saved in this browser session.</span>
|
||||
<button onClick={() => setStorageBannerDismissed(true)}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "#874d00" }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden", display: "flex", flexDirection: "column", background: "#fff" }}>
|
||||
{/* ---- Apps page view ---- */}
|
||||
{sidebarView === "apps" ? (
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", maxWidth: 800, margin: "0 auto", width: "100%", padding: "32px 24px" }}>
|
||||
<MCPAppsPanel
|
||||
accessToken={accessToken}
|
||||
selectedServers={selectedMCPServers}
|
||||
onChange={setSelectedMCPServers}
|
||||
/>
|
||||
</div>
|
||||
) : showBlankState ? (
|
||||
/* ---- Blank state ---- */
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 24px 80px",
|
||||
}}>
|
||||
{/* Greeting */}
|
||||
<h1 style={{
|
||||
margin: "0 0 32px",
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
color: "#111827",
|
||||
fontFamily: "inherit",
|
||||
letterSpacing: "-0.01em",
|
||||
textAlign: "center",
|
||||
}}>
|
||||
{greeting}
|
||||
</h1>
|
||||
|
||||
{/* Input card */}
|
||||
<div style={{
|
||||
width: "100%",
|
||||
maxWidth: 680,
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #e5e7eb",
|
||||
boxShadow: "0 1px 6px rgba(0,0,0,0.06)",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="How can I help you today?"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 80,
|
||||
padding: "20px 20px 8px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
fontSize: 15,
|
||||
color: "#111827",
|
||||
background: "transparent",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "8px 12px 12px",
|
||||
borderTop: "1px solid #f3f4f6",
|
||||
}}>
|
||||
<Popover
|
||||
open={mcpPopoverOpen}
|
||||
onOpenChange={setMcpPopoverOpen}
|
||||
content={
|
||||
<MCPConnectPicker
|
||||
accessToken={accessToken}
|
||||
selectedServers={selectedMCPServers}
|
||||
onChange={setSelectedMCPServers}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
placement="topLeft"
|
||||
>
|
||||
<Tooltip title="Attach tools">
|
||||
<button style={{
|
||||
background: "none", border: "1px solid #d1d5db",
|
||||
borderRadius: 6, padding: "5px 10px",
|
||||
cursor: "pointer", fontSize: 14, color: "#6b7280",
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
}}>
|
||||
<PlusOutlined />
|
||||
{selectedMCPServers.length > 0 && (
|
||||
<span style={{ fontSize: 12, color: "#1677ff", fontWeight: 500 }}>
|
||||
{selectedMCPServers.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: "#9ca3af", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{selectedModel || "No model"}
|
||||
</span>
|
||||
{isStreaming ? (
|
||||
<button onClick={handleStop} style={{
|
||||
background: "none", border: "1.5px solid #d1d5db", borderRadius: "50%",
|
||||
width: 32, height: 32, cursor: "pointer", color: "#374151",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ width: 10, height: 10, background: "#374151", borderRadius: 2 }} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSend(inputText)}
|
||||
disabled={!inputText.trim() || isLoadingModels || !selectedModel}
|
||||
style={{
|
||||
background: inputText.trim() && selectedModel ? "#1677ff" : "#f3f4f6",
|
||||
border: "none", borderRadius: 7,
|
||||
padding: "7px 16px", cursor: inputText.trim() && selectedModel ? "pointer" : "not-allowed",
|
||||
color: inputText.trim() && selectedModel ? "#fff" : "#9ca3af",
|
||||
fontSize: 14, fontWeight: 500,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestion chips */}
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 14, flexWrap: "wrap", justifyContent: "center" }}>
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setInputText(s + ": ")}
|
||||
style={{
|
||||
background: "#f9fafb",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 20,
|
||||
padding: "7px 16px",
|
||||
fontSize: 14,
|
||||
color: "#374151",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "#f3f4f6";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "#f9fafb";
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
/* ---- Active conversation ---- */
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", maxWidth: 760, margin: "0 auto", width: "100%", padding: "0 24px", position: "relative" }}>
|
||||
<div ref={messagesScrollRef} style={{ flex: 1, minHeight: 0, overflow: "auto", paddingTop: 24, overflowAnchor: "none" }}>
|
||||
<ChatMessages
|
||||
messages={activeConversation.messages}
|
||||
isStreaming={isStreaming}
|
||||
onEditMessage={handleEditAndResend}
|
||||
/>
|
||||
</div>
|
||||
{showScrollButton && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const el = messagesScrollRef.current;
|
||||
if (el) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
if (streamScrollLock.current !== null) {
|
||||
streamScrollLock.current = el.scrollHeight;
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 100,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.75)",
|
||||
backdropFilter: "blur(6px)",
|
||||
WebkitBackdropFilter: "blur(6px)",
|
||||
border: "1px solid rgba(0,0,0,0.1)",
|
||||
boxShadow: "0 1px 4px rgba(0,0,0,0.08)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#6b7280",
|
||||
zIndex: 10,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.95)"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.75)"; }}
|
||||
aria-label="Scroll to bottom"
|
||||
>
|
||||
<DownOutlined style={{ fontSize: 12 }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Input bar (in conversation) */}
|
||||
<div style={{ padding: "12px 0 24px" }}>
|
||||
<div style={{
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
border: "1px solid #e5e7eb",
|
||||
boxShadow: "0 1px 6px rgba(0,0,0,0.06)",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Send a message..."
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 52,
|
||||
padding: "16px 20px 8px",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
fontSize: 15,
|
||||
color: "#111827",
|
||||
background: "transparent",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "4px 12px 10px",
|
||||
borderTop: "1px solid #f3f4f6",
|
||||
}}>
|
||||
<Popover
|
||||
open={mcpPopoverOpen}
|
||||
onOpenChange={setMcpPopoverOpen}
|
||||
content={
|
||||
<MCPConnectPicker
|
||||
accessToken={accessToken}
|
||||
selectedServers={selectedMCPServers}
|
||||
onChange={setSelectedMCPServers}
|
||||
/>
|
||||
}
|
||||
trigger="click"
|
||||
placement="topLeft"
|
||||
>
|
||||
<button style={{
|
||||
background: "none", border: "1px solid #d1d5db",
|
||||
borderRadius: 6, padding: "5px 10px",
|
||||
cursor: "pointer", fontSize: 14, color: "#6b7280",
|
||||
display: "flex", alignItems: "center", gap: 4,
|
||||
}}>
|
||||
<PlusOutlined />
|
||||
{selectedMCPServers.length > 0 && (
|
||||
<span style={{ fontSize: 12, color: "#1677ff", fontWeight: 500 }}>
|
||||
{selectedMCPServers.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Popover>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: "#9ca3af" }}>
|
||||
{selectedMCPServers.length > 0 ? `${selectedMCPServers.length} tool${selectedMCPServers.length > 1 ? "s" : ""} connected` : ""}
|
||||
</span>
|
||||
{isStreaming ? (
|
||||
<button onClick={handleStop} style={{
|
||||
background: "none", border: "1.5px solid #d1d5db", borderRadius: "50%",
|
||||
width: 32, height: 32, cursor: "pointer", color: "#374151",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
flexShrink: 0, transition: "border-color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = "#9ca3af"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.borderColor = "#d1d5db"; }}
|
||||
>
|
||||
<div style={{ width: 10, height: 10, background: "#374151", borderRadius: 2 }} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSend(inputText)}
|
||||
disabled={!inputText.trim() || isLoadingModels || !selectedModel}
|
||||
style={{
|
||||
background: inputText.trim() && selectedModel ? "#1677ff" : "#f3f4f6",
|
||||
border: "none", borderRadius: 7,
|
||||
padding: "7px 16px", cursor: inputText.trim() && selectedModel ? "pointer" : "not-allowed",
|
||||
color: inputText.trim() && selectedModel ? "#fff" : "#9ca3af",
|
||||
fontSize: 14, fontWeight: 500,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPage;
|
||||
483
ui/litellm-dashboard/src/components/chat/ConversationList.tsx
Normal file
483
ui/litellm-dashboard/src/components/chat/ConversationList.tsx
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
Avatar,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
MessageOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import { Conversation } from "./types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onNewChat: () => void;
|
||||
onRename: (id: string, newTitle: string) => void;
|
||||
}
|
||||
|
||||
// ---- Date grouping helpers ----
|
||||
|
||||
type DateGroup = "Today" | "Yesterday" | "Last 7 Days" | "Older";
|
||||
|
||||
const getDateGroup = (timestamp: number): DateGroup => {
|
||||
const now = dayjs();
|
||||
const date = dayjs(timestamp);
|
||||
|
||||
if (date.isSame(now, "day")) return "Today";
|
||||
if (date.isSame(now.subtract(1, "day"), "day")) return "Yesterday";
|
||||
if (date.isAfter(now.subtract(7, "day"))) return "Last 7 Days";
|
||||
return "Older";
|
||||
};
|
||||
|
||||
const DATE_GROUP_ORDER: DateGroup[] = ["Today", "Yesterday", "Last 7 Days", "Older"];
|
||||
|
||||
interface GroupedConversations {
|
||||
group: DateGroup;
|
||||
items: Conversation[];
|
||||
}
|
||||
|
||||
const groupConversations = (conversations: Conversation[]): GroupedConversations[] => {
|
||||
const map = new Map<DateGroup, Conversation[]>();
|
||||
|
||||
for (const conv of conversations) {
|
||||
const group = getDateGroup(conv.updatedAt);
|
||||
if (!map.has(group)) map.set(group, []);
|
||||
map.get(group)!.push(conv);
|
||||
}
|
||||
|
||||
return DATE_GROUP_ORDER.filter((g) => map.has(g)).map((g) => ({
|
||||
group: g,
|
||||
items: map.get(g)!,
|
||||
}));
|
||||
};
|
||||
|
||||
// ---- Single conversation row ----
|
||||
|
||||
interface ConversationRowProps {
|
||||
conv: Conversation;
|
||||
isActive: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, newTitle: string) => void;
|
||||
}
|
||||
|
||||
const ConversationRow: React.FC<ConversationRowProps> = ({
|
||||
conv,
|
||||
isActive,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
}) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(conv.title);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const startEditing = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditValue(conv.title);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const commitRename = () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== conv.title) {
|
||||
onRename(conv.id, trimmed);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditValue(conv.title);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitRename();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelEditing();
|
||||
}
|
||||
};
|
||||
|
||||
const truncatedTitle =
|
||||
conv.title.length > 40 ? conv.title.slice(0, 40) + "…" : conv.title;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => !editing && onSelect(conv.id)}
|
||||
className="conversation-row group"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "6px 8px",
|
||||
borderRadius: 6,
|
||||
cursor: editing ? "default" : "pointer",
|
||||
backgroundColor: isActive ? "#e6f4ff" : "transparent",
|
||||
transition: "background-color 0.15s",
|
||||
minHeight: 34,
|
||||
position: "relative",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = "#f5f5f5";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{editing ? (
|
||||
<Input
|
||||
ref={(node) => {
|
||||
inputRef.current = node?.input ?? null;
|
||||
}}
|
||||
size="small"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={commitRename}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ flex: 1, fontSize: 13 }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: isActive ? "#1677ff" : "#333",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
}}
|
||||
title={conv.title}
|
||||
>
|
||||
{truncatedTitle}
|
||||
</Text>
|
||||
|
||||
{/* Action icons — visible only on hover via CSS opacity */}
|
||||
<div
|
||||
className="conversation-actions"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
opacity: 0,
|
||||
transition: "opacity 0.15s",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip title="Rename">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined style={{ fontSize: 12 }} />}
|
||||
onClick={startEditing}
|
||||
style={{ width: 22, height: 22, padding: 0, minWidth: 22 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Delete this conversation?"
|
||||
onConfirm={() => onDelete(conv.id)}
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Tooltip title="Delete">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined style={{ fontSize: 12 }} />}
|
||||
style={{ width: 22, height: 22, padding: 0, minWidth: 22 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Cmd+K search modal ----
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean;
|
||||
conversations: Conversation[];
|
||||
onSelect: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SearchModal: React.FC<SearchModalProps> = ({
|
||||
open,
|
||||
conversations,
|
||||
onSelect,
|
||||
onClose,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery("");
|
||||
}, [open]);
|
||||
|
||||
const filtered = query.trim()
|
||||
? conversations.filter((c) =>
|
||||
c.title.toLowerCase().includes(query.trim().toLowerCase())
|
||||
)
|
||||
: conversations;
|
||||
|
||||
const handleSelect = (id: string) => {
|
||||
onSelect(id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
title={null}
|
||||
width={480}
|
||||
styles={{ body: { padding: "16px 16px 8px" } }}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
prefix={<SearchOutlined style={{ color: "#bbb" }} />}
|
||||
placeholder="Search conversations…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{ marginBottom: 12 }}
|
||||
allowClear
|
||||
/>
|
||||
|
||||
<div style={{ maxHeight: 320, overflowY: "auto" }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "24px 0", color: "#999" }}>
|
||||
No conversations found
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((conv) => {
|
||||
const truncated =
|
||||
conv.title.length > 55 ? conv.title.slice(0, 55) + "…" : conv.title;
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => handleSelect(conv.id)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = "#f0f5ff";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent";
|
||||
}}
|
||||
>
|
||||
<MessageOutlined style={{ color: "#999", flexShrink: 0 }} />
|
||||
<Text style={{ fontSize: 13 }}>{truncated}</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 11, marginLeft: "auto", flexShrink: 0 }}
|
||||
>
|
||||
{dayjs(conv.updatedAt).format("MMM D")}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- Main ConversationList component ----
|
||||
|
||||
const ConversationList: React.FC<Props> = ({
|
||||
conversations,
|
||||
activeConversationId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onNewChat,
|
||||
onRename,
|
||||
}) => {
|
||||
const [searchModalOpen, setSearchModalOpen] = useState(false);
|
||||
|
||||
// Cmd+K / Ctrl+K listener
|
||||
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setSearchModalOpen((prev) => !prev);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleGlobalKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleGlobalKeyDown);
|
||||
}, [handleGlobalKeyDown]);
|
||||
|
||||
const grouped = groupConversations(conversations);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hover-reveal CSS for action icons */}
|
||||
<style>{`
|
||||
.conversation-row:hover .conversation-actions {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Top: New Chat button */}
|
||||
<div style={{ padding: "12px 10px 8px" }}>
|
||||
<Tooltip
|
||||
title="Chats are saved locally in this browser. All requests are logged in Spend → Logs."
|
||||
placement="right"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onNewChat}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
New Chat
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Conversation list (scrollable) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "0 6px",
|
||||
}}
|
||||
>
|
||||
{grouped.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
color: "#bbb",
|
||||
fontSize: 12,
|
||||
marginTop: 32,
|
||||
padding: "0 12px",
|
||||
}}
|
||||
>
|
||||
No conversations yet.
|
||||
<br />
|
||||
Start a new chat above.
|
||||
</div>
|
||||
) : (
|
||||
grouped.map(({ group, items }) => (
|
||||
<div key={group} style={{ marginBottom: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "#999",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
padding: "8px 8px 4px",
|
||||
}}
|
||||
>
|
||||
{group}
|
||||
</div>
|
||||
{items.map((conv) => (
|
||||
<ConversationRow
|
||||
key={conv.id}
|
||||
conv={conv}
|
||||
isActive={conv.id === activeConversationId}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: user avatar placeholder */}
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size={28}
|
||||
icon={<UserOutlined />}
|
||||
style={{ backgroundColor: "#e0e7ff", color: "#4f46e5", flexShrink: 0 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "#555",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
My Account
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cmd+K search modal */}
|
||||
<SearchModal
|
||||
open={searchModalOpen}
|
||||
conversations={conversations}
|
||||
onSelect={onSelect}
|
||||
onClose={() => setSearchModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationList;
|
||||
274
ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx
Normal file
274
ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Switch, Spin, Input, Button } from "antd";
|
||||
import { SearchOutlined, ArrowLeftOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { fetchMCPServers, listMCPTools } from "../networking";
|
||||
import { MCPServer } from "../mcp_tools/types";
|
||||
import { message } from "antd";
|
||||
|
||||
interface Props {
|
||||
accessToken: string;
|
||||
selectedServers: string[];
|
||||
onChange: (servers: string[]) => void;
|
||||
}
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
"#1677ff", "#52c41a", "#fa8c16", "#eb2f96", "#722ed1",
|
||||
"#13c2c2", "#fa541c", "#2f54eb", "#a0d911", "#faad14",
|
||||
];
|
||||
|
||||
function getAvatarColor(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
type TabKey = "all" | "connected";
|
||||
|
||||
const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
|
||||
const [servers, setServers] = useState<MCPServer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
|
||||
const [detailServer, setDetailServer] = useState<MCPServer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchMCPServers(accessToken)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const list: MCPServer[] = Array.isArray(data) ? data : (data?.data ?? []);
|
||||
setServers(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setServers([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [accessToken]);
|
||||
|
||||
const handleToggle = async (serverName: string, checked: boolean) => {
|
||||
if (!checked) {
|
||||
onChange(selectedServers.filter((s) => s !== serverName));
|
||||
return;
|
||||
}
|
||||
setTogglingOn((prev) => new Set(prev).add(serverName));
|
||||
try {
|
||||
const result = await listMCPTools(accessToken, serverName);
|
||||
if (result?.error) {
|
||||
message.warning(`Could not load tools for ${serverName}`);
|
||||
return;
|
||||
}
|
||||
onChange([...selectedServers, serverName]);
|
||||
} catch {
|
||||
message.warning(`Could not load tools for ${serverName}`);
|
||||
} finally {
|
||||
setTogglingOn((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(serverName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nameOf = (s: MCPServer) => s.server_name ?? s.alias ?? s.server_id;
|
||||
|
||||
const filtered = servers.filter((s) => {
|
||||
const name = nameOf(s);
|
||||
const matchesQuery = !query.trim() ||
|
||||
name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(s.description ?? "").toLowerCase().includes(query.toLowerCase());
|
||||
const matchesTab = activeTab === "all" || selectedServers.includes(name);
|
||||
return matchesQuery && matchesTab;
|
||||
});
|
||||
|
||||
const connectedCount = servers.filter((s) => selectedServers.includes(nameOf(s))).length;
|
||||
|
||||
// ── Detail view ──
|
||||
if (detailServer) {
|
||||
const name = nameOf(detailServer);
|
||||
const isConnected = selectedServers.includes(name);
|
||||
const isTogglingOn = togglingOn.has(name);
|
||||
const color = getAvatarColor(name);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%" }}>
|
||||
{/* Back */}
|
||||
<button
|
||||
onClick={() => setDetailServer(null)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
background: "none", border: "none", cursor: "pointer",
|
||||
color: "#6b7280", fontSize: 13, padding: "0 0 20px 0",
|
||||
}}
|
||||
>
|
||||
<ArrowLeftOutlined style={{ fontSize: 12 }} />
|
||||
Back
|
||||
</button>
|
||||
|
||||
{/* Avatar + name + connect */}
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 20, marginBottom: 28 }}>
|
||||
<div style={{
|
||||
width: 64, height: 64, borderRadius: 16,
|
||||
background: color, display: "flex",
|
||||
alignItems: "center", justifyContent: "center",
|
||||
color: "#fff", fontWeight: 700, fontSize: 28, flexShrink: 0,
|
||||
}}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h2 style={{ margin: "0 0 4px", fontSize: 22, fontWeight: 700, color: "#111827" }}>{name}</h2>
|
||||
<p style={{ margin: 0, fontSize: 14, color: "#6b7280" }}>{detailServer.description ?? "MCP server"}</p>
|
||||
</div>
|
||||
<Button
|
||||
type={isConnected ? "default" : "primary"}
|
||||
loading={isTogglingOn}
|
||||
onClick={() => handleToggle(name, !isConnected)}
|
||||
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
|
||||
>
|
||||
{isConnected ? "Disconnect" : "Connect"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Info table */}
|
||||
<h3 style={{ margin: "0 0 12px", fontSize: 15, fontWeight: 600, color: "#111827" }}>Information</h3>
|
||||
<div style={{ border: "1px solid #e5e7eb", borderRadius: 10, overflow: "hidden" }}>
|
||||
{[
|
||||
["Server ID", detailServer.server_id],
|
||||
["Transport", (detailServer as MCPServer & { mcp_info?: { server_url?: string } }).mcp_info?.server_url ? "HTTP" : "stdio"],
|
||||
["Status", isConnected ? "Connected" : "Not connected"],
|
||||
].filter(([, v]) => v).map(([label, value], i, arr) => (
|
||||
<div key={label} style={{
|
||||
display: "flex",
|
||||
padding: "12px 16px",
|
||||
borderBottom: i < arr.length - 1 ? "1px solid #f3f4f6" : "none",
|
||||
fontSize: 13,
|
||||
}}>
|
||||
<span style={{ width: 140, color: "#9ca3af", flexShrink: 0 }}>{label}</span>
|
||||
<span style={{ color: "#111827", fontWeight: 500 }}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── List view ──
|
||||
return (
|
||||
<div style={{ width: "100%" }}>
|
||||
|
||||
{/* Header row */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20, gap: 16, flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: "#111827" }}>MCP Servers</h2>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: "#1677ff",
|
||||
background: "#e8f4ff", borderRadius: 4, padding: "1px 6px",
|
||||
letterSpacing: "0.05em", textTransform: "uppercase",
|
||||
}}>Beta</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "#6b7280" }}>
|
||||
Connect tools to your chat.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
prefix={<SearchOutlined style={{ color: "#9ca3af", fontSize: 13 }} />}
|
||||
placeholder="Search servers..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220, borderRadius: 8, fontSize: 13 }}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: "flex", borderBottom: "1px solid #e5e7eb", marginBottom: 16 }}>
|
||||
{(["all", "connected"] as TabKey[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: "8px 16px", border: "none",
|
||||
borderBottom: activeTab === tab ? "2px solid #1677ff" : "2px solid transparent",
|
||||
cursor: "pointer", fontSize: 13,
|
||||
fontWeight: activeTab === tab ? 600 : 400,
|
||||
background: "transparent",
|
||||
color: activeTab === tab ? "#1677ff" : "#6b7280",
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
{tab === "all" ? "All" : `Connected${connectedCount > 0 ? ` (${connectedCount})` : ""}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{loading ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "48px 0" }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 13, padding: "48px 12px" }}>
|
||||
{servers.length === 0
|
||||
? "No MCP servers configured. Add servers in Tools → MCP Servers."
|
||||
: activeTab === "connected" ? "No servers connected yet." : "No servers match your search."}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: 0, border: "1px solid #e5e7eb", borderRadius: 10, overflow: "hidden" }}>
|
||||
{filtered.map((server, idx) => {
|
||||
const name = nameOf(server);
|
||||
const isConnected = selectedServers.includes(name);
|
||||
const color = getAvatarColor(name);
|
||||
const isLeftCol = idx % 2 === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={server.server_id}
|
||||
onClick={() => setDetailServer(server)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 12,
|
||||
padding: "14px 16px", background: "#fff",
|
||||
borderRight: isLeftCol ? "1px solid #f3f4f6" : "none",
|
||||
borderBottom: Math.floor(idx / 2) < Math.floor((filtered.length - 1) / 2) ? "1px solid #f3f4f6" : "none",
|
||||
cursor: "pointer", minWidth: 0,
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fafafa"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fff"; }}
|
||||
>
|
||||
<div style={{
|
||||
width: 38, height: 38, borderRadius: 10, background: color,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
color: "#fff", fontWeight: 700, fontSize: 16, flexShrink: 0,
|
||||
}}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: "#111827", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#9ca3af", marginTop: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{server.description ?? "MCP server"}
|
||||
</div>
|
||||
</div>
|
||||
{isConnected && (
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: "#1677ff", flexShrink: 0 }} />
|
||||
)}
|
||||
<RightOutlined style={{ fontSize: 11, color: "#d1d5db", flexShrink: 0 }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MCPAppsPanel;
|
||||
157
ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx
Normal file
157
ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Switch, Spin, message } from "antd";
|
||||
import { fetchMCPServers, listMCPTools } from "../networking";
|
||||
import { MCPServer } from "../mcp_tools/types";
|
||||
|
||||
interface Props {
|
||||
accessToken: string;
|
||||
selectedServers: string[];
|
||||
onChange: (servers: string[]) => void;
|
||||
}
|
||||
|
||||
const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
|
||||
const [servers, setServers] = useState<MCPServer[]>([]);
|
||||
const [loadingServers, setLoadingServers] = useState(true);
|
||||
// Track which individual servers are being toggled on (verifying tools)
|
||||
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setLoadingServers(true);
|
||||
try {
|
||||
const data = await fetchMCPServers(accessToken);
|
||||
if (cancelled) return;
|
||||
// API returns { data: MCPServer[] } or MCPServer[]
|
||||
const list: MCPServer[] = Array.isArray(data) ? data : (data?.data ?? []);
|
||||
setServers(list);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setServers([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoadingServers(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
const handleToggle = async (serverName: string, checked: boolean) => {
|
||||
if (!checked) {
|
||||
// Toggle OFF — remove immediately, no tool fetch needed
|
||||
onChange(selectedServers.filter((s) => s !== serverName));
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle ON — verify tools are reachable first
|
||||
setTogglingOn((prev) => new Set(prev).add(serverName));
|
||||
try {
|
||||
const result = await listMCPTools(accessToken, serverName);
|
||||
// listMCPTools never throws; it returns { tools, error, message } on failure
|
||||
if (result?.error) {
|
||||
message.warning(
|
||||
`Could not load tools for ${serverName} — it will be excluded from this message.`
|
||||
);
|
||||
// Do not add to selectedServers
|
||||
return;
|
||||
}
|
||||
onChange([...selectedServers, serverName]);
|
||||
} catch {
|
||||
message.warning(
|
||||
`Could not load tools for ${serverName} — it will be excluded from this message.`
|
||||
);
|
||||
// Do not add to selectedServers
|
||||
} finally {
|
||||
setTogglingOn((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(serverName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 320,
|
||||
maxHeight: 400,
|
||||
overflowY: "auto",
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{loadingServers ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "24px 0" }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div style={{ padding: "16px 12px", color: "#8c8c8c", fontSize: 13, textAlign: "center" }}>
|
||||
No MCP servers configured
|
||||
</div>
|
||||
) : (
|
||||
servers.map((server) => {
|
||||
const name = server.server_name ?? server.alias ?? server.server_id;
|
||||
const isSelected = selectedServers.includes(name);
|
||||
const isTogglingOn = togglingOn.has(name);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={server.server_id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
padding: "8px 12px",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
fontSize: 13,
|
||||
color: "#1f1f1f",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{server.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#8c8c8c",
|
||||
marginTop: 2,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{server.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={isSelected}
|
||||
loading={isTogglingOn}
|
||||
onChange={(checked) => handleToggle(name, checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MCPConnectPicker;
|
||||
20
ui/litellm-dashboard/src/components/chat/types.ts
Normal file
20
ui/litellm-dashboard/src/components/chat/types.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
toolName?: string;
|
||||
toolArgs?: Record<string, unknown>;
|
||||
toolResult?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
mcpServerNames: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
230
ui/litellm-dashboard/src/components/chat/useChatHistory.ts
Normal file
230
ui/litellm-dashboard/src/components/chat/useChatHistory.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChatMessage, Conversation } from "./types";
|
||||
|
||||
const STORAGE_KEY = "litellm_chat_history_v1";
|
||||
const MAX_CONVERSATIONS = 100;
|
||||
const TITLE_MAX_LENGTH = 40;
|
||||
|
||||
function generateTitle(firstUserMessage: string): string {
|
||||
const trimmed = firstUserMessage.trim();
|
||||
if (trimmed.length <= TITLE_MAX_LENGTH) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.slice(0, TITLE_MAX_LENGTH) + "…";
|
||||
}
|
||||
|
||||
function loadFromStorage(): { conversations: Conversation[]; storageUnavailable: boolean } {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { conversations: [], storageUnavailable: false };
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Conversation[];
|
||||
return { conversations: parsed, storageUnavailable: false };
|
||||
} catch {
|
||||
return { conversations: [], storageUnavailable: true };
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(conversations: Conversation[]): boolean {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function trimConversations(conversations: Conversation[]): Conversation[] {
|
||||
if (conversations.length <= MAX_CONVERSATIONS) {
|
||||
return conversations;
|
||||
}
|
||||
return [...conversations]
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, MAX_CONVERSATIONS);
|
||||
}
|
||||
|
||||
export function useChatHistory(activeConversationId: string | null): {
|
||||
conversations: Conversation[];
|
||||
activeConversation: Conversation | null;
|
||||
storageUnavailable: boolean;
|
||||
staleId: boolean;
|
||||
createConversation: (model: string) => string;
|
||||
appendMessage: (conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => void;
|
||||
updateLastAssistantMessage: (conversationId: string, updates: Partial<Pick<ChatMessage, "content" | "reasoningContent">>) => void;
|
||||
truncateAfterMessage: (conversationId: string, messageId: string) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
renameConversation: (id: string, newTitle: string) => void;
|
||||
setActiveConversationId: (id: string | null) => void;
|
||||
} {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [storageUnavailable, setStorageUnavailable] = useState(false);
|
||||
const [staleId, setStaleId] = useState(false);
|
||||
const [currentActiveId, setCurrentActiveId] = useState<string | null>(activeConversationId);
|
||||
// Ref so updater functions stay pure (no state setter calls inside setConversations)
|
||||
const storageUnavailableRef = useRef(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// Sync internal active id whenever the URL-derived prop changes (e.g. "New chat" → null)
|
||||
useEffect(() => {
|
||||
setCurrentActiveId(activeConversationId);
|
||||
setStaleId(false);
|
||||
}, [activeConversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
const { conversations: loaded, storageUnavailable: unavailable } = loadFromStorage();
|
||||
storageUnavailableRef.current = unavailable;
|
||||
setConversations(loaded);
|
||||
setStorageUnavailable(unavailable);
|
||||
initializedRef.current = true;
|
||||
|
||||
if (activeConversationId !== null) {
|
||||
const found = loaded.some((c) => c.id === activeConversationId);
|
||||
if (!found) {
|
||||
setStaleId(true);
|
||||
}
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Persist to localStorage after every conversations change (pure effect, no setState inside updaters)
|
||||
useEffect(() => {
|
||||
if (!initializedRef.current) return;
|
||||
if (storageUnavailableRef.current) return;
|
||||
const success = saveToStorage(conversations);
|
||||
if (!success) {
|
||||
storageUnavailableRef.current = true;
|
||||
setStorageUnavailable(true);
|
||||
}
|
||||
}, [conversations]);
|
||||
|
||||
const createConversation = useCallback(
|
||||
(model: string): string => {
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const newConversation: Conversation = {
|
||||
id,
|
||||
title: "New conversation",
|
||||
model,
|
||||
messages: [],
|
||||
mcpServerNames: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setConversations((prev) => trimConversations([newConversation, ...prev]));
|
||||
setCurrentActiveId(id);
|
||||
return id;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const appendMessage = useCallback(
|
||||
(conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => {
|
||||
const newMessage: ChatMessage = {
|
||||
...message,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setConversations((prev) => {
|
||||
const updated = prev.map((conv) => {
|
||||
if (conv.id !== conversationId) return conv;
|
||||
const updatedMessages = [...conv.messages, newMessage];
|
||||
let title = conv.title;
|
||||
if (
|
||||
title === "New conversation" &&
|
||||
newMessage.role === "user" &&
|
||||
conv.messages.filter((m) => m.role === "user").length === 0
|
||||
) {
|
||||
title = generateTitle(newMessage.content);
|
||||
}
|
||||
return { ...conv, title, messages: updatedMessages, updatedAt: Date.now() };
|
||||
});
|
||||
return trimConversations(updated);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateLastAssistantMessage = useCallback(
|
||||
(
|
||||
conversationId: string,
|
||||
updates: Partial<Pick<ChatMessage, "content" | "reasoningContent">>,
|
||||
) => {
|
||||
setConversations((prev) => {
|
||||
const updated = prev.map((conv) => {
|
||||
if (conv.id !== conversationId) return conv;
|
||||
const messages = [...conv.messages];
|
||||
const lastAssistantIndex = messages.reduceRight((found, msg, idx) => {
|
||||
if (found !== -1) return found;
|
||||
return msg.role === "assistant" ? idx : -1;
|
||||
}, -1);
|
||||
if (lastAssistantIndex === -1) return conv;
|
||||
messages[lastAssistantIndex] = { ...messages[lastAssistantIndex], ...updates };
|
||||
return { ...conv, messages, updatedAt: Date.now() };
|
||||
});
|
||||
return trimConversations(updated);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const truncateAfterMessage = useCallback(
|
||||
(conversationId: string, messageId: string) => {
|
||||
setConversations((prev) => {
|
||||
const updated = prev.map((conv) => {
|
||||
if (conv.id !== conversationId) return conv;
|
||||
const idx = conv.messages.findIndex((m) => m.id === messageId);
|
||||
if (idx === -1) return conv;
|
||||
return { ...conv, messages: conv.messages.slice(0, idx), updatedAt: Date.now() };
|
||||
});
|
||||
return trimConversations(updated);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
(id: string) => {
|
||||
setConversations((prev) => trimConversations(prev.filter((c) => c.id !== id)));
|
||||
if (currentActiveId === id) setCurrentActiveId(null);
|
||||
},
|
||||
[currentActiveId],
|
||||
);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
(id: string, newTitle: string) => {
|
||||
setConversations((prev) =>
|
||||
trimConversations(
|
||||
prev.map((conv) =>
|
||||
conv.id === id ? { ...conv, title: newTitle, updatedAt: Date.now() } : conv,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setActiveConversationId = useCallback((id: string | null) => {
|
||||
setCurrentActiveId(id);
|
||||
setStaleId(false);
|
||||
}, []);
|
||||
|
||||
const activeConversation =
|
||||
currentActiveId !== null
|
||||
? (conversations.find((c) => c.id === currentActiveId) ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
conversations,
|
||||
activeConversation,
|
||||
storageUnavailable,
|
||||
staleId,
|
||||
createConversation,
|
||||
appendMessage,
|
||||
updateLastAssistantMessage,
|
||||
truncateAfterMessage,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
setActiveConversationId,
|
||||
};
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
FolderOutlined,
|
||||
KeyOutlined,
|
||||
LineChartOutlined,
|
||||
MessageOutlined,
|
||||
PlayCircleOutlined,
|
||||
RobotOutlined,
|
||||
SafetyOutlined,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useHealthReadiness } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadiness";
|
||||
import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import { getProxyBaseUrl, serverRootPath } from "@/components/networking";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { clearTokenCookies } from "@/utils/cookieUtils";
|
||||
import { fetchProxySettings } from "@/utils/proxyUtils";
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, SunOutlined } from "@ant-design/icons";
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined, MessageOutlined, MoonOutlined, SunOutlined } from "@ant-design/icons";
|
||||
import { Button, Switch, Tag } from "antd";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
|
@ -128,6 +128,41 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
</div>
|
||||
{/* Right side nav items */}
|
||||
<div className="flex items-center space-x-5 ml-auto">
|
||||
{/* Chat CTA — always visible, opens in new tab */}
|
||||
<a
|
||||
href={`${serverRootPath && serverRootPath !== "/" ? serverRootPath : ""}/ui/chat`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 14px",
|
||||
borderRadius: 8,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
textDecoration: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLAnchorElement).style.background = "#0958d9"; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLAnchorElement).style.background = "#1677ff"; }}
|
||||
>
|
||||
<MessageOutlined style={{ fontSize: 14 }} />
|
||||
Chat
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
background: "#fff",
|
||||
color: "#1677ff",
|
||||
borderRadius: 3,
|
||||
padding: "1px 4px",
|
||||
letterSpacing: "0.05em",
|
||||
}}>
|
||||
NEW
|
||||
</span>
|
||||
</a>
|
||||
<CommunityEngagementButtons />
|
||||
{/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below.
|
||||
Do not set this to true by default until all components are confirmed to support dark mode styles. */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue