hongjli
2025-04-24 6379adfd761b0a6cddd03d42dd96f1a619cda2c0
聊天页面
已添加2个文件
已修改4个文件
828 ■■■■■ 文件已修改
next.config.js 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package-lock.json 273 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/app/chat/page.tsx 510 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/layout/ClientLayoutContent.tsx 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tailwind.config.js 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
next.config.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        // åŒ¹é…æ‰€æœ‰API路由
        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
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",
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",
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');
    // èŽ·å–å­˜å‚¨çš„API 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 ç¡®ä¿çŠ¶æ€å˜åŒ–è¢«æ­£ç¡®æ¸²æŸ“
    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">
                    æŒ‰Enter发送,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>
  );
}
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">
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'),
  ],
}