From 6379adfd761b0a6cddd03d42dd96f1a619cda2c0 Mon Sep 17 00:00:00 2001 From: hongjli <3117313295@qq.com> Date: 星期四, 24 四月 2025 16:48:42 +0800 Subject: [PATCH] 聊天页面 --- tailwind.config.js | 20 + package-lock.json | 273 +++++++++++++++++++ package.json | 3 next.config.js | 19 + src/components/layout/ClientLayoutContent.tsx | 3 src/app/chat/page.tsx | 510 +++++++++++++++++++++++++++++++++--- 6 files changed, 780 insertions(+), 48 deletions(-) diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..5977389 --- /dev/null +++ b/next.config.js @@ -0,0 +1,19 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + async headers() { + return [ + { + // 鍖归厤鎵�鏈堿PI璺敱 + source: "/:path*", + headers: [ + { key: "Access-Control-Allow-Credentials", value: "true" }, + { key: "Access-Control-Allow-Origin", value: "*" }, + { key: "Access-Control-Allow-Methods", value: "GET,DELETE,PATCH,POST,PUT" }, + { key: "Access-Control-Allow-Headers", value: "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization" }, + ] + } + ] + } +} + +module.exports = nextConfig \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 947ab44..2340ca5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "styled-components": "^6.1.16", "zustand": "^5.0.3" @@ -23,6 +25,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.16", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1304,6 +1307,22 @@ "tailwindcss": "4.0.16" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.6", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.6.tgz", @@ -2497,6 +2516,19 @@ "postcss-value-parser": "^4.0.2" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2724,6 +2756,18 @@ }, "engines": { "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/es-abstract": { @@ -3822,6 +3866,79 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -3849,6 +3966,35 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5/node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -3856,6 +4002,23 @@ "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" }, "funding": { "type": "opencollective", @@ -3879,6 +4042,16 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/ignore": { @@ -4800,6 +4973,20 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -6120,6 +6307,18 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6203,6 +6402,20 @@ }, "engines": { "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" } }, "node_modules/postcss-value-parser": { @@ -6376,6 +6589,35 @@ }, "funding": { "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, "node_modules/remark-gfm": { @@ -7501,6 +7743,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -7509,6 +7758,20 @@ "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" }, "funding": { "type": "opencollective", @@ -7529,6 +7792,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index fa31aae..131c9d7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "styled-components": "^6.1.16", "zustand": "^5.0.3" @@ -24,6 +26,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.16", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 415bf60..21ff564 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -1,70 +1,486 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; +import Image from 'next/image'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeSanitize from 'rehype-sanitize'; + +interface Message { + role: 'user' | 'assistant'; + content: string; + timestamp: number; + id: string; + conversation_id?: string; + feedback?: 'like' | 'dislike' | null; + metadata?: { + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + total_price: string; + }; + }; +} + +const BASE_URL = 'http://121.43.139.99:7000'; + +// 榛樿鐢ㄦ埛澶村儚 +const DEFAULT_USER_AVATAR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23999999'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z'/%3E%3C/svg%3E"; +const AI_AVATAR = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%234F46E5'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 1c4.96 0 9 4.04 9 9s-4.04 9-9 9-9-4.04-9-9 4.04-9 9-9zm0 3.5c-2.48 0-4.5 2.02-4.5 4.5h1.5c0-1.66 1.34-3 3-3s3 1.34 3 3h1.5c0-2.48-2.02-4.5-4.5-4.5zM8 13h2v2H8v-2zm6 0h2v2h-2v-2z'/%3E%3C/svg%3E"; export default function Page() { const router = useRouter(); - const [token, setToken] = useState<string | null>(null); + const [apiKey, setApiKey] = useState<string>(''); + const [showApiKeyInput, setShowApiKeyInput] = useState(false); + const [message, setMessage] = useState(''); + const [messages, setMessages] = useState<Message[]>([]); + const [isStreaming, setIsStreaming] = useState(false); + const [error, setError] = useState<string | null>(null); + const [showError, setShowError] = useState(false); + const [conversationId, setConversationId] = useState<string | null>(null); + const [isMessageComplete, setIsMessageComplete] = useState(true); + const [currentMessageId, setCurrentMessageId] = useState<string | null>(null); + + // 娣诲姞涓�涓柊鐨勭姸鎬佹潵鎺у埗娑堟伅鐨勬樉绀� + const [showMessages, setShowMessages] = useState(false); + + const messagesEndRef = useRef<HTMLDivElement>(null); + const errorTimeoutRef = useRef<any>(null); useEffect(() => { - // 鑾峰彇token - const storedToken = localStorage.getItem('token'); - setToken(storedToken); - - // 濡傛灉娌℃湁token锛岀洿鎺ヨ烦杞埌鐧诲綍椤甸潰 - if (!storedToken) { - router.push('/login'); + // 鑾峰彇瀛樺偍鐨凙PI Key + const storedApiKey = localStorage.getItem('api-key'); + if (storedApiKey) { + setApiKey(storedApiKey); + } else { + setShowApiKeyInput(true); } }, []); - // 濡傛灉娌℃湁token锛屼笉娓叉煋浠讳綍鍐呭 - if (!token) { - return null; - } + useEffect(() => { + // 婊氬姩鍒版渶鏂版秷鎭� + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // 澶勭悊閿欒鏄剧ず鍜岃嚜鍔ㄦ秷澶� + const showErrorMessage = useCallback((message: string) => { + setError(message); + // 鍏堣缃负false纭繚鍔ㄧ敾鑳介噸鏂拌Е鍙� + setShowError(false); + // 浣跨敤 requestAnimationFrame 纭繚鐘舵�佸彉鍖栬姝g‘娓叉煋 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setShowError(true); + }); + }); + + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + } + + errorTimeoutRef.current = setTimeout(() => { + setShowError(false); + setTimeout(() => { + setError(null); + }, 400); + }, 3000); + }, []); + + // 缁勪欢鍗歌浇鏃舵竻闄ゅ畾鏃跺櫒 + useEffect(() => { + const cleanup = () => { + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + } + }; + return cleanup; + }, []); + + const handleApiKeySubmit = () => { + if (apiKey.trim()) { + localStorage.setItem('api-key', apiKey); + setShowApiKeyInput(false); + setError(null); + } + }; + + const handleSendMessage = async () => { + if (!message.trim() || !isMessageComplete) return; + if (!apiKey) { + showErrorMessage('璇峰厛璁剧疆API Key'); + return; + } + + setIsStreaming(true); + setIsMessageComplete(false); + + // 鍒涘缓鏂版秷鎭� + const userMessage: Message = { + role: 'user', + content: message.trim(), + timestamp: Date.now(), + id: 'user-' + Date.now().toString() + }; + + const assistantMessage: Message = { + role: 'assistant', + content: '', + timestamp: Date.now(), + id: 'temp-' + Date.now().toString() + }; + + // 浣跨敤鍑芥暟寮忔洿鏂扮‘淇濈姸鎬佹洿鏂扮殑涓�鑷存�� + setMessages(prevMessages => { + const newMessages = [...prevMessages]; + // 濡傛灉瀛樺湪鏈畬鎴愮殑鍔╂墜娑堟伅锛屽厛绉婚櫎瀹� + if (!isMessageComplete && currentMessageId) { + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage && lastMessage.role === 'assistant' && lastMessage.id === currentMessageId) { + newMessages.pop(); + } + } + return [...newMessages, userMessage, assistantMessage]; + }); + + setCurrentMessageId(assistantMessage.id); + setMessage(''); + + try { + const response = await fetch(`${BASE_URL}/v1/chat-messages`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + mode: 'cors', + body: JSON.stringify({ + inputs: {}, + query: userMessage.content, + response_mode: 'streaming', + conversation_id: conversationId || '', + user: 'abc-123', + files: [] + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error || `Error: ${response.status}`; + } catch { + errorMessage = `Error: ${response.status}`; + } + throw new Error(errorMessage); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error('No reader available'); + + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + setIsMessageComplete(true); + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim() === '') continue; + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + + // 蹇界暐ping浜嬩欢 + if (data.event === 'ping') continue; + + switch (data.event) { + case 'message': + setMessages(prev => { + const newMessages = [...prev]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage?.role === 'assistant' && lastMessage.id === currentMessageId) { + lastMessage.content = data.answer || lastMessage.content; + if (data.message_id) { + lastMessage.id = data.message_id; + setCurrentMessageId(data.message_id); + } + } + return newMessages; + }); + break; + + case 'message_end': + if (data.conversation_id) { + setConversationId(data.conversation_id); + setMessages(prev => { + const newMessages = [...prev]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage?.role === 'assistant' && lastMessage.id === currentMessageId) { + lastMessage.conversation_id = data.conversation_id; + if (data.metadata) { + lastMessage.metadata = data.metadata; + } + } + return newMessages; + }); + } + setIsMessageComplete(true); + break; + + case 'error': + console.error('Error event received:', data); + setIsMessageComplete(true); + throw new Error(data.message || '鍙戦�佹秷鎭椂鍑洪敊'); + } + } catch (e) { + console.error('Error parsing SSE data:', e); + if (!messages[messages.length - 1]?.content) { + setIsMessageComplete(true); + throw e; + } + } + } + } + } + } catch (err) { + console.error('Chat error:', err); + showErrorMessage(err instanceof Error ? err.message : '鍙戦�佹秷鎭椂鍑洪敊'); + + setMessages(prev => { + const newMessages = [...prev]; + const lastMessage = newMessages[newMessages.length - 1]; + if (lastMessage?.role === 'assistant') { + lastMessage.content = '鎶辨瓑锛屾秷鎭彂閫佸け璐ワ紝璇风◢鍚庨噸璇曘��'; + } + return newMessages; + }); + } finally { + setIsStreaming(false); + } + }; + + // 鏇存柊鍙戦�佹寜閽殑绂佺敤鐘舵�� + const isSendDisabled = isStreaming || !message.trim() || !isMessageComplete; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(); + } + }; + + useEffect(() => { + // 椤甸潰鍔犺浇鍚庡欢杩熸樉绀烘秷鎭垪琛紝閬垮厤闂儊 + const timer = setTimeout(() => { + setShowMessages(true); + }, 100); + return () => clearTimeout(timer); + }, []); return ( - <div className="min-h-screen bg-[#0A1033] text-white"> - {/* 椤堕儴瀵艰埅 */} - <div className="bg-[#131C41] p-4 border-b border-[#6ADBFF]/20"> - <div className="max-w-4xl mx-auto flex justify-between items-center"> - <Link href="/" className="text-[#6ADBFF] hover:underline flex items-center gap-2"> - <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> - <path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" /> - </svg> - 杩斿洖棣栭〉 - </Link> - <h1 className="text-xl font-bold">AI鍔╂墜瀵硅瘽</h1> - </div> - </div> - - {/* 鑱婂ぉ鍖哄煙 */} - <div className="max-w-4xl mx-auto p-4"> - <div className="space-y-4 mb-20"> - <div className="flex justify-start"> - <div className="max-w-[80%] rounded-lg p-4 bg-[#131C41]"> - <p>浣犲ソ锛佹杩庢潵鍒拌亰澶╁銆�</p> + <div className="min-h-screen bg-gradient-to-b from-gray-50 to-white text-gray-900 flex flex-col"> + {/* API Key Modal */} + {showApiKeyInput && ( + <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> + <div className="bg-white rounded-2xl p-6 w-[400px] space-y-4 shadow-xl"> + <div className="flex justify-between items-center"> + <h2 className="text-xl font-semibold">璁剧疆 API Key</h2> + <button + onClick={() => setShowApiKeyInput(false)} + className="text-gray-400 hover:text-gray-600" + > + <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> + <path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" /> + </svg> + </button> + </div> + <div className="space-y-2"> + <input + type="password" + value={apiKey} + onChange={(e) => setApiKey(e.target.value)} + placeholder="璇疯緭鍏ユ偍鐨� API Key" + className="w-full px-4 py-2 rounded-xl border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-100/50" + /> + </div> + <div className="flex justify-end gap-2"> + <button + onClick={() => setShowApiKeyInput(false)} + className="px-4 py-2 text-gray-600 hover:text-gray-900" + > + 鍙栨秷 + </button> + <button + onClick={handleApiKeySubmit} + className="px-4 py-2 bg-blue-500 text-white rounded-xl hover:bg-blue-600" + > + 纭畾 + </button> </div> </div> </div> - </div> + )} - {/* 杈撳叆鍖哄煙 */} - <div className="fixed bottom-0 left-0 right-0 bg-[#131C41] border-t border-[#6ADBFF]/20 p-4"> - <div className="max-w-4xl mx-auto flex gap-4"> - <input - type="text" - placeholder="杈撳叆娑堟伅..." - className="flex-1 bg-[#1E2B63] rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#6ADBFF]/50" - /> - <button - className="bg-gradient-to-r from-[#6ADBFF] to-[#5E72EB] text-white px-6 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity" - > - 鍙戦�� - </button> + {/* 閿欒鎻愮ず */} + {error && ( + <div + className={`fixed top-20 left-1/2 transform -translate-x-1/2 + flex items-center gap-2 px-4 py-2 text-sm text-white + transition-all duration-400 ease-out + ${showError + ? 'opacity-100 translate-y-0 scale-100' + : 'opacity-0 -translate-y-2 scale-95' + } + before:content-[''] before:absolute before:inset-0 before:bg-red-500 + before:rounded-lg before:opacity-90 before:-z-10 + after:content-[''] after:absolute after:inset-0 after:bg-red-500/50 + after:blur-md after:rounded-lg after:-z-20`} + > + <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"> + <path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> + </svg> + <span className="relative">{error}</span> + </div> + )} + + {/* API Key 鎸夐挳 */} + <button + onClick={() => setShowApiKeyInput(true)} + className="fixed top-24 right-6 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-white/80 hover:bg-white shadow-sm border border-gray-100 transition-colors" + title="璁剧疆API Key" + > + <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" /> + </svg> + </button> + + {/* 鑱婂ぉ鍖哄煙 */} + <div className="flex-1 flex flex-col h-screen"> + <div className="flex-1 overflow-y-auto pt-20 pb-32"> + <div className="max-w-4xl mx-auto px-6"> + <div className={`space-y-6 ${showMessages ? 'opacity-100' : 'opacity-0'} transition-opacity duration-200`}> + {messages.map((msg, index) => ( + <div + key={msg.id} + className={`flex items-start gap-4 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`} + > + <div className="relative flex-shrink-0"> + <div className="w-8 h-8 rounded-lg overflow-hidden shadow-inner bg-gray-50"> + <Image + src={msg.role === 'assistant' ? "/images/logo.jpg" : DEFAULT_USER_AVATAR} + alt={msg.role === 'assistant' ? "AI鍔╂墜" : "鐢ㄦ埛"} + width={32} + height={32} + className="w-full h-full object-cover" + /> + </div> + {msg.role === 'assistant' && ( + <div className="absolute -bottom-1 -right-1 w-3 h-3 bg-green-400 rounded-full border-2 border-white"></div> + )} + </div> + <div className={`flex-1 min-w-0 ${msg.role === 'user' ? 'text-right' : ''}`}> + <div className={`${ + msg.role === 'assistant' + ? 'bg-white rounded-xl shadow-sm border border-gray-100' + : 'bg-[#E8F4FF] rounded-xl' + } inline-block max-w-[85%] relative min-h-[42px]`}> + {msg.role === 'assistant' && !isMessageComplete && currentMessageId === msg.id && ( + <div className="absolute left-0 top-0 w-full h-full flex items-center justify-center"> + <div className="w-5 h-5 border-2 border-blue-500/30 border-t-blue-500/80 rounded-full animate-spin"></div> + </div> + )} + <div className={`flex items-center px-4 min-h-[42px] ${ + msg.role === 'assistant' && !isMessageComplete && currentMessageId === msg.id ? 'invisible' : '' + }`}> + <div className="prose prose-sm max-w-none text-[14px] leading-[1.3] + prose-p:my-0 prose-p:leading-[1.3] + prose-headings:font-medium prose-headings:text-gray-800 + prose-h1:text-lg prose-h1:my-2 + prose-h2:text-base prose-h2:my-2 + prose-h3:text-base prose-h3:my-1.5 + prose-ul:my-1.5 prose-ul:pl-4 prose-li:my-0.5 + prose-ol:my-1.5 prose-ol:pl-4 + prose-code:px-1 prose-code:py-0.5 prose-code:bg-gray-100 prose-code:rounded prose-code:text-gray-800 prose-code:before:content-[''] prose-code:after:content-[''] + prose-pre:my-2 prose-pre:p-2.5 prose-pre:bg-gray-800 prose-pre:rounded-lg + prose-a:text-blue-500 prose-a:no-underline hover:prose-a:underline + prose-blockquote:my-2 prose-blockquote:pl-3 prose-blockquote:border-l-4 prose-blockquote:border-gray-200 + prose-strong:font-medium prose-strong:text-gray-800 + prose-table:my-2 prose-tr:border-gray-200 prose-td:py-1 prose-td:px-2"> + {msg.content.split('\n').map((line, i) => ( + <div key={i} className="my-0"> + {line.trim() && ( + <ReactMarkdown + remarkPlugins={[remarkGfm]} + rehypePlugins={[rehypeRaw, rehypeSanitize]} + > + {line.trim()} + </ReactMarkdown> + )} + </div> + ))} + </div> + </div> + </div> + <div className="mt-0.5 text-xs text-gray-400"> + {msg.role === 'user' && new Date(msg.timestamp).toLocaleTimeString()} + </div> + </div> + </div> + ))} + <div ref={messagesEndRef} /> + </div> + </div> + </div> + + {/* 杈撳叆鍖哄煙 */} + <div className="fixed bottom-0 left-0 right-0 bg-gradient-to-t from-white via-white to-white/95 pt-4 pb-6"> + <div className="max-w-4xl mx-auto px-6"> + <div className="relative"> + <div className="absolute -top-6 left-1/2 -translate-x-1/2 w-48 h-[1px] bg-gradient-to-r from-transparent via-gray-200 to-transparent"></div> + + <div className="flex gap-4 items-start"> + <div className="flex-1 relative"> + <textarea + value={message} + onChange={(e) => setMessage(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="杈撳叆娑堟伅..." + disabled={isStreaming} + className="w-full resize-none rounded-xl border-0 bg-gray-50 px-4 py-3 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-100/50 focus:bg-white min-h-[48px] max-h-32 shadow-inner transition-all duration-300 ease-in-out hover:bg-gray-100/70 disabled:opacity-50 disabled:cursor-not-allowed" + style={{ height: '48px' }} + /> + <div className="absolute right-4 bottom-2 text-xs text-gray-400 bg-gray-50 px-2"> + 鎸塃nter鍙戦�侊紝Shift+Enter鎹㈣ + </div> + </div> + <button + onClick={handleSendMessage} + disabled={isSendDisabled} + className="h-12 px-5 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-medium rounded-xl transition-all duration-300 flex items-center gap-2 shadow-lg shadow-blue-500/20 hover:shadow-blue-500/30 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none" + > + <span>{isStreaming ? '鍥炲涓�...' : (isMessageComplete ? '鍙戦��' : '澶勭悊涓�...')}</span> + <svg xmlns="http://www.w3.org/2000/svg" className={`h-4 w-4 transform rotate-45 ${isStreaming ? 'animate-pulse' : ''}`} viewBox="0 0 20 20" fill="currentColor"> + <path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" /> + </svg> + </button> + </div> + </div> + </div> </div> </div> </div> ); } + diff --git a/src/components/layout/ClientLayoutContent.tsx b/src/components/layout/ClientLayoutContent.tsx index bf997c2..64b02f9 100644 --- a/src/components/layout/ClientLayoutContent.tsx +++ b/src/components/layout/ClientLayoutContent.tsx @@ -22,6 +22,7 @@ const isHomePage = pathname === '/'; const isAIScenePage = pathname === '/ai-scene'; const isAISceneChatPage = pathname.startsWith('/ai-scene/chat'); + const isChatPage = pathname === '/chat'; // 娣诲姞鑱婂ぉ椤甸潰鍒ゆ柇 // 璁剧疆瀹㈡埛绔姸鎬� useEffect(() => { @@ -98,7 +99,7 @@ <main className={`flex-1 ${isHomePage || isAIScenePage ? '' : 'bg-gradient-to-b from-[var(--ai-surface)] to-white'} pt-0 mt-0`}> {children} </main> - {!isLoginPage && !isRegisterPage && !isAISceneChatPage && ( + {!isLoginPage && !isRegisterPage && !isAISceneChatPage && !isChatPage && ( <footer className="relative z-20 bg-gradient-to-br from-[#0A1033] via-[#1E2B63] to-[#131C41] text-white py-10 overflow-hidden"> {/* 绉戞妧鎰熷姩鎬佽儗鏅厓绱� */} <div className="absolute inset-0 overflow-hidden pointer-events-none"> diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..b19d36d --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,20 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + './src/app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + backgroundImage: { + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', + 'gradient-conic': + 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', + }, + }, + }, + plugins: [ + require('@tailwindcss/typography'), + ], +} \ No newline at end of file -- Gitblit v1.9.3