-
-
- {staff.name.charAt(0)}
-
+
{staff.name}
@@ -412,6 +426,7 @@ StaffFeedbackItem.displayName = "StaffFeedbackItem";
export function TicketDetailsSidebar({ ticket }: { ticket: TicketType }) {
const { t } = useTranslation();
+ const setUsers = useUserCacheStore((state) => state.setUsers);
const statusDisplay = getStatusDisplay(ticket?.status, t);
// Fetch technicians and their feedback data
@@ -419,6 +434,24 @@ export function TicketDetailsSidebar({ ticket }: { ticket: TicketType }) {
technicianFeedbackQueryOptions(ticket.id),
);
+ // 🔄 缓存工单相关用户信息
+ useEffect(() => {
+ if (ticket) {
+ const usersToCache = [
+ ticket.customer,
+ ticket.agent,
+ ...ticket.technicians,
+ ].filter(Boolean);
+
+ setUsers(usersToCache);
+
+ // 输出缓存统计信息(开发环境)
+ if (import.meta.env.DEV) {
+ console.log(`📊 [TicketDetailsSidebar] 已缓存 ${usersToCache.length} 个用户`);
+ }
+ }
+ }, [ticket, setUsers]);
+
if (ticket) {
return (
diff --git a/frontend/src/hooks/use-text-optimizer.ts b/frontend/src/hooks/use-text-optimizer.ts
new file mode 100644
index 0000000..2a31c69
--- /dev/null
+++ b/frontend/src/hooks/use-text-optimizer.ts
@@ -0,0 +1,149 @@
+import { useState, useCallback, useRef } from "react";
+import { useToast } from "tentix-ui";
+import { useTranslation } from "i18n";
+
+interface OptimizationResult {
+ optimizedText: string;
+ confidence: number;
+ suggestions: string[];
+ reasoning: string;
+}
+
+interface UseTextOptimizerProps {
+ ticketId: string;
+ messageType?: "public" | "internal";
+ priority?: string;
+}
+
+export function useTextOptimizer({
+ ticketId,
+ messageType = "public",
+ priority
+}: UseTextOptimizerProps) {
+ const [isOptimizing, setIsOptimizing] = useState(false);
+ const [lastOptimization, setLastOptimization] = useState<{
+ original: string;
+ result: OptimizationResult;
+ } | null>(null);
+
+ const { toast } = useToast();
+ const { t } = useTranslation();
+ const abortControllerRef = useRef
(null);
+
+ const optimizeText = useCallback(async (originalText: string): Promise => {
+ const trimmedText = originalText.trim();
+ if (!trimmedText) {
+ toast({
+ title: t("optimization_failed"),
+ description: "文本为空,无法优化",
+ variant: "destructive",
+ });
+ return null;
+ }
+
+ // 取消之前的请求
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ abortControllerRef.current = new AbortController();
+
+ setIsOptimizing(true);
+
+ try {
+ const token = localStorage.getItem("token");
+ if (!token) {
+ throw new Error("未登录");
+ }
+
+ const response = await fetch("/api/optimize/optimize-text", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ originalText: trimmedText,
+ ticketId,
+ messageType,
+ priority,
+ }),
+ signal: abortControllerRef.current.signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ if (data.success) {
+ const result: OptimizationResult = {
+ optimizedText: data.optimizedText,
+ confidence: data.confidence,
+ suggestions: data.suggestions || [],
+ reasoning: data.reasoning || "",
+ };
+
+ setLastOptimization({
+ original: trimmedText,
+ result,
+ });
+
+ toast({
+ title: "优化成功",
+ description: `置信度: ${Math.round(result.confidence * 100)}%`,
+ });
+
+ return result;
+ } else {
+ throw new Error(data.error || "优化失败");
+ }
+ } catch (error: any) {
+ if (error.name === "AbortError") {
+ return null;
+ }
+
+ console.error("Text optimization error:", error);
+ toast({
+ title: "优化失败",
+ description: error.message || "网络错误,请重试",
+ variant: "destructive",
+ });
+ return null;
+ } finally {
+ setIsOptimizing(false);
+ abortControllerRef.current = null;
+ }
+ }, [ticketId, messageType, priority, toast, t]);
+
+ const undoOptimization = useCallback(() => {
+ if (lastOptimization) {
+ const original = lastOptimization.original;
+ setLastOptimization(null);
+
+ toast({
+ title: "已撤销优化",
+ description: "文本已恢复到原始状态",
+ });
+
+ return original;
+ }
+ return null;
+ }, [lastOptimization, toast]);
+
+ const cancelOptimization = useCallback(() => {
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ setIsOptimizing(false);
+ }, []);
+
+ return {
+ isOptimizing,
+ lastOptimization: lastOptimization?.result || null,
+ hasOptimization: !!lastOptimization,
+ optimizeText,
+ undoOptimization,
+ cancelOptimization,
+ };
+}
\ No newline at end of file
diff --git a/frontend/src/modal/use-context-organizer-modal.tsx b/frontend/src/modal/use-context-organizer-modal.tsx
new file mode 100644
index 0000000..c9f882e
--- /dev/null
+++ b/frontend/src/modal/use-context-organizer-modal.tsx
@@ -0,0 +1,335 @@
+import { useState, useCallback, useEffect } from "react";
+import { Dialog, DialogContent, DialogFooter, Button, Textarea, useToast, cn } from "tentix-ui";
+import { CopyIcon, CheckIcon, RefreshCwIcon, FileTextIcon, BotIcon } from "lucide-react";
+
+export interface ContextData {
+ userId: string;
+ userName: string;
+ namespace?: string;
+ region?: string;
+ ticketId: string;
+ priority: string;
+ status: string;
+ title: string;
+ description: string;
+ recentMessages: string;
+ createdAt: string;
+ category?: string;
+ module?: string;
+ organizedText?: string;
+}
+
+interface ContextOrganizerDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ ticketId: string;
+ authToken: string;
+}
+
+export function ContextOrganizerDialog({
+ open,
+ onOpenChange,
+ ticketId,
+ authToken,
+}: ContextOrganizerDialogProps) {
+ const { toast } = useToast();
+ const [contextData, setContextData] = useState(null);
+ const [editableText, setEditableText] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [isCopied, setIsCopied] = useState(false);
+ const [processingStep, setProcessingStep] = useState("");
+
+ const formatContextText = useCallback((data: ContextData): string => {
+ if (data.organizedText) {
+ return data.organizedText;
+ }
+
+ return `===== Tantix 工单上下文信息 =====
+
+🔹 用户信息
+• 用户ID: ${data.userId}
+• 用户名称: ${data.userName}
+• 命名空间: ${data.namespace || '无'}
+• 区域: ${data.region || '无'}
+
+🔹 工单基础信息
+• 工单ID: ${data.ticketId}
+• 标题: ${data.title}
+• 模块: ${data.module || '无'}
+• 分类: ${data.category || '无'}
+• 优先级: ${data.priority}
+• 状态: ${data.status}
+• 创建时间: ${data.createdAt}
+
+🔹 问题描述
+${data.description}
+
+🔹 对话记录
+${data.recentMessages}
+
+===========================
+整理时间: ${new Date().toLocaleString()}`;
+ }, []);
+
+ const handleFetchContext = useCallback(async () => {
+ if (!ticketId || !authToken) {
+ console.log("缺少必要参数:", { ticketId, authToken: authToken ? "存在" : "不存在" });
+ toast({
+ title: "参数错误",
+ description: `缺少必要参数: ${!ticketId ? "工单ID" : "认证令牌"}`,
+ variant: "destructive",
+ });
+ return;
+ }
+
+ console.log("开始获取上下文,参数:", { ticketId, authToken: authToken.substring(0, 10) + "..." });
+ setIsLoading(true);
+ setProcessingStep("tentix 正在获取工单基本信息...");
+
+ try {
+ setProcessingStep("tentix 正在获取工单基本信息...");
+
+ // 修正: 更新API端点路径
+ console.log("发送请求到:", "/api/optimize/get-context-data");
+ const contextResponse = await fetch("/api/optimize/get-context-data", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${authToken}`,
+ },
+ body: JSON.stringify({ ticketId }),
+ });
+
+ console.log("API响应状态:", contextResponse.status);
+
+ if (!contextResponse.ok) {
+ const errorText = await contextResponse.text();
+ console.error("API响应错误:", errorText);
+ throw new Error(`获取数据失败: ${contextResponse.status} - ${errorText}`);
+ }
+
+ const contextResult = await contextResponse.json();
+ if (!contextResult.success) {
+ throw new Error(contextResult.error || "获取数据失败");
+ }
+
+ const rawData = contextResult.data;
+ console.log("获取到的基础数据:", rawData);
+
+ setProcessingStep("tentix 正在分析对话记录和上下文...");
+ await new Promise(resolve => setTimeout(resolve, 800));
+
+ setProcessingStep("tentix 正在智能整理格式...");
+
+ // 修正: 更新AI整理API端点路径
+ const aiResponse = await fetch("/api/optimize/ai-organize", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${authToken}`,
+ },
+ body: JSON.stringify({ rawData }),
+ });
+
+ if (!aiResponse.ok) {
+ throw new Error(`AI整理失败: ${aiResponse.status}`);
+ }
+
+ const aiResult = await aiResponse.json();
+ if (!aiResult.success) {
+ throw new Error(aiResult.error || "AI整理失败");
+ }
+
+ // 修正: 根据新的响应结构获取整理后的文本
+ const organizedText = aiResult.data.organizedText;
+ console.log("AI整理后的数据:", organizedText);
+
+ setProcessingStep("tentix 正在生成内容...");
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ // 创建包含整理后文本的数据对象
+ const organizedData = {
+ ...rawData,
+ organizedText: organizedText
+ };
+
+ setContextData(organizedData);
+ const formattedText = formatContextText(organizedData);
+ console.log("最终格式化文本:", formattedText);
+ setEditableText(formattedText);
+
+ toast({
+ title: "整理成功",
+ description: "AI已成功整理工单上下文",
+ variant: "default",
+ });
+ } catch (error: any) {
+ console.error("获取上下文失败:", error);
+ toast({
+ title: "整理失败",
+ description: error.message || "无法获取工单上下文,请重试",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ setProcessingStep("");
+ }
+ }, [ticketId, authToken, formatContextText, toast]);
+
+ const handleCopy = useCallback(async () => {
+ if (!editableText.trim()) {
+ toast({
+ title: "复制失败",
+ description: "没有内容可复制",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(editableText);
+ setIsCopied(true);
+ toast({
+ title: "复制成功",
+ description: "上下文信息已复制到剪贴板",
+ });
+
+ setTimeout(() => setIsCopied(false), 3000);
+ } catch (error) {
+ console.error("复制失败:", error);
+ toast({
+ title: "复制失败",
+ description: "无法复制到剪贴板,请手动复制",
+ variant: "destructive",
+ });
+ }
+ }, [editableText, toast]);
+
+ const handleRefresh = useCallback(() => {
+ if (contextData) {
+ setEditableText(formatContextText(contextData));
+ toast({
+ title: "已重置",
+ description: "内容已重置为原始格式",
+ });
+ }
+ }, [contextData, formatContextText, toast]);
+
+ useEffect(() => {
+ if (open && ticketId && authToken) {
+ handleFetchContext();
+ }
+ }, [open, ticketId, authToken, handleFetchContext]);
+
+ useEffect(() => {
+ if (!open) {
+ setContextData(null);
+ setEditableText("");
+ setIsCopied(false);
+ }
+ }, [open]);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/store/user-cache.ts b/frontend/src/store/user-cache.ts
new file mode 100644
index 0000000..93e0b59
--- /dev/null
+++ b/frontend/src/store/user-cache.ts
@@ -0,0 +1,197 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+export interface CachedUser {
+ id: number;
+ name: string;
+ nickname: string;
+ avatar: string;
+ role?: string;
+ updatedAt: number;
+}
+
+interface UserCacheStore {
+ users: Map;
+ setUser: (user: Omit) => void;
+ setUsers: (users: Omit[]) => void;
+ getUser: (id: number) => CachedUser | undefined;
+ clearCache: () => void;
+
+ debugInfo: {
+ cacheHits: number;
+ cacheMisses: number;
+ };
+ getCacheStats: () => {
+ cacheHits: number;
+ cacheMisses: number;
+ hitRate: string;
+ totalUsers: number;
+ };
+}
+
+
+export const useUserCacheStore = create()(
+ persist(
+ (set, get) => ({
+ users: new Map(),
+ debugInfo: {
+ cacheHits: 0,
+ cacheMisses: 0,
+ },
+
+ setUser: (user) => set((state) => {
+ const userWithTimestamp: CachedUser = {
+ ...user,
+ updatedAt: Date.now(),
+ };
+ const newUsers = new Map(state.users);
+
+ newUsers.set(user.id, userWithTimestamp);
+
+
+ return { users: newUsers };
+ }),
+
+ setUsers: (users) => set((state) => {
+ const timestamp = Date.now();
+ const newUsers = new Map(state.users);
+
+ users.forEach(user => {
+ newUsers.set(user.id, {
+ ...user,
+ updatedAt: timestamp,
+ });
+ });
+
+ if (import.meta.env.DEV) {
+ console.log(`🔄 [UserCache] 批量缓存 ${users.length} 个用户`);
+ }
+
+ return { users: newUsers };
+ }),
+
+
+ getUser: (id) => {
+ const state = get();
+ const user = state.users.get(id);
+
+ if (!user) {
+
+ set((state) => ({
+ debugInfo: {
+ ...state.debugInfo,
+ cacheMisses: state.debugInfo.cacheMisses + 1,
+ }
+ }));
+
+ if (import.meta.env.DEV) {
+ console.log(`❌ [UserCache] 缓存未命中: 用户 ${id} 不存在`);
+ }
+ return undefined;
+ }
+
+ set((state) => ({
+ debugInfo: {
+ ...state.debugInfo,
+ cacheHits: state.debugInfo.cacheHits + 1,
+ }
+ }));
+
+
+ if (import.meta.env.DEV) {
+ console.log(`✅ [UserCache] 缓存命中: 用户 ${user.name} (ID: ${id})`);
+ }
+
+ return user;
+ },
+
+ clearCache: () => set({
+ users: new Map(),
+ debugInfo: {
+ cacheHits: 0,
+ cacheMisses: 0,
+ }
+ }),
+
+ getCacheStats: () => {
+ const state = get();
+ const { cacheHits, cacheMisses } = state.debugInfo;
+ const totalQueries = cacheHits + cacheMisses;
+ const hitRate = totalQueries > 0
+ ? ((cacheHits / totalQueries) * 100).toFixed(2) + '%'
+ : '0%';
+
+ return {
+ cacheHits,
+ cacheMisses,
+ hitRate,
+ totalUsers: state.users.size,
+ };
+ },
+ }),
+ {
+ name: 'user-cache-storage',
+
+ storage: {
+ getItem: (name) => {
+ try {
+ const str = localStorage.getItem(name);
+ if (!str) return null;
+
+ const { state } = JSON.parse(str);
+ return {
+ state: {
+ ...state,
+
+ users: new Map(Array.isArray(state.users) ? state.users : []),
+ }
+ };
+ } catch (error) {
+ console.error('[UserCache] 解析缓存数据失败:', error);
+ return {
+ state: {
+ users: new Map(),
+ debugInfo: { cacheHits: 0, cacheMisses: 0 }
+ }
+ };
+ }
+ },
+ setItem: (name, value) => {
+ try {
+ const { users, ...rest } = value.state;
+ const serialized = JSON.stringify({
+ state: {
+ ...rest,
+
+ users: Array.from(users.entries()),
+ }
+ });
+ localStorage.setItem(name, serialized);
+ } catch (error) {
+ console.error('[UserCache] 保存缓存数据失败:', error);
+ }
+ },
+ removeItem: (name) => localStorage.removeItem(name),
+ },
+ }
+ )
+);
+
+
+export const logCacheStats = () => {
+ const stats = useUserCacheStore.getState().getCacheStats();
+ console.table({
+ '缓存统计': {
+ '缓存命中': stats.cacheHits,
+ '缓存未命中': stats.cacheMisses,
+ '命中率': stats.hitRate,
+ '缓存用户数': stats.totalUsers,
+ }
+ });
+};
+
+
+if (import.meta.env.DEV) {
+ (window as any).userCacheStats = logCacheStats;
+ (window as any).clearUserCache = () => useUserCacheStore.getState().clearCache();
+}
diff --git a/packages/i18n/index.ts b/packages/i18n/index.ts
index a9a07cb..929c903 100644
--- a/packages/i18n/index.ts
+++ b/packages/i18n/index.ts
@@ -366,6 +366,28 @@ export const translations = {
invalid_or_expired_binding_session:
"Invalid or expired binding session.",
bound_user_not_found: "Bound user not found after binding",
+
+ // Text optimization translations
+ optimization_success: "Optimization successful",
+ optimization_failed: "Optimization failed",
+ optimization_undone: "Optimization undone",
+ text_optimized_successfully: "Text optimized successfully",
+ failed_to_optimize_text: "Failed to optimize text, please try again",
+ empty_text_cannot_optimize: "Empty text cannot be optimized",
+ text_restored_to_original: "Text restored to original state",
+ optimizing: "Optimizing...",
+ confidence: "Confidence",
+ press_tab_to_optimize: "Press Tab to optimize text",
+ optimize_text: "Optimize text",
+ undo_optimization: "Undo optimization",
+ cancel_optimization: "Cancel optimization",
+ enable_tab_optimization: "Enable Tab optimization",
+ ai_optimizing_text: "AI is optimizing text...",
+ text_optimization_completed: "Text optimization completed",
+ optimization_suggestions: "Optimization suggestions",
+ no_text_to_optimize: "Cannot optimize",
+ please_enter_text_first: "Please enter some text first",
+ organize_ticket_context: "Organize ticket context",
},
},
zh: {
@@ -716,6 +738,28 @@ export const translations = {
user_not_found_admin: "未找到用户,请联系管理员。",
invalid_or_expired_binding_session: "绑定会话无效或已过期。",
bound_user_not_found: "绑定后未找到对应用户",
+
+ // Text optimization translations
+ optimization_success: "优化成功",
+ optimization_failed: "优化失败",
+ optimization_undone: "已撤销优化",
+ text_optimized_successfully: "文本已成功优化",
+ failed_to_optimize_text: "文本优化失败,请重试",
+ empty_text_cannot_optimize: "空文本无法优化",
+ text_restored_to_original: "文本已恢复到原始状态",
+ optimizing: "优化中...",
+ confidence: "置信度",
+ press_tab_to_optimize: "按Tab键优化文本",
+ optimize_text: "优化文本",
+ undo_optimization: "撤销优化",
+ cancel_optimization: "取消优化",
+ enable_tab_optimization: "启用Tab键优化",
+ ai_optimizing_text: "AI正在优化文本...",
+ text_optimization_completed: "文本优化完成",
+ optimization_suggestions: "优化建议",
+ no_text_to_optimize: "无法优化",
+ please_enter_text_first: "请先输入一些文本",
+ organize_ticket_context: "整理工单上下文",
},
},
};
diff --git a/packages/ui/uisrc/components/minimal-tiptap/extensions/index.ts b/packages/ui/uisrc/components/minimal-tiptap/extensions/index.ts
index ea7dace..7f457f0 100644
--- a/packages/ui/uisrc/components/minimal-tiptap/extensions/index.ts
+++ b/packages/ui/uisrc/components/minimal-tiptap/extensions/index.ts
@@ -7,3 +7,5 @@ export * from "./selection/index.ts";
export * from "./unset-all-marks/index.ts";
export * from "./file-handler/index.ts";
export * from "./shortcut/index.ts";
+//export * from "./text-optimizer-extension.ts";
+export * from "./text-optimizer/text-optimizer.ts"
diff --git a/packages/ui/uisrc/components/minimal-tiptap/extensions/text-optimizer/index.ts b/packages/ui/uisrc/components/minimal-tiptap/extensions/text-optimizer/index.ts
new file mode 100644
index 0000000..8d585e5
--- /dev/null
+++ b/packages/ui/uisrc/components/minimal-tiptap/extensions/text-optimizer/index.ts
@@ -0,0 +1 @@
+export * from "./text-optimizer.ts"
\ No newline at end of file
diff --git a/packages/ui/uisrc/components/minimal-tiptap/extensions/text-optimizer/text-optimizer.ts b/packages/ui/uisrc/components/minimal-tiptap/extensions/text-optimizer/text-optimizer.ts
new file mode 100644
index 0000000..8438310
--- /dev/null
+++ b/packages/ui/uisrc/components/minimal-tiptap/extensions/text-optimizer/text-optimizer.ts
@@ -0,0 +1,64 @@
+import { Extension } from "@tiptap/core"
+import { Plugin, PluginKey } from "@tiptap/pm/state"
+
+export interface TextOptimizerOptions {
+ onOptimize?: (text: string) => Promise
+ isOptimizing?: boolean
+ enabled?: boolean
+}
+
+export const TextOptimizer = Extension.create({
+ name: "textOptimizer",
+
+ addOptions() {
+ return {
+ onOptimize: async () => {},
+ isOptimizing: false,
+ enabled: true,
+ }
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Tab: ({ editor }) => {
+ console.log("TextOptimizer Tab triggered", {
+ enabled: this.options.enabled,
+ isOptimizing: this.options.isOptimizing,
+ })
+
+ if (!this.options.enabled || this.options.isOptimizing) {
+ console.log("⚠️ TextOptimizer conditions not met")
+ return false
+ }
+
+ const text = editor.getText().trim()
+ console.log("📝 TextOptimizer text:", text)
+
+ // 只有有文本内容时才触发优化
+ if (text.length > 0) {
+ console.log("✅ TextOptimizer triggering optimization")
+ this.options.onOptimize?.(text)
+ return true
+ }
+
+ console.log("⚠️ TextOptimizer no text to optimize")
+ return true
+ },
+ }
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: new PluginKey("textOptimizerStatus"),
+ props: {
+ handleKeyDown: (view, event) => {
+ return false
+ },
+ },
+ }),
+ ]
+ },
+})
+
+export default TextOptimizer
diff --git a/packages/ui/uisrc/components/minimal-tiptap/hooks/use-minimal-tiptap.ts b/packages/ui/uisrc/components/minimal-tiptap/hooks/use-minimal-tiptap.ts
index 2f368a4..2c3160b 100644
--- a/packages/ui/uisrc/components/minimal-tiptap/hooks/use-minimal-tiptap.ts
+++ b/packages/ui/uisrc/components/minimal-tiptap/hooks/use-minimal-tiptap.ts
@@ -38,6 +38,7 @@ export interface UseMinimalTiptapEditorProps extends UseEditorOptions {
enablePerformanceMode?: boolean;
isSSR?: boolean;
editorProps?: any;
+ extensions?: any[];
}
type FileUploadErrorReason =
@@ -56,6 +57,7 @@ const fileUploadErrorMapping: Record = {
const createExtensions = (
placeholder: string,
toast: (...args: any[]) => void,
+ customExtensions: any[] = [],
) => [
StarterKit.configure({
horizontalRule: false,
@@ -165,6 +167,7 @@ const createExtensions = (
HorizontalRule,
CodeBlockLowlight,
Placeholder.configure({ placeholder: () => placeholder }),
+ ...customExtensions,
];
export const useMinimalTiptapEditor = ({
@@ -178,6 +181,7 @@ export const useMinimalTiptapEditor = ({
enablePerformanceMode = true,
isSSR = false,
editorProps: externalEditorProps,
+ extensions: customExtensions = [],
...props
}: UseMinimalTiptapEditorProps) => {
const { toast } = useToast();
@@ -242,7 +246,7 @@ export const useMinimalTiptapEditor = ({
};
const baseConfig: UseEditorOptions = {
- extensions: createExtensions(placeholder, toast),
+ extensions: createExtensions(placeholder, toast, customExtensions),
editorProps: mergedEditorProps, // 🔥 使用合并后的 editorProps
onUpdate: ({ editor }: { editor: Editor }) => handleUpdate(editor),
onCreate: ({ editor }: { editor: Editor }) => handleCreate(editor),
@@ -269,6 +273,7 @@ export const useMinimalTiptapEditor = ({
}, [
placeholder,
toast,
+ customExtensions,
enablePerformanceMode,
isSSR,
editorClassName,
diff --git a/packages/ui/uisrc/components/minimal-tiptap/staff-chat-editor.tsx b/packages/ui/uisrc/components/minimal-tiptap/staff-chat-editor.tsx
index 72c9548..66ec747 100644
--- a/packages/ui/uisrc/components/minimal-tiptap/staff-chat-editor.tsx
+++ b/packages/ui/uisrc/components/minimal-tiptap/staff-chat-editor.tsx
@@ -1,13 +1,16 @@
import { EditorContent, type Content } from "@tiptap/react";
-import { EyeIcon, EyeOffIcon } from "lucide-react";
-import { forwardRef, useImperativeHandle, useState } from "react";
+import { EyeIcon, EyeOffIcon, SparklesIcon } from "lucide-react";
+import { forwardRef, useImperativeHandle, useState, useCallback } from "react";
import { cn } from "uisrc/lib/utils.ts";
import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group.tsx";
import { LinkBubbleMenu } from "./components/bubble-menu/link-bubble-menu.tsx";
import { MeasuredContainer } from "./components/measured-container.tsx";
import { SectionTwo } from "./components/section/two.tsx";
+import { ToolbarButton } from "./components/toolbar-button.tsx";
import { useMinimalTiptapEditor } from "./hooks/use-minimal-tiptap.ts";
import type { MinimalTiptapProps } from "./minimal-tiptap.tsx";
+import { TextOptimizer } from "./extensions/text-optimizer/text-optimizer.ts";
+
import "./styles/index.css";
import { useTranslation } from "i18n";
@@ -15,17 +18,138 @@ export interface EditorRef {
isInternal: boolean;
clearContent: () => void;
getJSON: () => Content;
+ optimizeText?: () => void;
+}
+
+export interface StaffChatEditorProps extends MinimalTiptapProps {
+ ticketId?: string;
+ authToken?: string;
}
-export const StaffChatEditor = forwardRef(
+export const StaffChatEditor = forwardRef(
function ChatEditor(
- { value, onChange, className, editorContentClassName, ...props },
+ { value, onChange, className, editorContentClassName, ticketId, authToken, ...props },
ref,
) {
const { t } = useTranslation();
const [messageType, setMessageType] = useState<"public" | "internal">(
"public",
);
+ const [isOptimizing, setIsOptimizing] = useState(false);
+ const [optimizeError, setOptimizeError] = useState("");
+
+ // 清除错误状态
+ const clearError = useCallback(() => {
+ if (optimizeError) {
+ setOptimizeError("");
+ }
+ }, [optimizeError]);
+
+ // 优化文本的核心逻辑
+ const handleOptimizeText = useCallback(async (text: string) => {
+ if (!authToken || !ticketId) return;
+
+ setIsOptimizing(true);
+ setOptimizeError("");
+
+ try {
+ const response = await fetch("/api/optimize/optimize-text", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${authToken}`,
+ },
+ body: JSON.stringify({
+ originalText: text,
+ ticketId,
+ messageType,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ if (data.success && data.optimizedText) {
+ // 更新编辑器内容
+ const optimizedContent = {
+ type: "doc",
+ content: [{
+ type: "paragraph",
+ content: [{ type: "text", text: data.optimizedText }],
+ }],
+ };
+ editor?.commands.setContent(optimizedContent);
+ } else {
+ throw new Error(data.error || "优化失败");
+ }
+ } catch (error: any) {
+ console.error("优化失败:", error);
+ setOptimizeError(error.message || "优化失败,请重试");
+ } finally {
+ setIsOptimizing(false);
+ }
+ }, [authToken, ticketId, messageType]);
+
+ // 内联的优化按钮组件
+ const OptimizeButton = () => {
+ const getButtonContent = () => {
+ if (isOptimizing) {
+ return (
+
+ );
+ }
+
+ if (optimizeError) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ };
+
+ const getTooltip = () => {
+ if (isOptimizing) {
+ return "AI 优化中...";
+ }
+
+ if (optimizeError) {
+ return optimizeError;
+ }
+
+ return "TAB ⌘ ↹";
+ };
+
+ return (
+ {
+ const text = editor?.getText()?.trim();
+ if (text) {
+ handleOptimizeText(text);
+ }
+ }}
+ disabled={!editor?.getText()?.trim() || isOptimizing}
+ tooltip={getTooltip()}
+ className={cn(
+ "!w-9 !h-9 transition-colors",
+ {
+ "hover:bg-blue-50": !isOptimizing && !optimizeError,
+ "hover:bg-red-50": optimizeError && !isOptimizing,
+ "bg-blue-50": isOptimizing,
+ "bg-red-50": optimizeError && !isOptimizing,
+ }
+ )}
+ aria-label={getTooltip()}
+ >
+ {getButtonContent()}
+
+ );
+ };
const editor = useMinimalTiptapEditor({
value,
@@ -35,6 +159,13 @@ export const StaffChatEditor = forwardRef(
messageType === "public"
? t("type_your_message")
: t("add_internal_note"),
+ extensions: authToken && ticketId ? [
+ TextOptimizer.configure({
+ enabled: true,
+ isOptimizing,
+ onOptimize: handleOptimizeText,
+ }),
+ ] : [],
...props,
});
@@ -44,13 +175,14 @@ export const StaffChatEditor = forwardRef(
},
isInternal: messageType === "internal",
getJSON: () => editor?.getJSON() as Content,
+ optimizeText: () => {
+ const text = editor?.getText()?.trim();
+ if (text) {
+ handleOptimizeText(text);
+ }
+ },
}));
- // TODO: 添加模板选择功能
- // const handleTemplateSelect = (content: string) => {
- // editor?.commands.insertContent(content);
- // };
-
if (!editor) {
return null;
}
@@ -83,7 +215,11 @@ export const StaffChatEditor = forwardRef(
size="sm"
className="!w-9 !h-9"
/>
+
+ {/* AI 优化按钮 */}
+ {authToken && ticketId && }
+
{/*
*/}
diff --git a/server/api/ai-tool/index.ts b/server/api/ai-tool/index.ts
new file mode 100644
index 0000000..f701e38
--- /dev/null
+++ b/server/api/ai-tool/index.ts
@@ -0,0 +1,422 @@
+import { z } from "zod";
+import { describeRoute } from "hono-openapi";
+import { resolver, validator as zValidator } from "hono-openapi/zod";
+import { factory, authMiddleware } from "../middleware.ts";
+import { connectDB } from "@/utils/tools.ts";
+import { eq, desc, and } from "drizzle-orm";
+import * as schema from "@/db/schema.ts";
+import { optimizeTextWithAI } from "@/utils/kb/text-optimizer.ts";
+import { organizeContextWithAI } from "@/utils/kb/context-organizer.ts";
+
+const getContextDataSchema = z.object({
+ ticketId: z.string(),
+});
+
+const getContextDataResponseSchema = z.object({
+ success: z.boolean(),
+ data: z.object({
+ userId: z.string(),
+ userName: z.string(),
+ namespace: z.string(),
+ region: z.string(),
+ ticketId: z.string(),
+ priority: z.string(),
+ status: z.string(),
+ title: z.string(),
+ description: z.string(),
+ recentMessages: z.string(),
+ createdAt: z.string(),
+ category: z.string(),
+ module: z.string(),
+ }).optional(),
+ error: z.string().optional(),
+});
+
+const organizeContextSchema = z.object({
+ rawData: z.object({
+ userId: z.string(),
+ userName: z.string(),
+ namespace: z.string(),
+ region: z.string(),
+ ticketId: z.string(),
+ priority: z.string(),
+ status: z.string(),
+ title: z.string(),
+ description: z.string(),
+ recentMessages: z.string(),
+ createdAt: z.string(),
+ category: z.string(),
+ module: z.string(),
+ }),
+});
+
+const organizeContextResponseSchema = z.object({
+ success: z.boolean(),
+ data: z.object({
+ organizedText: z.string(),
+ }),
+ error: z.string().optional(),
+});
+
+const optimizeTextSchema = z.object({
+ originalText: z.string().min(1).max(2000),
+ ticketId: z.string().length(13),
+ messageType: z.enum(["public", "internal"]).default("public"),
+ priority: z.enum(["low", "medium", "high", "urgent"]).optional(),
+});
+
+const optimizeTextResponseSchema = z.object({
+ success: z.boolean(),
+ optimizedText: z.string(),
+ confidence: z.number().min(0).max(1),
+ suggestions: z.array(z.string()),
+ reasoning: z.string(),
+ error: z.string().optional(),
+});
+
+// ============== 路由定义 ==============
+
+export const optimizeRouter = factory
+ .createApp()
+ .use(authMiddleware)
+
+ // 获取工单基础数据
+ .post(
+ "/get-context-data",
+ describeRoute({
+ tags: ["Optimize"],
+ summary: "获取工单基础数据",
+ description: "第一步:获取工单的基础信息,包括用户信息、工单详情和对话记录",
+ security: [{ bearerAuth: [] }],
+ responses: {
+ 200: {
+ description: "成功获取工单数据",
+ content: {
+ "application/json": { schema: resolver(getContextDataResponseSchema) },
+ },
+ },
+ },
+ }),
+ zValidator("json", getContextDataSchema),
+ async (c) => {
+ const { ticketId } = c.req.valid("json");
+ const db = connectDB();
+
+ try {
+ const ticket = await db.query.tickets.findFirst({
+ where: (t, { eq }) => eq(t.id, ticketId),
+ columns: {
+ id: true,
+ title: true,
+ description: true,
+ module: true,
+ category: true,
+ priority: true,
+ status: true,
+ customerId: true,
+ createdAt: true,
+ sealosNamespace: true,
+ area: true,
+ },
+ });
+
+ if (!ticket) {
+ return c.json({ success: false, error: "工单不存在" }, 404);
+ }
+
+ // 获取客户信息
+ console.log('🔍 Debug - ticket.customerId:', ticket.customerId, 'type:', typeof ticket.customerId);
+ const customer = await db.query.users.findFirst({
+ where: (u, { eq }) => eq(u.id, ticket.customerId),
+ columns: {
+ id: true,
+ name: true,
+ },
+ });
+ console.log('🔍 Debug - found customer:', customer);
+
+ // 获取最近的消息记录
+ const messages = await db.query.chatMessages.findMany({
+ where: (m, { and, eq }) => and(
+ eq(m.ticketId, ticketId),
+ eq(m.isInternal, false)
+ ),
+ orderBy: [desc(schema.chatMessages.createdAt)],
+ limit: 10,
+ columns: {
+ content: true,
+ createdAt: true,
+ },
+ with: {
+ sender: {
+ columns: {
+ name: true,
+ role: true,
+ },
+ },
+ },
+ });
+
+ // 格式化消息记录
+ const formattedMessages = messages
+ .reverse()
+ .map((msg, index) => {
+ const senderName = msg.sender?.name || '未知';
+ const senderRole = msg.sender?.role || '未知';
+
+ let content = '';
+ if (typeof msg.content === 'string') {
+ content = msg.content;
+ } else if (msg.content && typeof msg.content === 'object') {
+ try {
+ const jsonContent = msg.content as any;
+ if (jsonContent.content && Array.isArray(jsonContent.content)) {
+ content = jsonContent.content
+ .map((node: any) => {
+ if (node.type === 'paragraph' && node.content) {
+ return node.content
+ .map((textNode: any) => textNode.text || '')
+ .join('');
+ }
+ return '';
+ })
+ .join('\n');
+ } else {
+ content = JSON.stringify(jsonContent);
+ }
+ } catch (e) {
+ content = JSON.stringify(msg.content);
+ }
+ }
+
+ const time = new Date(msg.createdAt).toLocaleString();
+ return `${index + 1}. [${senderName}(${senderRole})] ${time}\n ${content}`;
+ })
+ .join('\n\n');
+
+ // 处理工单描述
+ let descriptionText = '';
+ if (ticket.description) {
+ if (typeof ticket.description === 'string') {
+ descriptionText = ticket.description;
+ } else {
+ try {
+ const desc = ticket.description as any;
+ if (desc.content && Array.isArray(desc.content)) {
+ descriptionText = desc.content
+ .map((node: any) => {
+ if (node.type === 'paragraph' && node.content) {
+ return node.content
+ .map((textNode: any) => textNode.text || '')
+ .join('');
+ }
+ return '';
+ })
+ .join('\n');
+ } else {
+ descriptionText = JSON.stringify(desc);
+ }
+ } catch (e) {
+ descriptionText = JSON.stringify(ticket.description);
+ }
+ }
+ }
+
+ const rawData = {
+ userId: customer?.id?.toString() || '未知',
+ userName: customer?.name || '未知',
+ namespace: ticket.sealosNamespace || '无',
+ region: ticket.area || '无',
+ ticketId: ticket.id,
+ priority: ticket.priority || '无',
+ status: ticket.status,
+ title: ticket.title || '无标题',
+ description: descriptionText || '无描述',
+ recentMessages: formattedMessages || '无对话记录',
+ createdAt: new Date(ticket.createdAt).toLocaleString(),
+ category: ticket.category || '无',
+ module: ticket.module || '无',
+ };
+
+ console.log('🔍 Debug - final rawData.userId:', rawData.userId, 'userName:', rawData.userName);
+
+ return c.json({
+ success: true,
+ data: rawData,
+ });
+ } catch (error: any) {
+ console.error("获取上下文数据失败:", error);
+ return c.json(
+ {
+ success: false,
+ error: error.message || "获取数据失败,请重试"
+ },
+ 500
+ );
+ }
+ },
+ )
+
+ // 整理工单上下文 - 修正API路径
+ .post(
+ "/ai-organize",
+ describeRoute({
+ tags: ["Optimize"],
+ summary: "整理工单上下文",
+ description: "第二步:使用AI整理工单上下文信息,生成结构化的技术工单摘要",
+ security: [{ bearerAuth: [] }],
+ responses: {
+ 200: {
+ description: "成功整理上下文",
+ content: {
+ "application/json": { schema: resolver(organizeContextResponseSchema) },
+ },
+ },
+ },
+ }),
+ zValidator("json", organizeContextSchema),
+ async (c) => {
+ const { rawData } = c.req.valid("json");
+
+ try {
+ if (!rawData) {
+ return c.json({
+ success: false,
+ error: "缺少原始数据,请先调用 get-context-data 接口"
+ }, 400);
+ }
+
+ const organizedText = await organizeContextWithAI(rawData);
+
+ return c.json({
+ success: true,
+ data: {
+ organizedText,
+ },
+ });
+ } catch (error: any) {
+ console.error("整理上下文失败:", error);
+ return c.json(
+ {
+ success: false,
+ error: error.message || "整理上下文失败,请重试"
+ },
+ 500
+ );
+ }
+ },
+ )
+
+ // 优化文本
+ .post(
+ "/optimize-text",
+ describeRoute({
+ tags: ["Optimize"],
+ summary: "优化文本",
+ description: "第三步:使用AI优化客服回复文本,使其更加专业、清晰、友好",
+ security: [{ bearerAuth: [] }],
+ responses: {
+ 200: {
+ description: "文本优化成功",
+ content: {
+ "application/json": { schema: resolver(optimizeTextResponseSchema) },
+ },
+ },
+ },
+ }),
+ zValidator("json", optimizeTextSchema),
+ async (c) => {
+ const { originalText, ticketId, messageType, priority } = c.req.valid("json");
+ const userId = c.var.userId;
+ const role = c.var.role;
+
+ console.log('🎯 Optimize API called:', { originalText, ticketId, messageType, userId, role });
+
+ try {
+ const db = connectDB();
+
+ // 获取工单上下文
+ const ticket = await db.query.tickets.findFirst({
+ where: eq(schema.tickets.id, ticketId),
+ with: {
+ messages: {
+ orderBy: desc(schema.chatMessages.createdAt),
+ limit: 5,
+ with: {
+ sender: {
+ columns: { role: true, name: true }
+ }
+ }
+ }
+ }
+ });
+
+ if (!ticket) {
+ return c.json({
+ success: false,
+ error: "工单不存在"
+ }, 404);
+ }
+
+ console.log('📄 Ticket found:', !!ticket);
+
+ // 构建上下文并进行AI优化
+ console.log('🤖 Starting AI optimization...');
+ const result = await optimizeTextWithAI({
+ originalText,
+ ticketModule: ticket.module || "",
+ ticketCategory: ticket.category || "",
+ ticketDescription: extractTextContent(ticket.description) || "",
+ recentMessages: ticket.messages
+ ?.slice(0, 3)
+ .map((msg: any) => `${msg.sender.role}: ${extractTextContent(msg.content)}`)
+ .join("\n") || "",
+ messageType,
+ priority
+ });
+
+ return c.json({
+ success: true,
+ ...result
+ });
+
+ } catch (error) {
+ console.error("Text optimization error:", error);
+ return c.json({
+ success: false,
+ error: "优化文本失败,请重试"
+ }, 500);
+ }
+ }
+ );
+
+function extractTextContent(content: any): string {
+ if (typeof content === "string") return content;
+ if (!content || typeof content !== "object") return "";
+
+ try {
+ if (content.type === "doc" && Array.isArray(content.content)) {
+ return content.content
+ .map((node: any) => extractNodeText(node))
+ .filter(Boolean)
+ .join(" ");
+ }
+ return "";
+ } catch {
+ return "";
+ }
+}
+
+function extractNodeText(node: any): string {
+ if (!node || typeof node !== "object") return "";
+
+ if (node.type === "text") return node.text || "";
+ if (node.type === "paragraph" && Array.isArray(node.content)) {
+ return node.content.map(extractNodeText).join("");
+ }
+ if (Array.isArray(node.content)) {
+ return node.content.map(extractNodeText).join("");
+ }
+
+ return "";
+}
diff --git a/server/api/index.ts b/server/api/index.ts
index 81487a8..fa54be5 100644
--- a/server/api/index.ts
+++ b/server/api/index.ts
@@ -19,6 +19,8 @@ import { startAllJobs } from "@/utils/jobs/kb-jobs/index.ts";
import "@/utils/events/handoff/index.ts";
import { kbRouter } from "./kb/index.ts";
import { logInfo } from "@/utils/log.ts";
+import { optimizeRouter } from "./ai-tool/index.ts";
+
const app = factory.createApp();
@@ -74,6 +76,14 @@ app.use(
{
name: "Auth",
},
+ {
+ name: "Chat",
+ description: "Chat related endpoints",
+ },
+ {
+ name: "Optimize",
+ description: "AI optimization related endpoints",
+ },
{
name: "Playground",
description: "Test endpoint. Not for production use.",
@@ -110,6 +120,7 @@ app.use(
},
}),
);
+
app.get(
"/api/reference",
Scalar({
@@ -118,6 +129,7 @@ app.get(
hideClientButton: true,
}),
);
+
app.get("/health", (c) => c.json({ status: "ok" }));
const routes = app // RPC routes
@@ -126,11 +138,13 @@ const routes = app // RPC routes
.route("/ticket", ticketRouter)
.route("/auth", authRouter)
.route("/chat", chatRouter)
+ .route("/optimize", optimizeRouter)
.route("/file", fileRouter)
.route("/admin", adminRouter)
.route("/feishu", feishuRouter)
.route("/feedback", feedbackRouter)
.route("/kb", kbRouter);
+
if (global.customEnv.NODE_ENV !== "production") {
routes.route("/playground", playgroundRouter);
}
diff --git a/server/api/precede.ts b/server/api/precede.ts
index 205a151..ff3f668 100644
--- a/server/api/precede.ts
+++ b/server/api/precede.ts
@@ -23,6 +23,9 @@ const envSchema = z.object({
ENCRYPTION_KEY: z.string().base64().trim(),
SEALOS_APP_TOKEN: z.string().trim().optional(),
+ TAB_CHAT_MODEL:z.string().trim().optional(),
+
+
OPENAI_BASE_URL: z.string().url().trim().optional(),
OPENAI_API_KEY: z.string().trim().optional(),
SUMMARY_MODEL: z.string().trim().optional(),
diff --git a/server/utils/kb/config.ts b/server/utils/kb/config.ts
index 0f3772d..d95e9f0 100644
--- a/server/utils/kb/config.ts
+++ b/server/utils/kb/config.ts
@@ -7,6 +7,7 @@ export const SOURCE_WEIGHTS: Record
= {
export const OPENAI_CONFIG = {
baseURL: global.customEnv.OPENAI_BASE_URL,
apiKey: global.customEnv.OPENAI_API_KEY,
+ tabChatModel: global.customEnv.TAB_CHAT_MODEL,
summaryModel: global.customEnv.SUMMARY_MODEL,
fastModel: global.customEnv.FAST_MODEL,
chatModel: global.customEnv.CHAT_MODEL,
diff --git a/server/utils/kb/context-organizer.ts b/server/utils/kb/context-organizer.ts
new file mode 100644
index 0000000..5241b38
--- /dev/null
+++ b/server/utils/kb/context-organizer.ts
@@ -0,0 +1,146 @@
+import { ChatOpenAI } from "@langchain/openai";
+import { OPENAI_CONFIG } from "@/utils/kb/config.ts";
+
+export interface RawTicketData {
+ userId: string;
+ userName: string;
+ namespace: string;
+ region: string;
+ ticketId: string;
+ priority: string;
+ status: string;
+ title: string;
+ description: string;
+ recentMessages: string;
+ createdAt: string;
+ category: string;
+ module: string;
+}
+
+/**
+ * 使用AI整理工单上下文信息
+ * @param rawData 原始工单数据
+ * @returns 整理后的上下文文本
+ */
+export async function organizeContextWithAI(rawData: RawTicketData): Promise {
+ try {
+ const model = new ChatOpenAI({
+ apiKey: OPENAI_CONFIG.apiKey,
+ model: OPENAI_CONFIG.tabChatModel,
+ temperature: 0.3,
+ maxTokens: 1000,
+ configuration: {
+ baseURL: OPENAI_CONFIG.baseURL,
+ },
+ });
+
+ const systemPrompt = `
+你是专业的工单上下文整理助手,负责将工单信息整理成清晰、专业、结构化的格式,方便技术人员快速了解工单情况。
+
+## 整理原则
+1. **准确性**: 保持所有信息的准确性,不要编造或修改原始数据
+2. **结构化**: 使用清晰的层次结构和格式
+3. **专业性**: 使用技术术语,保持专业的表达方式
+4. **完整性**: 包含所有关键信息,便于技术分析
+
+## 输出格式要求
+请严格按照以下格式整理工单上下文:
+
+用户信息
+• 用户ID: [用户ID]
+• 用户名称: [用户名称]
+• 命名空间: [命名空间]
+• 区域: [区域]
+
+工单基础信息
+• 工单ID: [工单ID]
+• 标题: [工单标题]
+• 模块: [相关模块]
+• 分类: [工单分类]
+• 优先级: [优先级]
+• 状态: [当前状态]
+• 创建时间: [创建时间]
+
+问题描述
+[整理和总结问题描述,使其更清晰易懂,自然段总结]
+
+对话记录摘要
+[总结最近的关键对话内容,突出重要信息]
+
+🔧 技术要点
+• 涉及组件: [相关技术组件]
+• 问题类型: [问题分类]
+• 影响范围: [影响评估]
+
+请确保格式整洁,信息准确,便于技术人员快速理解工单状况。
+`;
+
+ const userPrompt = `
+请根据以下工单原始数据,按照上述格式要求进行专业整理:
+
+原始数据:
+- 用户名称: ${rawData.userName}
+- 用户ID: ${rawData.userId}
+- 命名空间: ${rawData.namespace}
+- 区域: ${rawData.region}
+- 工单ID: ${rawData.ticketId}
+- 标题: ${rawData.title}
+- 模块: ${rawData.module}
+- 分类: ${rawData.category}
+- 优先级: ${rawData.priority}
+- 状态: ${rawData.status}
+- 创建时间: ${rawData.createdAt}
+- 问题描述: ${rawData.description}
+- 最近对话: ${rawData.recentMessages}
+
+请整理成专业的技术工单上下文信息。
+`;
+
+ const response = await model.invoke([
+ { role: "system", content: systemPrompt },
+ { role: "user", content: userPrompt }
+ ]);
+
+ const content = typeof response.content === "string"
+ ? response.content
+ : JSON.stringify(response.content);
+
+ return content;
+ } catch (error) {
+ console.error("AI整理上下文失败:", error);
+
+ // 返回后备格式
+ return generateFallbackFormat(rawData);
+ }
+}
+
+/**
+ * 生成后备格式(当AI服务不可用时)
+ * @param rawData 原始工单数据
+ * @returns 基础格式的上下文文本
+ */
+function generateFallbackFormat(rawData: RawTicketData): string {
+ return `
+📋 用户信息
+• 用户ID: ${rawData.userId}
+• 用户名称: ${rawData.userName}
+• 命名空间: ${rawData.namespace}
+• 区域: ${rawData.region}
+
+🎫 工单基础信息
+• 工单ID: ${rawData.ticketId}
+• 标题: ${rawData.title}
+• 模块: ${rawData.module}
+• 分类: ${rawData.category}
+• 优先级: ${rawData.priority}
+• 状态: ${rawData.status}
+• 创建时间: ${rawData.createdAt}
+
+🔍 问题描述
+${rawData.description}
+
+💬 对话记录
+${rawData.recentMessages}
+
+(注: AI整理服务暂时不可用,显示基础格式)`;
+}
diff --git a/server/utils/kb/text-optimizer.ts b/server/utils/kb/text-optimizer.ts
new file mode 100644
index 0000000..a52c0f3
--- /dev/null
+++ b/server/utils/kb/text-optimizer.ts
@@ -0,0 +1,170 @@
+import { ChatOpenAI } from "@langchain/openai";
+import { z } from "zod";
+import { OPENAI_CONFIG } from "@/utils/kb/config.ts";
+
+export interface OptimizationContext {
+ originalText: string;
+ ticketModule: string;
+ ticketCategory: string;
+ ticketDescription: string;
+ recentMessages: string;
+ messageType: "public" | "internal";
+ priority?: string;
+}
+
+export interface OptimizationResult {
+ optimizedText: string;
+ confidence: number;
+ suggestions: string[];
+ reasoning: string;
+}
+
+const optimizationSchema = z.object({
+ optimizedText: z.string(),
+ confidence: z.number().min(0).max(1),
+ suggestions: z.array(z.string()),
+ reasoning: z.string(),
+});
+
+/**
+ * 使用AI优化文本
+ * @param context 优化上下文
+ * @returns 优化结果
+ */
+export async function optimizeTextWithAI(
+ context: OptimizationContext
+): Promise {
+
+ console.log('🚀 Creating ChatOpenAI model...');
+
+ try {
+ const model = new ChatOpenAI({
+ apiKey: OPENAI_CONFIG.apiKey,
+ model: OPENAI_CONFIG.tabChatModel,
+ temperature: 0.3,
+ maxTokens: 1000,
+ configuration: {
+ baseURL: OPENAI_CONFIG.baseURL,
+ },
+ });
+
+ const systemPrompt = `
+你是专业的客服文本优化助手,负责优化客服回复文本,使其更加专业、清晰、友好。
+
+## 优化原则
+1. **专业性**: 使用准确的技术术语,避免口语化表达
+2. **清晰性**: 逻辑清晰,步骤明确,易于理解
+3. **友好性**: 保持礼貌和耐心,体现服务意识
+4. **简洁性**: 去除冗余,突出重点信息
+5. **一致性**: 与工单主题和上下文保持一致
+
+## 特殊要求
+- 如果是内部消息(internal),可以更加技术化和简洁
+- 如果是公开消息(public),需要更加友好和易懂
+- 根据优先级调整回复的紧急程度表达
+
+## 输出格式
+请严格按照以下JSON格式输出:
+{
+ "optimizedText": "优化后的文本",
+ "confidence": 0.95,
+ "suggestions": ["建议1", "建议2"],
+ "reasoning": "优化理由"
+}
+
+confidence: 0-1之间的数值,表示优化质量
+suggestions: 2-3条改进建议
+reasoning: 简要说明优化思路
+`;
+
+ const userPrompt = `
+## 工单信息
+- 模块: ${context.ticketModule}
+- 分类: ${context.ticketCategory}
+- 描述: ${context.ticketDescription}
+- 消息类型: ${context.messageType}
+- 优先级: ${context.priority || "未设置"}
+
+## 最近对话
+${context.recentMessages}
+
+## 待优化文本
+${context.originalText}
+
+请根据以上上下文优化文本,确保专业性和一致性。
+`;
+
+ console.log('🔄 Invoking AI model...');
+ const response = await model.invoke([
+ { role: "system", content: systemPrompt },
+ { role: "user", content: userPrompt }
+ ]);
+
+ const content = typeof response.content === "string"
+ ? response.content
+ : JSON.stringify(response.content);
+
+
+ let cleanContent = content.trim();
+
+
+ if (cleanContent.startsWith('```json')) {
+ cleanContent = cleanContent.replace(/^```json\s*/, '').replace(/\s*```$/, '');
+ } else if (cleanContent.startsWith('```')) {
+ cleanContent = cleanContent.replace(/^```\s*/, '').replace(/\s*```$/, '');
+ }
+
+
+ const jsonStart = cleanContent.indexOf('{');
+ const jsonEnd = cleanContent.lastIndexOf('}');
+
+ if (jsonStart !== -1 && jsonEnd !== -1 && jsonEnd > jsonStart) {
+ cleanContent = cleanContent.substring(jsonStart, jsonEnd + 1);
+ }
+
+ console.log('✅ Cleaned content:', cleanContent.substring(0, 200) + '...');
+
+ const result = optimizationSchema.parse(JSON.parse(cleanContent));
+ return result;
+ } catch (error) {
+ console.error("❌ AI optimization failed:", error);
+ return generateFallbackOptimization(context);
+ }
+}
+
+/**
+ * 生成后备优化结果(当AI服务不可用时)
+ * @param context 优化上下文
+ * @returns 基础优化结果
+ */
+function generateFallbackOptimization(context: OptimizationContext): OptimizationResult {
+
+ let optimizedText = context.originalText;
+
+
+ optimizedText = optimizedText
+ .replace(/\s+/g, ' ')
+ .trim();
+
+
+ if (context.messageType === 'public') {
+
+ if (!optimizedText.includes('您好') && !optimizedText.includes('你好')) {
+ optimizedText = '您好,' + optimizedText;
+ }
+ if (!optimizedText.includes('谢谢') && !optimizedText.includes('感谢')) {
+ optimizedText += ',感谢您的理解。';
+ }
+ }
+
+ return {
+ optimizedText,
+ confidence: 0.3,
+ suggestions: [
+ "AI优化服务暂时不可用,请手动检查文本",
+ "建议检查语法和专业术语的准确性",
+ "确保回复符合客服标准"
+ ],
+ reasoning: "AI优化服务不可用,应用了基础文本清理规则"
+ };
+}