Skip to content

Commit cea03c7

Browse files
committed
feat: add tab navigation, text cleanup and avatar caching
1 parent 8918e5a commit cea03c7

28 files changed

+2508
-128
lines changed
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import React, { useState, useCallback } from "react";
2+
import { type JSONContentZod } from "tentix-server/types";
3+
import { useTextOptimizer } from "../../hooks/use-text-optimizer";
4+
import { OptimizeButton, OptimizeStatus } from "./optimize-button";
5+
import { useToast } from "tentix-ui";
6+
7+
interface EnhancedMessageInputProps {
8+
ticketId: string;
9+
messageType?: "public" | "internal";
10+
priority?: string;
11+
onSendMessage: (content: JSONContentZod) => Promise<void>;
12+
onTyping?: () => void;
13+
isLoading?: boolean;
14+
children: React.ReactElement;
15+
}
16+
17+
export function EnhancedMessageInput({
18+
ticketId,
19+
messageType = "public",
20+
priority,
21+
onTyping,
22+
isLoading,
23+
children,
24+
}: EnhancedMessageInputProps) {
25+
const { toast } = useToast();
26+
const [currentText, setCurrentText] = useState("");
27+
const [isEnabled, setIsEnabled] = useState(true);
28+
29+
const {
30+
isOptimizing,
31+
lastOptimization,
32+
hasOptimization,
33+
optimizeText,
34+
undoOptimization,
35+
cancelOptimization,
36+
} = useTextOptimizer({ ticketId, messageType, priority });
37+
38+
39+
const extractTextFromContent = useCallback((content: JSONContentZod): string => {
40+
if (!content || !content.content) return "";
41+
42+
const extractFromNode = (node: any): string => {
43+
if (typeof node === "string") return node;
44+
if (node.type === "text") return node.text || "";
45+
if (node.content && Array.isArray(node.content)) {
46+
return node.content.map(extractFromNode).join("");
47+
}
48+
return "";
49+
};
50+
51+
return content.content.map(extractFromNode).join(" ").trim();
52+
}, []);
53+
54+
55+
const createOptimizedContent = useCallback((optimizedText: string): JSONContentZod => {
56+
return {
57+
type: "doc",
58+
content: [
59+
{
60+
type: "paragraph",
61+
content: [{ type: "text", text: optimizedText }],
62+
},
63+
],
64+
};
65+
}, []);
66+
67+
68+
const handleOptimize = useCallback(async (text?: string) => {
69+
const textToOptimize = text || currentText;
70+
if (!textToOptimize.trim()) {
71+
toast({
72+
title: "无法优化",
73+
description: "请先输入一些文本",
74+
variant: "destructive",
75+
});
76+
return;
77+
}
78+
79+
const result = await optimizeText(textToOptimize);
80+
if (result) {
81+
82+
const childRef = (children as any)?.ref;
83+
if (childRef?.current) {
84+
const optimizedContent = createOptimizedContent(result.optimizedText);
85+
childRef.current.setContent?.(optimizedContent);
86+
setCurrentText(result.optimizedText);
87+
}
88+
}
89+
}, [currentText, optimizeText, createOptimizedContent, toast, children]);
90+
91+
92+
const handleUndo = useCallback(() => {
93+
const originalText = undoOptimization();
94+
if (originalText) {
95+
const childRef = (children as any)?.ref;
96+
if (childRef?.current) {
97+
const originalContent = createOptimizedContent(originalText);
98+
childRef.current.setContent?.(originalContent);
99+
setCurrentText(originalText);
100+
}
101+
}
102+
}, [undoOptimization, createOptimizedContent, children]);
103+
104+
105+
const childProps = children.props as any;
106+
const enhancedChildren = React.cloneElement(children, {
107+
...childProps,
108+
onChange: (content: JSONContentZod) => {
109+
110+
if (childProps.onChange) {
111+
childProps.onChange(content);
112+
}
113+
114+
115+
const text = extractTextFromContent(content);
116+
setCurrentText(text);
117+
118+
119+
if (onTyping) {
120+
onTyping();
121+
}
122+
},
123+
124+
125+
editorProps: {
126+
...childProps.editorProps,
127+
handleKeyDown: (view: any, event: KeyboardEvent) => {
128+
129+
if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
130+
event.preventDefault();
131+
if (isEnabled && !isOptimizing && currentText.trim()) {
132+
handleOptimize();
133+
return true;
134+
}
135+
}
136+
137+
138+
if (childProps.editorProps?.handleKeyDown) {
139+
return childProps.editorProps.handleKeyDown(view, event);
140+
}
141+
142+
return false;
143+
},
144+
},
145+
});
146+
147+
return (
148+
<div className="border-t relative">
149+
150+
<div className="flex items-center justify-between p-2 border-b bg-gray-50/50">
151+
<div className="flex items-center gap-3">
152+
<OptimizeButton
153+
isOptimizing={isOptimizing}
154+
hasOptimization={hasOptimization}
155+
confidence={lastOptimization?.confidence}
156+
onOptimize={() => handleOptimize()}
157+
onUndo={handleUndo}
158+
onCancel={cancelOptimization}
159+
disabled={isLoading || !isEnabled}
160+
/>
161+
162+
<label className="flex items-center gap-2 text-xs">
163+
<input
164+
type="checkbox"
165+
checked={isEnabled}
166+
onChange={(e) => setIsEnabled(e.target.checked)}
167+
className="w-3 h-3"
168+
/>
169+
<span className="text-gray-600">启用Tab键优化</span>
170+
</label>
171+
</div>
172+
173+
<OptimizeStatus
174+
isOptimizing={isOptimizing}
175+
hasOptimization={hasOptimization}
176+
confidence={lastOptimization?.confidence}
177+
suggestions={lastOptimization?.suggestions}
178+
/>
179+
</div>
180+
181+
182+
{enhancedChildren}
183+
</div>
184+
);
185+
}

frontend/src/components/chat/message-item.tsx

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { type TicketType } from "tentix-server/rpc";
1313
import {
1414
Avatar,
1515
AvatarFallback,
16-
AvatarImage,
1716
Badge,
1817
Button,
1918
Popover,
@@ -28,6 +27,8 @@ import {
2827
} from "tentix-ui";
2928
import useLocalUser from "../../hooks/use-local-user.tsx";
3029
import { useChatStore, useSessionMembersStore } from "../../store/index.ts";
30+
import { useUserCacheStore } from "@store/user-cache";
31+
import { CachedAvatar } from "@comp/common/cached-avatar";
3132
import ContentRenderer from "./content-renderer.tsx";
3233
import { useTranslation } from "i18n";
3334
import { memo, useState, useEffect } from "react";
@@ -39,13 +40,37 @@ interface MessageItemProps {
3940
message: TicketType["messages"][number];
4041
}
4142

43+
44+
const useMessageSender = (message: TicketType["messages"][number]) => {
45+
const { sessionMembers } = useSessionMembersStore();
46+
const setUser = useUserCacheStore((state: any) => state.setUser);
47+
48+
const messageSender = sessionMembers?.find(
49+
(member) => member.id === message.senderId,
50+
);
51+
52+
// 缓存消息发送者信息
53+
useEffect(() => {
54+
if (messageSender) {
55+
setUser({
56+
id: messageSender.id,
57+
name: messageSender.name,
58+
nickname: messageSender.nickname || messageSender.name,
59+
avatar: messageSender.avatar,
60+
role: messageSender.role || 'unknown'
61+
});
62+
}
63+
}, [messageSender, setUser]);
64+
65+
return messageSender;
66+
};
67+
4268
// other's message component
4369
const OtherMessage = ({
4470
message,
4571
}: {
4672
message: TicketType["messages"][number];
4773
}) => {
48-
const { sessionMembers } = useSessionMembersStore();
4974
const { role } = useLocalUser();
5075
const { currentTicketId, updateMessage } = useChatStore();
5176
const notCustomer = role !== "customer";
@@ -57,9 +82,8 @@ const OtherMessage = ({
5782
const [feedbackComment, setFeedbackComment] = useState("");
5883
const [hasComplaint, setHasComplaint] = useState(false);
5984

60-
const messageSender = sessionMembers?.find(
61-
(member) => member.id === message.senderId,
62-
);
85+
// 使用提取的Hook
86+
const messageSender = useMessageSender(message);
6387

6488
// Get current feedback status
6589
const currentFeedback = message.feedbacks?.[0];
@@ -158,15 +182,17 @@ const OtherMessage = ({
158182
return (
159183
<div className="flex flex-col animate-fadeIn justify-start">
160184
<div className="flex max-w-[85%] gap-3 min-w-0">
161-
<Avatar className="h-8 w-8 shrink-0">
162-
<AvatarImage
163-
src={messageSender?.avatar}
164-
alt={messageSender?.nickname ?? t("unknown")}
185+
{messageSender ? (
186+
<CachedAvatar
187+
user={messageSender}
188+
size="md"
189+
showDebugInfo={import.meta.env.DEV}
165190
/>
166-
<AvatarFallback>
167-
{messageSender?.nickname?.charAt(0) ?? "U"}
168-
</AvatarFallback>
169-
</Avatar>
191+
) : (
192+
<Avatar className="h-8 w-8 shrink-0">
193+
<AvatarFallback>U</AvatarFallback>
194+
</Avatar>
195+
)}
170196
<div
171197
className={cn(
172198
"flex flex-col gap-2 min-w-0 flex-1",
@@ -434,27 +460,28 @@ const MyMessage = ({
434460
}: {
435461
message: TicketType["messages"][number];
436462
}) => {
437-
const { sessionMembers } = useSessionMembersStore();
438463
const { isMessageSending, withdrawMessageFunc: withdrawMessage, kbSelectionMode } =
439464
useChatStore();
440465
const { t } = useTranslation();
441466

442-
const messageSender = sessionMembers?.find(
443-
(member) => member.id === message.senderId,
444-
);
467+
// 使用提取的Hook
468+
const messageSender = useMessageSender(message);
445469

446470
return (
447471
<div className="flex animate-fadeIn justify-end">
448472
<div className="flex max-w-[85%] flex-row-reverse min-w-0">
449-
<Avatar className="h-8 w-8 shrink-0 ml-3">
450-
<AvatarImage
451-
src={messageSender?.avatar}
452-
alt={messageSender?.nickname ?? t("unknown")}
473+
{messageSender ? (
474+
<CachedAvatar
475+
user={messageSender}
476+
size="md"
477+
className="ml-3"
478+
showDebugInfo={import.meta.env.DEV}
453479
/>
454-
<AvatarFallback>
455-
{messageSender?.nickname?.charAt(0) ?? "U"}
456-
</AvatarFallback>
457-
</Avatar>
480+
) : (
481+
<Avatar className="h-8 w-8 shrink-0 ml-3">
482+
<AvatarFallback>U</AvatarFallback>
483+
</Avatar>
484+
)}
458485
<div
459486
className={cn(
460487
"flex flex-col gap-2 rounded-xl py-4 px-5 ml-1 min-w-0 flex-1",

0 commit comments

Comments
 (0)