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.
- package/README.md +234 -0
- package/app/(admin)/approvals/page.tsx +16 -0
- package/app/(admin)/audit/page.tsx +18 -0
- package/app/(admin)/layout.tsx +47 -0
- package/app/(admin)/scheduled-tasks/page.tsx +17 -0
- package/app/(admin)/settings/page.tsx +46 -0
- package/app/(admin)/skills/[name]/page.tsx +378 -0
- package/app/(admin)/skills/page.tsx +406 -0
- package/app/(admin)/statistics/page.tsx +416 -0
- package/app/(admin)/tickets/[id]/page.tsx +348 -0
- package/app/(admin)/tickets/new/page.tsx +309 -0
- package/app/(admin)/tickets/page.tsx +27 -0
- package/app/api/audit/route.ts +30 -0
- package/app/api/auth/feishu/callback/route.ts +72 -0
- package/app/api/auth/feishu/login/route.ts +17 -0
- package/app/api/auth/feishu/sso/route.ts +78 -0
- package/app/api/auth/login/route.ts +85 -0
- package/app/api/auth/oauth/route.ts +168 -0
- package/app/api/config/providers/route.ts +105 -0
- package/app/api/config/route.ts +115 -0
- package/app/api/config/status/route.ts +56 -0
- package/app/api/config/test/route.ts +212 -0
- package/app/api/documents/[id]/route.ts +88 -0
- package/app/api/documents/route.ts +53 -0
- package/app/api/health/route.ts +32 -0
- package/app/api/knowledge/[id]/route.ts +152 -0
- package/app/api/knowledge/from-session/route.ts +27 -0
- package/app/api/knowledge/route.ts +100 -0
- package/app/api/market/knowledge/[id]/route.ts +92 -0
- package/app/api/market/knowledge/route.ts +130 -0
- package/app/api/marketplace/skills/[id]/approve/route.ts +68 -0
- package/app/api/marketplace/skills/[id]/certify/route.ts +54 -0
- package/app/api/marketplace/skills/[id]/install/route.ts +180 -0
- package/app/api/marketplace/skills/[id]/promote-to-system/route.ts +219 -0
- package/app/api/marketplace/skills/[id]/rate/route.ts +90 -0
- package/app/api/marketplace/skills/[id]/ratings/route.ts +55 -0
- package/app/api/marketplace/skills/[id]/reject/route.ts +68 -0
- package/app/api/marketplace/skills/[id]/route.ts +177 -0
- package/app/api/marketplace/skills/route.ts +235 -0
- package/app/api/memory/route.ts +40 -0
- package/app/api/my/files/[id]/route.ts +52 -0
- package/app/api/my/files/route.ts +230 -0
- package/app/api/my/knowledge/route.ts +36 -0
- package/app/api/pi-chat/route.ts +443 -0
- package/app/api/recommend/route.ts +38 -0
- package/app/api/scheduled-tasks/[id]/execute/route.ts +132 -0
- package/app/api/scheduled-tasks/[id]/route.ts +165 -0
- package/app/api/scheduled-tasks/[id]/toggle/route.ts +53 -0
- package/app/api/scheduled-tasks/route.ts +101 -0
- package/app/api/sessions/[id]/messages/route.ts +212 -0
- package/app/api/sessions/route.ts +101 -0
- package/app/api/share/file/[id]/route.ts +37 -0
- package/app/api/skills/[name]/execute/route.ts +121 -0
- package/app/api/skills/[name]/route.ts +167 -0
- package/app/api/skills/create/route.ts +65 -0
- package/app/api/skills/generate/route.ts +405 -0
- package/app/api/skills/installed/route.ts +151 -0
- package/app/api/skills/route.ts +174 -0
- package/app/api/skills/translate/route.ts +40 -0
- package/app/api/skills/user/[name]/route.ts +159 -0
- package/app/api/skills/user/route.ts +90 -0
- package/app/api/statistics/route.ts +94 -0
- package/app/api/task-executions/[id]/route.ts +34 -0
- package/app/api/task-executions/route.ts +29 -0
- package/app/api/tickets/[id]/approve/route.ts +129 -0
- package/app/api/tickets/[id]/execute/route.ts +201 -0
- package/app/api/tickets/[id]/route.ts +127 -0
- package/app/api/tickets/route.ts +103 -0
- package/app/api/user/skills/route.ts +175 -0
- package/app/api/users/route.ts +80 -0
- package/app/chat/page.tsx +5 -0
- package/app/globals.css +84 -0
- package/app/h5/layout.tsx +5 -0
- package/app/h5/mobile-approvals-page.tsx +167 -0
- package/app/h5/mobile-chat-page.tsx +951 -0
- package/app/h5/mobile-profile-page.tsx +147 -0
- package/app/h5/mobile-tickets-page.tsx +121 -0
- package/app/h5/page.tsx +23 -0
- package/app/h5/ticket-action-buttons.tsx +80 -0
- package/app/layout.tsx +26 -0
- package/app/login/page.tsx +318 -0
- package/app/market/knowledge/[id]/page.tsx +77 -0
- package/app/market/knowledge/page.tsx +358 -0
- package/app/market/layout.tsx +29 -0
- package/app/market/page.tsx +18 -0
- package/app/market/skills/page.tsx +397 -0
- package/app/my/files/page.tsx +511 -0
- package/app/my/knowledge/[id]/page.tsx +271 -0
- package/app/my/knowledge/new/page.tsx +234 -0
- package/app/my/knowledge/page.tsx +248 -0
- package/app/my/layout.tsx +32 -0
- package/app/my/memory/page.tsx +164 -0
- package/app/my/page.tsx +18 -0
- package/app/my/scheduled-tasks/[id]/edit/page.tsx +290 -0
- package/app/my/scheduled-tasks/[id]/executions/page.tsx +275 -0
- package/app/my/scheduled-tasks/[id]/page.tsx +284 -0
- package/app/my/scheduled-tasks/new/page.tsx +230 -0
- package/app/my/scheduled-tasks/page.tsx +27 -0
- package/app/my/skills/[name]/page.tsx +320 -0
- package/app/my/skills/new/page.tsx +394 -0
- package/app/my/skills/page.tsx +303 -0
- package/app/page.tsx +2288 -0
- package/app/share/[sessionId]/page.tsx +226 -0
- package/app/share/file/[id]/page.tsx +140 -0
- package/bin/README.md +63 -0
- package/bin/generate-api-system +300 -0
- package/bin/postinstall.js +95 -0
- package/bin/work-agent.js +173 -0
- package/components/ai-elements/agent.tsx +142 -0
- package/components/ai-elements/artifact.tsx +149 -0
- package/components/ai-elements/attachments.tsx +427 -0
- package/components/ai-elements/audio-player.tsx +232 -0
- package/components/ai-elements/canvas.tsx +26 -0
- package/components/ai-elements/chain-of-thought.tsx +223 -0
- package/components/ai-elements/checkpoint.tsx +72 -0
- package/components/ai-elements/code-block.tsx +555 -0
- package/components/ai-elements/commit.tsx +449 -0
- package/components/ai-elements/confirmation.tsx +173 -0
- package/components/ai-elements/connection.tsx +28 -0
- package/components/ai-elements/context.tsx +410 -0
- package/components/ai-elements/controls.tsx +19 -0
- package/components/ai-elements/conversation.tsx +167 -0
- package/components/ai-elements/edge.tsx +144 -0
- package/components/ai-elements/environment-variables.tsx +325 -0
- package/components/ai-elements/file-tree.tsx +298 -0
- package/components/ai-elements/image.tsx +25 -0
- package/components/ai-elements/inline-citation.tsx +294 -0
- package/components/ai-elements/jsx-preview.tsx +250 -0
- package/components/ai-elements/message.tsx +367 -0
- package/components/ai-elements/mic-selector.tsx +372 -0
- package/components/ai-elements/model-selector.tsx +214 -0
- package/components/ai-elements/node.tsx +72 -0
- package/components/ai-elements/open-in-chat.tsx +367 -0
- package/components/ai-elements/package-info.tsx +235 -0
- package/components/ai-elements/panel.tsx +16 -0
- package/components/ai-elements/persona.tsx +280 -0
- package/components/ai-elements/plan.tsx +144 -0
- package/components/ai-elements/prompt-input.tsx +1341 -0
- package/components/ai-elements/queue.tsx +275 -0
- package/components/ai-elements/reasoning.tsx +355 -0
- package/components/ai-elements/sandbox.tsx +133 -0
- package/components/ai-elements/schema-display.tsx +473 -0
- package/components/ai-elements/shimmer.tsx +78 -0
- package/components/ai-elements/snippet.tsx +141 -0
- package/components/ai-elements/sources.tsx +78 -0
- package/components/ai-elements/speech-input.tsx +324 -0
- package/components/ai-elements/stack-trace.tsx +531 -0
- package/components/ai-elements/suggestion.tsx +58 -0
- package/components/ai-elements/task.tsx +88 -0
- package/components/ai-elements/terminal.tsx +277 -0
- package/components/ai-elements/test-results.tsx +497 -0
- package/components/ai-elements/tool.tsx +174 -0
- package/components/ai-elements/toolbar.tsx +17 -0
- package/components/ai-elements/transcription.tsx +126 -0
- package/components/ai-elements/voice-selector.tsx +525 -0
- package/components/ai-elements/web-preview.tsx +282 -0
- package/components/audit-log-list.tsx +114 -0
- package/components/chat/EmptyPreviewState.tsx +12 -0
- package/components/chat/KnowledgePickerDialog.tsx +464 -0
- package/components/chat/KnowledgePreview.tsx +70 -0
- package/components/chat/KnowledgePreviewPanel.tsx +86 -0
- package/components/chat/MentionInput.tsx +309 -0
- package/components/chat/OrganizeDialog.tsx +258 -0
- package/components/chat/RecommendationBanner.tsx +94 -0
- package/components/chat/SaveToKnowledgeDialog.tsx +193 -0
- package/components/chat/SkillSelector.tsx +305 -0
- package/components/chat/SkillSwitcher.tsx +163 -0
- package/components/client-layout.tsx +15 -0
- package/components/knowledge/KnowledgeMetadataPanel.tsx +293 -0
- package/components/layout-wrapper.tsx +18 -0
- package/components/mobile-layout.tsx +62 -0
- package/components/scheduled-task-list.tsx +356 -0
- package/components/setup-guide.tsx +484 -0
- package/components/sub-nav.tsx +54 -0
- package/components/ticket-detail-content.tsx +383 -0
- package/components/ticket-list.tsx +366 -0
- package/components/top-nav.tsx +132 -0
- package/components/ui/accordion.tsx +58 -0
- package/components/ui/alert.tsx +59 -0
- package/components/ui/avatar.tsx +50 -0
- package/components/ui/badge.tsx +36 -0
- package/components/ui/button-group.tsx +83 -0
- package/components/ui/button.tsx +57 -0
- package/components/ui/card.tsx +91 -0
- package/components/ui/carousel.tsx +262 -0
- package/components/ui/collapsible.tsx +11 -0
- package/components/ui/command.tsx +153 -0
- package/components/ui/dialog.tsx +122 -0
- package/components/ui/dropdown-menu.tsx +200 -0
- package/components/ui/hover-card.tsx +29 -0
- package/components/ui/input-group.tsx +170 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +26 -0
- package/components/ui/popover.tsx +31 -0
- package/components/ui/progress.tsx +28 -0
- package/components/ui/scroll-area.tsx +48 -0
- package/components/ui/select.tsx +174 -0
- package/components/ui/separator.tsx +31 -0
- package/components/ui/spinner.tsx +16 -0
- package/components/ui/switch.tsx +29 -0
- package/components/ui/table.tsx +120 -0
- package/components/ui/tabs.tsx +55 -0
- package/components/ui/textarea.tsx +22 -0
- package/components/ui/tooltip.tsx +30 -0
- package/components/welcome-guide.tsx +182 -0
- package/components.json +24 -0
- package/lib/command-parser.ts +331 -0
- package/lib/dangerous-commands.ts +672 -0
- package/lib/db.ts +2250 -0
- package/lib/feishu-auth.ts +135 -0
- package/lib/file-storage.ts +306 -0
- package/lib/file-tool.ts +583 -0
- package/lib/knowledge-tool.ts +152 -0
- package/lib/knowledge-types.ts +66 -0
- package/lib/market-client.ts +313 -0
- package/lib/market-db.ts +736 -0
- package/lib/market-types.ts +51 -0
- package/lib/memory-tool.ts +211 -0
- package/lib/memory.ts +197 -0
- package/lib/pi-config.ts +436 -0
- package/lib/pi-session.ts +799 -0
- package/lib/pinyin.ts +13 -0
- package/lib/recommendation.ts +227 -0
- package/lib/risk-estimator.ts +350 -0
- package/lib/scheduled-task-tool.ts +184 -0
- package/lib/scheduler-init.ts +43 -0
- package/lib/scheduler.ts +416 -0
- package/lib/secure-bash-tool.ts +413 -0
- package/lib/skill-engine.ts +396 -0
- package/lib/skill-generator.ts +269 -0
- package/lib/skill-loader.ts +234 -0
- package/lib/skill-tool.ts +188 -0
- package/lib/skill-types.ts +82 -0
- package/lib/skills-init.ts +58 -0
- package/lib/ticket-tool.ts +246 -0
- package/lib/user-skill-types.ts +30 -0
- package/lib/user-skills.ts +362 -0
- package/lib/utils.ts +6 -0
- package/lib/workflow.ts +154 -0
- package/lib/zip-tool.ts +191 -0
- package/next.config.js +8 -0
- package/package.json +106 -0
- package/public/.gitkeep +1 -0
- package/public/icon.svg +1 -0
- 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
|
+
}
|