work-agent 0.1.0

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.
Files changed (245) hide show
  1. package/README.md +234 -0
  2. package/app/(admin)/approvals/page.tsx +16 -0
  3. package/app/(admin)/audit/page.tsx +18 -0
  4. package/app/(admin)/layout.tsx +47 -0
  5. package/app/(admin)/scheduled-tasks/page.tsx +17 -0
  6. package/app/(admin)/settings/page.tsx +46 -0
  7. package/app/(admin)/skills/[name]/page.tsx +378 -0
  8. package/app/(admin)/skills/page.tsx +406 -0
  9. package/app/(admin)/statistics/page.tsx +416 -0
  10. package/app/(admin)/tickets/[id]/page.tsx +348 -0
  11. package/app/(admin)/tickets/new/page.tsx +309 -0
  12. package/app/(admin)/tickets/page.tsx +27 -0
  13. package/app/api/audit/route.ts +30 -0
  14. package/app/api/auth/feishu/callback/route.ts +72 -0
  15. package/app/api/auth/feishu/login/route.ts +17 -0
  16. package/app/api/auth/feishu/sso/route.ts +78 -0
  17. package/app/api/auth/login/route.ts +85 -0
  18. package/app/api/auth/oauth/route.ts +168 -0
  19. package/app/api/config/providers/route.ts +105 -0
  20. package/app/api/config/route.ts +115 -0
  21. package/app/api/config/status/route.ts +56 -0
  22. package/app/api/config/test/route.ts +212 -0
  23. package/app/api/documents/[id]/route.ts +88 -0
  24. package/app/api/documents/route.ts +53 -0
  25. package/app/api/health/route.ts +32 -0
  26. package/app/api/knowledge/[id]/route.ts +152 -0
  27. package/app/api/knowledge/from-session/route.ts +27 -0
  28. package/app/api/knowledge/route.ts +100 -0
  29. package/app/api/market/knowledge/[id]/route.ts +92 -0
  30. package/app/api/market/knowledge/route.ts +130 -0
  31. package/app/api/marketplace/skills/[id]/approve/route.ts +68 -0
  32. package/app/api/marketplace/skills/[id]/certify/route.ts +54 -0
  33. package/app/api/marketplace/skills/[id]/install/route.ts +180 -0
  34. package/app/api/marketplace/skills/[id]/promote-to-system/route.ts +219 -0
  35. package/app/api/marketplace/skills/[id]/rate/route.ts +90 -0
  36. package/app/api/marketplace/skills/[id]/ratings/route.ts +55 -0
  37. package/app/api/marketplace/skills/[id]/reject/route.ts +68 -0
  38. package/app/api/marketplace/skills/[id]/route.ts +177 -0
  39. package/app/api/marketplace/skills/route.ts +235 -0
  40. package/app/api/memory/route.ts +40 -0
  41. package/app/api/my/files/[id]/route.ts +52 -0
  42. package/app/api/my/files/route.ts +230 -0
  43. package/app/api/my/knowledge/route.ts +36 -0
  44. package/app/api/pi-chat/route.ts +443 -0
  45. package/app/api/recommend/route.ts +38 -0
  46. package/app/api/scheduled-tasks/[id]/execute/route.ts +132 -0
  47. package/app/api/scheduled-tasks/[id]/route.ts +165 -0
  48. package/app/api/scheduled-tasks/[id]/toggle/route.ts +53 -0
  49. package/app/api/scheduled-tasks/route.ts +101 -0
  50. package/app/api/sessions/[id]/messages/route.ts +212 -0
  51. package/app/api/sessions/route.ts +101 -0
  52. package/app/api/share/file/[id]/route.ts +37 -0
  53. package/app/api/skills/[name]/execute/route.ts +121 -0
  54. package/app/api/skills/[name]/route.ts +167 -0
  55. package/app/api/skills/create/route.ts +65 -0
  56. package/app/api/skills/generate/route.ts +405 -0
  57. package/app/api/skills/installed/route.ts +151 -0
  58. package/app/api/skills/route.ts +174 -0
  59. package/app/api/skills/translate/route.ts +40 -0
  60. package/app/api/skills/user/[name]/route.ts +159 -0
  61. package/app/api/skills/user/route.ts +90 -0
  62. package/app/api/statistics/route.ts +94 -0
  63. package/app/api/task-executions/[id]/route.ts +34 -0
  64. package/app/api/task-executions/route.ts +29 -0
  65. package/app/api/tickets/[id]/approve/route.ts +129 -0
  66. package/app/api/tickets/[id]/execute/route.ts +201 -0
  67. package/app/api/tickets/[id]/route.ts +127 -0
  68. package/app/api/tickets/route.ts +103 -0
  69. package/app/api/user/skills/route.ts +175 -0
  70. package/app/api/users/route.ts +80 -0
  71. package/app/chat/page.tsx +5 -0
  72. package/app/globals.css +84 -0
  73. package/app/h5/layout.tsx +5 -0
  74. package/app/h5/mobile-approvals-page.tsx +167 -0
  75. package/app/h5/mobile-chat-page.tsx +951 -0
  76. package/app/h5/mobile-profile-page.tsx +147 -0
  77. package/app/h5/mobile-tickets-page.tsx +121 -0
  78. package/app/h5/page.tsx +23 -0
  79. package/app/h5/ticket-action-buttons.tsx +80 -0
  80. package/app/layout.tsx +26 -0
  81. package/app/login/page.tsx +318 -0
  82. package/app/market/knowledge/[id]/page.tsx +77 -0
  83. package/app/market/knowledge/page.tsx +358 -0
  84. package/app/market/layout.tsx +29 -0
  85. package/app/market/page.tsx +18 -0
  86. package/app/market/skills/page.tsx +397 -0
  87. package/app/my/files/page.tsx +511 -0
  88. package/app/my/knowledge/[id]/page.tsx +271 -0
  89. package/app/my/knowledge/new/page.tsx +234 -0
  90. package/app/my/knowledge/page.tsx +248 -0
  91. package/app/my/layout.tsx +32 -0
  92. package/app/my/memory/page.tsx +164 -0
  93. package/app/my/page.tsx +18 -0
  94. package/app/my/scheduled-tasks/[id]/edit/page.tsx +290 -0
  95. package/app/my/scheduled-tasks/[id]/executions/page.tsx +275 -0
  96. package/app/my/scheduled-tasks/[id]/page.tsx +284 -0
  97. package/app/my/scheduled-tasks/new/page.tsx +230 -0
  98. package/app/my/scheduled-tasks/page.tsx +27 -0
  99. package/app/my/skills/[name]/page.tsx +320 -0
  100. package/app/my/skills/new/page.tsx +394 -0
  101. package/app/my/skills/page.tsx +303 -0
  102. package/app/page.tsx +2288 -0
  103. package/app/share/[sessionId]/page.tsx +226 -0
  104. package/app/share/file/[id]/page.tsx +140 -0
  105. package/bin/README.md +63 -0
  106. package/bin/generate-api-system +300 -0
  107. package/bin/postinstall.js +95 -0
  108. package/bin/work-agent.js +173 -0
  109. package/components/ai-elements/agent.tsx +142 -0
  110. package/components/ai-elements/artifact.tsx +149 -0
  111. package/components/ai-elements/attachments.tsx +427 -0
  112. package/components/ai-elements/audio-player.tsx +232 -0
  113. package/components/ai-elements/canvas.tsx +26 -0
  114. package/components/ai-elements/chain-of-thought.tsx +223 -0
  115. package/components/ai-elements/checkpoint.tsx +72 -0
  116. package/components/ai-elements/code-block.tsx +555 -0
  117. package/components/ai-elements/commit.tsx +449 -0
  118. package/components/ai-elements/confirmation.tsx +173 -0
  119. package/components/ai-elements/connection.tsx +28 -0
  120. package/components/ai-elements/context.tsx +410 -0
  121. package/components/ai-elements/controls.tsx +19 -0
  122. package/components/ai-elements/conversation.tsx +167 -0
  123. package/components/ai-elements/edge.tsx +144 -0
  124. package/components/ai-elements/environment-variables.tsx +325 -0
  125. package/components/ai-elements/file-tree.tsx +298 -0
  126. package/components/ai-elements/image.tsx +25 -0
  127. package/components/ai-elements/inline-citation.tsx +294 -0
  128. package/components/ai-elements/jsx-preview.tsx +250 -0
  129. package/components/ai-elements/message.tsx +367 -0
  130. package/components/ai-elements/mic-selector.tsx +372 -0
  131. package/components/ai-elements/model-selector.tsx +214 -0
  132. package/components/ai-elements/node.tsx +72 -0
  133. package/components/ai-elements/open-in-chat.tsx +367 -0
  134. package/components/ai-elements/package-info.tsx +235 -0
  135. package/components/ai-elements/panel.tsx +16 -0
  136. package/components/ai-elements/persona.tsx +280 -0
  137. package/components/ai-elements/plan.tsx +144 -0
  138. package/components/ai-elements/prompt-input.tsx +1341 -0
  139. package/components/ai-elements/queue.tsx +275 -0
  140. package/components/ai-elements/reasoning.tsx +355 -0
  141. package/components/ai-elements/sandbox.tsx +133 -0
  142. package/components/ai-elements/schema-display.tsx +473 -0
  143. package/components/ai-elements/shimmer.tsx +78 -0
  144. package/components/ai-elements/snippet.tsx +141 -0
  145. package/components/ai-elements/sources.tsx +78 -0
  146. package/components/ai-elements/speech-input.tsx +324 -0
  147. package/components/ai-elements/stack-trace.tsx +531 -0
  148. package/components/ai-elements/suggestion.tsx +58 -0
  149. package/components/ai-elements/task.tsx +88 -0
  150. package/components/ai-elements/terminal.tsx +277 -0
  151. package/components/ai-elements/test-results.tsx +497 -0
  152. package/components/ai-elements/tool.tsx +174 -0
  153. package/components/ai-elements/toolbar.tsx +17 -0
  154. package/components/ai-elements/transcription.tsx +126 -0
  155. package/components/ai-elements/voice-selector.tsx +525 -0
  156. package/components/ai-elements/web-preview.tsx +282 -0
  157. package/components/audit-log-list.tsx +114 -0
  158. package/components/chat/EmptyPreviewState.tsx +12 -0
  159. package/components/chat/KnowledgePickerDialog.tsx +464 -0
  160. package/components/chat/KnowledgePreview.tsx +70 -0
  161. package/components/chat/KnowledgePreviewPanel.tsx +86 -0
  162. package/components/chat/MentionInput.tsx +309 -0
  163. package/components/chat/OrganizeDialog.tsx +258 -0
  164. package/components/chat/RecommendationBanner.tsx +94 -0
  165. package/components/chat/SaveToKnowledgeDialog.tsx +193 -0
  166. package/components/chat/SkillSelector.tsx +305 -0
  167. package/components/chat/SkillSwitcher.tsx +163 -0
  168. package/components/client-layout.tsx +15 -0
  169. package/components/knowledge/KnowledgeMetadataPanel.tsx +293 -0
  170. package/components/layout-wrapper.tsx +18 -0
  171. package/components/mobile-layout.tsx +62 -0
  172. package/components/scheduled-task-list.tsx +356 -0
  173. package/components/setup-guide.tsx +484 -0
  174. package/components/sub-nav.tsx +54 -0
  175. package/components/ticket-detail-content.tsx +383 -0
  176. package/components/ticket-list.tsx +366 -0
  177. package/components/top-nav.tsx +132 -0
  178. package/components/ui/accordion.tsx +58 -0
  179. package/components/ui/alert.tsx +59 -0
  180. package/components/ui/avatar.tsx +50 -0
  181. package/components/ui/badge.tsx +36 -0
  182. package/components/ui/button-group.tsx +83 -0
  183. package/components/ui/button.tsx +57 -0
  184. package/components/ui/card.tsx +91 -0
  185. package/components/ui/carousel.tsx +262 -0
  186. package/components/ui/collapsible.tsx +11 -0
  187. package/components/ui/command.tsx +153 -0
  188. package/components/ui/dialog.tsx +122 -0
  189. package/components/ui/dropdown-menu.tsx +200 -0
  190. package/components/ui/hover-card.tsx +29 -0
  191. package/components/ui/input-group.tsx +170 -0
  192. package/components/ui/input.tsx +22 -0
  193. package/components/ui/label.tsx +26 -0
  194. package/components/ui/popover.tsx +31 -0
  195. package/components/ui/progress.tsx +28 -0
  196. package/components/ui/scroll-area.tsx +48 -0
  197. package/components/ui/select.tsx +174 -0
  198. package/components/ui/separator.tsx +31 -0
  199. package/components/ui/spinner.tsx +16 -0
  200. package/components/ui/switch.tsx +29 -0
  201. package/components/ui/table.tsx +120 -0
  202. package/components/ui/tabs.tsx +55 -0
  203. package/components/ui/textarea.tsx +22 -0
  204. package/components/ui/tooltip.tsx +30 -0
  205. package/components/welcome-guide.tsx +182 -0
  206. package/components.json +24 -0
  207. package/lib/command-parser.ts +331 -0
  208. package/lib/dangerous-commands.ts +672 -0
  209. package/lib/db.ts +2250 -0
  210. package/lib/feishu-auth.ts +135 -0
  211. package/lib/file-storage.ts +306 -0
  212. package/lib/file-tool.ts +583 -0
  213. package/lib/knowledge-tool.ts +152 -0
  214. package/lib/knowledge-types.ts +66 -0
  215. package/lib/market-client.ts +313 -0
  216. package/lib/market-db.ts +736 -0
  217. package/lib/market-types.ts +51 -0
  218. package/lib/memory-tool.ts +211 -0
  219. package/lib/memory.ts +197 -0
  220. package/lib/pi-config.ts +436 -0
  221. package/lib/pi-session.ts +799 -0
  222. package/lib/pinyin.ts +13 -0
  223. package/lib/recommendation.ts +227 -0
  224. package/lib/risk-estimator.ts +350 -0
  225. package/lib/scheduled-task-tool.ts +184 -0
  226. package/lib/scheduler-init.ts +43 -0
  227. package/lib/scheduler.ts +416 -0
  228. package/lib/secure-bash-tool.ts +413 -0
  229. package/lib/skill-engine.ts +396 -0
  230. package/lib/skill-generator.ts +269 -0
  231. package/lib/skill-loader.ts +234 -0
  232. package/lib/skill-tool.ts +188 -0
  233. package/lib/skill-types.ts +82 -0
  234. package/lib/skills-init.ts +58 -0
  235. package/lib/ticket-tool.ts +246 -0
  236. package/lib/user-skill-types.ts +30 -0
  237. package/lib/user-skills.ts +362 -0
  238. package/lib/utils.ts +6 -0
  239. package/lib/workflow.ts +154 -0
  240. package/lib/zip-tool.ts +191 -0
  241. package/next.config.js +8 -0
  242. package/package.json +106 -0
  243. package/public/.gitkeep +1 -0
  244. package/public/icon.svg +1 -0
  245. package/tsconfig.json +42 -0
@@ -0,0 +1,951 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect } from 'react';
4
+ import { Send, Bot, User, Plus, X, Loader2, BookOpen, ChevronDown, Brain, Check, Share2, MessageSquare, Trash2, ChevronRight } from 'lucide-react';
5
+ import { Button } from '@/components/ui/button';
6
+ import { cn } from '@/lib/utils';
7
+ import {
8
+ ChainOfThought,
9
+ ChainOfThoughtContent,
10
+ ChainOfThoughtHeader,
11
+ ChainOfThoughtStep,
12
+ } from '@/components/ai-elements/chain-of-thought';
13
+ import { SkillSwitcher } from '@/components/chat/SkillSwitcher';
14
+ import { MentionInput } from '@/components/chat/MentionInput';
15
+ import { KnowledgePickerDialog } from '@/components/chat/KnowledgePickerDialog';
16
+ import { TicketActionButtons } from './ticket-action-buttons';
17
+
18
+ function generateUUID(): string {
19
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
20
+ return crypto.randomUUID();
21
+ }
22
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
23
+ const r = (Math.random() * 16) | 0;
24
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
25
+ return v.toString(16);
26
+ });
27
+ }
28
+
29
+ type Message = {
30
+ id: string;
31
+ role: 'user' | 'assistant' | 'system';
32
+ content: string;
33
+ timestamp: Date;
34
+ isStreaming?: boolean;
35
+ ticket?: {
36
+ id: string;
37
+ title: string;
38
+ type: string;
39
+ status: string;
40
+ priority: string;
41
+ description?: string;
42
+ };
43
+ };
44
+
45
+ type SessionInfo = {
46
+ sessionId: string;
47
+ userId?: string;
48
+ createdAt: string;
49
+ lastAccessedAt: string;
50
+ messageCount: number;
51
+ title?: string;
52
+ recentSummary?: string;
53
+ };
54
+
55
+ type ToolExecution = {
56
+ toolName: string;
57
+ startTime: Date;
58
+ endTime?: Date;
59
+ input?: any;
60
+ output?: any;
61
+ isError?: boolean;
62
+ };
63
+
64
+ type MessageChainOfThought = {
65
+ isStreaming: boolean;
66
+ thinking: string;
67
+ toolExecutions: ToolExecution[];
68
+ };
69
+
70
+ type PIEvent = {
71
+ type: 'session_start' | 'session_restored' | 'text' | 'thinking' | 'tool_start' | 'tool_output' | 'tool_end' | 'turn_start' | 'turn_end' | 'message_end' | 'agent_end' | 'error';
72
+ content?: string;
73
+ toolName?: string;
74
+ args?: any;
75
+ sessionId?: string;
76
+ isError?: boolean;
77
+ message?: any;
78
+ messages?: any[];
79
+ toolResults?: any[];
80
+ error?: string;
81
+ };
82
+
83
+ export default function MobileChatPage() {
84
+ const [sessionId, setSessionId] = useState<string | null>(null);
85
+ const [messages, setMessages] = useState<Message[]>([]);
86
+ const [input, setInput] = useState('');
87
+ const [messageChainOfThought, setMessageChainOfThought] = useState<Record<string, MessageChainOfThought>>({});
88
+ const [loading, setLoading] = useState(false);
89
+ const [currentTool, setCurrentTool] = useState<string | null>(null);
90
+ const [userId, setUserId] = useState<string>('');
91
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
92
+ const [skillNameMap, setSkillNameMap] = useState<Record<string, string>>({});
93
+ const messagesEndRef = useRef<HTMLDivElement>(null);
94
+ const abortControllerRef = useRef<AbortController | null>(null);
95
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
96
+ const lastMessageCountRef = useRef(0);
97
+ const [showScrollButton, setShowScrollButton] = useState(false);
98
+ const [scrollMode, setScrollMode] = useState<'auto' | 'follow'>('auto');
99
+ const [availableSkills, setAvailableSkills] = useState<{ name: string; displayName?: string; description?: string }[]>([]);
100
+ const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
101
+ const [knowledgePickerOpen, setKnowledgePickerOpen] = useState(false);
102
+ const [skillSwitcherOpen, setSkillSwitcherOpen] = useState(false);
103
+ const [shareCopied, setShareCopied] = useState(false);
104
+ const [isConfigured, setIsConfigured] = useState(true);
105
+ const [configChecked, setConfigChecked] = useState(false);
106
+
107
+ // 会话列表相关状态
108
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
109
+ const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
110
+
111
+ const loadSkillNameMap = async (uid: string) => {
112
+ try {
113
+ const response = await fetch(`/api/skills?userId=${uid}`);
114
+ if (response.ok) {
115
+ const skills = await response.json();
116
+ const map: Record<string, string> = {};
117
+ for (const skill of skills) {
118
+ if (skill.name && skill.displayName) {
119
+ map[skill.name] = skill.displayName;
120
+ }
121
+ }
122
+ setSkillNameMap(map);
123
+ }
124
+ } catch (error) {
125
+ console.error('Failed to load skill name map:', error);
126
+ }
127
+ };
128
+
129
+ useEffect(() => {
130
+ const checkLoginStatus = () => {
131
+ const userId = localStorage.getItem('userId');
132
+ setIsLoggedIn(!!userId);
133
+ if (userId) {
134
+ setUserId(userId);
135
+ loadSkillNameMap(userId);
136
+ }
137
+ };
138
+ checkLoginStatus();
139
+ const handleStorageChange = () => checkLoginStatus();
140
+ window.addEventListener('storage', handleStorageChange);
141
+ return () => window.removeEventListener('storage', handleStorageChange);
142
+ }, []);
143
+
144
+ useEffect(() => {
145
+ async function checkConfig() {
146
+ try {
147
+ const response = await fetch('/api/config/status');
148
+ if (response.ok) {
149
+ const data = await response.json();
150
+ setIsConfigured(data.isConfigured);
151
+ }
152
+ } catch (e) {
153
+ console.error('Failed to check config:', e);
154
+ } finally {
155
+ setConfigChecked(true);
156
+ }
157
+ }
158
+ checkConfig();
159
+ }, []);
160
+
161
+ useEffect(() => {
162
+ async function loadSkills() {
163
+ const uid = localStorage.getItem('userId');
164
+ if (!uid) return;
165
+ try {
166
+ const response = await fetch(`/api/user/skills?userId=${uid}`);
167
+ if (response.ok) {
168
+ const data = await response.json();
169
+ const userSkills = data.skills || [];
170
+ setAvailableSkills(userSkills.map((s: any) => ({
171
+ name: s.skillName,
172
+ displayName: s.skillName,
173
+ description: s.isBuiltin ? '内置Skill' : (s.source === 'personal' ? '个人Skill' : '已安装'),
174
+ })));
175
+ }
176
+ } catch (error) {
177
+ console.error('Failed to load user skills:', error);
178
+ }
179
+ }
180
+ loadSkills();
181
+ }, []);
182
+
183
+ useEffect(() => {
184
+ const initSession = async () => {
185
+ const savedSessionId = localStorage.getItem('assistant-session-id');
186
+ if (savedSessionId) {
187
+ setSessionId(savedSessionId);
188
+ try {
189
+ const response = await fetch(`/api/sessions/${savedSessionId}/messages`);
190
+ if (response.ok) {
191
+ const data = await response.json();
192
+ const backendMessages = data.messages || [];
193
+ if (backendMessages.length > 0) {
194
+ const converted: Message[] = [];
195
+ const cotData: Record<string, MessageChainOfThought> = {};
196
+ backendMessages.forEach((m: any) => {
197
+ converted.push({
198
+ id: m.id,
199
+ role: m.role as 'user' | 'assistant',
200
+ content: m.content,
201
+ timestamp: new Date(m.timestamp),
202
+ });
203
+ if (m.chainOfThought) {
204
+ cotData[m.id] = {
205
+ isStreaming: false,
206
+ thinking: m.chainOfThought.thinking || '',
207
+ toolExecutions: (m.chainOfThought.toolExecutions || []).map((t: any) => ({
208
+ ...t,
209
+ startTime: new Date(t.startTime),
210
+ endTime: t.endTime ? new Date(t.endTime) : undefined,
211
+ }))
212
+ };
213
+ }
214
+ });
215
+ setMessages(converted);
216
+ setMessageChainOfThought(cotData);
217
+ }
218
+ }
219
+ } catch (error) {
220
+ console.error('Failed to load messages:', error);
221
+ }
222
+ } else {
223
+ const newSessionId = generateUUID();
224
+ setSessionId(newSessionId);
225
+ localStorage.setItem('assistant-session-id', newSessionId);
226
+ }
227
+ };
228
+ initSession();
229
+ }, []);
230
+
231
+ useEffect(() => {
232
+ if (sessionId) {
233
+ localStorage.setItem('assistant-session-id', sessionId);
234
+ }
235
+ }, [sessionId]);
236
+
237
+ // 加载会话列表
238
+ const loadSessions = async () => {
239
+ try {
240
+ const currentUserId = localStorage.getItem('userId');
241
+ const url = currentUserId ? `/api/sessions?userId=${encodeURIComponent(currentUserId)}` : '/api/sessions';
242
+ const response = await fetch(url);
243
+ const backendSessions: SessionInfo[] = response.ok ? (await response.json()).sessions || [] : [];
244
+
245
+ const localSessions: SessionInfo[] = JSON.parse(localStorage.getItem('assistant-local-sessions') || '[]');
246
+ const filteredLocalSessions = currentUserId
247
+ ? localSessions.filter(s => s.userId === currentUserId)
248
+ : localSessions;
249
+
250
+ const backendIds = new Set(backendSessions.map(s => s.sessionId));
251
+ const mergedSessions = [
252
+ ...backendSessions,
253
+ ...filteredLocalSessions.filter(s => !backendIds.has(s.sessionId))
254
+ ];
255
+
256
+ mergedSessions.sort((a, b) =>
257
+ new Date(b.lastAccessedAt).getTime() - new Date(a.lastAccessedAt).getTime()
258
+ );
259
+
260
+ setSessions(mergedSessions);
261
+ } catch (error) {
262
+ console.error('Failed to load sessions:', error);
263
+ }
264
+ };
265
+
266
+ // 创建新会话
267
+ const createNewSession = () => {
268
+ const newSessionId = generateUUID();
269
+ const now = new Date().toISOString();
270
+ const currentUserId = localStorage.getItem('userId') || undefined;
271
+
272
+ setSessionId(newSessionId);
273
+ setMessages([]);
274
+ setMessageChainOfThought({});
275
+ localStorage.setItem('assistant-session-id', newSessionId);
276
+ setSessionDrawerOpen(false);
277
+
278
+ const localSessions: SessionInfo[] = JSON.parse(localStorage.getItem('assistant-local-sessions') || '[]');
279
+ const newSession: SessionInfo = {
280
+ sessionId: newSessionId,
281
+ userId: currentUserId,
282
+ createdAt: now,
283
+ lastAccessedAt: now,
284
+ messageCount: 0,
285
+ };
286
+
287
+ if (!localSessions.find(s => s.sessionId === newSessionId)) {
288
+ localSessions.unshift(newSession);
289
+ localStorage.setItem('assistant-local-sessions', JSON.stringify(localSessions));
290
+ }
291
+
292
+ loadSessions();
293
+ };
294
+
295
+ // 切换会话
296
+ const switchSession = async (targetSessionId: string) => {
297
+ if (targetSessionId === sessionId) {
298
+ setSessionDrawerOpen(false);
299
+ return;
300
+ }
301
+
302
+ setSessionId(targetSessionId);
303
+ localStorage.setItem('assistant-session-id', targetSessionId);
304
+ setSessionDrawerOpen(false);
305
+ setMessages([]);
306
+ setMessageChainOfThought({});
307
+
308
+ try {
309
+ const response = await fetch(`/api/sessions/${targetSessionId}/messages`);
310
+ if (response.ok) {
311
+ const data = await response.json();
312
+ const backendMessages = data.messages || [];
313
+ if (backendMessages.length > 0) {
314
+ const converted: Message[] = [];
315
+ const cotData: Record<string, MessageChainOfThought> = {};
316
+ backendMessages.forEach((m: any) => {
317
+ converted.push({
318
+ id: m.id,
319
+ role: m.role as 'user' | 'assistant',
320
+ content: m.content,
321
+ timestamp: new Date(m.timestamp),
322
+ });
323
+ if (m.chainOfThought) {
324
+ cotData[m.id] = {
325
+ isStreaming: false,
326
+ thinking: m.chainOfThought.thinking || '',
327
+ toolExecutions: (m.chainOfThought.toolExecutions || []).map((t: any) => ({
328
+ ...t,
329
+ startTime: new Date(t.startTime),
330
+ endTime: t.endTime ? new Date(t.endTime) : undefined,
331
+ }))
332
+ };
333
+ }
334
+ });
335
+ setMessages(converted);
336
+ setMessageChainOfThought(cotData);
337
+ }
338
+ }
339
+ } catch (error) {
340
+ console.error('Failed to load messages:', error);
341
+ }
342
+ };
343
+
344
+ // 删除会话
345
+ const deleteSession = async (targetSessionId: string, e: React.MouseEvent) => {
346
+ e.stopPropagation();
347
+ try {
348
+ await fetch(`/api/sessions?sessionId=${targetSessionId}`, { method: 'DELETE' });
349
+ } catch (error) {
350
+ console.error('Failed to delete session:', error);
351
+ }
352
+
353
+ const localSessions = JSON.parse(localStorage.getItem('assistant-local-sessions') || '[]');
354
+ const updatedLocalSessions = localSessions.filter((s: SessionInfo) => s.sessionId !== targetSessionId);
355
+ localStorage.setItem('assistant-local-sessions', JSON.stringify(updatedLocalSessions));
356
+
357
+ if (targetSessionId === sessionId) {
358
+ createNewSession();
359
+ } else {
360
+ loadSessions();
361
+ }
362
+ };
363
+
364
+ // 获取会话标题
365
+ const getSessionTitle = (session: SessionInfo) => {
366
+ if (session.title) return session.title;
367
+ if (session.recentSummary) return session.recentSummary.slice(0, 20) + (session.recentSummary.length > 20 ? '...' : '');
368
+ return `会话 ${new Date(session.createdAt).toLocaleDateString('zh-CN')}`;
369
+ };
370
+
371
+ // 获取当前会话标题
372
+ const currentSessionTitle = sessions.find(s => s.sessionId === sessionId)
373
+ ? getSessionTitle(sessions.find(s => s.sessionId === sessionId)!)
374
+ : '';
375
+
376
+ // 初始化时加载会话列表
377
+ useEffect(() => {
378
+ loadSessions();
379
+ }, []);
380
+
381
+ const scrollToBottom = (smooth: boolean = false) => {
382
+ if (scrollContainerRef.current) {
383
+ scrollContainerRef.current.scrollTo({
384
+ top: scrollContainerRef.current.scrollHeight,
385
+ behavior: smooth ? 'smooth' : 'auto'
386
+ });
387
+ }
388
+ };
389
+
390
+ // Optimized scroll trigger - only scroll on new message append
391
+ useEffect(() => {
392
+ if (scrollMode !== 'auto') return;
393
+
394
+ if (messages.length > lastMessageCountRef.current) {
395
+ scrollToBottom(false);
396
+ }
397
+ lastMessageCountRef.current = messages.length;
398
+ }, [messages, scrollMode]);
399
+
400
+ // Scroll on thinking process update (streaming) with debounce
401
+ useEffect(() => {
402
+ if (scrollMode !== 'auto' || !loading) return;
403
+
404
+ const timer = setTimeout(() => {
405
+ scrollToBottom(false);
406
+ }, 100);
407
+
408
+ return () => clearTimeout(timer);
409
+ }, [messageChainOfThought, scrollMode, loading]);
410
+
411
+ // Enhanced scroll detection with scroll mode support
412
+ useEffect(() => {
413
+ const container = scrollContainerRef.current;
414
+ if (!container) return;
415
+ const handleScroll = () => {
416
+ const scrollTop = container.scrollTop;
417
+ const scrollHeight = container.scrollHeight;
418
+ const clientHeight = container.clientHeight;
419
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
420
+
421
+ // Switch to follow mode when > 100px from bottom
422
+ if (distanceFromBottom > 100) {
423
+ setScrollMode('follow');
424
+ } else if (distanceFromBottom < 50) {
425
+ setScrollMode('auto');
426
+ }
427
+
428
+ setShowScrollButton(distanceFromBottom > 100);
429
+ };
430
+ container.addEventListener('scroll', handleScroll, { passive: true });
431
+ return () => container.removeEventListener('scroll', handleScroll);
432
+ }, []);
433
+
434
+ const parseSSELine = (line: string): PIEvent | null => {
435
+ if (!line.startsWith('data: ')) return null;
436
+ try {
437
+ return JSON.parse(line.slice(6));
438
+ } catch {
439
+ return null;
440
+ }
441
+ };
442
+
443
+ const handleSend = async () => {
444
+ if (!input.trim() || loading) return;
445
+
446
+ const userMessage: Message = {
447
+ id: generateUUID(),
448
+ role: 'user',
449
+ content: input.trim(),
450
+ timestamp: new Date(),
451
+ };
452
+
453
+ setMessages((prev) => [...prev, userMessage]);
454
+ const messageToSend = input;
455
+ setInput('');
456
+ setLoading(true);
457
+ setCurrentTool(null);
458
+
459
+ abortControllerRef.current = new AbortController();
460
+
461
+ let assistantContent = '';
462
+ let assistantMessageId = generateUUID();
463
+ let createdTicket: Message['ticket'] | null = null;
464
+
465
+ const mentionedInMessage = (input.match(/@([\w\u4e00-\u9fa5]+)/g) || []).map(m => m.slice(1));
466
+ const combinedSkills = Array.from(new Set([...selectedSkills, ...mentionedInMessage]));
467
+
468
+ try {
469
+ const uid = localStorage.getItem('userId');
470
+ const response = await fetch('/api/pi-chat', {
471
+ method: 'POST',
472
+ headers: { 'Content-Type': 'application/json' },
473
+ body: JSON.stringify({
474
+ message: messageToSend,
475
+ sessionId: sessionId || undefined,
476
+ userId: uid || undefined,
477
+ skills: combinedSkills,
478
+ abort: true,
479
+ }),
480
+ signal: abortControllerRef.current.signal,
481
+ });
482
+
483
+ if (!response.ok) throw new Error(`请求失败: ${response.status}`);
484
+
485
+ const reader = response.body?.getReader();
486
+ const decoder = new TextDecoder();
487
+ if (!reader) throw new Error('No response body');
488
+
489
+ let buffer = '';
490
+ while (true) {
491
+ const { done, value } = await reader.read();
492
+ if (done) break;
493
+
494
+ buffer += decoder.decode(value, { stream: true });
495
+ const lines = buffer.split('\n');
496
+ buffer = lines.pop() || '';
497
+
498
+ for (const line of lines) {
499
+ const event = parseSSELine(line);
500
+ if (!event) continue;
501
+
502
+ switch (event.type) {
503
+ case 'session_start':
504
+ case 'session_restored':
505
+ if (event.sessionId) setSessionId(event.sessionId);
506
+ break;
507
+ case 'text':
508
+ assistantContent += event.content || '';
509
+ setMessages((prev) => {
510
+ const existing = prev.find((m) => m.id === assistantMessageId);
511
+ if (existing) {
512
+ return prev.map((m) =>
513
+ m.id === assistantMessageId ? { ...m, content: assistantContent, isStreaming: true } : m
514
+ );
515
+ }
516
+ return [...prev, { id: assistantMessageId, role: 'assistant', content: assistantContent, timestamp: new Date(), isStreaming: true }];
517
+ });
518
+ break;
519
+ case 'thinking':
520
+ setMessageChainOfThought((prev) => ({
521
+ ...prev,
522
+ [assistantMessageId]: { ...prev[assistantMessageId], isStreaming: true, thinking: (prev[assistantMessageId]?.thinking || '') + (event.content || '') },
523
+ }));
524
+ break;
525
+ case 'tool_start':
526
+ setCurrentTool(event.toolName || null);
527
+ setMessageChainOfThought((prev) => {
528
+ const current = prev[assistantMessageId] || { isStreaming: true, thinking: '', toolExecutions: [] };
529
+ return { ...prev, [assistantMessageId]: { ...current, toolExecutions: [...current.toolExecutions, { toolName: event.toolName || '', startTime: new Date(), input: event.args }] } };
530
+ });
531
+ break;
532
+ case 'tool_end':
533
+ setCurrentTool(null);
534
+ break;
535
+ case 'tool_output':
536
+ if (event.content) {
537
+ try {
538
+ let result = JSON.parse(event.content);
539
+ if (typeof result === 'string' && (result.startsWith('{') || result.startsWith('['))) {
540
+ try { result = JSON.parse(result); } catch {}
541
+ }
542
+ setMessageChainOfThought((prev) => {
543
+ const current = prev[assistantMessageId] || { isStreaming: true, thinking: '', toolExecutions: [] };
544
+ const tools = [...current.toolExecutions];
545
+ const lastTool = tools[tools.length - 1];
546
+ if (lastTool && !lastTool.output) {
547
+ lastTool.output = result;
548
+ lastTool.isError = event.isError;
549
+ lastTool.endTime = new Date();
550
+ }
551
+ return { ...prev, [assistantMessageId]: { ...current, toolExecutions: tools } };
552
+ });
553
+ if (event.toolName === 'create_ticket' && result.details?.success && result.details.ticket) {
554
+ const ticket = result.details.ticket;
555
+ createdTicket = { id: ticket.id, title: ticket.title, type: ticket.type, status: ticket.status, priority: ticket.priority, description: ticket.description };
556
+ }
557
+ } catch (e) {
558
+ console.error('Failed to parse tool output:', e);
559
+ }
560
+ }
561
+ break;
562
+ case 'turn_end':
563
+ setMessageChainOfThought((prev) => {
564
+ if (prev[assistantMessageId]) return { ...prev, [assistantMessageId]: { ...prev[assistantMessageId], isStreaming: false } };
565
+ return prev;
566
+ });
567
+ if (!createdTicket && event.toolResults && Array.isArray(event.toolResults)) {
568
+ for (const result of event.toolResults) {
569
+ if (result.toolName === 'create_ticket' && result.details?.ticket) {
570
+ const ticket = result.details.ticket;
571
+ createdTicket = { id: ticket.id, title: ticket.title, type: ticket.type, status: ticket.status, priority: ticket.priority, description: ticket.description };
572
+ break;
573
+ }
574
+ }
575
+ }
576
+ break;
577
+ case 'error':
578
+ setMessages((prev) => [...prev, { id: generateUUID(), role: 'system', content: `❌ 错误: ${event.error || '未知错误'}`, timestamp: new Date() }]);
579
+ break;
580
+ }
581
+ }
582
+ }
583
+
584
+ setMessages((prev) => prev.map((m) => m.id === assistantMessageId ? { ...m, isStreaming: false, ticket: createdTicket || undefined } : m));
585
+
586
+ } catch (error: unknown) {
587
+ if ((error as Error).name === 'AbortError') {
588
+ setMessages((prev) => [...prev, { id: generateUUID(), role: 'system', content: '\n⏸️ 已停止', timestamp: new Date() }]);
589
+ } else {
590
+ console.error('Stream error:', error);
591
+ setMessages((prev) => [...prev, { id: generateUUID(), role: 'system', content: `\n❌ 错误: ${error instanceof Error ? error.message : '未知错误'}`, timestamp: new Date() }]);
592
+ }
593
+ } finally {
594
+ setLoading(false);
595
+ setCurrentTool(null);
596
+ abortControllerRef.current = null;
597
+ }
598
+ };
599
+
600
+ const handleStop = () => {
601
+ if (abortControllerRef.current) abortControllerRef.current.abort();
602
+ setLoading(false);
603
+ setCurrentTool(null);
604
+ };
605
+
606
+ const convertSkillNamesForDisplay = (content: string): string => {
607
+ if (!skillNameMap || Object.keys(skillNameMap).length === 0) return content;
608
+ return content.replace(/@(\w+)/g, (match, dirName) => skillNameMap[dirName] ? `@${skillNameMap[dirName]}` : match);
609
+ };
610
+
611
+ const handleTicketAction = async (ticketId: string, action: 'approve' | 'reject' | 'execute') => {
612
+ try {
613
+ if (action === 'execute') {
614
+ await fetch(`/api/tickets/${ticketId}/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticketId }) });
615
+ } else {
616
+ await fetch(`/api/tickets/${ticketId}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, approver: 'user', comment: '' }) });
617
+ }
618
+ } catch (error) {
619
+ console.error('Ticket action failed:', error);
620
+ }
621
+ };
622
+
623
+ if (!configChecked) {
624
+ return (
625
+ <div className="flex-1 flex items-center justify-center">
626
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
627
+ </div>
628
+ );
629
+ }
630
+
631
+ if (!isConfigured || !isLoggedIn) {
632
+ return (
633
+ <div className="flex-1 flex items-center justify-center p-4">
634
+ <div className="bg-muted/50 border rounded-xl px-6 py-5 max-w-md text-center">
635
+ <Bot className="h-10 w-10 mx-auto mb-3 text-primary" />
636
+ <p className="font-medium mb-2">请先在桌面端完成配置</p>
637
+ <p className="text-sm text-muted-foreground">使用电脑访问以完成初始设置</p>
638
+ </div>
639
+ </div>
640
+ );
641
+ }
642
+
643
+ return (
644
+ <div className="flex-1 flex flex-col overflow-hidden">
645
+ {/* 工具栏 */}
646
+ <div className="px-2 py-2 border-b flex items-center gap-2 flex-shrink-0 overflow-x-auto">
647
+ {/* 会话列表按钮 */}
648
+ <Button
649
+ variant="outline"
650
+ size="sm"
651
+ onClick={() => setSessionDrawerOpen(true)}
652
+ className="h-8 flex-shrink-0"
653
+ >
654
+ <MessageSquare className="h-4 w-4 mr-1" />
655
+ 切换会话
656
+ </Button>
657
+
658
+ <Button variant="outline" size="sm" onClick={() => setSkillSwitcherOpen(true)} className="h-8 flex-shrink-0">
659
+ <Bot className="h-4 w-4 mr-1" />
660
+ 技能
661
+ </Button>
662
+ <Button variant="outline" size="sm" onClick={() => setKnowledgePickerOpen(true)} className="h-8 flex-shrink-0">
663
+ <BookOpen className="h-4 w-4 mr-1" />
664
+ 知识库
665
+ </Button>
666
+ <Button
667
+ variant="outline"
668
+ size="sm"
669
+ onClick={async () => {
670
+ if (!sessionId) return;
671
+ const shareUrl = `${window.location.origin}/h5/share/${sessionId}`;
672
+ await navigator.clipboard?.writeText(shareUrl);
673
+ setShareCopied(true);
674
+ setTimeout(() => setShareCopied(false), 2000);
675
+ }}
676
+ disabled={!sessionId}
677
+ className="h-8 flex-shrink-0"
678
+ >
679
+ {shareCopied ? <Check className="h-4 w-4" /> : <Share2 className="h-4 w-4" />}
680
+ </Button>
681
+ </div>
682
+
683
+ {/* 会话列表抽屉 */}
684
+ {sessionDrawerOpen && (
685
+ <>
686
+ <div className="fixed inset-0 bg-black/50 z-40" onClick={() => setSessionDrawerOpen(false)} />
687
+ <div className="fixed left-0 top-0 bottom-0 w-72 bg-card border-r z-50 flex flex-col">
688
+ <div className="p-3 border-b flex items-center justify-between">
689
+ <h3 className="font-medium">会话列表</h3>
690
+ <Button variant="ghost" size="sm" onClick={() => setSessionDrawerOpen(false)}>
691
+ <X className="h-4 w-4" />
692
+ </Button>
693
+ </div>
694
+
695
+ {/* 新建会话按钮 */}
696
+ <div className="p-3 border-b">
697
+ <Button variant="outline" className="w-full justify-start gap-2" onClick={createNewSession}>
698
+ <Plus className="h-4 w-4" />
699
+ 新建会话
700
+ </Button>
701
+ </div>
702
+
703
+ {/* 会话列表 */}
704
+ <div className="flex-1 overflow-y-auto p-2">
705
+ {sessions.length === 0 ? (
706
+ <div className="text-center text-muted-foreground text-sm py-8">暂无会话记录</div>
707
+ ) : (
708
+ <div className="space-y-1">
709
+ {sessions.map((session) => (
710
+ <div
711
+ key={session.sessionId}
712
+ onClick={() => switchSession(session.sessionId)}
713
+ className={cn(
714
+ "p-2 rounded-lg cursor-pointer group flex items-start gap-2",
715
+ session.sessionId === sessionId
716
+ ? "bg-primary/10 text-primary"
717
+ : "hover:bg-muted"
718
+ )}
719
+ >
720
+ <MessageSquare className="h-4 w-4 mt-0.5 flex-shrink-0" />
721
+ <div className="flex-1 min-w-0">
722
+ <div className="text-sm truncate">{getSessionTitle(session)}</div>
723
+ <div className="text-xs text-muted-foreground">
724
+ {new Date(session.lastAccessedAt).toLocaleDateString('zh-CN')}
725
+ </div>
726
+ </div>
727
+ <Button
728
+ variant="ghost"
729
+ size="icon"
730
+ className="h-6 w-6 opacity-0 group-hover:opacity-100"
731
+ onClick={(e) => deleteSession(session.sessionId, e)}
732
+ >
733
+ <Trash2 className="h-3 w-3" />
734
+ </Button>
735
+ </div>
736
+ ))}
737
+ </div>
738
+ )}
739
+ </div>
740
+ </div>
741
+ </>
742
+ )}
743
+
744
+ {/* 技能选择器弹窗 */}
745
+ {skillSwitcherOpen && (
746
+ <>
747
+ <div className="fixed inset-0 bg-black/50 z-40" onClick={() => setSkillSwitcherOpen(false)} />
748
+ <div className="fixed inset-x-0 bottom-20 bg-card border-t rounded-t-2xl z-50 max-h-[60vh] overflow-y-auto p-4">
749
+ <div className="flex items-center justify-between mb-3">
750
+ <h3 className="font-medium">选择技能</h3>
751
+ <Button variant="ghost" size="sm" onClick={() => setSkillSwitcherOpen(false)}><X className="h-4 w-4" /></Button>
752
+ </div>
753
+ <div className="space-y-2">
754
+ {availableSkills.length === 0 ? (
755
+ <p className="text-sm text-muted-foreground text-center py-4">暂无技能</p>
756
+ ) : (
757
+ availableSkills.map((skill) => (
758
+ <button
759
+ key={skill.name}
760
+ onClick={() => {
761
+ setSelectedSkills(prev => prev.includes(skill.name) ? prev.filter(s => s !== skill.name) : [...prev, skill.name]);
762
+ setSkillSwitcherOpen(false);
763
+ }}
764
+ className={cn(
765
+ "w-full p-3 rounded-lg border text-left",
766
+ selectedSkills.includes(skill.name) ? "border-primary bg-primary/10" : "hover:bg-muted"
767
+ )}
768
+ >
769
+ <div className="font-medium">{skill.displayName}</div>
770
+ <div className="text-xs text-muted-foreground">{skill.description}</div>
771
+ </button>
772
+ ))
773
+ )}
774
+ </div>
775
+ </div>
776
+ </>
777
+ )}
778
+
779
+ {/* 消息区域 */}
780
+ <div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-3">
781
+ {messages.length === 0 && (
782
+ <div className="flex justify-center items-center h-full">
783
+ <div className="bg-muted/50 border rounded-xl px-4 py-5 max-w-xs text-center">
784
+ <Bot className="h-8 w-8 mx-auto mb-2 text-primary" />
785
+ <p className="font-medium text-sm mb-1">智能助手</p>
786
+ <p className="text-xs text-muted-foreground">执行命令、创建工单、定时任务</p>
787
+ </div>
788
+ </div>
789
+ )}
790
+
791
+ {messages.map((message) => (
792
+ <div key={message.id} className={cn('flex gap-2 mb-4', message.role === 'user' && 'flex-row-reverse')}>
793
+ {message.role !== 'system' && (
794
+ <>
795
+ <div className="flex-shrink-0">
796
+ {message.role === 'assistant' ? (
797
+ <div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
798
+ <Bot className="h-5 w-5 text-primary" />
799
+ </div>
800
+ ) : (
801
+ <div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center">
802
+ <User className="h-5 w-5 text-primary-foreground" />
803
+ </div>
804
+ )}
805
+ </div>
806
+ <div className={cn('flex flex-col flex-1', message.role === 'user' && 'items-end')}>
807
+ {message.role === 'assistant' && messageChainOfThought[message.id] && messageChainOfThought[message.id].toolExecutions.length > 0 && (
808
+ <details className="mb-2">
809
+ <summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">查看思考过程</summary>
810
+ <ChainOfThought defaultOpen={false}>
811
+ <ChainOfThoughtContent>
812
+ {messageChainOfThought[message.id].thinking.trim().length > 0 && (
813
+ <ChainOfThoughtStep icon={Brain} label="思考" status={messageChainOfThought[message.id].isStreaming ? 'active' : 'complete'}>
814
+ <div className="text-xs text-muted-foreground whitespace-pre-wrap">{messageChainOfThought[message.id].thinking}</div>
815
+ </ChainOfThoughtStep>
816
+ )}
817
+ {messageChainOfThought[message.id].toolExecutions.map((tool, idx) => (
818
+ <ChainOfThoughtStep key={idx} label={tool.toolName} status={!tool.endTime ? 'active' : tool.isError ? 'pending' : 'complete'}>
819
+ {tool.input && <pre className="text-xs mt-1 p-1 bg-muted rounded overflow-x-auto">{typeof tool.input === 'string' ? tool.input : JSON.stringify(tool.input)}</pre>}
820
+ {tool.output && <pre className="text-xs mt-1 p-1 bg-muted rounded overflow-x-auto">{typeof tool.output === 'string' ? tool.output : JSON.stringify(tool.output)}</pre>}
821
+ </ChainOfThoughtStep>
822
+ ))}
823
+ </ChainOfThoughtContent>
824
+ </ChainOfThought>
825
+ </details>
826
+ )}
827
+ <div className={cn('rounded-2xl px-3 py-2 max-w-[85%]', message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted')}>
828
+ <p className="text-sm whitespace-pre-wrap">{convertSkillNamesForDisplay(message.content)}</p>
829
+ </div>
830
+ {message.role === 'assistant' && message.ticket && !message.isStreaming && (
831
+ <div className="mt-2">
832
+ <div className="text-xs bg-orange-100 text-orange-800 px-2 py-1 rounded inline-block mb-1">
833
+ 工单: {message.ticket.title}
834
+ </div>
835
+ <div className="flex gap-1">
836
+ <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => handleTicketAction(message.ticket!.id, 'approve')}>批准</Button>
837
+ <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => handleTicketAction(message.ticket!.id, 'reject')}>拒绝</Button>
838
+ {message.ticket.status === 'approved' && (
839
+ <Button size="sm" className="h-6 text-xs" onClick={() => handleTicketAction(message.ticket!.id, 'execute')}>执行</Button>
840
+ )}
841
+ </div>
842
+ </div>
843
+ )}
844
+ {message.isStreaming && (
845
+ <span className="text-xs text-primary mt-1 flex items-center gap-1">
846
+ <span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"></span>
847
+ 输入中...
848
+ </span>
849
+ )}
850
+ </div>
851
+ </>
852
+ )}
853
+ {message.role === 'system' && (
854
+ <div className="w-full">
855
+ <div className="bg-destructive/10 border border-destructive/20 rounded-lg px-3 py-2 text-sm text-destructive">
856
+ {message.content}
857
+ </div>
858
+ </div>
859
+ )}
860
+ </div>
861
+ ))}
862
+
863
+ {currentTool && (
864
+ <div className="flex justify-center mb-4">
865
+ <div className="bg-primary/10 border border-primary/20 rounded-lg px-3 py-1.5 text-xs text-primary flex items-center gap-2">
866
+ <span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"></span>
867
+ 执行: {currentTool}
868
+ </div>
869
+ </div>
870
+ )}
871
+ </div>
872
+
873
+ {/* 滚动模式切换按钮 */}
874
+ <div className="fixed bottom-20 right-4 z-30 flex flex-col gap-2">
875
+ {/* 滚动模式切换 */}
876
+ <Button
877
+ onClick={() => {
878
+ const newMode = scrollMode === 'auto' ? 'follow' : 'auto';
879
+ setScrollMode(newMode);
880
+ if (newMode === 'auto') {
881
+ scrollToBottom(true);
882
+ }
883
+ }}
884
+ className={cn(
885
+ "rounded-full shadow-lg h-9",
886
+ scrollMode === 'auto'
887
+ ? "bg-muted-foreground/80 hover:bg-muted-foreground/90"
888
+ : "bg-secondary hover:bg-secondary/80"
889
+ )}
890
+ size="sm"
891
+ >
892
+ {scrollMode === 'auto' ? (
893
+ <><ChevronDown className="h-3 w-3 mr-1" /><span className="text-xs">自动</span></>
894
+ ) : (
895
+ <span className="text-xs px-1">跟随</span>
896
+ )}
897
+ </Button>
898
+
899
+ {/* 滚动到底部按钮 */}
900
+ {showScrollButton && (
901
+ <Button
902
+ onClick={() => {
903
+ setScrollMode('auto');
904
+ scrollToBottom(true);
905
+ }}
906
+ className="rounded-full shadow-lg h-10"
907
+ size="sm"
908
+ >
909
+ <ChevronDown className="h-4 w-4" />
910
+ </Button>
911
+ )}
912
+ </div>
913
+
914
+ {/* 输入区域 */}
915
+ <div className="p-3 border-t bg-card flex-shrink-0">
916
+ {loading && (
917
+ <div className="flex items-center justify-between mb-2">
918
+ <div className="flex items-center gap-2">
919
+ <span className="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
920
+ <span className="text-xs text-muted-foreground">{currentTool ? `执行: ${currentTool}` : 'AI 思考中...'}</span>
921
+ </div>
922
+ <Button variant="destructive" size="sm" onClick={handleStop} className="h-7 text-xs">停止</Button>
923
+ </div>
924
+ )}
925
+ <div className="flex gap-2">
926
+ <input
927
+ type="text"
928
+ value={input}
929
+ onChange={(e) => setInput(e.target.value)}
930
+ onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !loading) { e.preventDefault(); handleSend(); } }}
931
+ placeholder="输入消息..."
932
+ className="flex-1 px-3 py-2 border rounded-lg bg-background text-sm"
933
+ disabled={loading}
934
+ />
935
+ <Button onClick={handleSend} disabled={loading || !input.trim()} size="icon" className="flex-shrink-0">
936
+ <Send className="h-4 w-4" />
937
+ </Button>
938
+ </div>
939
+ </div>
940
+
941
+ {/* 知识库选择弹窗 */}
942
+ <KnowledgePickerDialog
943
+ open={knowledgePickerOpen}
944
+ onOpenChange={setKnowledgePickerOpen}
945
+ onSelect={(knowledge) => {
946
+ setInput(prev => prev + `@doc:${knowledge.title}`);
947
+ }}
948
+ />
949
+ </div>
950
+ );
951
+ }