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
package/app/page.tsx ADDED
@@ -0,0 +1,2288 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect, Suspense } from 'react';
4
+ import { useRouter, useSearchParams } from 'next/navigation';
5
+ import { Send, Bot, Trash2, X, ClipboardList, CheckCircle, User, Plus, MessageSquare, ChevronLeft, ChevronRight, ChevronDown, Pencil, Loader2, BookOpen, Share2, Check, Brain } from 'lucide-react';
6
+ import { Button } from '@/components/ui/button';
7
+ import { Input } from '@/components/ui/input';
8
+ import { Badge } from '@/components/ui/badge';
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from '@/components/ui/dialog';
16
+ import {
17
+ Select,
18
+ SelectContent,
19
+ SelectItem,
20
+ SelectTrigger,
21
+ SelectValue,
22
+ } from '@/components/ui/select';
23
+ import { cn } from '@/lib/utils';
24
+ import { MessageResponse, Message } from '@/components/ai-elements/message';
25
+ import { TicketDetailContent } from '@/components/ticket-detail-content';
26
+ import type { Ticket } from '@/lib/db';
27
+ import { SkillSwitcher } from '@/components/chat/SkillSwitcher';
28
+ import { MentionInput } from '@/components/chat/MentionInput';
29
+ import { KnowledgePickerDialog } from '@/components/chat/KnowledgePickerDialog';
30
+ import {
31
+ ChainOfThought,
32
+ ChainOfThoughtContent,
33
+ ChainOfThoughtHeader,
34
+ ChainOfThoughtStep,
35
+ } from '@/components/ai-elements/chain-of-thought';
36
+ import { RecommendationBanner } from '@/components/chat/RecommendationBanner';
37
+ import { OrganizeDialog } from '@/components/chat/OrganizeDialog';
38
+ import { SetupGuide } from '@/components/setup-guide';
39
+ import { WelcomeGuide } from '@/components/welcome-guide';
40
+
41
+ // UUID 生成函数
42
+ function generateUUID(): string {
43
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
44
+ return crypto.randomUUID();
45
+ }
46
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
47
+ const r = (Math.random() * 16) | 0;
48
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
49
+ return v.toString(16);
50
+ });
51
+ }
52
+
53
+ // Track chain-of-thought data for each message
54
+ type ToolExecution = {
55
+ toolName: string;
56
+ startTime: Date;
57
+ endTime?: Date;
58
+ input?: any;
59
+ output?: any;
60
+ isError?: boolean;
61
+ };
62
+
63
+ type MessageChainOfThought = {
64
+ isStreaming: boolean;
65
+ thinking: string;
66
+ toolExecutions: ToolExecution[];
67
+ };
68
+
69
+ type Message = {
70
+ id: string;
71
+ role: 'user' | 'assistant' | 'system';
72
+ content: string;
73
+ timestamp: Date;
74
+ isStreaming?: boolean;
75
+ ticket?: {
76
+ id: string;
77
+ title: string;
78
+ type: string;
79
+ status: string;
80
+ priority: string;
81
+ description?: string;
82
+ };
83
+ chainOfThought?: MessageChainOfThought;
84
+ };
85
+
86
+ type PIEvent = {
87
+ type: 'session_start' | 'session_restored' | 'text' | 'thinking' | 'tool_start' | 'tool_output' | 'tool_end' | 'turn_start' | 'turn_end' | 'message_end' | 'agent_end' | 'error';
88
+ content?: string;
89
+ toolName?: string;
90
+ args?: any;
91
+ sessionId?: string;
92
+ isError?: boolean;
93
+ message?: any;
94
+ messages?: any[];
95
+ toolResults?: any[];
96
+ error?: string;
97
+ };
98
+
99
+ type SidePanel = 'none' | 'tickets' | 'approvals' | 'ticket-detail';
100
+
101
+ // 会话元数据类型
102
+ type SessionInfo = {
103
+ sessionId: string;
104
+ userId?: string;
105
+ createdAt: string;
106
+ lastAccessedAt: string;
107
+ messageCount: number;
108
+ title?: string; // 会话标题,从第一条用户消息提取
109
+ recentSummary?: string; // 会话摘要
110
+ };
111
+
112
+ // 工单操作按钮组件
113
+ function TicketActionButtons({
114
+ ticketId,
115
+ ticketStatus,
116
+ onAction,
117
+ onViewDetails,
118
+ loadingTicket,
119
+ }: {
120
+ ticketId: string;
121
+ ticketStatus: string;
122
+ onAction: (ticketId: string, action: 'approve' | 'reject' | 'execute') => void;
123
+ onViewDetails: () => void;
124
+ loadingTicket: boolean;
125
+ }) {
126
+ const [currentStatus, setCurrentStatus] = useState(ticketStatus);
127
+ const [isOperating, setIsOperating] = useState(false);
128
+
129
+ // 获取最新工单状态
130
+ useEffect(() => {
131
+ async function fetchStatus() {
132
+ try {
133
+ const response = await fetch(`/api/tickets/${ticketId}`);
134
+ if (response.ok) {
135
+ const data = await response.json();
136
+ setCurrentStatus(data.ticket?.status || ticketStatus);
137
+ }
138
+ } catch (e) {
139
+ // 忽略错误,使用传入的状态
140
+ }
141
+ }
142
+ fetchStatus();
143
+ }, [ticketId, ticketStatus]);
144
+
145
+ const handleAction = async (action: 'approve' | 'reject' | 'execute') => {
146
+ setIsOperating(true);
147
+ // 立即更新状态以防止重复点击
148
+ if (action === 'approve') setCurrentStatus('approved');
149
+ else if (action === 'reject') setCurrentStatus('rejected');
150
+ else if (action === 'execute') setCurrentStatus('executing');
151
+
152
+ try {
153
+ await onAction(ticketId, action);
154
+ } finally {
155
+ setIsOperating(false);
156
+ }
157
+ };
158
+
159
+ return (
160
+ <div className="flex items-center gap-1">
161
+ {/* 查看详情按钮 */}
162
+ <Button
163
+ variant="ghost"
164
+ size="sm"
165
+ onClick={onViewDetails}
166
+ disabled={loadingTicket}
167
+ className="h-6 px-2 text-xs"
168
+ >
169
+ <ClipboardList className="h-3 w-3 mr-1" />
170
+ {loadingTicket ? '...' : '详情'}
171
+ </Button>
172
+
173
+ {/* 待审批状态:显示批准和拒绝按钮 */}
174
+ {currentStatus === 'pending' && (
175
+ <>
176
+ <Button
177
+ variant="default"
178
+ size="sm"
179
+ onClick={() => handleAction('approve')}
180
+ disabled={isOperating}
181
+ className="h-6 px-2 text-xs bg-green-600 hover:bg-green-700"
182
+ >
183
+ ✓ 批准
184
+ </Button>
185
+ <Button
186
+ variant="destructive"
187
+ size="sm"
188
+ onClick={() => handleAction('reject')}
189
+ disabled={isOperating}
190
+ className="h-6 px-2 text-xs"
191
+ >
192
+ ✗ 拒绝
193
+ </Button>
194
+ </>
195
+ )}
196
+
197
+ {/* 已批准状态:显示执行按钮 */}
198
+ {currentStatus === 'approved' && (
199
+ <Button
200
+ variant="default"
201
+ size="sm"
202
+ onClick={() => handleAction('execute')}
203
+ disabled={isOperating}
204
+ className="h-6 px-2 text-xs bg-blue-600 hover:bg-blue-700"
205
+ >
206
+ ▶ 执行
207
+ </Button>
208
+ )}
209
+
210
+ {/* 执行中状态 */}
211
+ {currentStatus === 'executing' && (
212
+ <span className="text-xs text-blue-600 flex items-center gap-1 px-2">
213
+ <span className="w-1.5 h-1.5 rounded-full bg-blue-600 animate-pulse"></span>
214
+ 执行中...
215
+ </span>
216
+ )}
217
+
218
+ {/* 已完成状态 */}
219
+ {currentStatus === 'completed' && (
220
+ <span className="text-xs text-green-600 px-2">✓ 已完成</span>
221
+ )}
222
+
223
+ {/* 已拒绝状态 */}
224
+ {currentStatus === 'rejected' && (
225
+ <span className="text-xs text-red-600 px-2">✗ 已拒绝</span>
226
+ )}
227
+
228
+ {/* 失败状态 */}
229
+ {currentStatus === 'failed' && (
230
+ <span className="text-xs text-red-600 px-2">⚠ 失败</span>
231
+ )}
232
+ </div>
233
+ );
234
+ }
235
+
236
+ function HomePageContent() {
237
+ const router = useRouter();
238
+ const searchParams = useSearchParams();
239
+ const [sessionId, setSessionId] = useState<string | null>(null);
240
+ const [messages, setMessages] = useState<Message[]>([]);
241
+ const [input, setInput] = useState('');
242
+ const [loading, setLoading] = useState(false);
243
+ const [currentTool, setCurrentTool] = useState<string | null>(null);
244
+ const [userId, setUserId] = useState<string>('');
245
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
246
+ const [skillNameMap, setSkillNameMap] = useState<Record<string, string>>({}); // dirName -> skillName
247
+ const [attachedFiles, setAttachedFiles] = useState<Array<{ id: string; name: string; size: number }>>([]);
248
+ const [uploading, setUploading] = useState(false);
249
+ const messagesEndRef = useRef<HTMLDivElement>(null);
250
+ const abortControllerRef = useRef<AbortController | null>(null);
251
+ const urlParamsProcessedRef = useRef(false);
252
+
253
+ // Scroll handling refs and state
254
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
255
+ const isUserScrollingRef = useRef(false);
256
+ const lastScrollTopRef = useRef(0);
257
+ const lastMessageCountRef = useRef(0);
258
+ const [showScrollButton, setShowScrollButton] = useState(false);
259
+ const [scrollMode, setScrollMode] = useState<'auto' | 'follow'>('auto');
260
+
261
+ // 加载用户技能名称映射
262
+ const loadSkillNameMap = async (uid: string) => {
263
+ try {
264
+ const response = await fetch(`/api/skills?userId=${uid}`);
265
+ if (response.ok) {
266
+ const skills = await response.json();
267
+ const map: Record<string, string> = {};
268
+ for (const skill of skills) {
269
+ if (skill.name && skill.displayName) {
270
+ map[skill.name] = skill.displayName;
271
+ }
272
+ }
273
+ setSkillNameMap(map);
274
+ }
275
+ } catch (error) {
276
+ console.error('Failed to load skill name map:', error);
277
+ }
278
+ };
279
+
280
+ // 检查用户登录状态
281
+ useEffect(() => {
282
+ const checkLoginStatus = () => {
283
+ const userId = localStorage.getItem('userId');
284
+ setIsLoggedIn(!!userId);
285
+ if (userId) {
286
+ setUserId(userId);
287
+ loadSkillNameMap(userId);
288
+ }
289
+ };
290
+
291
+ checkLoginStatus();
292
+
293
+ // 监听登录状态变化
294
+ const handleStorageChange = () => {
295
+ checkLoginStatus();
296
+ };
297
+
298
+ window.addEventListener('storage', handleStorageChange);
299
+ return () => window.removeEventListener('storage', handleStorageChange);
300
+ }, []);
301
+
302
+ // 配置检查状态
303
+ const [configChecked, setConfigChecked] = useState(false);
304
+ const [isConfigured, setIsConfigured] = useState(true); // 默认 true,检查后更新
305
+
306
+ // 侧边栏状态
307
+ const [sidePanel, setSidePanel] = useState<SidePanel>('none');
308
+ const [sidePanelTicketId, setSidePanelTicketId] = useState<string | null>(null);
309
+ const [refreshKey, setRefreshKey] = useState(0);
310
+
311
+ // 会话管理状态
312
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
313
+ const [sessionSidebarOpen, setSessionSidebarOpen] = useState(true);
314
+
315
+ // Skill 选择状态
316
+ const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
317
+ const [availableSkills, setAvailableSkills] = useState<{ name: string; displayName?: string; description?: string }[]>([]);
318
+
319
+ // 分析对话框状态
320
+ const [analysisDialogOpen, setAnalysisDialogOpen] = useState(false);
321
+
322
+ // 知识库列表状态
323
+ const [availableKnowledge, setAvailableKnowledge] = useState<{ id: string; title: string; category?: string; isOwner?: boolean }[]>([]);
324
+
325
+ // 知识库选择弹框状态
326
+ const [knowledgePickerOpen, setKnowledgePickerOpen] = useState(false);
327
+
328
+ // 分享状态
329
+ const [shareCopied, setShareCopied] = useState(false);
330
+
331
+ // 检查配置状态
332
+ useEffect(() => {
333
+ async function checkConfig() {
334
+ try {
335
+ const response = await fetch('/api/config/status');
336
+ if (response.ok) {
337
+ const data = await response.json();
338
+ setIsConfigured(data.isConfigured);
339
+ }
340
+ } catch (e) {
341
+ console.error('Failed to check config:', e);
342
+ } finally {
343
+ setConfigChecked(true);
344
+ }
345
+ }
346
+ checkConfig();
347
+ }, []);
348
+
349
+ // 加载可用 skills 列表(只显示用户已安装的)
350
+ useEffect(() => {
351
+ async function loadSkills() {
352
+ const userId = localStorage.getItem('userId');
353
+ if (!userId) return;
354
+
355
+ try {
356
+ const response = await fetch(`/api/user/skills?userId=${userId}`);
357
+ if (response.ok) {
358
+ const data = await response.json();
359
+ const userSkills = data.skills || [];
360
+ setAvailableSkills(userSkills.map((s: any) => ({
361
+ name: s.skillName,
362
+ displayName: s.skillName,
363
+ description: s.isBuiltin ? '内置Skill' : (s.source === 'personal' ? '个人Skill' : '已安装'),
364
+ })));
365
+ }
366
+ } catch (error) {
367
+ console.error('Failed to load user skills:', error);
368
+ }
369
+ }
370
+
371
+ async function loadKnowledge() {
372
+ const userId = localStorage.getItem('userId');
373
+ if (!userId) return;
374
+
375
+ try {
376
+ const response = await fetch(`/api/knowledge?author=${encodeURIComponent(userId)}`);
377
+ if (response.ok) {
378
+ const data = await response.json();
379
+ const myDocs = (data.documents || []).map((d: any) => ({
380
+ id: d.id,
381
+ title: d.title,
382
+ category: d.category,
383
+ isOwner: true,
384
+ }));
385
+ setAvailableKnowledge(myDocs);
386
+ }
387
+ } catch (error) {
388
+ console.error('Failed to load knowledge:', error);
389
+ }
390
+ }
391
+
392
+ loadSkills();
393
+ loadKnowledge();
394
+ }, []);
395
+
396
+ // 处理 URL 参数(skill 和 prompt)
397
+ useEffect(() => {
398
+ if (urlParamsProcessedRef.current) return;
399
+
400
+ const skillParam = searchParams.get('skill');
401
+ const promptParam = searchParams.get('prompt');
402
+
403
+ if (!skillParam && !promptParam) return;
404
+
405
+ // 等待 availableSkills 加载完成
406
+ if (availableSkills.length === 0 && skillParam) return;
407
+
408
+ // 标记已处理
409
+ urlParamsProcessedRef.current = true;
410
+
411
+ // 处理 skill 参数
412
+ if (skillParam) {
413
+ const skillExists = availableSkills.some(s => s.name === skillParam);
414
+ if (skillExists) {
415
+ setSelectedSkills([skillParam]);
416
+ console.log(`[URL参数] 激活技能: ${skillParam}`);
417
+ } else {
418
+ console.warn(`[URL参数] 技能不存在: ${skillParam}`);
419
+ }
420
+ }
421
+
422
+ // 处理 prompt 参数
423
+ if (promptParam) {
424
+ setInput(promptParam);
425
+ console.log(`[URL参数] 设置输入: ${promptParam}`);
426
+ }
427
+
428
+ // 清理 URL 参数(可选,保持 URL 干净)
429
+ // window.history.replaceState({}, '', '/');
430
+ }, [searchParams, availableSkills]);
431
+
432
+ // 工单详情弹框状态
433
+ const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
434
+ const [ticketDialogOpen, setTicketDialogOpen] = useState(false);
435
+ const [loadingTicket, setLoadingTicket] = useState(false);
436
+
437
+ // 加载会话列表
438
+ const loadSessions = async () => {
439
+ try {
440
+ const currentUserId = localStorage.getItem('userId');
441
+
442
+ // 获取后端会话
443
+ const url = currentUserId ? `/api/sessions?userId=${encodeURIComponent(currentUserId)}` : '/api/sessions';
444
+ const response = await fetch(url);
445
+ const backendSessions: SessionInfo[] = response.ok ? (await response.json()).sessions || [] : [];
446
+
447
+ // 获取本地会话(尚未同步到后端的)
448
+ const localSessions: SessionInfo[] = JSON.parse(localStorage.getItem('assistant-local-sessions') || '[]');
449
+
450
+ // 过滤当前用户的本地会话
451
+ const filteredLocalSessions = currentUserId
452
+ ? localSessions.filter(s => s.userId === currentUserId)
453
+ : localSessions;
454
+
455
+ // 合并会话,去重(以后端为准)
456
+ const backendIds = new Set(backendSessions.map(s => s.sessionId));
457
+ const mergedSessions = [
458
+ ...backendSessions,
459
+ ...filteredLocalSessions.filter(s => !backendIds.has(s.sessionId))
460
+ ];
461
+
462
+ // 按最后访问时间排序
463
+ mergedSessions.sort((a, b) =>
464
+ new Date(b.lastAccessedAt).getTime() - new Date(a.lastAccessedAt).getTime()
465
+ );
466
+
467
+ setSessions(mergedSessions);
468
+ } catch (error) {
469
+ console.error('Failed to load sessions:', error);
470
+ }
471
+ };
472
+
473
+ // 初始化会话
474
+ useEffect(() => {
475
+ const initSession = async () => {
476
+ const savedSessionId = localStorage.getItem('assistant-session-id');
477
+
478
+ if (savedSessionId) {
479
+ setSessionId(savedSessionId);
480
+
481
+ // 从后端获取消息(包含 chain-of-thought)
482
+ try {
483
+ const response = await fetch(`/api/sessions/${savedSessionId}/messages`);
484
+ if (response.ok) {
485
+ const data = await response.json();
486
+ const backendMessages = data.messages || [];
487
+ if (backendMessages.length > 0) {
488
+ // 转换消息并解析 chain-of-thought,直接嵌入消息对象
489
+ const converted: Message[] = [];
490
+
491
+ backendMessages.forEach((m: any) => {
492
+ const message: Message = {
493
+ id: m.id,
494
+ role: m.role as 'user' | 'assistant',
495
+ content: m.content,
496
+ timestamp: new Date(m.timestamp),
497
+ };
498
+
499
+ // 解析 chain-of-thought 数据,直接嵌入消息对象
500
+ if (m.chainOfThought) {
501
+ message.chainOfThought = {
502
+ isStreaming: false,
503
+ thinking: m.chainOfThought.thinking || '',
504
+ toolExecutions: (m.chainOfThought.toolExecutions || []).map((t: any) => ({
505
+ ...t,
506
+ startTime: new Date(t.startTime),
507
+ endTime: t.endTime ? new Date(t.endTime) : undefined,
508
+ }))
509
+ };
510
+ }
511
+
512
+ converted.push(message);
513
+ });
514
+
515
+ setMessages(converted);
516
+ }
517
+ }
518
+ } catch (error) {
519
+ console.error('Failed to load messages from backend:', error);
520
+ }
521
+ } else {
522
+ const newSessionId = generateUUID();
523
+ setSessionId(newSessionId);
524
+ // 清空消息(chain-of-thought 会随消息一起清空)
525
+ setMessages([]);
526
+ localStorage.setItem('assistant-session-id', newSessionId);
527
+ }
528
+
529
+ // 加载会话列表
530
+ loadSessions();
531
+
532
+ // 初始化调度器(后台调用,不阻塞UI)
533
+ fetch('/api/health').catch(() => {
534
+ // 忽略健康检查失败
535
+ });
536
+ };
537
+
538
+ initSession();
539
+ }, []);
540
+
541
+ // 持久化会话ID到 localStorage(用于记住当前会话)
542
+ useEffect(() => {
543
+ if (sessionId) {
544
+ localStorage.setItem('assistant-session-id', sessionId);
545
+ }
546
+ }, [sessionId]);
547
+
548
+ // 创建新会话
549
+ const createNewSession = () => {
550
+ const newSessionId = generateUUID();
551
+ const now = new Date().toISOString();
552
+ const currentUserId = localStorage.getItem('userId') || undefined;
553
+
554
+ // Reset scroll lock when creating new session
555
+ isUserScrollingRef.current = false;
556
+
557
+ setSessionId(newSessionId);
558
+ setMessages([]); // chain-of-thought 会随消息一起清空
559
+ localStorage.setItem('assistant-session-id', newSessionId);
560
+
561
+ // 保存到本地会话列表
562
+ const localSessions: SessionInfo[] = JSON.parse(localStorage.getItem('assistant-local-sessions') || '[]');
563
+ const newSession: SessionInfo = {
564
+ sessionId: newSessionId,
565
+ userId: currentUserId,
566
+ createdAt: now,
567
+ lastAccessedAt: now,
568
+ messageCount: 0,
569
+ };
570
+
571
+ // 检查是否已存在
572
+ if (!localSessions.find(s => s.sessionId === newSessionId)) {
573
+ localSessions.unshift(newSession);
574
+ localStorage.setItem('assistant-local-sessions', JSON.stringify(localSessions));
575
+ }
576
+
577
+ // 刷新会话列表
578
+ loadSessions();
579
+ };
580
+
581
+ // 切换会话
582
+ const switchSession = async (targetSessionId: string) => {
583
+ if (targetSessionId === sessionId) return;
584
+
585
+ // Reset scroll lock when switching sessions
586
+ isUserScrollingRef.current = false;
587
+
588
+ // 切换到目标会话
589
+ setSessionId(targetSessionId);
590
+ localStorage.setItem('assistant-session-id', targetSessionId);
591
+
592
+ // 从后端获取消息
593
+ try {
594
+ const response = await fetch(`/api/sessions/${targetSessionId}/messages`);
595
+ if (response.ok) {
596
+ const data = await response.json();
597
+ const backendMessages = data.messages || [];
598
+ if (backendMessages.length > 0) {
599
+ // 转换消息并解析 chain-of-thought,直接嵌入消息对象
600
+ const converted: Message[] = [];
601
+
602
+ backendMessages.forEach((m: any) => {
603
+ const message: Message = {
604
+ id: m.id,
605
+ role: m.role as 'user' | 'assistant',
606
+ content: m.content,
607
+ timestamp: new Date(m.timestamp),
608
+ };
609
+
610
+ // 解析 chain-of-thought 数据,直接嵌入消息对象
611
+ if (m.chainOfThought) {
612
+ message.chainOfThought = {
613
+ isStreaming: false,
614
+ thinking: m.chainOfThought.thinking || '',
615
+ toolExecutions: (m.chainOfThought.toolExecutions || []).map((t: any) => ({
616
+ ...t,
617
+ startTime: new Date(t.startTime),
618
+ endTime: t.endTime ? new Date(t.endTime) : undefined,
619
+ }))
620
+ };
621
+ }
622
+
623
+ converted.push(message);
624
+ });
625
+
626
+ setMessages(converted);
627
+ }
628
+ }
629
+ } catch (error) {
630
+ console.error('Failed to load messages from backend:', error);
631
+ }
632
+ };
633
+
634
+ // 删除会话
635
+ const deleteSession = async (targetSessionId: string) => {
636
+ try {
637
+ // 调用后端 API 删除
638
+ await fetch(`/api/sessions?sessionId=${targetSessionId}`, { method: 'DELETE' });
639
+ } catch (error) {
640
+ console.error('Failed to delete session:', error);
641
+ }
642
+
643
+ // 从本地会话列表中移除
644
+ const localSessions = JSON.parse(localStorage.getItem('assistant-local-sessions') || '[]');
645
+ const updatedLocalSessions = localSessions.filter((s: SessionInfo) => s.sessionId !== targetSessionId);
646
+ localStorage.setItem('assistant-local-sessions', JSON.stringify(updatedLocalSessions));
647
+
648
+ // 如果删除的是当前会话,创建新会话
649
+ if (targetSessionId === sessionId) {
650
+ createNewSession();
651
+ }
652
+
653
+ // 刷新会话列表
654
+ loadSessions();
655
+ };
656
+
657
+ // 获取会话标题
658
+ const getSessionTitle = (session: SessionInfo) => {
659
+ // 优先使用 title,其次使用 recentSummary
660
+ if (session.title) {
661
+ return session.title;
662
+ }
663
+ if (session.recentSummary) {
664
+ return session.recentSummary.slice(0, 30) + (session.recentSummary.length > 30 ? '...' : '');
665
+ }
666
+ // 使用日期作为默认标题
667
+ return `会话 ${new Date(session.createdAt).toLocaleDateString('zh-CN')}`;
668
+ };
669
+
670
+ const scrollToBottom = (smooth: boolean = false) => {
671
+ if (scrollContainerRef.current) {
672
+ const container = scrollContainerRef.current;
673
+ container.scrollTo({
674
+ top: container.scrollHeight,
675
+ behavior: smooth ? 'smooth' : 'auto'
676
+ });
677
+ }
678
+ };
679
+
680
+ // 将消息内容中的 dirName 转换回 skillName 用于显示
681
+ const convertSkillNamesForDisplay = (content: string): string => {
682
+ if (!skillNameMap || Object.keys(skillNameMap).length === 0) {
683
+ return content;
684
+ }
685
+ return content.replace(/@(\w+)/g, (match, dirName) => {
686
+ const skillName = skillNameMap[dirName];
687
+ return skillName ? `@${skillName}` : match;
688
+ });
689
+ };
690
+
691
+ // Optimized scroll trigger - unified handling for messages and chain-of-thought
692
+ useEffect(() => {
693
+ if (scrollMode !== 'auto') return;
694
+
695
+ // 新消息追加时立即滚动
696
+ if (messages.length > lastMessageCountRef.current) {
697
+ scrollToBottom(false);
698
+ } else if (loading) {
699
+ // 内容更新(思考过程流式输出)时防抖滚动
700
+ const timer = setTimeout(() => {
701
+ scrollToBottom(false);
702
+ }, 150);
703
+ return () => clearTimeout(timer);
704
+ }
705
+ lastMessageCountRef.current = messages.length;
706
+ }, [messages, scrollMode, loading]);
707
+
708
+ // Enhanced user scroll detection with scroll mode support
709
+ useEffect(() => {
710
+ const container = scrollContainerRef.current;
711
+ if (!container) return;
712
+
713
+ const handleScroll = () => {
714
+ const scrollTop = container.scrollTop;
715
+ const scrollHeight = container.scrollHeight;
716
+ const clientHeight = container.clientHeight;
717
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
718
+
719
+ // Detect user scroll direction (up or down)
720
+ const isScrollingUp = scrollTop < lastScrollTopRef.current;
721
+ lastScrollTopRef.current = scrollTop;
722
+
723
+ // More sensitive threshold: switch to follow mode when > 100px from bottom
724
+ const isAtBottom = distanceFromBottom < 100;
725
+
726
+ // User scrolled up - switch to follow mode
727
+ if (isScrollingUp && distanceFromBottom > 100) {
728
+ setScrollMode('follow');
729
+ } else if (isAtBottom) {
730
+ // User scrolled to bottom - restore auto mode
731
+ setScrollMode('auto');
732
+ }
733
+
734
+ // Show/hide scroll button when not at bottom
735
+ setShowScrollButton(distanceFromBottom > 100);
736
+ };
737
+
738
+ container.addEventListener('scroll', handleScroll, { passive: true });
739
+ return () => container.removeEventListener('scroll', handleScroll);
740
+ }, []);
741
+
742
+ // Scroll to bottom on mount and when session changes
743
+ useEffect(() => {
744
+ scrollToBottom(true);
745
+ }, [sessionId]);
746
+
747
+ const parseSSELine = (line: string): PIEvent | null => {
748
+ if (!line.startsWith('data: ')) return null;
749
+ try {
750
+ return JSON.parse(line.slice(6));
751
+ } catch {
752
+ return null;
753
+ }
754
+ };
755
+
756
+ const handleFileAttach = async (file: File) => {
757
+ if (!userId) return;
758
+ setUploading(true);
759
+ try {
760
+ const formData = new FormData();
761
+ formData.append('userId', userId);
762
+ formData.append('file', file);
763
+ const res = await fetch('/api/my/files', { method: 'POST', body: formData });
764
+ if (res.ok) {
765
+ const data = await res.json();
766
+ setAttachedFiles(prev => [...prev, {
767
+ id: data.file.id,
768
+ name: file.name,
769
+ size: file.size,
770
+ }]);
771
+ } else {
772
+ const error = await res.json();
773
+ alert(error.error || '上传失败');
774
+ }
775
+ } catch (error) {
776
+ console.error('Upload error:', error);
777
+ alert('上传失败');
778
+ } finally {
779
+ setUploading(false);
780
+ }
781
+ };
782
+
783
+ const handleFileRemove = (fileId: string) => {
784
+ setAttachedFiles(prev => prev.filter(f => f.id !== fileId));
785
+ };
786
+
787
+ const handleSend = async () => {
788
+ if ((!input.trim() && attachedFiles.length === 0) || loading) return;
789
+
790
+ // Unlock scroll when user sends a new message
791
+ isUserScrollingRef.current = false;
792
+
793
+ // 构建消息内容(包含文件信息)
794
+ let messageContent = input.trim();
795
+ if (attachedFiles.length > 0) {
796
+ const fileInfo = attachedFiles.map(f => `[文件: ${f.name} (ID: ${f.id})]`).join('\n');
797
+ messageContent = messageContent ? `${messageContent}\n\n${fileInfo}` : fileInfo;
798
+ }
799
+
800
+ const userMessage: Message = {
801
+ id: generateUUID(),
802
+ role: 'user',
803
+ content: messageContent,
804
+ timestamp: new Date(),
805
+ };
806
+
807
+ setMessages((prev) => [...prev, userMessage]);
808
+ const messageToSend = messageContent;
809
+ const filesToSend = [...attachedFiles];
810
+ setInput('');
811
+ setAttachedFiles([]);
812
+ setLoading(true);
813
+ setCurrentTool(null);
814
+
815
+ abortControllerRef.current = new AbortController();
816
+
817
+ let assistantContent = '';
818
+ let assistantMessageId = generateUUID();
819
+ let createdTicket: Message['ticket'] | null = null;
820
+
821
+ // 从消息中提取 @mentions
822
+ const mentionedInMessage = (input.match(/@([\w\u4e00-\u9fa5]+)/g) || []).map(m => m.slice(1));
823
+
824
+ // 合并 selectedSkills 和 @mentions(去重)
825
+ const combinedSkills = Array.from(new Set([...selectedSkills, ...mentionedInMessage]));
826
+
827
+ try {
828
+ const userId = localStorage.getItem('userId');
829
+ const response = await fetch('/api/pi-chat', {
830
+ method: 'POST',
831
+ headers: { 'Content-Type': 'application/json' },
832
+ body: JSON.stringify({
833
+ message: messageToSend,
834
+ sessionId: sessionId || undefined,
835
+ userId: userId || undefined,
836
+ skills: combinedSkills,
837
+ abort: true,
838
+ }),
839
+ signal: abortControllerRef.current.signal,
840
+ });
841
+
842
+ if (!response.ok) {
843
+ throw new Error(`请求失败: ${response.status}`);
844
+ }
845
+
846
+ const reader = response.body?.getReader();
847
+ const decoder = new TextDecoder();
848
+
849
+ if (!reader) {
850
+ throw new Error('No response body');
851
+ }
852
+
853
+ let buffer = '';
854
+
855
+ while (true) {
856
+ const { done, value } = await reader.read();
857
+ if (done) break;
858
+
859
+ buffer += decoder.decode(value, { stream: true });
860
+ const lines = buffer.split('\n');
861
+ buffer = lines.pop() || '';
862
+
863
+ for (const line of lines) {
864
+ const event = parseSSELine(line);
865
+ if (!event) continue;
866
+
867
+ switch (event.type) {
868
+ case 'session_start':
869
+ case 'session_restored':
870
+ if (event.sessionId) {
871
+ setSessionId(event.sessionId);
872
+ }
873
+ break;
874
+
875
+ case 'text':
876
+ assistantContent += event.content || '';
877
+ setMessages((prev) => {
878
+ const existing = prev.find((m) => m.id === assistantMessageId);
879
+ if (existing) {
880
+ return prev.map((m) =>
881
+ m.id === assistantMessageId
882
+ ? { ...m, content: assistantContent, isStreaming: true }
883
+ : m,
884
+ );
885
+ } else {
886
+ return [
887
+ ...prev,
888
+ {
889
+ id: assistantMessageId,
890
+ role: 'assistant',
891
+ content: assistantContent,
892
+ timestamp: new Date(),
893
+ isStreaming: true,
894
+ },
895
+ ];
896
+ }
897
+ });
898
+ break;
899
+
900
+ case 'thinking':
901
+ // Capture thinking content - update directly in message
902
+ setMessages((prev) => prev.map((m) => {
903
+ if (m.id !== assistantMessageId) return m;
904
+ return {
905
+ ...m,
906
+ chainOfThought: {
907
+ isStreaming: true,
908
+ thinking: (m.chainOfThought?.thinking || '') + (event.content || ''),
909
+ toolExecutions: m.chainOfThought?.toolExecutions || [],
910
+ },
911
+ };
912
+ }));
913
+ break;
914
+
915
+ case 'tool_start':
916
+ setCurrentTool(event.toolName || null);
917
+ // Start tracking tool execution - update directly in message
918
+ setMessages((prev) => prev.map((m) => {
919
+ if (m.id !== assistantMessageId) return m;
920
+ return {
921
+ ...m,
922
+ chainOfThought: {
923
+ isStreaming: true,
924
+ thinking: m.chainOfThought?.thinking || '',
925
+ toolExecutions: [
926
+ ...(m.chainOfThought?.toolExecutions || []),
927
+ {
928
+ toolName: event.toolName || '',
929
+ startTime: new Date(),
930
+ input: event.args,
931
+ },
932
+ ],
933
+ },
934
+ };
935
+ }));
936
+ break;
937
+
938
+ case 'tool_end':
939
+ setCurrentTool(null);
940
+ break;
941
+
942
+ case 'tool_output':
943
+ // Capture tool output for chain-of-thought display - update directly in message
944
+ if (event.content) {
945
+ try {
946
+ let result = JSON.parse(event.content);
947
+ // Handle double-encoded JSON: if result is a string that looks like JSON, parse it again
948
+ if (typeof result === 'string' && (result.startsWith('{') || result.startsWith('['))) {
949
+ try {
950
+ result = JSON.parse(result);
951
+ } catch {
952
+ // Keep as string if second parse fails
953
+ }
954
+ }
955
+ setMessages((prev) => prev.map((m) => {
956
+ if (m.id !== assistantMessageId) return m;
957
+ const tools = [...(m.chainOfThought?.toolExecutions || [])];
958
+ const lastTool = tools[tools.length - 1];
959
+
960
+ if (lastTool && !lastTool.output) {
961
+ lastTool.output = result;
962
+ lastTool.isError = event.isError;
963
+ lastTool.endTime = new Date();
964
+ }
965
+
966
+ return {
967
+ ...m,
968
+ chainOfThought: {
969
+ isStreaming: m.chainOfThought?.isStreaming ?? true,
970
+ thinking: m.chainOfThought?.thinking || '',
971
+ toolExecutions: tools,
972
+ },
973
+ };
974
+ }));
975
+
976
+ // Keep existing ticket creation logic
977
+ if (event.toolName === 'create_ticket' && result.details?.success && result.details.ticket) {
978
+ const ticket = result.details.ticket;
979
+ createdTicket = {
980
+ id: ticket.id,
981
+ title: ticket.title,
982
+ type: ticket.type,
983
+ status: ticket.status,
984
+ priority: ticket.priority,
985
+ description: ticket.description,
986
+ };
987
+ }
988
+ } catch (e) {
989
+ console.error('[pi-chat] Failed to parse tool output:', e);
990
+ }
991
+ }
992
+ break;
993
+
994
+ case 'error':
995
+ setMessages((prev) => [
996
+ ...prev,
997
+ {
998
+ id: generateUUID(),
999
+ role: 'system',
1000
+ content: `❌ 错误: ${event.error || '未知错误'}`,
1001
+ timestamp: new Date(),
1002
+ },
1003
+ ]);
1004
+ break;
1005
+
1006
+ case 'message_end':
1007
+ // 处理消息中的错误(如 API 限流、认证错误等)
1008
+ if (event.message?.stopReason === 'error' && event.message?.errorMessage) {
1009
+ setMessages((prev) => [
1010
+ ...prev,
1011
+ {
1012
+ id: generateUUID(),
1013
+ role: 'system',
1014
+ content: `❌ API 错误: ${event.message.errorMessage}`,
1015
+ timestamp: new Date(),
1016
+ },
1017
+ ]);
1018
+ }
1019
+ break;
1020
+
1021
+ case 'agent_end':
1022
+ // 检查 agent 结束时的错误
1023
+ if (event.messages) {
1024
+ for (const msg of event.messages) {
1025
+ if (msg.stopReason === 'error' && msg.errorMessage) {
1026
+ // 错误已在 message_end 中处理,这里避免重复
1027
+ break;
1028
+ }
1029
+ }
1030
+ }
1031
+ break;
1032
+
1033
+ case 'turn_end':
1034
+ // Mark chain-of-thought as complete - update directly in message
1035
+ setMessages((prev) => prev.map((m) => {
1036
+ if (m.id !== assistantMessageId) return m;
1037
+ if (!m.chainOfThought) return m;
1038
+ return {
1039
+ ...m,
1040
+ chainOfThought: {
1041
+ ...m.chainOfThought,
1042
+ isStreaming: false,
1043
+ },
1044
+ };
1045
+ }));
1046
+ // Extract ticket from tool results (only if we haven't found one yet)
1047
+ if (!createdTicket && event.toolResults && Array.isArray(event.toolResults) && event.toolResults.length > 0) {
1048
+ for (const result of event.toolResults) {
1049
+ if (result.toolName === 'create_ticket' && result.details?.ticket) {
1050
+ const ticket = result.details.ticket;
1051
+ createdTicket = {
1052
+ id: ticket.id,
1053
+ title: ticket.title,
1054
+ type: ticket.type,
1055
+ status: ticket.status,
1056
+ priority: ticket.priority,
1057
+ description: ticket.description,
1058
+ };
1059
+ break;
1060
+ }
1061
+ }
1062
+ }
1063
+ break;
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ setMessages((prev) =>
1069
+ prev.map((m) =>
1070
+ m.id === assistantMessageId
1071
+ ? { ...m, isStreaming: false, ticket: createdTicket || undefined }
1072
+ : m,
1073
+ ),
1074
+ );
1075
+
1076
+ } catch (error: unknown) {
1077
+ if ((error as Error).name === 'AbortError') {
1078
+ setMessages((prev) => [
1079
+ ...prev,
1080
+ {
1081
+ id: generateUUID(),
1082
+ role: 'system',
1083
+ content: '\n⏸️ 已停止',
1084
+ timestamp: new Date(),
1085
+ },
1086
+ ]);
1087
+ } else {
1088
+ // Enhanced error logging
1089
+ console.error('[pi-chat] Stream error:', {
1090
+ name: (error as Error).name,
1091
+ message: (error as Error).message,
1092
+ stack: (error as Error).stack,
1093
+ });
1094
+
1095
+ setMessages((prev) => [
1096
+ ...prev,
1097
+ {
1098
+ id: generateUUID(),
1099
+ role: 'system',
1100
+ content: `\n❌ 错误: ${error instanceof Error ? error.message : '未知错误'}`,
1101
+ timestamp: new Date(),
1102
+ },
1103
+ ]);
1104
+ }
1105
+ } finally {
1106
+ setLoading(false);
1107
+ setCurrentTool(null);
1108
+ abortControllerRef.current = null;
1109
+ // 刷新会话列表以更新标题
1110
+ loadSessions();
1111
+ }
1112
+ };
1113
+
1114
+ const handleStop = async () => {
1115
+ // Abort HTTP request
1116
+ if (abortControllerRef.current) {
1117
+ abortControllerRef.current.abort();
1118
+ }
1119
+
1120
+ // Also abort on backend
1121
+ if (sessionId) {
1122
+ try {
1123
+ await fetch(`/api/pi-chat?sessionId=${sessionId}`, { method: 'PUT' });
1124
+ } catch (e) {
1125
+ // Ignore errors
1126
+ }
1127
+ }
1128
+
1129
+ setLoading(false);
1130
+ setCurrentTool(null);
1131
+ };
1132
+
1133
+ const handleKeyPress = (e: React.KeyboardEvent) => {
1134
+ if (e.key === 'Enter' && !e.shiftKey && !loading) {
1135
+ e.preventDefault();
1136
+ handleSend();
1137
+ }
1138
+ };
1139
+
1140
+ // 刷新面板数据
1141
+ const refreshPanel = () => {
1142
+ setRefreshKey(prev => prev + 1);
1143
+ };
1144
+
1145
+ // 处理工单操作(审批/拒绝/执行)
1146
+ const handleTicketAction = async (ticketId: string, action: 'approve' | 'reject' | 'execute') => {
1147
+ let resultMessage = '';
1148
+ let resultType: 'success' | 'error' = 'success';
1149
+
1150
+ // 先获取工单信息用于显示
1151
+ let ticketInfo = '';
1152
+ let currentTicket: Ticket | null = null;
1153
+ try {
1154
+ const ticketResponse = await fetch(`/api/tickets/${ticketId}`);
1155
+ if (ticketResponse.ok) {
1156
+ const ticketData = await ticketResponse.json();
1157
+ currentTicket = ticketData.ticket;
1158
+ if (currentTicket) {
1159
+ ticketInfo = `**工单:** ${currentTicket.title}\n**ID:** ${ticketId}\n\n`;
1160
+ }
1161
+ }
1162
+ } catch (e) {
1163
+ // 如果获取工单信息失败,至少显示 ID
1164
+ ticketInfo = `**工单 ID:** ${ticketId}\n\n`;
1165
+ }
1166
+
1167
+ try {
1168
+ if (action === 'execute') {
1169
+ // Check if ticket is in correct status
1170
+ if (currentTicket && currentTicket.status !== 'approved') {
1171
+ resultMessage = `❌ 无法执行\n\n${ticketInfo}工单状态为 ${currentTicket.status},必须是已批准状态才能执行。`;
1172
+ resultType = 'error';
1173
+ } else {
1174
+ const response = await fetch(`/api/tickets/${ticketId}/execute`, {
1175
+ method: 'POST',
1176
+ headers: { 'Content-Type': 'application/json' },
1177
+ body: JSON.stringify({ ticketId }),
1178
+ });
1179
+ const data = await response.json();
1180
+
1181
+ if (response.ok && data.success) {
1182
+ resultMessage = `✅ 工单执行成功\n\n${ticketInfo}${data.output || '操作已完成'}`;
1183
+ } else {
1184
+ resultMessage = `❌ 工单执行失败\n\n${ticketInfo}${data.error || '未知错误'}`;
1185
+ resultType = 'error';
1186
+ }
1187
+ }
1188
+
1189
+ // 刷新侧边栏
1190
+ refreshPanel();
1191
+ } else {
1192
+ // approve 或 reject
1193
+ const response = await fetch(`/api/tickets/${ticketId}/approve`, {
1194
+ method: 'POST',
1195
+ headers: { 'Content-Type': 'application/json' },
1196
+ body: JSON.stringify({
1197
+ action,
1198
+ approver: 'user',
1199
+ comment: '',
1200
+ }),
1201
+ });
1202
+ const data = await response.json();
1203
+
1204
+ if (response.ok) {
1205
+ resultMessage = action === 'approve'
1206
+ ? `✅ 工单已批准\n\n${ticketInfo}工单已批准,可以执行。`
1207
+ : `✅ 工单已拒绝\n\n${ticketInfo}工单已被拒绝。`;
1208
+ } else {
1209
+ resultMessage = `❌ 操作失败\n\n${ticketInfo}${data.error || '未知错误'}`;
1210
+ resultType = 'error';
1211
+ }
1212
+
1213
+ // 刷新侧边栏
1214
+ refreshPanel();
1215
+ }
1216
+ } catch (error) {
1217
+ resultMessage = `❌ 操作失败\n\n${ticketInfo}${error instanceof Error ? error.message : '未知错误'}`;
1218
+ resultType = 'error';
1219
+ }
1220
+
1221
+ // 将操作结果作为系统消息添加到对话
1222
+ setMessages((prev) => [
1223
+ ...prev,
1224
+ {
1225
+ id: generateUUID(),
1226
+ role: 'system',
1227
+ content: resultMessage,
1228
+ timestamp: new Date(),
1229
+ },
1230
+ ]);
1231
+ };
1232
+
1233
+ // 未配置时显示设置引导
1234
+ if (!configChecked) {
1235
+ return (
1236
+ <div className="h-full flex items-center justify-center">
1237
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
1238
+ </div>
1239
+ );
1240
+ }
1241
+
1242
+ if (!isConfigured) {
1243
+ return (
1244
+ <div className="h-full overflow-auto bg-background">
1245
+ <SetupGuide />
1246
+ </div>
1247
+ );
1248
+ }
1249
+
1250
+ return (
1251
+ <>
1252
+ {!isLoggedIn ? (
1253
+ <WelcomeGuide />
1254
+ ) : (
1255
+ <div className="h-full flex bg-background">
1256
+ {/* 左侧会话列表 */}
1257
+ <div className={cn(
1258
+ "bg-card border-r flex flex-col transition-all duration-300 flex-shrink-0",
1259
+ sessionSidebarOpen ? "w-64" : "w-0 overflow-hidden"
1260
+ )}>
1261
+ {/* 会话列表头部 */}
1262
+ <div className="px-3 py-3 border-b flex items-center justify-between">
1263
+ <span className="text-sm font-medium">会话列表</span>
1264
+ <Button
1265
+ variant="ghost"
1266
+ size="sm"
1267
+ onClick={createNewSession}
1268
+ className="h-7 px-2"
1269
+ title="新建会话"
1270
+ >
1271
+ <Plus className="h-4 w-4" />
1272
+ </Button>
1273
+ </div>
1274
+
1275
+ {/* 会话列表 */}
1276
+ <div className="flex-1 overflow-y-auto">
1277
+ {sessions.length === 0 ? (
1278
+ <div className="px-3 py-4 text-center text-sm text-muted-foreground">
1279
+ 暂无会话记录
1280
+ </div>
1281
+ ) : (
1282
+ <div className="py-1">
1283
+ {sessions.map((session) => (
1284
+ <div
1285
+ key={session.sessionId}
1286
+ className={cn(
1287
+ "px-3 py-2 mx-1 rounded-lg cursor-pointer group flex items-start gap-2",
1288
+ session.sessionId === sessionId
1289
+ ? "bg-primary/10 text-primary"
1290
+ : "hover:bg-muted"
1291
+ )}
1292
+ onClick={() => switchSession(session.sessionId)}
1293
+ >
1294
+ <MessageSquare className="h-4 w-4 mt-0.5 flex-shrink-0" />
1295
+ <div className="flex-1 min-w-0">
1296
+ <div className="text-sm truncate">
1297
+ {getSessionTitle(session)}
1298
+ </div>
1299
+ <div className="text-xs text-muted-foreground mt-0.5">
1300
+ {new Date(session.lastAccessedAt).toLocaleDateString('zh-CN')}
1301
+ </div>
1302
+ </div>
1303
+ <Button
1304
+ variant="ghost"
1305
+ size="sm"
1306
+ onClick={(e) => {
1307
+ e.stopPropagation();
1308
+ deleteSession(session.sessionId);
1309
+ }}
1310
+ className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
1311
+ title="删除会话"
1312
+ >
1313
+ <Trash2 className="h-3 w-3" />
1314
+ </Button>
1315
+ </div>
1316
+ ))}
1317
+ </div>
1318
+ )}
1319
+ </div>
1320
+
1321
+ {/* 底部:新建会话按钮 */}
1322
+ <div className="p-2 border-t">
1323
+ <Button
1324
+ variant="outline"
1325
+ size="sm"
1326
+ onClick={createNewSession}
1327
+ className="w-full"
1328
+ >
1329
+ <Plus className="h-4 w-4 mr-1" />
1330
+ 新建会话
1331
+ </Button>
1332
+ </div>
1333
+ </div>
1334
+
1335
+ {/* 会话边栏折叠按钮 */}
1336
+ <Button
1337
+ variant="ghost"
1338
+ size="icon"
1339
+ onClick={() => setSessionSidebarOpen(!sessionSidebarOpen)}
1340
+ className="absolute left-0 top-1/2 -translate-y-1/2 z-10 h-8 w-6 rounded-r-md bg-card border border-l-0 shadow-sm"
1341
+ style={{ left: sessionSidebarOpen ? '256px' : '0' }}
1342
+ >
1343
+ {sessionSidebarOpen ? (
1344
+ <ChevronLeft className="h-4 w-4" />
1345
+ ) : (
1346
+ <ChevronRight className="h-4 w-4" />
1347
+ )}
1348
+ </Button>
1349
+
1350
+ {/* 主聊天区域 */}
1351
+ <div className="flex-1 flex flex-col min-w-0">
1352
+ {/* 顶部标题栏 */}
1353
+ <header className="bg-card border-b flex-shrink-0">
1354
+ <div className="px-4 py-3">
1355
+ <div className="flex items-center justify-between gap-4">
1356
+ <div className="flex items-center gap-3">
1357
+ <SkillSwitcher
1358
+ skills={availableSkills}
1359
+ selectedSkills={selectedSkills}
1360
+ onSkillsChange={setSelectedSkills}
1361
+ />
1362
+ <button
1363
+ onClick={() => setKnowledgePickerOpen(true)}
1364
+ className="flex items-center gap-1.5 px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
1365
+ >
1366
+ <BookOpen className="w-4 h-4 text-gray-500" />
1367
+ <span className="text-sm text-gray-600">知识库</span>
1368
+ </button>
1369
+ </div>
1370
+ <div className="flex items-center gap-1.5">
1371
+ {/* 结束会话并分析 */}
1372
+ <Button
1373
+ variant="outline"
1374
+ size="sm"
1375
+ onClick={() => setAnalysisDialogOpen(true)}
1376
+ className="h-8"
1377
+ >
1378
+ <Bot className="h-4 w-4 mr-1" />
1379
+ 整理保存
1380
+ </Button>
1381
+
1382
+ {/* 分享按钮 */}
1383
+ <Button
1384
+ variant="outline"
1385
+ size="sm"
1386
+ onClick={async () => {
1387
+ if (!sessionId) return;
1388
+ const shareUrl = `${window.location.origin}/share/${sessionId}`;
1389
+ if (navigator?.clipboard?.writeText) {
1390
+ await navigator.clipboard.writeText(shareUrl);
1391
+ } else {
1392
+ const input = document.createElement('input');
1393
+ input.value = shareUrl;
1394
+ document.body.appendChild(input);
1395
+ input.select();
1396
+ document.execCommand('copy');
1397
+ document.body.removeChild(input);
1398
+ }
1399
+ setShareCopied(true);
1400
+ setTimeout(() => setShareCopied(false), 2000);
1401
+ }}
1402
+ disabled={!sessionId}
1403
+ className="h-8"
1404
+ title="复制分享链接"
1405
+ >
1406
+ {shareCopied ? <Check className="h-4 w-4 mr-1" /> : <Share2 className="h-4 w-4 mr-1" />}
1407
+ {shareCopied ? '已复制' : '分享'}
1408
+ </Button>
1409
+
1410
+ {/* 分屏按钮 */}
1411
+ <Button
1412
+ variant={sidePanel === 'tickets' ? 'default' : 'outline'}
1413
+ size="sm"
1414
+ onClick={() => setSidePanel(sidePanel === 'tickets' ? 'none' : 'tickets')}
1415
+ className="h-8"
1416
+ >
1417
+ <ClipboardList className="h-4 w-4 mr-1" />
1418
+ 工单
1419
+ </Button>
1420
+ <Button
1421
+ variant={sidePanel === 'approvals' ? 'default' : 'outline'}
1422
+ size="sm"
1423
+ onClick={() => setSidePanel(sidePanel === 'approvals' ? 'none' : 'approvals')}
1424
+ className="h-8"
1425
+ >
1426
+ <CheckCircle className="h-4 w-4 mr-1" />
1427
+ 审核
1428
+ </Button>
1429
+
1430
+ <Button
1431
+ variant="ghost"
1432
+ size="icon"
1433
+ onClick={() => {
1434
+ setMessages([]); // chain-of-thought 会随消息一起清空
1435
+ }}
1436
+ className="h-8 w-8"
1437
+ title="清空对话"
1438
+ >
1439
+ <Trash2 className="h-4 w-4" />
1440
+ </Button>
1441
+ </div>
1442
+ </div>
1443
+ </div>
1444
+ </header>
1445
+
1446
+ {/* 消息区域 */}
1447
+ <div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-4">
1448
+ <div className="w-full px-4 space-y-6">
1449
+ {messages.length === 0 && (
1450
+ <div className="flex justify-center items-center h-full">
1451
+ <div className="bg-muted/50 border rounded-xl px-6 py-5 max-w-md text-center">
1452
+ <Bot className="h-10 w-10 mx-auto mb-3 text-primary" />
1453
+ <p className="font-medium mb-2">智能助手</p>
1454
+ <p className="text-sm text-muted-foreground mb-3">执行命令、创建工单、定时任务</p>
1455
+ <p className="text-xs text-muted-foreground/70">所有危险操作都需要审核批准</p>
1456
+ </div>
1457
+ </div>
1458
+ )}
1459
+
1460
+ {/* 智能推荐横幅 */}
1461
+ {userId && messages.length > 0 && messages.length < 3 && (
1462
+ <RecommendationBanner
1463
+ userId={userId}
1464
+ onInstallAndUse={async (skillName) => {
1465
+ try {
1466
+ await fetch('/api/user/skills', {
1467
+ method: 'POST',
1468
+ headers: { 'Content-Type': 'application/json' },
1469
+ body: JSON.stringify({
1470
+ userId,
1471
+ action: 'install',
1472
+ skillName,
1473
+ source: 'market',
1474
+ }),
1475
+ });
1476
+ setSelectedSkills([...selectedSkills, skillName]);
1477
+ } catch (error) {
1478
+ console.error('Failed to install skill:', error);
1479
+ }
1480
+ }}
1481
+ />
1482
+ )}
1483
+
1484
+ {/* 会话整理保存对话框 */}
1485
+ {sessionId && userId && (
1486
+ <OrganizeDialog
1487
+ sessionId={sessionId}
1488
+ userId={userId}
1489
+ messages={messages}
1490
+ open={analysisDialogOpen}
1491
+ onOpenChange={setAnalysisDialogOpen}
1492
+ />
1493
+ )}
1494
+
1495
+ {/* 知识库选择弹框 */}
1496
+ <KnowledgePickerDialog
1497
+ open={knowledgePickerOpen}
1498
+ onOpenChange={setKnowledgePickerOpen}
1499
+ onSelect={(knowledge) => {
1500
+ setInput(prev => prev + `@doc:${knowledge.title}`);
1501
+ }}
1502
+ />
1503
+
1504
+ {messages.map((message) => (
1505
+ <div key={message.id} className={cn('flex gap-3', message.role === 'user' && 'flex-row-reverse')}>
1506
+ {message.role === 'system' ? (
1507
+ <div className="flex justify-center w-full">
1508
+ <div className="bg-destructive/10 border border-destructive/20 rounded-lg px-4 py-2 text-sm text-destructive">
1509
+ <p>{message.content}</p>
1510
+ </div>
1511
+ </div>
1512
+ ) : (
1513
+ <>
1514
+ {/* Avatar column */}
1515
+ <div className="flex flex-col items-center gap-1 flex-shrink-0">
1516
+ {message.role === 'assistant' && (
1517
+ <div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
1518
+ <Bot className="h-5 w-5 text-primary" />
1519
+ </div>
1520
+ )}
1521
+ {message.role === 'user' && (
1522
+ <div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center">
1523
+ <User className="h-5 w-5 text-primary-foreground" />
1524
+ </div>
1525
+ )}
1526
+ {/* Timestamp - always below avatar */}
1527
+ <div className={cn('text-xs opacity-60', message.role === 'user' && 'text-right')}>
1528
+ {new Date(message.timestamp).toLocaleTimeString('zh-CN')}
1529
+ </div>
1530
+ </div>
1531
+
1532
+ {/* Message content column */}
1533
+ <div className={cn('flex flex-col flex-1', message.role === 'user' && 'items-end')}>
1534
+ <div
1535
+ className={cn(
1536
+ 'rounded-2xl px-4 py-3',
1537
+ message.role === 'user'
1538
+ ? 'max-w-[70%] bg-primary text-primary-foreground'
1539
+ : 'w-full bg-muted text-foreground'
1540
+ )}
1541
+ >
1542
+ {/* Chain-of-Thought Display - 使用嵌入消息的 chainOfThought */}
1543
+ {message.role === 'assistant' && message.chainOfThought && (
1544
+ <ChainOfThought
1545
+ key={`cot-${message.id}`}
1546
+ defaultOpen={message.chainOfThought.isStreaming}
1547
+ >
1548
+ <ChainOfThoughtHeader>
1549
+ {message.chainOfThought.isStreaming ? '思考中...' : '思考过程'}
1550
+ </ChainOfThoughtHeader>
1551
+ <ChainOfThoughtContent>
1552
+ {/* Thinking Section */}
1553
+ {message.chainOfThought.thinking.trim().length > 0 && (
1554
+ <ChainOfThoughtStep
1555
+ icon={Brain}
1556
+ label="思考"
1557
+ status={message.chainOfThought.isStreaming ? 'active' : 'complete'}
1558
+ >
1559
+ <div className="text-muted-foreground whitespace-pre-wrap">
1560
+ {message.chainOfThought.thinking}
1561
+ </div>
1562
+ </ChainOfThoughtStep>
1563
+ )}
1564
+
1565
+ {/* Tool Executions Section */}
1566
+ {message.chainOfThought.toolExecutions.map((tool, idx) => (
1567
+ <ChainOfThoughtStep
1568
+ key={`${message.id}-tool-${idx}`}
1569
+ label={tool.toolName === 'bash' && tool.input?.command
1570
+ ? `执行: ${tool.input.command}`
1571
+ : tool.toolName}
1572
+ status={!tool.endTime ? 'active' : tool.isError ? 'pending' : 'complete'}
1573
+ >
1574
+ {/* Input for other tools */}
1575
+ {tool.toolName !== 'bash' && tool.input && (
1576
+ <div className="mt-2 text-xs">
1577
+ <strong>输入:</strong>
1578
+ <pre className="mt-1 overflow-x-auto bg-muted rounded p-2">
1579
+ {typeof tool.input === 'string' ? tool.input : JSON.stringify(tool.input, null, 2)}
1580
+ </pre>
1581
+ </div>
1582
+ )}
1583
+
1584
+ {/* Output with better formatting */}
1585
+ {tool.output && (
1586
+ <div className="mt-2 text-xs">
1587
+ <strong>输出:</strong>
1588
+ {(() => {
1589
+ // Handle pi-coding-agent output format: { content: [{ type: "text", text: "..." }] }
1590
+ if (tool.toolName === 'bash' && typeof tool.output === 'object' && tool.output.content) {
1591
+ const textContent = tool.output.content
1592
+ ?.filter((item: any) => item?.type === 'text')
1593
+ ?.map((item: any) => item?.text || '')
1594
+ ?.join('\n') || '';
1595
+ const exitCode = tool.output.details?.exitCode ?? 0;
1596
+ return (
1597
+ <pre className={cn(
1598
+ "mt-1 overflow-x-auto rounded p-2 whitespace-pre-wrap break-all",
1599
+ exitCode === 0 ? "bg-muted" : "bg-destructive/10"
1600
+ )}>
1601
+ {textContent || JSON.stringify(tool.output, null, 2)}
1602
+ </pre>
1603
+ );
1604
+ }
1605
+ // Handle traditional bash output format: { stdout, stderr, exitCode }
1606
+ if (tool.toolName === 'bash' && typeof tool.output === 'object' && tool.output.stdout !== undefined) {
1607
+ return (
1608
+ <pre className={cn(
1609
+ "mt-1 overflow-x-auto rounded p-2 whitespace-pre-wrap break-all",
1610
+ tool.output.exitCode === 0 ? "bg-muted" : "bg-destructive/10"
1611
+ )}>
1612
+ {tool.output.stdout || ''}
1613
+ {tool.output.stderr && (
1614
+ <div className="text-destructive mt-1">{tool.output.stderr}</div>
1615
+ )}
1616
+ </pre>
1617
+ );
1618
+ }
1619
+ // Fallback for other tools or string output
1620
+ return (
1621
+ <pre className="mt-1 overflow-x-auto bg-muted rounded p-2">
1622
+ {typeof tool.output === 'string' ? tool.output : JSON.stringify(tool.output, null, 2)}
1623
+ </pre>
1624
+ );
1625
+ })()}
1626
+ </div>
1627
+ )}
1628
+ </ChainOfThoughtStep>
1629
+ ))}
1630
+ </ChainOfThoughtContent>
1631
+ </ChainOfThought>
1632
+ )}
1633
+ {/* Message Content */}
1634
+ {message.role === 'assistant' ? (
1635
+ <MessageResponse>{convertSkillNamesForDisplay(message.content)}</MessageResponse>
1636
+ ) : (
1637
+ <span>{convertSkillNamesForDisplay(message.content)}</span>
1638
+ )}
1639
+ </div>
1640
+ {/* Action buttons below message */}
1641
+ {message.role === 'assistant' && (
1642
+ <div className="flex items-center gap-2 mt-2">
1643
+ {/* 工单操作按钮 */}
1644
+ {message.ticket && !message.isStreaming && (
1645
+ <TicketActionButtons
1646
+ ticketId={message.ticket.id}
1647
+ ticketStatus={message.ticket.status}
1648
+ onAction={handleTicketAction}
1649
+ onViewDetails={async () => {
1650
+ setLoadingTicket(true);
1651
+ try {
1652
+ const response = await fetch(`/api/tickets/${message.ticket!.id}`);
1653
+ if (response.ok) {
1654
+ const data = await response.json();
1655
+ setSelectedTicket(data.ticket);
1656
+ setTicketDialogOpen(true);
1657
+ }
1658
+ } finally {
1659
+ setLoadingTicket(false);
1660
+ }
1661
+ }}
1662
+ loadingTicket={loadingTicket}
1663
+ />
1664
+ )}
1665
+ {/* 流式状态 */}
1666
+ {message.isStreaming && (
1667
+ <span className="text-xs text-primary flex items-center gap-1">
1668
+ <span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"></span>
1669
+ 输入中...
1670
+ </span>
1671
+ )}
1672
+ </div>
1673
+ )}
1674
+ </div>
1675
+ </>
1676
+ )}
1677
+ </div>
1678
+ ))}
1679
+
1680
+ {/* 当前工具状态 */}
1681
+ {currentTool && (
1682
+ <div className="flex justify-center">
1683
+ <div className="bg-primary/10 border border-primary/20 rounded-lg px-4 py-2 text-sm text-primary flex items-center gap-2">
1684
+ <span className="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
1685
+ 正在执行: <code className="bg-primary/20 px-2 py-0.5 rounded">{currentTool}</code>
1686
+ </div>
1687
+ </div>
1688
+ )}
1689
+ </div>
1690
+ </div>
1691
+
1692
+ {/* Scroll mode toggle and scroll to bottom button */}
1693
+ <div className="fixed bottom-28 right-8 z-50 flex flex-col gap-2">
1694
+ {/* Scroll mode indicator/toggle */}
1695
+ <Button
1696
+ onClick={() => {
1697
+ const newMode = scrollMode === 'auto' ? 'follow' : 'auto';
1698
+ setScrollMode(newMode);
1699
+ if (newMode === 'auto') {
1700
+ scrollToBottom(true);
1701
+ }
1702
+ }}
1703
+ className={cn(
1704
+ "rounded-full shadow-lg",
1705
+ scrollMode === 'auto'
1706
+ ? "bg-muted-foreground/80 hover:bg-muted-foreground/90 text-background"
1707
+ : "bg-secondary hover:bg-secondary/80 text-foreground"
1708
+ )}
1709
+ size="sm"
1710
+ variant="default"
1711
+ >
1712
+ {scrollMode === 'auto' ? (
1713
+ <><ChevronDown className="h-3 w-3 mr-1" /><span className="text-xs">自动</span></>
1714
+ ) : (
1715
+ <span className="text-xs px-1">跟随</span>
1716
+ )}
1717
+ </Button>
1718
+
1719
+ {/* Scroll to bottom button - shows when not at bottom */}
1720
+ {showScrollButton && (
1721
+ <Button
1722
+ onClick={() => {
1723
+ setScrollMode('auto');
1724
+ scrollToBottom(true);
1725
+ }}
1726
+ className="rounded-full shadow-lg bg-primary hover:bg-primary/90"
1727
+ size="default"
1728
+ variant="default"
1729
+ >
1730
+ <ChevronDown className="h-4 w-4 mr-1" />
1731
+ <span className="text-sm">最新</span>
1732
+ </Button>
1733
+ )}
1734
+ </div>
1735
+
1736
+ {/* 输入区域 */}
1737
+ <div className="p-4 border-t bg-card flex-shrink-0">
1738
+ {loading && (
1739
+ <div className="flex items-center justify-between mb-2">
1740
+ <div className="flex items-center gap-2">
1741
+ <div className="w-2 h-2 rounded-full bg-primary animate-pulse"></div>
1742
+ <span className="text-xs text-muted-foreground">{currentTool ? `正在执行: ${currentTool}` : 'AI 正在思考...'}</span>
1743
+ </div>
1744
+ <Button variant="destructive" size="sm" onClick={handleStop} className="h-7">
1745
+ 停止
1746
+ </Button>
1747
+ </div>
1748
+ )}
1749
+ <div className="mb-2">
1750
+ <MentionInput
1751
+ skills={availableSkills}
1752
+ value={input}
1753
+ onChange={setInput}
1754
+ onSend={handleSend}
1755
+ disabled={loading}
1756
+ attachedFiles={attachedFiles}
1757
+ onFileAttach={handleFileAttach}
1758
+ onFileRemove={handleFileRemove}
1759
+ uploading={uploading}
1760
+ />
1761
+ </div>
1762
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
1763
+ <span>💡</span>
1764
+ <button onClick={() => setInput('查看当前目录')} className="hover:text-foreground">列出文件</button>
1765
+ <span>•</span>
1766
+ <button onClick={() => setInput('系统信息')} className="hover:text-foreground">系统状态</button>
1767
+ <span>•</span>
1768
+ <button onClick={() => setInput('帮我创建一个执行任务的工单')} className="hover:text-foreground">创建工单</button>
1769
+ </div>
1770
+ </div>
1771
+ </div>
1772
+
1773
+ {/* 右侧面板 */}
1774
+ {sidePanel !== 'none' && (
1775
+ <div className="w-96 bg-card border-l flex flex-col shadow-lg flex-shrink-0">
1776
+ {/* 面板标题栏 */}
1777
+ <div className="px-4 py-3 border-b flex items-center justify-between bg-muted/30">
1778
+ <h2 className="text-sm font-semibold">
1779
+ {sidePanel === 'tickets' && (
1780
+ <span className="flex items-center gap-2">
1781
+ <ClipboardList className="h-4 w-4" />
1782
+ 工单列表
1783
+ </span>
1784
+ )}
1785
+ {sidePanel === 'approvals' && (
1786
+ <span className="flex items-center gap-2">
1787
+ <CheckCircle className="h-4 w-4" />
1788
+ 审核队列
1789
+ </span>
1790
+ )}
1791
+ </h2>
1792
+ <Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setSidePanel('none')}>
1793
+ <X className="h-4 w-4" />
1794
+ </Button>
1795
+ </div>
1796
+
1797
+ {/* 面板内容 */}
1798
+ <div className="flex-1 overflow-auto">
1799
+ {sidePanel === 'tickets' && (
1800
+ <TicketsPanel
1801
+ key={`${refreshKey}-${sessionId}`}
1802
+ sessionId={sessionId}
1803
+ onTicketClick={async (id) => {
1804
+ setLoadingTicket(true);
1805
+ try {
1806
+ const response = await fetch(`/api/tickets/${id}`);
1807
+ if (response.ok) {
1808
+ const data = await response.json();
1809
+ setSelectedTicket(data.ticket);
1810
+ setTicketDialogOpen(true);
1811
+ }
1812
+ } finally {
1813
+ setLoadingTicket(false);
1814
+ }
1815
+ }}
1816
+ />
1817
+ )}
1818
+ {sidePanel === 'approvals' && (
1819
+ <ApprovalsPanel
1820
+ key={`${refreshKey}-${sessionId}`}
1821
+ sessionId={sessionId}
1822
+ onTicketClick={async (id) => {
1823
+ setLoadingTicket(true);
1824
+ try {
1825
+ const response = await fetch(`/api/tickets/${id}`);
1826
+ if (response.ok) {
1827
+ const data = await response.json();
1828
+ setSelectedTicket(data.ticket);
1829
+ setTicketDialogOpen(true);
1830
+ }
1831
+ } finally {
1832
+ setLoadingTicket(false);
1833
+ }
1834
+ }}
1835
+ />
1836
+ )}
1837
+ </div>
1838
+ </div>
1839
+ )}
1840
+
1841
+ {/* 工单详情对话框 */}
1842
+ <Dialog open={ticketDialogOpen} onOpenChange={setTicketDialogOpen}>
1843
+ <DialogContent className="max-w-3xl w-[90vw] max-h-[85vh] overflow-hidden flex flex-col">
1844
+ {selectedTicket && (
1845
+ <>
1846
+ <DialogHeader>
1847
+ <DialogTitle>工单详情</DialogTitle>
1848
+ <DialogDescription>{selectedTicket.id}</DialogDescription>
1849
+ </DialogHeader>
1850
+ <div className="overflow-y-auto flex-1">
1851
+ <TicketDetailContent
1852
+ ticket={selectedTicket}
1853
+ onApprove={async () => {
1854
+ await handleTicketAction(selectedTicket.id, 'approve');
1855
+ const response = await fetch(`/api/tickets/${selectedTicket.id}`);
1856
+ if (response.ok) {
1857
+ const data = await response.json();
1858
+ setSelectedTicket(data.ticket);
1859
+ }
1860
+ }}
1861
+ onReject={async () => {
1862
+ await handleTicketAction(selectedTicket.id, 'reject');
1863
+ const response = await fetch(`/api/tickets/${selectedTicket.id}`);
1864
+ if (response.ok) {
1865
+ const data = await response.json();
1866
+ setSelectedTicket(data.ticket);
1867
+ }
1868
+ }}
1869
+ onExecute={async () => {
1870
+ await handleTicketAction(selectedTicket.id, 'execute');
1871
+ const response = await fetch(`/api/tickets/${selectedTicket.id}`);
1872
+ if (response.ok) {
1873
+ const data = await response.json();
1874
+ setSelectedTicket(data.ticket);
1875
+ }
1876
+ }}
1877
+ />
1878
+ </div>
1879
+ </>
1880
+ )}
1881
+ </DialogContent>
1882
+ </Dialog>
1883
+ </div>
1884
+ )}
1885
+ </>
1886
+ );
1887
+ }
1888
+
1889
+ // 工单列表面板
1890
+ function TicketsPanel({ sessionId, onTicketClick }: { sessionId: string | null; onTicketClick: (id: string) => void }) {
1891
+ const [tickets, setTickets] = useState<any[]>([]);
1892
+ const [loading, setLoading] = useState(true);
1893
+
1894
+ useEffect(() => {
1895
+ async function load() {
1896
+ try {
1897
+ const url = sessionId
1898
+ ? `/api/tickets?aiSessionId=${sessionId}&limit=20`
1899
+ : '/api/tickets?limit=20';
1900
+ const response = await fetch(url);
1901
+ if (!response.ok) throw new Error('Failed to load tickets');
1902
+ const data = await response.json();
1903
+ const tickets = data.tickets || [];
1904
+ setTickets(Array.isArray(tickets) ? tickets.slice(0, 20) : []);
1905
+ } catch (error) {
1906
+ console.error('Failed to load tickets:', error);
1907
+ setTickets([]);
1908
+ } finally {
1909
+ setLoading(false);
1910
+ }
1911
+ }
1912
+ load();
1913
+ }, [sessionId]);
1914
+
1915
+ if (loading) return <div className="text-center text-muted-foreground text-sm py-4">加载中...</div>;
1916
+
1917
+ return (
1918
+ <div className="p-3 space-y-2">
1919
+ {tickets.map((ticket) => (
1920
+ <div
1921
+ key={ticket.id}
1922
+ onClick={() => onTicketClick(ticket.id)}
1923
+ className="p-3 bg-muted/30 hover:bg-muted rounded-lg cursor-pointer transition-colors"
1924
+ >
1925
+ <div className="flex items-start justify-between mb-1">
1926
+ <span className="font-medium text-sm">{ticket.title}</span>
1927
+ <StatusBadge status={ticket.status} />
1928
+ </div>
1929
+ <div className="text-xs text-muted-foreground">
1930
+ {ticket.type === 'bash-execute' ? '命令执行' : 'Skill 脚本'} · {ticket.riskLevel || 'medium'}
1931
+ </div>
1932
+ </div>
1933
+ ))}
1934
+ {tickets.length === 0 && (
1935
+ <div className="text-center text-muted-foreground text-sm py-8">暂无工单</div>
1936
+ )}
1937
+ </div>
1938
+ );
1939
+ }
1940
+
1941
+ // 审核队列面板
1942
+ function ApprovalsPanel({ sessionId, onTicketClick }: { sessionId: string | null; onTicketClick: (id: string) => void }) {
1943
+ const [tickets, setTickets] = useState<any[]>([]);
1944
+ const [loading, setLoading] = useState(true);
1945
+
1946
+ useEffect(() => {
1947
+ async function load() {
1948
+ try {
1949
+ const url = sessionId
1950
+ ? `/api/tickets?status=pending&aiSessionId=${sessionId}`
1951
+ : '/api/tickets?status=pending';
1952
+ const response = await fetch(url);
1953
+ const data = await response.json();
1954
+ setTickets(data.tickets || []);
1955
+ } catch (error) {
1956
+ console.error('Failed to load approvals:', error);
1957
+ } finally {
1958
+ setLoading(false);
1959
+ }
1960
+ }
1961
+ load();
1962
+ }, [sessionId]);
1963
+
1964
+ if (loading) return <div className="text-center text-muted-foreground text-sm py-4">加载中...</div>;
1965
+
1966
+ return (
1967
+ <div className="p-3 space-y-2">
1968
+ {tickets.map((ticket) => (
1969
+ <div
1970
+ key={ticket.id}
1971
+ onClick={() => onTicketClick(ticket.id)}
1972
+ className="p-3 bg-muted/30 hover:bg-muted rounded-lg cursor-pointer transition-colors"
1973
+ >
1974
+ <div className="font-medium text-sm">{ticket.title}</div>
1975
+ <div className="text-xs text-muted-foreground mt-1">
1976
+ {ticket.type === 'bash-execute' ? '命令执行' : 'Skill 脚本'} · {ticket.riskLevel || 'medium'}
1977
+ </div>
1978
+ </div>
1979
+ ))}
1980
+ {tickets.length === 0 && (
1981
+ <div className="text-center text-muted-foreground text-sm py-8">暂无待审核工单</div>
1982
+ )}
1983
+ </div>
1984
+ );
1985
+ }
1986
+
1987
+ // 工单详情面板
1988
+ function TicketDetailPanel({ ticketId, onClose }: { ticketId: string; onClose: () => void }) {
1989
+ const [ticket, setTicket] = useState<any>(null);
1990
+ const [loading, setLoading] = useState(true);
1991
+ const [approving, setApproving] = useState(false);
1992
+ const [comment, setComment] = useState('');
1993
+
1994
+ const loadTicket = async () => {
1995
+ setLoading(true);
1996
+ try {
1997
+ const response = await fetch(`/api/tickets/${ticketId}`);
1998
+ if (response.ok) {
1999
+ const data = await response.json();
2000
+ setTicket(data.ticket || null);
2001
+ } else {
2002
+ setTicket(null);
2003
+ }
2004
+ } catch (error) {
2005
+ console.error('Failed to load ticket:', error);
2006
+ setTicket(null);
2007
+ } finally {
2008
+ setLoading(false);
2009
+ }
2010
+ };
2011
+
2012
+ useEffect(() => {
2013
+ loadTicket();
2014
+ }, [ticketId]);
2015
+
2016
+ const handleApprove = async () => {
2017
+ setApproving(true);
2018
+ try {
2019
+ const response = await fetch(`/api/tickets/${ticketId}/approve`, {
2020
+ method: 'POST',
2021
+ headers: { 'Content-Type': 'application/json' },
2022
+ body: JSON.stringify({
2023
+ action: 'approve',
2024
+ approver: 'user',
2025
+ comment,
2026
+ }),
2027
+ });
2028
+ if (response.ok) {
2029
+ const data = await response.json();
2030
+ setTicket(data.ticket || null);
2031
+ setComment('');
2032
+ }
2033
+ } finally {
2034
+ setApproving(false);
2035
+ }
2036
+ };
2037
+
2038
+ const handleReject = async () => {
2039
+ setApproving(true);
2040
+ try {
2041
+ const response = await fetch(`/api/tickets/${ticketId}/approve`, {
2042
+ method: 'POST',
2043
+ headers: { 'Content-Type': 'application/json' },
2044
+ body: JSON.stringify({
2045
+ action: 'reject',
2046
+ approver: 'user',
2047
+ comment,
2048
+ }),
2049
+ });
2050
+ if (response.ok) {
2051
+ const data = await response.json();
2052
+ setTicket(data.ticket || null);
2053
+ setComment('');
2054
+ }
2055
+ } finally {
2056
+ setApproving(false);
2057
+ }
2058
+ };
2059
+
2060
+ const handleExecute = async () => {
2061
+ setApproving(true);
2062
+ // Immediately update UI to show executing state
2063
+ setTicket((prev: Ticket | null) => prev ? { ...prev, status: 'executing' as const } : null);
2064
+
2065
+ try {
2066
+ const response = await fetch(`/api/tickets/${ticketId}/execute`, {
2067
+ method: 'POST',
2068
+ headers: { 'Content-Type': 'application/json' },
2069
+ body: JSON.stringify({ ticketId }),
2070
+ });
2071
+
2072
+ const data = await response.json();
2073
+ if (response.ok) {
2074
+ setTicket(data.ticket || null);
2075
+ } else {
2076
+ if (data?.ticket) {
2077
+ setTicket(data.ticket);
2078
+ }
2079
+ alert(data?.error || data?.message || '执行失败');
2080
+ }
2081
+ } catch (error: unknown) {
2082
+ alert(error instanceof Error ? error.message : '执行失败');
2083
+ loadTicket();
2084
+ } finally {
2085
+ setApproving(false);
2086
+ }
2087
+ };
2088
+
2089
+ const getRiskBadge = (risk: string) => {
2090
+ const config: Record<string, { label: string; className: string }> = {
2091
+ low: { label: '低风险', className: 'bg-green-100 text-green-800 border-green-200' },
2092
+ medium: { label: '中风险', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' },
2093
+ high: { label: '高风险', className: 'bg-orange-100 text-orange-800 border-orange-200' },
2094
+ critical: { label: '极高风险', className: 'bg-red-100 text-red-800 border-red-200' },
2095
+ };
2096
+ const { label, className } = config[risk] || config.medium;
2097
+ return <span className={`px-2 py-0.5 rounded-full text-xs border ${className}`}>{label}</span>;
2098
+ };
2099
+
2100
+ if (loading) return <div className="text-center text-muted-foreground text-sm py-4">加载中...</div>;
2101
+ if (!ticket) return <div className="text-center text-muted-foreground text-sm py-4">工单不存在</div>;
2102
+
2103
+ return (
2104
+ <div className="p-4 space-y-4">
2105
+ {/* 返回按钮 */}
2106
+ <Button variant="ghost" size="sm" onClick={onClose} className="mb-2">
2107
+ ← 返回列表
2108
+ </Button>
2109
+
2110
+ {/* 标题和状态 */}
2111
+ <div className="flex items-start justify-between gap-2">
2112
+ <h3 className="font-semibold text-lg leading-tight">{ticket.title}</h3>
2113
+ <StatusBadge status={ticket.status} />
2114
+ </div>
2115
+
2116
+ {/* 风险等级 */}
2117
+ <div className="flex items-center gap-2">
2118
+ {ticket.riskLevel && getRiskBadge(ticket.riskLevel)}
2119
+ <span className="text-xs text-muted-foreground">
2120
+ 类型: {ticket.type === 'bash-execute' ? '命令执行' : 'Skill 脚本'}
2121
+ </span>
2122
+ </div>
2123
+
2124
+ {/* 核心信息卡片 */}
2125
+ <div className="bg-muted/50 rounded-lg p-4 space-y-4">
2126
+ {/* 要执行的命令 */}
2127
+ {ticket.command && (
2128
+ <div>
2129
+ <h4 className="text-xs font-medium text-muted-foreground mb-1">执行命令</h4>
2130
+ <div className="bg-background rounded border px-3 py-2 font-mono text-sm overflow-x-auto">
2131
+ <pre className="whitespace-pre-wrap break-all">{ticket.command}</pre>
2132
+ </div>
2133
+ </div>
2134
+ )}
2135
+
2136
+ {/* Skill 脚本 */}
2137
+ {ticket.scriptContent && (
2138
+ <div>
2139
+ <h4 className="text-xs font-medium text-muted-foreground mb-1">
2140
+ Skill: {ticket.skillName || '未知'}
2141
+ </h4>
2142
+ <div className="bg-background rounded border px-3 py-2 font-mono text-sm max-h-48 overflow-y-auto">
2143
+ <pre className="whitespace-pre-wrap break-all">{ticket.scriptContent}</pre>
2144
+ </div>
2145
+ </div>
2146
+ )}
2147
+
2148
+ {/* 受影响资源 */}
2149
+ {ticket.affectedResources && ticket.affectedResources.length > 0 && (
2150
+ <div>
2151
+ <h4 className="text-xs font-medium text-muted-foreground mb-1">受影响资源</h4>
2152
+ <div className="flex flex-wrap gap-1">
2153
+ {ticket.affectedResources.map((resource: string, idx: number) => (
2154
+ <span key={idx} className="px-2 py-0.5 bg-orange-100 text-orange-800 rounded text-xs">
2155
+ {resource}
2156
+ </span>
2157
+ ))}
2158
+ </div>
2159
+ </div>
2160
+ )}
2161
+ </div>
2162
+
2163
+ {/* 执行结果 */}
2164
+ {ticket.status === 'completed' && ticket.output && (
2165
+ <div className="bg-green-50 border border-green-200 rounded-lg p-4">
2166
+ <h4 className="text-xs font-medium text-green-800 mb-1">执行结果</h4>
2167
+ <div className="bg-background rounded px-3 py-2 font-mono text-sm max-h-48 overflow-y-auto">
2168
+ <pre className="whitespace-pre-wrap break-all">{ticket.output}</pre>
2169
+ </div>
2170
+ </div>
2171
+ )}
2172
+
2173
+ {ticket.status === 'failed' && ticket.error && (
2174
+ <div className="bg-red-50 border border-red-200 rounded-lg p-4">
2175
+ <h4 className="text-xs font-medium text-red-800 mb-1">执行失败</h4>
2176
+ <div className="text-red-700 text-sm">{ticket.error}</div>
2177
+ </div>
2178
+ )}
2179
+
2180
+ {/* 审批意见 */}
2181
+ {ticket.description && (
2182
+ <div className="text-sm text-muted-foreground bg-muted/30 p-3 rounded">
2183
+ {ticket.description}
2184
+ </div>
2185
+ )}
2186
+
2187
+ {/* 时间信息 */}
2188
+ <div className="text-xs text-muted-foreground space-y-1">
2189
+ <div>创建时间: {new Date(ticket.createdAt).toLocaleString('zh-CN')}</div>
2190
+ {ticket.updatedAt && ticket.updatedAt !== '1970-01-01T00:00:00.000Z' && (
2191
+ <div>更新时间: {new Date(ticket.updatedAt).toLocaleString('zh-CN')}</div>
2192
+ )}
2193
+ </div>
2194
+
2195
+ {/* 审批记录 */}
2196
+ {ticket.approvals && ticket.approvals.length > 0 && (
2197
+ <div className="border-t pt-4">
2198
+ <h4 className="text-sm font-medium mb-2">审批记录</h4>
2199
+ <div className="space-y-2">
2200
+ {ticket.approvals.map((approval: any, idx: number) => (
2201
+ <div key={idx} className="text-sm bg-muted/30 p-2 rounded">
2202
+ <div className="flex items-center justify-between">
2203
+ <span className="font-medium">{approval.approver}</span>
2204
+ <span className={`text-xs px-2 py-0.5 rounded ${
2205
+ approval.decision === 'approved'
2206
+ ? 'bg-green-100 text-green-800'
2207
+ : 'bg-red-100 text-red-800'
2208
+ }`}>
2209
+ {approval.decision === 'approved' ? '已批准' : '已拒绝'}
2210
+ </span>
2211
+ </div>
2212
+ {approval.comment && (
2213
+ <div className="text-muted-foreground mt-1">{approval.comment}</div>
2214
+ )}
2215
+ <div className="text-xs text-muted-foreground mt-1">
2216
+ {new Date(approval.timestamp).toLocaleString('zh-CN')}
2217
+ </div>
2218
+ </div>
2219
+ ))}
2220
+ </div>
2221
+ </div>
2222
+ )}
2223
+
2224
+ {/* 操作按钮 */}
2225
+ {ticket.status === 'pending' && (
2226
+ <div className="space-y-2 border-t pt-4">
2227
+ <textarea
2228
+ value={comment}
2229
+ onChange={(e) => setComment(e.target.value)}
2230
+ placeholder="审批意见(可选)"
2231
+ className="w-full px-3 py-2 border rounded-lg text-sm bg-background"
2232
+ rows={2}
2233
+ />
2234
+ <div className="flex gap-2">
2235
+ <Button onClick={handleApprove} disabled={approving} className="flex-1" size="sm">
2236
+ ✓ 批准
2237
+ </Button>
2238
+ <Button variant="destructive" onClick={handleReject} disabled={approving} className="flex-1" size="sm">
2239
+ ✗ 拒绝
2240
+ </Button>
2241
+ </div>
2242
+ </div>
2243
+ )}
2244
+
2245
+ {ticket.status === 'approved' && (
2246
+ <Button onClick={handleExecute} disabled={approving} className="w-full" size="sm">
2247
+ ▶️ 执行工单
2248
+ </Button>
2249
+ )}
2250
+
2251
+ {ticket.status === 'executing' && (
2252
+ <div className="text-center py-2 text-muted-foreground">
2253
+ <span className="inline-flex items-center gap-2">
2254
+ <span className="w-2 h-2 rounded-full bg-blue-600 animate-pulse"></span>
2255
+ 执行中...
2256
+ </span>
2257
+ </div>
2258
+ )}
2259
+ </div>
2260
+ );
2261
+ }
2262
+
2263
+ // 状态徽章
2264
+ function StatusBadge({ status }: { status: string }) {
2265
+ const config = {
2266
+ draft: { label: '草稿', variant: 'secondary' as const },
2267
+ pending: { label: '待审核', variant: 'outline' as const },
2268
+ approved: { label: '已批准', variant: 'default' as const },
2269
+ rejected: { label: '已拒绝', variant: 'destructive' as const },
2270
+ executing: { label: '执行中', variant: 'outline' as const },
2271
+ completed: { label: '已完成', variant: 'default' as const },
2272
+ failed: { label: '失败', variant: 'destructive' as const },
2273
+ };
2274
+ const { label, variant } = config[status as keyof typeof config] || config.draft;
2275
+ return <Badge variant={variant}>{label}</Badge>;
2276
+ }
2277
+
2278
+ export default function HomePage() {
2279
+ return (
2280
+ <Suspense fallback={
2281
+ <div className="h-full flex items-center justify-center">
2282
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
2283
+ </div>
2284
+ }>
2285
+ <HomePageContent />
2286
+ </Suspense>
2287
+ );
2288
+ }