Skip to content

Commit 95e8613

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

File tree

24 files changed

+2408
-105
lines changed

24 files changed

+2408
-105
lines changed

.env.template

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

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)