@sciol/xyzen 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ import { motion } from "framer-motion";
2
+ import React, { useState } from "react";
3
+
4
+ const EmptyChat: React.FC = () => {
5
+ const [isCreating] = useState(false);
6
+
7
+ // const handleStartNewChat = async () => {
8
+ // setIsCreating(true);
9
+
10
+ // try {
11
+ // // 使用service中的创建默认频道方法,它会处理UUID生成、连接创建以及视图切换
12
+ // await createDefaultChannel();
13
+ // } catch (error) {
14
+ // console.error('Failed to create chat:', error);
15
+ // } finally {
16
+ // setTimeout(() => setIsCreating(false), 1000);
17
+ // }
18
+ // };
19
+
20
+ return (
21
+ <div className="flex h-full flex-col items-center justify-center space-y-6 p-4 text-center">
22
+ <motion.div
23
+ initial={{ opacity: 0, y: -20 }}
24
+ animate={{ opacity: 1, y: 0 }}
25
+ transition={{ duration: 0.5 }}
26
+ className="rounded-full bg-neutral-100 p-4 dark:bg-neutral-800"
27
+ >
28
+ <svg
29
+ xmlns="http://www.w3.org/2000/svg"
30
+ className="h-8 w-8 text-neutral-500 dark:text-neutral-400"
31
+ fill="none"
32
+ viewBox="0 0 24 24"
33
+ stroke="currentColor"
34
+ >
35
+ <path
36
+ strokeLinecap="round"
37
+ strokeLinejoin="round"
38
+ strokeWidth={1.5}
39
+ d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"
40
+ />
41
+ </svg>
42
+ </motion.div>
43
+
44
+ <motion.div
45
+ initial={{ opacity: 0 }}
46
+ animate={{ opacity: 1 }}
47
+ transition={{ delay: 0.2, duration: 0.5 }}
48
+ >
49
+ <h3 className="text-lg font-medium text-neutral-900 dark:text-white">
50
+ 开始一个新对话
51
+ </h3>
52
+ <p className="mt-2 max-w-md text-sm text-neutral-600 dark:text-neutral-300">
53
+ 您可以从列表选择一个专业助手,或者直接开始一个自由对话。
54
+ </p>
55
+ </motion.div>
56
+
57
+ <motion.div
58
+ initial={{ opacity: 0, y: 20 }}
59
+ animate={{ opacity: 1, y: 0 }}
60
+ transition={{ delay: 0.4, duration: 0.5 }}
61
+ className="flex flex-col space-y-3 sm:flex-row sm:space-x-3 sm:space-y-0"
62
+ >
63
+ <button
64
+ onClick={() => {}}
65
+ disabled={isCreating}
66
+ className={`flex items-center justify-center rounded-md ${
67
+ isCreating
68
+ ? "bg-indigo-400 dark:bg-indigo-700"
69
+ : "bg-indigo-600 hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600"
70
+ } px-4 py-2 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2`}
71
+ >
72
+ {isCreating ? (
73
+ <>
74
+ <svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24">
75
+ <circle
76
+ className="opacity-25"
77
+ cx="12"
78
+ cy="12"
79
+ r="10"
80
+ stroke="currentColor"
81
+ strokeWidth="4"
82
+ fill="none"
83
+ />
84
+ <path
85
+ className="opacity-75"
86
+ fill="currentColor"
87
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
88
+ />
89
+ </svg>
90
+ 创建中...
91
+ </>
92
+ ) : (
93
+ <>
94
+ <svg
95
+ xmlns="http://www.w3.org/2000/svg"
96
+ className="mr-2 h-4 w-4"
97
+ fill="none"
98
+ viewBox="0 0 24 24"
99
+ stroke="currentColor"
100
+ >
101
+ <path
102
+ strokeLinecap="round"
103
+ strokeLinejoin="round"
104
+ strokeWidth={2}
105
+ d="M12 4v16m8-8H4"
106
+ />
107
+ </svg>
108
+ 开始新对话
109
+ </>
110
+ )}
111
+ </button>
112
+ </motion.div>
113
+ </div>
114
+ );
115
+ };
116
+
117
+ export default EmptyChat;
@@ -0,0 +1,104 @@
1
+ import { motion } from "framer-motion";
2
+ import React from "react";
3
+
4
+ export interface ChatData {
5
+ id: string;
6
+ title: string;
7
+ assistant?: string; // 助手ID
8
+ assistant_name?: string; // 助手名称
9
+ messages_count: number;
10
+ last_message?: {
11
+ content: string;
12
+ timestamp: string;
13
+ };
14
+ created_at: string;
15
+ updated_at: string;
16
+ is_pinned: boolean;
17
+ }
18
+
19
+ export interface Assistant {
20
+ id: string;
21
+ key?: string; // 助手的唯一标识符
22
+ title: string;
23
+ description: string;
24
+ iconType: string;
25
+ iconColor: string;
26
+ category: string;
27
+ chats?: ChatData[]; // 与该助手的历史对话列表
28
+ }
29
+
30
+ interface WelcomeMessageProps {
31
+ assistant?: Assistant | null;
32
+ }
33
+
34
+ const WelcomeMessage: React.FC<WelcomeMessageProps> = ({ assistant }) => {
35
+ const iconColor = assistant?.iconColor || "indigo";
36
+
37
+ // Fix dynamic class name issue by mapping to pre-defined classes
38
+ const iconColorMap: Record<string, string> = {
39
+ blue: "bg-blue-100 dark:bg-blue-900/30",
40
+ green: "bg-green-100 dark:bg-green-900/30",
41
+ purple: "bg-purple-100 dark:bg-purple-900/30",
42
+ amber: "bg-amber-100 dark:bg-amber-900/30",
43
+ red: "bg-red-100 dark:bg-red-900/30",
44
+ indigo: "bg-indigo-100 dark:bg-indigo-900/30",
45
+ };
46
+
47
+ const iconTextColorMap: Record<string, string> = {
48
+ blue: "text-blue-600 dark:text-blue-400",
49
+ green: "text-green-600 dark:text-green-400",
50
+ purple: "text-purple-600 dark:text-purple-400",
51
+ amber: "text-amber-600 dark:text-amber-400",
52
+ red: "text-red-600 dark:text-red-400",
53
+ indigo: "text-indigo-600 dark:text-indigo-400",
54
+ };
55
+
56
+ const bgColorClass = iconColorMap[iconColor] || iconColorMap.indigo;
57
+ const textColorClass = iconTextColorMap[iconColor] || iconTextColorMap.indigo;
58
+
59
+ // Determine title and message based on whether an assistant is selected
60
+ const title = assistant ? `欢迎使用 ${assistant.title}` : "欢迎使用自由对话";
61
+ const description =
62
+ assistant?.description ||
63
+ "您现在可以自由提问任何问题。无需选择特定助手,系统将根据您的问题提供合适的回复。";
64
+
65
+ return (
66
+ <motion.div
67
+ initial={{ opacity: 0 }}
68
+ animate={{ opacity: 1 }}
69
+ transition={{ duration: 0.6 }}
70
+ className="flex flex-col items-center justify-center space-y-4 p-6 text-center"
71
+ >
72
+ <div className={`rounded-full ${bgColorClass} p-5`}>
73
+ <svg
74
+ xmlns="http://www.w3.org/2000/svg"
75
+ className={`h-10 w-10 ${textColorClass}`}
76
+ fill="none"
77
+ viewBox="0 0 24 24"
78
+ stroke="currentColor"
79
+ >
80
+ <path
81
+ strokeLinecap="round"
82
+ strokeLinejoin="round"
83
+ strokeWidth={1.5}
84
+ d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
85
+ />
86
+ </svg>
87
+ </div>
88
+ <motion.div
89
+ initial={{ y: 20, opacity: 0 }}
90
+ animate={{ y: 0, opacity: 1 }}
91
+ transition={{ delay: 0.2, duration: 0.5 }}
92
+ >
93
+ <h3 className="text-lg font-medium text-neutral-900 dark:text-white">
94
+ {title}
95
+ </h3>
96
+ <p className="mt-2 max-w-md text-sm leading-relaxed text-neutral-600 dark:text-neutral-300">
97
+ {description}
98
+ </p>
99
+ </motion.div>
100
+ </motion.div>
101
+ );
102
+ };
103
+
104
+ export default WelcomeMessage;
@@ -0,0 +1 @@
1
+ export const DEFAULT_BACKEND_URL = "http://localhost:8000";
File without changes
@@ -0,0 +1,79 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ type Theme = "light" | "dark" | "system";
4
+
5
+ const useTheme = () => {
6
+ const [theme, setTheme] = useState<Theme>("system");
7
+ const [isClient, setIsClient] = useState(false);
8
+
9
+ useEffect(() => {
10
+ setIsClient(true);
11
+ }, []);
12
+
13
+ const applyTheme = useCallback(
14
+ (selectedTheme: Theme) => {
15
+ if (!isClient) return;
16
+
17
+ const root = window.document.documentElement;
18
+ const isDark =
19
+ selectedTheme === "dark" ||
20
+ (selectedTheme === "system" &&
21
+ window.matchMedia("(prefers-color-scheme: dark)").matches);
22
+
23
+ // 注入CSS以禁用过渡
24
+ const style = document.createElement("style");
25
+ style.innerHTML =
26
+ "*, *::before, *::after { transition: none !important; }";
27
+ document.head.appendChild(style);
28
+
29
+ root.classList.toggle("dark", isDark);
30
+ localStorage.setItem("theme", selectedTheme);
31
+
32
+ // 在短时间后移除样式,以恢复过渡效果
33
+ // 这确保了仅在主题切换的瞬间禁用过渡
34
+ setTimeout(() => {
35
+ if (document.head.contains(style)) {
36
+ document.head.removeChild(style);
37
+ }
38
+ }, 50);
39
+ },
40
+ [isClient],
41
+ );
42
+
43
+ useEffect(() => {
44
+ if (!isClient) return;
45
+
46
+ const savedTheme = localStorage.getItem("theme") as Theme | null;
47
+ const initialTheme = savedTheme || "system";
48
+ setTheme(initialTheme);
49
+ applyTheme(initialTheme);
50
+ }, [isClient, applyTheme]);
51
+
52
+ useEffect(() => {
53
+ if (!isClient) return;
54
+
55
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
56
+ const handleChange = () => {
57
+ if (theme === "system") {
58
+ applyTheme("system");
59
+ }
60
+ };
61
+
62
+ mediaQuery.addEventListener("change", handleChange);
63
+ return () => mediaQuery.removeEventListener("change", handleChange);
64
+ }, [isClient, theme, applyTheme]);
65
+
66
+ const cycleTheme = () => {
67
+ if (!isClient) return;
68
+
69
+ const themes: Theme[] = ["light", "dark", "system"];
70
+ const currentIndex = themes.indexOf(theme);
71
+ const nextTheme = themes[(currentIndex + 1) % themes.length];
72
+ setTheme(nextTheme);
73
+ applyTheme(nextTheme);
74
+ };
75
+
76
+ return { theme, cycleTheme };
77
+ };
78
+
79
+ export default useTheme;
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ "use client";
2
+
3
+ export { Xyzen, type XyzenProps } from "@/app/App";
4
+ export { default as useTheme } from "@/hooks/useTheme";
5
+ export { default as xyzenService } from "@/service/xyzenService";
6
+ export { useXyzen } from "@/store/xyzenStore";
@@ -0,0 +1,98 @@
1
+ import React, { useState } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
4
+ import rehypeHighlight from "rehype-highlight";
5
+ import rehypeKatex from "rehype-katex";
6
+ import rehypeRaw from "rehype-raw";
7
+ import remarkGfm from "remark-gfm";
8
+ import remarkMath from "remark-math";
9
+
10
+ import "katex/dist/katex.css";
11
+ import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
12
+
13
+ interface MarkdownProps {
14
+ content: string;
15
+ }
16
+
17
+ const Markdown: React.FC<MarkdownProps> = function Markdown(props) {
18
+ const { content = "" } = props;
19
+ const [copiedCode, setCopiedCode] = useState<string | null>(null);
20
+
21
+ const copyToClipboard = (code: string) => {
22
+ navigator.clipboard.writeText(code).then(() => {
23
+ setCopiedCode(code);
24
+ setTimeout(() => {
25
+ setCopiedCode(null);
26
+ }, 2000);
27
+ });
28
+ };
29
+
30
+ const MarkdownComponents = {
31
+ code({
32
+ inline,
33
+ className,
34
+ children,
35
+ ...props
36
+ }: React.ComponentPropsWithoutRef<"code"> & { inline?: boolean }) {
37
+ const match = /language-(\w+)/.exec(className || "");
38
+ const code = String(children).replace(/\n$/, "");
39
+
40
+ return !inline && match ? (
41
+ <div style={{ position: "relative" }}>
42
+ <button
43
+ onClick={() => copyToClipboard(code)}
44
+ style={{
45
+ position: "absolute",
46
+ top: "5px",
47
+ right: "5px",
48
+ padding: "4px 8px",
49
+ backgroundColor: "#282c34",
50
+ color: "white",
51
+ border: "1px solid #444",
52
+ borderRadius: "4px",
53
+ cursor: "pointer",
54
+ fontSize: "12px",
55
+ zIndex: 2,
56
+ opacity: 0.8,
57
+ transition: "opacity 0.2s",
58
+ }}
59
+ onMouseEnter={(e) => {
60
+ e.currentTarget.style.opacity = "1";
61
+ }}
62
+ onMouseLeave={(e) => {
63
+ e.currentTarget.style.opacity = "0.8";
64
+ }}
65
+ >
66
+ {copiedCode === code ? (
67
+ <span className=" text-green-400">Copied!</span>
68
+ ) : (
69
+ "Copy"
70
+ )}
71
+ </button>
72
+ <SyntaxHighlighter style={oneDark} language={match[1]} PreTag="div">
73
+ {code}
74
+ </SyntaxHighlighter>
75
+ </div>
76
+ ) : (
77
+ <code className={className} {...props}>
78
+ {children}
79
+ </code>
80
+ );
81
+ },
82
+ };
83
+ return (
84
+ <ReactMarkdown
85
+ components={MarkdownComponents}
86
+ remarkPlugins={[remarkMath, remarkGfm]}
87
+ rehypePlugins={[
88
+ rehypeKatex,
89
+ rehypeRaw,
90
+ [rehypeHighlight, { ignoreMissing: true }],
91
+ ]}
92
+ >
93
+ {content}
94
+ </ReactMarkdown>
95
+ );
96
+ };
97
+
98
+ export default Markdown;
@@ -0,0 +1,32 @@
1
+ import { format, formatDistanceToNow, isToday, isYesterday } from "date-fns";
2
+ import { zhCN } from "date-fns/locale";
3
+
4
+ export function formatTime(dateString: string): string {
5
+ const date = new Date(dateString);
6
+ const now = new Date();
7
+
8
+ // 使用 formatDistanceToNow 显示相对时间,并添加中文支持
9
+ // addSuffix: true 会添加 "前" 或 "后"
10
+ const relativeTime = formatDistanceToNow(date, {
11
+ addSuffix: true,
12
+ locale: zhCN,
13
+ });
14
+
15
+ // 如果是一天内,直接返回相对时间,例如 "约5小时前"
16
+ if (now.getTime() - date.getTime() < 24 * 60 * 60 * 1000) {
17
+ return relativeTime;
18
+ }
19
+
20
+ // 如果是昨天
21
+ if (isYesterday(date)) {
22
+ return `昨天 ${format(date, "HH:mm")}`;
23
+ }
24
+
25
+ // 如果是今天(理论上被前一个if覆盖,但作为保险)
26
+ if (isToday(date)) {
27
+ return format(date, "HH:mm");
28
+ }
29
+
30
+ // 如果是更早的时间,显示具体日期
31
+ return format(date, "yyyy-MM-dd");
32
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,9 @@
1
+ import { Xyzen } from "@/app/App";
2
+ import { StrictMode } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+
5
+ createRoot(document.getElementById("root")!).render(
6
+ <StrictMode>
7
+ <Xyzen />
8
+ </StrictMode>,
9
+ );
@@ -0,0 +1,90 @@
1
+ import { type Message } from "@/store/xyzenStore";
2
+
3
+ interface StatusChangePayload {
4
+ connected: boolean;
5
+ error: string | null;
6
+ }
7
+
8
+ type ServiceCallback<T> = (payload: T) => void;
9
+
10
+ class XyzenService {
11
+ private ws: WebSocket | null = null;
12
+ private onMessageCallback: ServiceCallback<Message> | null = null;
13
+ private onStatusChangeCallback: ServiceCallback<StatusChangePayload> | null =
14
+ null;
15
+ private backendUrl = "";
16
+
17
+ public setBackendUrl(url: string) {
18
+ this.backendUrl = url;
19
+ }
20
+
21
+ public connect(
22
+ sessionId: string,
23
+ topicId: string,
24
+ onMessage: ServiceCallback<Message>,
25
+ onStatusChange: ServiceCallback<StatusChangePayload>,
26
+ ) {
27
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
28
+ console.log("WebSocket is already connected.");
29
+ return;
30
+ }
31
+
32
+ this.onMessageCallback = onMessage;
33
+ this.onStatusChangeCallback = onStatusChange;
34
+
35
+ const wsUrl = `${this.backendUrl.replace(
36
+ /^http(s?):\/\//,
37
+ "ws$1://",
38
+ )}/ws/v1/chat/sessions/${sessionId}/topics/${topicId}`;
39
+ this.ws = new WebSocket(wsUrl);
40
+
41
+ this.ws.onopen = () => {
42
+ console.log("XyzenService: WebSocket connected");
43
+ this.onStatusChangeCallback?.({ connected: true, error: null });
44
+ };
45
+
46
+ this.ws.onmessage = (event) => {
47
+ try {
48
+ const messageData = JSON.parse(event.data);
49
+ this.onMessageCallback?.(messageData);
50
+ } catch (error) {
51
+ console.error("XyzenService: Failed to parse message data:", error);
52
+ }
53
+ };
54
+
55
+ this.ws.onclose = () => {
56
+ console.log("XyzenService: WebSocket disconnected");
57
+ this.onStatusChangeCallback?.({
58
+ connected: false,
59
+ error: "Connection closed.",
60
+ });
61
+ };
62
+
63
+ this.ws.onerror = (error) => {
64
+ console.error("XyzenService: WebSocket error:", error);
65
+ this.onStatusChangeCallback?.({
66
+ connected: false,
67
+ error: "A connection error occurred.",
68
+ });
69
+ };
70
+ }
71
+
72
+ public sendMessage(message: string) {
73
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
74
+ this.ws.send(JSON.stringify({ message }));
75
+ } else {
76
+ console.error("XyzenService: WebSocket is not connected.");
77
+ }
78
+ }
79
+
80
+ public disconnect() {
81
+ if (this.ws) {
82
+ this.ws.close();
83
+ this.ws = null;
84
+ }
85
+ }
86
+ }
87
+
88
+ // Export a singleton instance of the service
89
+ const xyzenService = new XyzenService();
90
+ export default xyzenService;