@jvittechs/j 1.0.11
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 +25 -0
- package/dist/chunk-6XCY6MPX.js +400 -0
- package/dist/chunk-6XCY6MPX.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +16875 -0
- package/dist/cli.js.map +1 -0
- package/dist/summary-G3W57YYB.js +9 -0
- package/dist/summary-G3W57YYB.js.map +1 -0
- package/dist/web-chat/README.md +418 -0
- package/dist/web-chat/app.js +2012 -0
- package/dist/web-chat/index.html +325 -0
- package/dist/web-chat/style.css +2129 -0
- package/package.json +101 -0
- package/redmine.config.example.yaml +29 -0
- package/scripts/postinstall.js +75 -0
- package/scripts/redmine-sync-issue.sh +35 -0
|
@@ -0,0 +1,2012 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jai1 Web Chat - Main Application
|
|
3
|
+
* Refined Terminal Aesthetic - Vanilla JS
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
// ============================================
|
|
10
|
+
// Constants
|
|
11
|
+
// ============================================
|
|
12
|
+
const STORAGE_KEYS = {
|
|
13
|
+
SESSION: 'wc_session',
|
|
14
|
+
CONVERSATIONS: 'wc_conversations',
|
|
15
|
+
SETTINGS: 'wc_settings',
|
|
16
|
+
THEME: 'wc_theme',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const MAX_STORAGE_SIZE = 4.5 * 1024 * 1024;
|
|
20
|
+
const MAX_CONTEXT_TOKENS = 175000; // 175K token limit
|
|
21
|
+
const MAX_MENTIONED_FILES = 5; // Maximum files that can be mentioned
|
|
22
|
+
|
|
23
|
+
// ============================================
|
|
24
|
+
// Agent Definitions
|
|
25
|
+
// ============================================
|
|
26
|
+
const AGENTS = [
|
|
27
|
+
{
|
|
28
|
+
id: 'default',
|
|
29
|
+
name: 'General Assistant',
|
|
30
|
+
icon: '⚡',
|
|
31
|
+
description: 'General purpose AI assistant',
|
|
32
|
+
systemPrompt: null,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'architect',
|
|
36
|
+
name: 'Software Architect',
|
|
37
|
+
icon: '🏗️',
|
|
38
|
+
description: 'System design & architecture expert',
|
|
39
|
+
systemPrompt: `You are a Senior Software Architect specializing in web application architecture for offshore development projects.
|
|
40
|
+
|
|
41
|
+
## Core Expertise
|
|
42
|
+
- **Architecture Patterns**: Monolithic (primary), MVC, Modular architecture
|
|
43
|
+
- **Backend**: Laravel 12, NestJS 11, CakePHP 5, ExpressJS 5, Hono 4
|
|
44
|
+
- **Frontend**: Next.js 16, Nuxt.js 4 with Tailwind CSS 4
|
|
45
|
+
- **Databases**: MySQL 9, PostgreSQL 18, MSSQL (SQL Server 2022)
|
|
46
|
+
- **Caching**: Redis 8, Valkey 9
|
|
47
|
+
- **Cloud**: AWS (EC2, ALB, S3, SQS, SES, CloudFront), Cloudflare (Workers, D1, R2, KV, Pages)
|
|
48
|
+
|
|
49
|
+
## Mindset
|
|
50
|
+
- Design for maintainability and scalability within monolithic architecture
|
|
51
|
+
- Consider team size (2-15 members per lab) when proposing solutions
|
|
52
|
+
- Balance between ideal architecture and practical implementation
|
|
53
|
+
- Focus on clear documentation for offshore collaboration
|
|
54
|
+
|
|
55
|
+
## Best Practices
|
|
56
|
+
- Prefer monolithic architecture unless microservices are truly needed
|
|
57
|
+
- Use modular structure within monolith (Laravel Modules, NestJS modules)
|
|
58
|
+
- Design clear API contracts for frontend-backend separation
|
|
59
|
+
- Plan database schema with proper indexes and relationships
|
|
60
|
+
- Consider caching strategy early (Redis/Valkey)
|
|
61
|
+
|
|
62
|
+
When designing architecture:
|
|
63
|
+
1. Understand project requirements and team constraints
|
|
64
|
+
2. Choose appropriate tech stack from the standard toolset
|
|
65
|
+
3. Design database schema and API structure
|
|
66
|
+
4. Plan for deployment (AWS/Cloudflare)
|
|
67
|
+
5. Document architecture decisions and rationale`,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'fullstack',
|
|
71
|
+
name: 'Fullstack Developer',
|
|
72
|
+
icon: '🔄',
|
|
73
|
+
description: 'End-to-end development specialist',
|
|
74
|
+
systemPrompt: `You are a Senior Fullstack Developer proficient in the JV-IT tech stack.
|
|
75
|
+
|
|
76
|
+
## Tech Stack
|
|
77
|
+
**Backend**: Laravel 12, NestJS 11, ExpressJS 5, Hono 4
|
|
78
|
+
**Frontend**: Next.js 16, Nuxt.js 4
|
|
79
|
+
**Styling**: Tailwind CSS 4, shadcn/ui + Radix UI, Nuxt UI, Lucide React
|
|
80
|
+
**Database**: MySQL 9, PostgreSQL 18, Redis 8
|
|
81
|
+
**Tools**: Git, Monorepo structure for full-stack Node.js projects
|
|
82
|
+
|
|
83
|
+
## Mindset
|
|
84
|
+
- Build features end-to-end with cohesive user experience
|
|
85
|
+
- Write clean, maintainable code following framework conventions
|
|
86
|
+
- Consider both developer experience and user experience
|
|
87
|
+
- Think about data flow from UI to database and back
|
|
88
|
+
- Use Day.js or date-fns for date/time handling
|
|
89
|
+
|
|
90
|
+
## Best Practices
|
|
91
|
+
- Follow MVC pattern consistently
|
|
92
|
+
- Use TypeScript in Node.js projects for type safety
|
|
93
|
+
- Implement proper error handling at every layer
|
|
94
|
+
- Use Tailwind CSS utility classes efficiently
|
|
95
|
+
- Leverage shadcn/ui components for React, Nuxt UI for Vue
|
|
96
|
+
|
|
97
|
+
When developing features:
|
|
98
|
+
1. Understand requirements completely
|
|
99
|
+
2. Design database schema and API contracts
|
|
100
|
+
3. Implement backend with proper validation
|
|
101
|
+
4. Build frontend with accessible UI components
|
|
102
|
+
5. Add error handling and edge cases
|
|
103
|
+
6. Test across different scenarios`,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'backend',
|
|
107
|
+
name: 'Backend Developer',
|
|
108
|
+
icon: '⚙️',
|
|
109
|
+
description: 'Server-side & API specialist',
|
|
110
|
+
systemPrompt: `You are a Senior Backend Developer specializing in Laravel, NestJS, and Node.js.
|
|
111
|
+
|
|
112
|
+
## Core Expertise
|
|
113
|
+
**PHP**: Laravel 12, CakePHP 5, PSR standards, Composer
|
|
114
|
+
**Node.js**: NestJS 11, ExpressJS 5, Hono 4, TypeScript
|
|
115
|
+
**Databases**: MySQL 9, PostgreSQL 18, MSSQL, Redis 8, Valkey 9
|
|
116
|
+
**Cloud**: AWS (EC2, S3, SQS, SES), Cloudflare Workers, D1, R2
|
|
117
|
+
|
|
118
|
+
## Mindset
|
|
119
|
+
- Performance-first: optimize queries, use caching (Redis/Valkey)
|
|
120
|
+
- Security-conscious: input validation, SQL injection prevention, XSS
|
|
121
|
+
- Reliability: proper error handling, logging
|
|
122
|
+
- Follow framework conventions strictly
|
|
123
|
+
|
|
124
|
+
## Laravel Best Practices
|
|
125
|
+
- Use Eloquent relationships and scopes effectively
|
|
126
|
+
- Leverage Form Requests for validation
|
|
127
|
+
- Use Jobs and Queues (SQS) for heavy operations
|
|
128
|
+
- Cache aggressively with Redis
|
|
129
|
+
- Follow PSR-12 coding standards
|
|
130
|
+
|
|
131
|
+
## Node.js Best Practices
|
|
132
|
+
- Use NestJS modules for clean architecture
|
|
133
|
+
- Leverage decorators and dependency injection
|
|
134
|
+
- Use Hono for edge computing (Cloudflare Workers)
|
|
135
|
+
- TypeScript with strict mode enabled
|
|
136
|
+
|
|
137
|
+
When developing backend features:
|
|
138
|
+
1. Define API contracts (endpoints, request/response)
|
|
139
|
+
2. Design database schema with proper migrations
|
|
140
|
+
3. Implement business logic with separation of concerns
|
|
141
|
+
4. Add input validation and error handling
|
|
142
|
+
5. Consider caching and queue strategies`,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'laravel',
|
|
146
|
+
name: 'Laravel Specialist',
|
|
147
|
+
icon: '🔴',
|
|
148
|
+
description: 'Laravel framework expert',
|
|
149
|
+
systemPrompt: `You are a Laravel Expert with deep knowledge of Laravel 12 and the PHP ecosystem.
|
|
150
|
+
|
|
151
|
+
## Core Expertise
|
|
152
|
+
- Laravel 12 features and conventions
|
|
153
|
+
- Eloquent ORM: relationships, scopes, accessors/mutators, query optimization
|
|
154
|
+
- Blade templates and components
|
|
155
|
+
- Laravel packages: Sanctum, Horizon, Scout, Telescope
|
|
156
|
+
- Queue system with AWS SQS
|
|
157
|
+
- Testing: PHPUnit, Pest
|
|
158
|
+
|
|
159
|
+
## Laravel Patterns
|
|
160
|
+
- Repository pattern for data access abstraction
|
|
161
|
+
- Service classes for business logic
|
|
162
|
+
- Form Requests for validation
|
|
163
|
+
- API Resources for response transformation
|
|
164
|
+
- Events, Listeners, and Jobs for async processing
|
|
165
|
+
- Policies and Gates for authorization
|
|
166
|
+
|
|
167
|
+
## Database
|
|
168
|
+
- MySQL 9 / PostgreSQL 18 optimization
|
|
169
|
+
- Proper migrations and seeders
|
|
170
|
+
- Query optimization with eager loading
|
|
171
|
+
- Redis 8 / Valkey 9 for caching and sessions
|
|
172
|
+
|
|
173
|
+
## Best Practices
|
|
174
|
+
- Follow "The Laravel Way" - use built-in features first
|
|
175
|
+
- Keep controllers thin, models focused
|
|
176
|
+
- Use strict typing and return types (PHP 8.3+)
|
|
177
|
+
- Follow PSR-12 coding standards
|
|
178
|
+
- Cache routes, config, views in production
|
|
179
|
+
- Use environment-based configuration
|
|
180
|
+
|
|
181
|
+
When building Laravel features:
|
|
182
|
+
1. Plan database schema with migrations
|
|
183
|
+
2. Create Models with relationships and scopes
|
|
184
|
+
3. Add Form Requests for validation
|
|
185
|
+
4. Implement business logic in Service classes
|
|
186
|
+
5. Build Controllers that orchestrate the flow
|
|
187
|
+
6. Write Feature and Unit tests`,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: 'frontend',
|
|
191
|
+
name: 'Frontend Developer',
|
|
192
|
+
icon: '🎨',
|
|
193
|
+
description: 'UI/UX & frontend specialist',
|
|
194
|
+
systemPrompt: `You are a Senior Frontend Developer specializing in Next.js and Nuxt.js.
|
|
195
|
+
|
|
196
|
+
## Core Expertise
|
|
197
|
+
**React**: Next.js 16, shadcn/ui, Radix UI, Lucide React
|
|
198
|
+
**Vue**: Nuxt.js 4, Nuxt UI
|
|
199
|
+
**Styling**: Tailwind CSS 4 (utility-first approach)
|
|
200
|
+
**State**: React Query, SWR, Pinia (Vue)
|
|
201
|
+
**Date/Time**: Day.js, date-fns
|
|
202
|
+
|
|
203
|
+
## Mindset
|
|
204
|
+
- User-centric: prioritize usability and accessibility
|
|
205
|
+
- Performance: Core Web Vitals, lazy loading, image optimization
|
|
206
|
+
- Component-driven: reusable, composable UI components
|
|
207
|
+
- Use Tailwind CSS utility classes efficiently
|
|
208
|
+
|
|
209
|
+
## Component Libraries
|
|
210
|
+
- **React projects**: shadcn/ui + Radix UI (accessible, customizable)
|
|
211
|
+
- **Vue projects**: Nuxt UI (native Nuxt integration)
|
|
212
|
+
- **Icons**: Lucide React for React, Lucide Vue for Vue
|
|
213
|
+
|
|
214
|
+
## Best Practices
|
|
215
|
+
- Follow accessibility standards (WCAG 2.1 AA)
|
|
216
|
+
- Implement responsive design (mobile-first with Tailwind)
|
|
217
|
+
- Use semantic HTML elements
|
|
218
|
+
- Handle loading, error, and empty states properly
|
|
219
|
+
- Optimize images with Next.js Image or Nuxt Image
|
|
220
|
+
|
|
221
|
+
## Tailwind CSS Tips
|
|
222
|
+
- Use design tokens from tailwind.config
|
|
223
|
+
- Leverage @apply sparingly, prefer utility classes
|
|
224
|
+
- Use dark mode with class strategy
|
|
225
|
+
- Group related utilities logically
|
|
226
|
+
|
|
227
|
+
When building UI:
|
|
228
|
+
1. Break down into reusable components
|
|
229
|
+
2. Use appropriate component library (shadcn or Nuxt UI)
|
|
230
|
+
3. Implement with accessibility in mind
|
|
231
|
+
4. Add proper loading and error states
|
|
232
|
+
5. Ensure responsive design with Tailwind`,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: 'devops',
|
|
236
|
+
name: 'DevOps Engineer',
|
|
237
|
+
icon: '🚀',
|
|
238
|
+
description: 'CI/CD & infrastructure specialist',
|
|
239
|
+
systemPrompt: `You are a DevOps Engineer with expertise in AWS and Cloudflare infrastructure.
|
|
240
|
+
|
|
241
|
+
## AWS Stack
|
|
242
|
+
- **Compute**: EC2 with Auto Scaling, Bastion Host for secure access
|
|
243
|
+
- **Load Balancing**: ALB (Application Load Balancer)
|
|
244
|
+
- **Storage**: S3 for files and assets
|
|
245
|
+
- **Messaging**: SQS for queues, SES for emails
|
|
246
|
+
- **CDN**: CloudFront for content delivery
|
|
247
|
+
- **Monitoring**: CloudWatch for logs and metrics
|
|
248
|
+
- **API**: API Gateway for managed APIs
|
|
249
|
+
|
|
250
|
+
## Cloudflare Stack
|
|
251
|
+
- **Edge Computing**: Cloudflare Workers (JavaScript/TypeScript)
|
|
252
|
+
- **Database**: D1 (serverless SQLite)
|
|
253
|
+
- **Storage**: R2 (S3-compatible, zero egress)
|
|
254
|
+
- **Cache**: KV for low-latency data
|
|
255
|
+
- **Hosting**: Cloudflare Pages for static sites
|
|
256
|
+
|
|
257
|
+
## Mindset
|
|
258
|
+
- Automate deployment processes (CI/CD improvement area)
|
|
259
|
+
- Infrastructure as Code when possible
|
|
260
|
+
- Security: proper IAM roles, security groups
|
|
261
|
+
- Cost optimization: right-sizing instances, reserved capacity
|
|
262
|
+
- Monitoring and alerting for production issues
|
|
263
|
+
|
|
264
|
+
## Deployment Patterns
|
|
265
|
+
- EC2 with ALB for traditional apps
|
|
266
|
+
- Cloudflare Workers for edge computing
|
|
267
|
+
- S3/R2 + CloudFront/CDN for static assets
|
|
268
|
+
- SQS for async job processing
|
|
269
|
+
|
|
270
|
+
## Best Practices
|
|
271
|
+
- Use Bastion Host for secure SSH access
|
|
272
|
+
- Configure proper security groups
|
|
273
|
+
- Set up CloudWatch alarms for critical metrics
|
|
274
|
+
- Use environment variables for configuration
|
|
275
|
+
- Document deployment procedures
|
|
276
|
+
|
|
277
|
+
When designing infrastructure:
|
|
278
|
+
1. Understand workload requirements
|
|
279
|
+
2. Choose AWS vs Cloudflare based on use case
|
|
280
|
+
3. Design for high availability
|
|
281
|
+
4. Set up monitoring and alerting
|
|
282
|
+
5. Document runbooks for operations`,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: 'reviewer',
|
|
286
|
+
name: 'Code Reviewer',
|
|
287
|
+
icon: '🔍',
|
|
288
|
+
description: 'Code quality & best practices expert',
|
|
289
|
+
systemPrompt: `You are a Senior Code Reviewer focused on code quality and best practices.
|
|
290
|
+
|
|
291
|
+
## Review Focus
|
|
292
|
+
**Code Quality**: Readability, maintainability, framework conventions
|
|
293
|
+
**Security**: OWASP Top 10, input validation, SQL injection, XSS
|
|
294
|
+
**Performance**: Query optimization, N+1 problems, caching
|
|
295
|
+
**Testing**: Coverage gaps, test quality (improvement area)
|
|
296
|
+
|
|
297
|
+
## Framework-Specific Checks
|
|
298
|
+
|
|
299
|
+
### Laravel
|
|
300
|
+
- Eloquent N+1 queries (use eager loading)
|
|
301
|
+
- Mass assignment protection
|
|
302
|
+
- Form Request validation
|
|
303
|
+
- Proper use of middleware
|
|
304
|
+
- PSR-12 compliance
|
|
305
|
+
|
|
306
|
+
### NestJS/Node.js
|
|
307
|
+
- TypeScript strict mode compliance
|
|
308
|
+
- Proper dependency injection
|
|
309
|
+
- Error handling middleware
|
|
310
|
+
- Input validation with class-validator
|
|
311
|
+
|
|
312
|
+
### Frontend (Next.js/Nuxt.js)
|
|
313
|
+
- Component accessibility
|
|
314
|
+
- Proper use of Tailwind utilities
|
|
315
|
+
- Image optimization
|
|
316
|
+
- Client vs server rendering decisions
|
|
317
|
+
|
|
318
|
+
## Mindset
|
|
319
|
+
- Constructive feedback with specific suggestions
|
|
320
|
+
- Explain the "why" behind each suggestion
|
|
321
|
+
- Prioritize: security > correctness > performance > style
|
|
322
|
+
- Consider team context and constraints
|
|
323
|
+
- Acknowledge good practices too
|
|
324
|
+
|
|
325
|
+
## Common Issues
|
|
326
|
+
- SQL injection in raw queries
|
|
327
|
+
- Missing input validation
|
|
328
|
+
- N+1 query problems in Eloquent/ORM
|
|
329
|
+
- Hardcoded credentials or secrets
|
|
330
|
+
- Missing error handling
|
|
331
|
+
- Inconsistent coding style
|
|
332
|
+
|
|
333
|
+
When reviewing code:
|
|
334
|
+
1. Check for security vulnerabilities first
|
|
335
|
+
2. Verify correctness and edge cases
|
|
336
|
+
3. Evaluate framework convention compliance
|
|
337
|
+
4. Suggest specific improvements with examples
|
|
338
|
+
5. Note areas that need test coverage`,
|
|
339
|
+
},
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
// ============================================
|
|
343
|
+
// State
|
|
344
|
+
// ============================================
|
|
345
|
+
let state = {
|
|
346
|
+
session: null,
|
|
347
|
+
models: [],
|
|
348
|
+
selectedModel: '',
|
|
349
|
+
selectedAgent: 'default',
|
|
350
|
+
conversations: [],
|
|
351
|
+
currentConversationId: null,
|
|
352
|
+
isStreaming: false,
|
|
353
|
+
modelStats: null, // { date, models: { [modelId]: { limit, used, remaining } } }
|
|
354
|
+
// File mention state
|
|
355
|
+
mentionedFiles: [], // Array of { path, name, size, language }
|
|
356
|
+
fileSearchResults: [], // Autocomplete results
|
|
357
|
+
isFileSearching: false, // Autocomplete loading state
|
|
358
|
+
fileServiceInfo: null, // { workingDir, maxFiles }
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// ============================================
|
|
362
|
+
// Token Estimation Helper
|
|
363
|
+
// ============================================
|
|
364
|
+
function estimateTokens(text) {
|
|
365
|
+
if (!text) return 0;
|
|
366
|
+
// Rough estimation: ~4 characters per token for English
|
|
367
|
+
// This is a simple heuristic, actual tokenization varies by model
|
|
368
|
+
return Math.ceil(text.length / 4);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function getConversationTokens(conversation) {
|
|
372
|
+
if (!conversation || !conversation.messages) return 0;
|
|
373
|
+
let totalTokens = 0;
|
|
374
|
+
conversation.messages.forEach((msg) => {
|
|
375
|
+
totalTokens += estimateTokens(msg.content);
|
|
376
|
+
});
|
|
377
|
+
return totalTokens;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function formatTokenCount(tokens) {
|
|
381
|
+
// Always show in K format, minimum 1K
|
|
382
|
+
if (tokens < 1000) {
|
|
383
|
+
return '< 1K';
|
|
384
|
+
}
|
|
385
|
+
return (tokens / 1000).toFixed(1) + 'K';
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// ============================================
|
|
389
|
+
// DOM Elements
|
|
390
|
+
// ============================================
|
|
391
|
+
const elements = {
|
|
392
|
+
modelSelect: document.getElementById('model-select'),
|
|
393
|
+
connectionStatus: document.getElementById('connection-status'),
|
|
394
|
+
newChatBtn: document.getElementById('new-chat-btn'),
|
|
395
|
+
conversationsList: document.getElementById('conversations-list'),
|
|
396
|
+
messages: document.getElementById('messages'),
|
|
397
|
+
welcomeMessage: document.getElementById('welcome-message'),
|
|
398
|
+
messageInput: document.getElementById('message-input'),
|
|
399
|
+
sendBtn: document.getElementById('send-btn'),
|
|
400
|
+
charCount: document.getElementById('char-count'),
|
|
401
|
+
errorModal: document.getElementById('error-modal'),
|
|
402
|
+
errorTitle: document.getElementById('error-title'),
|
|
403
|
+
errorMessage: document.getElementById('error-message'),
|
|
404
|
+
errorCloseBtn: document.getElementById('error-close-btn'),
|
|
405
|
+
loadingOverlay: document.getElementById('loading-overlay'),
|
|
406
|
+
// Stats panel elements
|
|
407
|
+
sidebarRight: document.getElementById('sidebar-right'),
|
|
408
|
+
toggleRightSidebarBtn: document.getElementById('toggle-right-sidebar-btn'),
|
|
409
|
+
statsList: document.getElementById('stats-list'),
|
|
410
|
+
statsDate: document.getElementById('stats-date'),
|
|
411
|
+
refreshStatsBtn: document.getElementById('refresh-stats-btn'),
|
|
412
|
+
promptsList: document.getElementById('prompts-list'),
|
|
413
|
+
// Theme toggle
|
|
414
|
+
themeToggleBtn: document.getElementById('theme-toggle-btn'),
|
|
415
|
+
// Agent selector
|
|
416
|
+
agentSelect: document.getElementById('agent-select'),
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// ============================================
|
|
420
|
+
// Storage Utilities
|
|
421
|
+
// ============================================
|
|
422
|
+
const Storage = {
|
|
423
|
+
get(key) {
|
|
424
|
+
try {
|
|
425
|
+
const item = localStorage.getItem(key);
|
|
426
|
+
return item ? JSON.parse(item) : null;
|
|
427
|
+
} catch (e) {
|
|
428
|
+
console.error('Storage get error:', e);
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
set(key, value) {
|
|
434
|
+
try {
|
|
435
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
436
|
+
return true;
|
|
437
|
+
} catch (e) {
|
|
438
|
+
if (e.name === 'QuotaExceededError') {
|
|
439
|
+
this.cleanup();
|
|
440
|
+
try {
|
|
441
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
442
|
+
return true;
|
|
443
|
+
} catch (e2) {
|
|
444
|
+
console.error('Storage still full after cleanup:', e2);
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
console.error('Storage set error:', e);
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
remove(key) {
|
|
454
|
+
try {
|
|
455
|
+
localStorage.removeItem(key);
|
|
456
|
+
} catch (e) {
|
|
457
|
+
console.error('Storage remove error:', e);
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
cleanup() {
|
|
462
|
+
const conversations = this.get(STORAGE_KEYS.CONVERSATIONS) || [];
|
|
463
|
+
while (conversations.length > 1 && this.getSize() > MAX_STORAGE_SIZE) {
|
|
464
|
+
conversations.shift();
|
|
465
|
+
this.set(STORAGE_KEYS.CONVERSATIONS, conversations);
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
getSize() {
|
|
470
|
+
let total = 0;
|
|
471
|
+
for (const key in localStorage) {
|
|
472
|
+
if (localStorage.hasOwnProperty(key)) {
|
|
473
|
+
total += localStorage[key].length * 2;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return total;
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// ============================================
|
|
481
|
+
// Theme Manager
|
|
482
|
+
// ============================================
|
|
483
|
+
const Theme = {
|
|
484
|
+
DARK: 'dark',
|
|
485
|
+
LIGHT: 'light',
|
|
486
|
+
|
|
487
|
+
init() {
|
|
488
|
+
// Check saved preference, then system preference
|
|
489
|
+
const saved = Storage.get(STORAGE_KEYS.THEME);
|
|
490
|
+
if (saved) {
|
|
491
|
+
this.set(saved);
|
|
492
|
+
} else {
|
|
493
|
+
// Check system preference
|
|
494
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
495
|
+
this.set(prefersDark ? this.DARK : this.LIGHT);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Listen for system theme changes
|
|
499
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
|
500
|
+
if (!Storage.get(STORAGE_KEYS.THEME)) {
|
|
501
|
+
this.set(e.matches ? this.DARK : this.LIGHT);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
get() {
|
|
507
|
+
return document.documentElement.getAttribute('data-theme') || this.DARK;
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
set(theme) {
|
|
511
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
512
|
+
Storage.set(STORAGE_KEYS.THEME, theme);
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
toggle() {
|
|
516
|
+
const current = this.get();
|
|
517
|
+
this.set(current === this.DARK ? this.LIGHT : this.DARK);
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// ============================================
|
|
522
|
+
// API Client
|
|
523
|
+
// ============================================
|
|
524
|
+
const API = {
|
|
525
|
+
getSessionToken() {
|
|
526
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
527
|
+
const urlToken = urlParams.get('session');
|
|
528
|
+
if (urlToken) {
|
|
529
|
+
return urlToken;
|
|
530
|
+
}
|
|
531
|
+
const session = Storage.get(STORAGE_KEYS.SESSION);
|
|
532
|
+
return session?.token || null;
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
async request(endpoint, options = {}) {
|
|
536
|
+
const token = this.getSessionToken();
|
|
537
|
+
const headers = {
|
|
538
|
+
'Content-Type': 'application/json',
|
|
539
|
+
...(token && { 'X-Session-Token': token }),
|
|
540
|
+
...options.headers,
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const response = await fetch(endpoint, {
|
|
544
|
+
...options,
|
|
545
|
+
headers,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
if (!response.ok) {
|
|
549
|
+
const error = await response.json().catch(() => ({}));
|
|
550
|
+
throw new APIError(
|
|
551
|
+
error.error?.message || response.statusText,
|
|
552
|
+
error.error?.code || `HTTP_${response.status}`,
|
|
553
|
+
response.status
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return response;
|
|
558
|
+
},
|
|
559
|
+
|
|
560
|
+
async getSession() {
|
|
561
|
+
const response = await this.request('/api/session');
|
|
562
|
+
return response.json();
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
async getModels() {
|
|
566
|
+
const response = await this.request('/api/models');
|
|
567
|
+
return response.json();
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
async getStats() {
|
|
571
|
+
const response = await this.request('/api/stats');
|
|
572
|
+
return response.json();
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
async *streamChat(message, model, history = [], systemPrompt = null) {
|
|
576
|
+
const response = await this.request('/api/chat', {
|
|
577
|
+
method: 'POST',
|
|
578
|
+
body: JSON.stringify({ message, model, history, systemPrompt }),
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const reader = response.body.getReader();
|
|
582
|
+
const decoder = new TextDecoder();
|
|
583
|
+
let buffer = '';
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
while (true) {
|
|
587
|
+
const { done, value } = await reader.read();
|
|
588
|
+
if (done) break;
|
|
589
|
+
|
|
590
|
+
// Append new chunk to buffer
|
|
591
|
+
buffer += decoder.decode(value, { stream: true });
|
|
592
|
+
|
|
593
|
+
// Process only complete lines (ending with \n)
|
|
594
|
+
const lines = buffer.split('\n');
|
|
595
|
+
// Keep the last incomplete line in buffer
|
|
596
|
+
buffer = lines.pop() || '';
|
|
597
|
+
|
|
598
|
+
for (const line of lines) {
|
|
599
|
+
const trimmedLine = line.trim();
|
|
600
|
+
if (!trimmedLine) continue;
|
|
601
|
+
|
|
602
|
+
if (trimmedLine.startsWith('data: ')) {
|
|
603
|
+
const data = trimmedLine.slice(6);
|
|
604
|
+
try {
|
|
605
|
+
yield JSON.parse(data);
|
|
606
|
+
} catch (e) {
|
|
607
|
+
// Skip malformed data
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Process any remaining content in buffer
|
|
614
|
+
if (buffer.trim()) {
|
|
615
|
+
const trimmedLine = buffer.trim();
|
|
616
|
+
if (trimmedLine.startsWith('data: ')) {
|
|
617
|
+
const data = trimmedLine.slice(6);
|
|
618
|
+
try {
|
|
619
|
+
yield JSON.parse(data);
|
|
620
|
+
} catch (e) {
|
|
621
|
+
// Skip malformed data
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
} finally {
|
|
626
|
+
reader.releaseLock();
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
|
|
630
|
+
// === File API Methods ===
|
|
631
|
+
|
|
632
|
+
async getFileServiceInfo() {
|
|
633
|
+
const response = await this.request('/api/files/info');
|
|
634
|
+
return response.json();
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
async searchFiles(query, limit = 10) {
|
|
638
|
+
const response = await this.request(`/api/files/search?q=${encodeURIComponent(query)}&limit=${limit}`);
|
|
639
|
+
return response.json();
|
|
640
|
+
},
|
|
641
|
+
|
|
642
|
+
async readFile(filePath) {
|
|
643
|
+
const response = await this.request(`/api/files/read?path=${encodeURIComponent(filePath)}`);
|
|
644
|
+
return response.json();
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
async readFiles(paths) {
|
|
648
|
+
const response = await this.request('/api/files/batch', {
|
|
649
|
+
method: 'POST',
|
|
650
|
+
body: JSON.stringify({ paths }),
|
|
651
|
+
});
|
|
652
|
+
return response.json();
|
|
653
|
+
},
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
class APIError extends Error {
|
|
657
|
+
constructor(message, code, status) {
|
|
658
|
+
super(message);
|
|
659
|
+
this.code = code;
|
|
660
|
+
this.status = status;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ============================================
|
|
665
|
+
// Conversation Management
|
|
666
|
+
// ============================================
|
|
667
|
+
const Conversations = {
|
|
668
|
+
load() {
|
|
669
|
+
state.conversations = Storage.get(STORAGE_KEYS.CONVERSATIONS) || [];
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
save() {
|
|
673
|
+
Storage.set(STORAGE_KEYS.CONVERSATIONS, state.conversations);
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
create() {
|
|
677
|
+
const id = 'conv_' + Date.now();
|
|
678
|
+
const conversation = {
|
|
679
|
+
id,
|
|
680
|
+
title: 'New Thread',
|
|
681
|
+
model: state.selectedModel,
|
|
682
|
+
agent: state.selectedAgent,
|
|
683
|
+
createdAt: new Date().toISOString(),
|
|
684
|
+
messages: [],
|
|
685
|
+
};
|
|
686
|
+
state.conversations.unshift(conversation);
|
|
687
|
+
state.currentConversationId = id;
|
|
688
|
+
this.save();
|
|
689
|
+
return conversation;
|
|
690
|
+
},
|
|
691
|
+
|
|
692
|
+
getCurrent() {
|
|
693
|
+
return state.conversations.find((c) => c.id === state.currentConversationId);
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
addMessage(role, content) {
|
|
697
|
+
const conversation = this.getCurrent();
|
|
698
|
+
if (!conversation) return;
|
|
699
|
+
|
|
700
|
+
conversation.messages.push({
|
|
701
|
+
role,
|
|
702
|
+
content,
|
|
703
|
+
timestamp: new Date().toISOString(),
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
if (role === 'user' && conversation.messages.length === 1) {
|
|
707
|
+
conversation.title = content.slice(0, 40) + (content.length > 40 ? '...' : '');
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
this.save();
|
|
711
|
+
},
|
|
712
|
+
|
|
713
|
+
updateLastAssistantMessage(content) {
|
|
714
|
+
const conversation = this.getCurrent();
|
|
715
|
+
if (!conversation) return;
|
|
716
|
+
|
|
717
|
+
const lastMsg = conversation.messages[conversation.messages.length - 1];
|
|
718
|
+
if (lastMsg && lastMsg.role === 'assistant') {
|
|
719
|
+
lastMsg.content = content;
|
|
720
|
+
this.save();
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
delete(id) {
|
|
725
|
+
const index = state.conversations.findIndex((c) => c.id === id);
|
|
726
|
+
if (index !== -1) {
|
|
727
|
+
state.conversations.splice(index, 1);
|
|
728
|
+
if (state.currentConversationId === id) {
|
|
729
|
+
state.currentConversationId = state.conversations[0]?.id || null;
|
|
730
|
+
}
|
|
731
|
+
this.save();
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
|
|
735
|
+
switchTo(id) {
|
|
736
|
+
state.currentConversationId = id;
|
|
737
|
+
const conversation = this.getCurrent();
|
|
738
|
+
if (conversation) {
|
|
739
|
+
if (conversation.model) {
|
|
740
|
+
state.selectedModel = conversation.model;
|
|
741
|
+
elements.modelSelect.value = conversation.model;
|
|
742
|
+
}
|
|
743
|
+
if (conversation.agent) {
|
|
744
|
+
state.selectedAgent = conversation.agent;
|
|
745
|
+
if (elements.agentSelect) {
|
|
746
|
+
elements.agentSelect.value = conversation.agent;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
// ============================================
|
|
754
|
+
// File Mention System
|
|
755
|
+
// ============================================
|
|
756
|
+
const FileMention = {
|
|
757
|
+
autocompleteEl: null,
|
|
758
|
+
debounceTimer: null,
|
|
759
|
+
selectedIndex: -1,
|
|
760
|
+
mentionStartPos: -1,
|
|
761
|
+
lastQuery: '',
|
|
762
|
+
|
|
763
|
+
init() {
|
|
764
|
+
// Create autocomplete dropdown
|
|
765
|
+
this.createAutocompleteElement();
|
|
766
|
+
// Create mentioned files container
|
|
767
|
+
this.createMentionedFilesContainer();
|
|
768
|
+
// Load file service info
|
|
769
|
+
this.loadFileServiceInfo();
|
|
770
|
+
},
|
|
771
|
+
|
|
772
|
+
createAutocompleteElement() {
|
|
773
|
+
this.autocompleteEl = document.createElement('div');
|
|
774
|
+
this.autocompleteEl.className = 'file-autocomplete hidden';
|
|
775
|
+
this.autocompleteEl.innerHTML = `
|
|
776
|
+
<div class="file-autocomplete-header">
|
|
777
|
+
<span class="autocomplete-icon">📁</span>
|
|
778
|
+
<span class="autocomplete-title">Files</span>
|
|
779
|
+
<span class="autocomplete-hint">↑↓ to navigate, Enter to select, Esc to close</span>
|
|
780
|
+
</div>
|
|
781
|
+
<div class="file-autocomplete-list"></div>
|
|
782
|
+
`;
|
|
783
|
+
// Insert before input container
|
|
784
|
+
const inputArea = document.querySelector('.input-area');
|
|
785
|
+
if (inputArea) {
|
|
786
|
+
inputArea.insertBefore(this.autocompleteEl, inputArea.querySelector('.input-container'));
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
|
|
790
|
+
createMentionedFilesContainer() {
|
|
791
|
+
const container = document.createElement('div');
|
|
792
|
+
container.id = 'mentioned-files';
|
|
793
|
+
container.className = 'mentioned-files hidden';
|
|
794
|
+
// Insert before input selectors
|
|
795
|
+
const inputArea = document.querySelector('.input-area');
|
|
796
|
+
const selectors = inputArea?.querySelector('.input-selectors');
|
|
797
|
+
if (inputArea && selectors) {
|
|
798
|
+
inputArea.insertBefore(container, selectors);
|
|
799
|
+
}
|
|
800
|
+
},
|
|
801
|
+
|
|
802
|
+
async loadFileServiceInfo() {
|
|
803
|
+
try {
|
|
804
|
+
const response = await API.getFileServiceInfo();
|
|
805
|
+
if (response.success && response.data) {
|
|
806
|
+
state.fileServiceInfo = response.data;
|
|
807
|
+
}
|
|
808
|
+
} catch (error) {
|
|
809
|
+
console.error('Failed to load file service info:', error);
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
|
|
813
|
+
// Detect @ mention in textarea
|
|
814
|
+
detectMention(textarea) {
|
|
815
|
+
const text = textarea.value;
|
|
816
|
+
const cursorPos = textarea.selectionStart;
|
|
817
|
+
|
|
818
|
+
// Find @ before cursor
|
|
819
|
+
let atPos = -1;
|
|
820
|
+
for (let i = cursorPos - 1; i >= 0; i--) {
|
|
821
|
+
const char = text[i];
|
|
822
|
+
if (char === '@') {
|
|
823
|
+
atPos = i;
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
// Stop at whitespace or newline (not a mention)
|
|
827
|
+
if (char === ' ' || char === '\n' || char === '\t') {
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (atPos === -1) {
|
|
833
|
+
this.hideAutocomplete();
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Extract query after @
|
|
838
|
+
const query = text.substring(atPos + 1, cursorPos);
|
|
839
|
+
this.mentionStartPos = atPos;
|
|
840
|
+
return query;
|
|
841
|
+
},
|
|
842
|
+
|
|
843
|
+
async handleInput(textarea) {
|
|
844
|
+
const query = this.detectMention(textarea);
|
|
845
|
+
|
|
846
|
+
if (query === null) {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Debounce search
|
|
851
|
+
clearTimeout(this.debounceTimer);
|
|
852
|
+
this.debounceTimer = setTimeout(async () => {
|
|
853
|
+
if (query.length >= 1) {
|
|
854
|
+
await this.searchFiles(query);
|
|
855
|
+
} else {
|
|
856
|
+
this.hideAutocomplete();
|
|
857
|
+
}
|
|
858
|
+
}, 150);
|
|
859
|
+
},
|
|
860
|
+
|
|
861
|
+
async searchFiles(query) {
|
|
862
|
+
if (query === this.lastQuery) return;
|
|
863
|
+
this.lastQuery = query;
|
|
864
|
+
|
|
865
|
+
state.isFileSearching = true;
|
|
866
|
+
this.selectedIndex = 0; // Reset selection for new search
|
|
867
|
+
this.showAutocomplete();
|
|
868
|
+
this.renderLoading();
|
|
869
|
+
|
|
870
|
+
try {
|
|
871
|
+
const response = await API.searchFiles(query, 10);
|
|
872
|
+
if (response.success && response.data) {
|
|
873
|
+
state.fileSearchResults = response.data.files || [];
|
|
874
|
+
this.selectedIndex = state.fileSearchResults.length > 0 ? 0 : -1;
|
|
875
|
+
this.renderResults();
|
|
876
|
+
}
|
|
877
|
+
} catch (error) {
|
|
878
|
+
console.error('File search error:', error);
|
|
879
|
+
this.renderError('Failed to search files');
|
|
880
|
+
} finally {
|
|
881
|
+
state.isFileSearching = false;
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
showAutocomplete() {
|
|
886
|
+
if (this.autocompleteEl) {
|
|
887
|
+
this.autocompleteEl.classList.remove('hidden');
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
|
|
891
|
+
hideAutocomplete() {
|
|
892
|
+
if (this.autocompleteEl) {
|
|
893
|
+
this.autocompleteEl.classList.add('hidden');
|
|
894
|
+
}
|
|
895
|
+
this.selectedIndex = -1;
|
|
896
|
+
this.mentionStartPos = -1;
|
|
897
|
+
this.lastQuery = '';
|
|
898
|
+
state.fileSearchResults = [];
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
renderLoading() {
|
|
902
|
+
const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
|
|
903
|
+
if (list) {
|
|
904
|
+
list.innerHTML = `
|
|
905
|
+
<div class="file-autocomplete-loading">
|
|
906
|
+
<span class="loading-spinner">◌</span>
|
|
907
|
+
<span>Searching files...</span>
|
|
908
|
+
</div>
|
|
909
|
+
`;
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
|
|
913
|
+
renderError(message) {
|
|
914
|
+
const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
|
|
915
|
+
if (list) {
|
|
916
|
+
list.innerHTML = `
|
|
917
|
+
<div class="file-autocomplete-error">
|
|
918
|
+
<span>⚠</span>
|
|
919
|
+
<span>${escapeHtml(message)}</span>
|
|
920
|
+
</div>
|
|
921
|
+
`;
|
|
922
|
+
}
|
|
923
|
+
},
|
|
924
|
+
|
|
925
|
+
renderResults() {
|
|
926
|
+
const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
|
|
927
|
+
if (!list) return;
|
|
928
|
+
|
|
929
|
+
if (state.fileSearchResults.length === 0) {
|
|
930
|
+
list.innerHTML = `
|
|
931
|
+
<div class="file-autocomplete-empty">
|
|
932
|
+
<span>No files found</span>
|
|
933
|
+
</div>
|
|
934
|
+
`;
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
list.innerHTML = '';
|
|
939
|
+
state.fileSearchResults.forEach((file, index) => {
|
|
940
|
+
const item = document.createElement('div');
|
|
941
|
+
item.className = 'file-autocomplete-item' + (index === this.selectedIndex ? ' selected' : '');
|
|
942
|
+
item.dataset.index = index;
|
|
943
|
+
item.innerHTML = `
|
|
944
|
+
<span class="file-icon">${this.getFileIcon(file.extension)}</span>
|
|
945
|
+
<span class="file-path">${escapeHtml(file.path)}</span>
|
|
946
|
+
<span class="file-size">${this.formatFileSize(file.size)}</span>
|
|
947
|
+
`;
|
|
948
|
+
item.addEventListener('click', () => this.selectFile(index));
|
|
949
|
+
item.addEventListener('mouseenter', () => {
|
|
950
|
+
this.selectedIndex = index;
|
|
951
|
+
this.updateSelection();
|
|
952
|
+
});
|
|
953
|
+
list.appendChild(item);
|
|
954
|
+
});
|
|
955
|
+
},
|
|
956
|
+
|
|
957
|
+
getFileIcon(extension) {
|
|
958
|
+
const iconMap = {
|
|
959
|
+
'ts': '📘', 'tsx': '📘', 'js': '📙', 'jsx': '📙',
|
|
960
|
+
'vue': '💚', 'php': '🐘', 'py': '🐍',
|
|
961
|
+
'json': '📋', 'md': '📝', 'css': '🎨', 'scss': '🎨',
|
|
962
|
+
'html': '🌐', 'sql': '🗄️', 'sh': '⚙️',
|
|
963
|
+
};
|
|
964
|
+
return iconMap[extension] || '📄';
|
|
965
|
+
},
|
|
966
|
+
|
|
967
|
+
formatFileSize(bytes) {
|
|
968
|
+
if (bytes < 1024) return bytes + 'B';
|
|
969
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
|
|
970
|
+
return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
|
|
971
|
+
},
|
|
972
|
+
|
|
973
|
+
updateSelection() {
|
|
974
|
+
const items = this.autocompleteEl?.querySelectorAll('.file-autocomplete-item');
|
|
975
|
+
items?.forEach((item, index) => {
|
|
976
|
+
item.classList.toggle('selected', index === this.selectedIndex);
|
|
977
|
+
});
|
|
978
|
+
},
|
|
979
|
+
|
|
980
|
+
handleKeydown(e, textarea) {
|
|
981
|
+
if (this.autocompleteEl?.classList.contains('hidden')) {
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// No results to navigate
|
|
986
|
+
if (state.fileSearchResults.length === 0) {
|
|
987
|
+
if (e.key === 'Escape') {
|
|
988
|
+
e.preventDefault();
|
|
989
|
+
this.hideAutocomplete();
|
|
990
|
+
return true;
|
|
991
|
+
}
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
switch (e.key) {
|
|
996
|
+
case 'ArrowDown':
|
|
997
|
+
e.preventDefault();
|
|
998
|
+
if (this.selectedIndex < 0) {
|
|
999
|
+
this.selectedIndex = 0;
|
|
1000
|
+
} else {
|
|
1001
|
+
this.selectedIndex = Math.min(this.selectedIndex + 1, state.fileSearchResults.length - 1);
|
|
1002
|
+
}
|
|
1003
|
+
this.updateSelection();
|
|
1004
|
+
this.scrollToSelected();
|
|
1005
|
+
return true;
|
|
1006
|
+
|
|
1007
|
+
case 'ArrowUp':
|
|
1008
|
+
e.preventDefault();
|
|
1009
|
+
if (this.selectedIndex < 0) {
|
|
1010
|
+
this.selectedIndex = state.fileSearchResults.length - 1;
|
|
1011
|
+
} else if (this.selectedIndex > 0) {
|
|
1012
|
+
this.selectedIndex--;
|
|
1013
|
+
}
|
|
1014
|
+
this.updateSelection();
|
|
1015
|
+
this.scrollToSelected();
|
|
1016
|
+
return true;
|
|
1017
|
+
|
|
1018
|
+
case 'Enter':
|
|
1019
|
+
if (this.selectedIndex >= 0) {
|
|
1020
|
+
e.preventDefault();
|
|
1021
|
+
this.selectFile(this.selectedIndex, textarea);
|
|
1022
|
+
return true;
|
|
1023
|
+
} else if (state.fileSearchResults.length > 0) {
|
|
1024
|
+
// Select first item if none selected
|
|
1025
|
+
e.preventDefault();
|
|
1026
|
+
this.selectFile(0, textarea);
|
|
1027
|
+
return true;
|
|
1028
|
+
}
|
|
1029
|
+
break;
|
|
1030
|
+
|
|
1031
|
+
case 'Escape':
|
|
1032
|
+
e.preventDefault();
|
|
1033
|
+
this.hideAutocomplete();
|
|
1034
|
+
return true;
|
|
1035
|
+
|
|
1036
|
+
case 'Tab':
|
|
1037
|
+
if (state.fileSearchResults.length > 0) {
|
|
1038
|
+
e.preventDefault();
|
|
1039
|
+
this.selectFile(Math.max(this.selectedIndex, 0), textarea);
|
|
1040
|
+
return true;
|
|
1041
|
+
}
|
|
1042
|
+
break;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
return false;
|
|
1046
|
+
},
|
|
1047
|
+
|
|
1048
|
+
scrollToSelected() {
|
|
1049
|
+
const list = this.autocompleteEl?.querySelector('.file-autocomplete-list');
|
|
1050
|
+
const selectedItem = list?.querySelector('.file-autocomplete-item.selected');
|
|
1051
|
+
if (selectedItem && list) {
|
|
1052
|
+
selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
1053
|
+
}
|
|
1054
|
+
},
|
|
1055
|
+
|
|
1056
|
+
selectFile(index, textarea = elements.messageInput) {
|
|
1057
|
+
const file = state.fileSearchResults[index];
|
|
1058
|
+
if (!file) return;
|
|
1059
|
+
|
|
1060
|
+
// Check if already mentioned
|
|
1061
|
+
if (state.mentionedFiles.some(f => f.path === file.path)) {
|
|
1062
|
+
this.hideAutocomplete();
|
|
1063
|
+
// Remove the @query from textarea
|
|
1064
|
+
this.removeMentionText(textarea);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Check max files limit
|
|
1069
|
+
if (state.mentionedFiles.length >= MAX_MENTIONED_FILES) {
|
|
1070
|
+
alert(`Maximum ${MAX_MENTIONED_FILES} files can be mentioned per message`);
|
|
1071
|
+
this.hideAutocomplete();
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Add to mentioned files
|
|
1076
|
+
state.mentionedFiles.push(file);
|
|
1077
|
+
this.renderMentionedFiles();
|
|
1078
|
+
|
|
1079
|
+
// Remove @query from textarea
|
|
1080
|
+
this.removeMentionText(textarea);
|
|
1081
|
+
|
|
1082
|
+
this.hideAutocomplete();
|
|
1083
|
+
textarea.focus();
|
|
1084
|
+
},
|
|
1085
|
+
|
|
1086
|
+
removeMentionText(textarea) {
|
|
1087
|
+
if (this.mentionStartPos >= 0) {
|
|
1088
|
+
const text = textarea.value;
|
|
1089
|
+
const cursorPos = textarea.selectionStart;
|
|
1090
|
+
const newText = text.substring(0, this.mentionStartPos) + text.substring(cursorPos);
|
|
1091
|
+
textarea.value = newText;
|
|
1092
|
+
textarea.selectionStart = textarea.selectionEnd = this.mentionStartPos;
|
|
1093
|
+
// Trigger input event
|
|
1094
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
|
|
1098
|
+
renderMentionedFiles() {
|
|
1099
|
+
const container = document.getElementById('mentioned-files');
|
|
1100
|
+
if (!container) return;
|
|
1101
|
+
|
|
1102
|
+
if (state.mentionedFiles.length === 0) {
|
|
1103
|
+
container.classList.add('hidden');
|
|
1104
|
+
container.innerHTML = '';
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
container.classList.remove('hidden');
|
|
1109
|
+
container.innerHTML = `
|
|
1110
|
+
<div class="mentioned-files-header">
|
|
1111
|
+
<span class="mentioned-icon">📎</span>
|
|
1112
|
+
<span class="mentioned-title">Attached Files (${state.mentionedFiles.length}/${MAX_MENTIONED_FILES})</span>
|
|
1113
|
+
</div>
|
|
1114
|
+
<div class="mentioned-files-list">
|
|
1115
|
+
${state.mentionedFiles.map((file, index) => `
|
|
1116
|
+
<div class="mentioned-file-badge" data-index="${index}">
|
|
1117
|
+
<span class="badge-icon">${this.getFileIcon(file.extension)}</span>
|
|
1118
|
+
<span class="badge-path">${escapeHtml(file.path)}</span>
|
|
1119
|
+
<button class="badge-remove" title="Remove file">×</button>
|
|
1120
|
+
</div>
|
|
1121
|
+
`).join('')}
|
|
1122
|
+
</div>
|
|
1123
|
+
`;
|
|
1124
|
+
|
|
1125
|
+
// Add remove handlers
|
|
1126
|
+
container.querySelectorAll('.badge-remove').forEach(btn => {
|
|
1127
|
+
btn.addEventListener('click', (e) => {
|
|
1128
|
+
const index = parseInt(e.target.closest('.mentioned-file-badge').dataset.index);
|
|
1129
|
+
this.removeFile(index);
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
},
|
|
1133
|
+
|
|
1134
|
+
removeFile(index) {
|
|
1135
|
+
state.mentionedFiles.splice(index, 1);
|
|
1136
|
+
this.renderMentionedFiles();
|
|
1137
|
+
},
|
|
1138
|
+
|
|
1139
|
+
clearFiles() {
|
|
1140
|
+
state.mentionedFiles = [];
|
|
1141
|
+
this.renderMentionedFiles();
|
|
1142
|
+
},
|
|
1143
|
+
|
|
1144
|
+
// Get file contents for mentioned files
|
|
1145
|
+
async getFileContents() {
|
|
1146
|
+
if (state.mentionedFiles.length === 0) {
|
|
1147
|
+
return [];
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const paths = state.mentionedFiles.map(f => f.path);
|
|
1151
|
+
try {
|
|
1152
|
+
const response = await API.readFiles(paths);
|
|
1153
|
+
if (response.success && response.data) {
|
|
1154
|
+
return response.data.files || [];
|
|
1155
|
+
}
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
console.error('Failed to read files:', error);
|
|
1158
|
+
}
|
|
1159
|
+
return [];
|
|
1160
|
+
},
|
|
1161
|
+
|
|
1162
|
+
// Format file context for LLM
|
|
1163
|
+
formatFileContext(files) {
|
|
1164
|
+
if (!files || files.length === 0) return '';
|
|
1165
|
+
|
|
1166
|
+
let context = '\n\n[Attached Files]\n\n';
|
|
1167
|
+
files.forEach(file => {
|
|
1168
|
+
if (file.error) {
|
|
1169
|
+
context += `--- ${file.path} ---\n[Error: ${file.error}]\n\n`;
|
|
1170
|
+
} else {
|
|
1171
|
+
context += `--- ${file.path} ---\n\`\`\`${file.language || 'text'}\n${file.content}\n\`\`\`\n\n`;
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
return context;
|
|
1175
|
+
},
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
// ============================================
|
|
1179
|
+
// UI Rendering
|
|
1180
|
+
// ============================================
|
|
1181
|
+
const UI = {
|
|
1182
|
+
renderConversationsList() {
|
|
1183
|
+
elements.conversationsList.innerHTML = '';
|
|
1184
|
+
|
|
1185
|
+
if (state.conversations.length === 0) {
|
|
1186
|
+
elements.conversationsList.innerHTML = `
|
|
1187
|
+
<div style="padding: 16px; color: var(--color-text-dim); text-align: center; font-size: 12px; font-family: var(--font-mono);">
|
|
1188
|
+
No threads yet
|
|
1189
|
+
</div>
|
|
1190
|
+
`;
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
state.conversations.forEach((conv) => {
|
|
1195
|
+
const tokens = getConversationTokens(conv);
|
|
1196
|
+
const tokenClass = tokens > MAX_CONTEXT_TOKENS ? 'token-exceeded' : (tokens > MAX_CONTEXT_TOKENS * 0.8 ? 'token-warning' : '');
|
|
1197
|
+
const item = document.createElement('div');
|
|
1198
|
+
item.className = 'conversation-item' + (conv.id === state.currentConversationId ? ' active' : '');
|
|
1199
|
+
item.innerHTML = `
|
|
1200
|
+
<div class="conversation-info">
|
|
1201
|
+
<span class="title">${escapeHtml(conv.title)}</span>
|
|
1202
|
+
<span class="token-count ${tokenClass}" title="Estimated tokens">${formatTokenCount(tokens)}</span>
|
|
1203
|
+
</div>
|
|
1204
|
+
<button class="delete-btn" title="Delete thread">×</button>
|
|
1205
|
+
`;
|
|
1206
|
+
|
|
1207
|
+
item.querySelector('.conversation-info').addEventListener('click', () => {
|
|
1208
|
+
Conversations.switchTo(conv.id);
|
|
1209
|
+
this.renderConversationsList();
|
|
1210
|
+
this.renderMessages();
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
item.querySelector('.delete-btn').addEventListener('click', (e) => {
|
|
1214
|
+
e.stopPropagation();
|
|
1215
|
+
if (confirm('Delete this thread?')) {
|
|
1216
|
+
Conversations.delete(conv.id);
|
|
1217
|
+
this.renderConversationsList();
|
|
1218
|
+
this.renderMessages();
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
elements.conversationsList.appendChild(item);
|
|
1223
|
+
});
|
|
1224
|
+
},
|
|
1225
|
+
|
|
1226
|
+
renderMessages() {
|
|
1227
|
+
const conversation = Conversations.getCurrent();
|
|
1228
|
+
|
|
1229
|
+
if (!conversation || conversation.messages.length === 0) {
|
|
1230
|
+
elements.welcomeMessage.classList.remove('hidden');
|
|
1231
|
+
const children = Array.from(elements.messages.children);
|
|
1232
|
+
children.forEach((child) => {
|
|
1233
|
+
if (child !== elements.welcomeMessage) {
|
|
1234
|
+
child.remove();
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
elements.welcomeMessage.classList.add('hidden');
|
|
1241
|
+
elements.messages.innerHTML = '';
|
|
1242
|
+
|
|
1243
|
+
conversation.messages.forEach((msg) => {
|
|
1244
|
+
this.appendMessage(msg.role, msg.content, msg.timestamp);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
this.scrollToBottom();
|
|
1248
|
+
},
|
|
1249
|
+
|
|
1250
|
+
appendMessage(role, content, timestamp = null, isLoading = false) {
|
|
1251
|
+
elements.welcomeMessage.classList.add('hidden');
|
|
1252
|
+
|
|
1253
|
+
const time = timestamp
|
|
1254
|
+
? new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
1255
|
+
: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
1256
|
+
const avatar = role === 'user' ? '◉' : '⚡';
|
|
1257
|
+
const roleName = role === 'user' ? 'you' : 'jai1';
|
|
1258
|
+
|
|
1259
|
+
const msgEl = document.createElement('div');
|
|
1260
|
+
msgEl.className = `message ${role}`;
|
|
1261
|
+
|
|
1262
|
+
msgEl.innerHTML = `
|
|
1263
|
+
<div class="message-avatar">${avatar}</div>
|
|
1264
|
+
<div class="message-content">
|
|
1265
|
+
<div class="message-header">
|
|
1266
|
+
<span class="message-role">${roleName}</span>
|
|
1267
|
+
<div class="message-meta">
|
|
1268
|
+
<button class="message-copy-btn" title="Copy raw message" aria-label="Copy raw message">
|
|
1269
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
1270
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
1271
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
1272
|
+
</svg>
|
|
1273
|
+
</button>
|
|
1274
|
+
<span class="message-time">${time}</span>
|
|
1275
|
+
</div>
|
|
1276
|
+
</div>
|
|
1277
|
+
<div class="message-body"></div>
|
|
1278
|
+
</div>
|
|
1279
|
+
`;
|
|
1280
|
+
|
|
1281
|
+
// Add content to message body (after DOM is created)
|
|
1282
|
+
const bodyEl = msgEl.querySelector('.message-body');
|
|
1283
|
+
if (isLoading) {
|
|
1284
|
+
bodyEl.innerHTML = '<div class="loading-message"><span class="loading-dot"></span><span class="loading-dot"></span><span class="loading-dot"></span></div>';
|
|
1285
|
+
} else {
|
|
1286
|
+
// Create zero-md element programmatically
|
|
1287
|
+
const zeroMd = this.createZeroMdElement(content);
|
|
1288
|
+
bodyEl.appendChild(zeroMd);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Attach event listener for copy button
|
|
1292
|
+
const copyBtn = msgEl.querySelector('.message-copy-btn');
|
|
1293
|
+
if (copyBtn) {
|
|
1294
|
+
copyBtn.addEventListener('click', async () => {
|
|
1295
|
+
try {
|
|
1296
|
+
await navigator.clipboard.writeText(content);
|
|
1297
|
+
copyBtn.classList.add('copied');
|
|
1298
|
+
setTimeout(() => copyBtn.classList.remove('copied'), 2000);
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
console.error('Failed to copy', err);
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
elements.messages.appendChild(msgEl);
|
|
1306
|
+
this.postProcessMessage(msgEl);
|
|
1307
|
+
this.scrollToBottom();
|
|
1308
|
+
|
|
1309
|
+
return msgEl;
|
|
1310
|
+
},
|
|
1311
|
+
|
|
1312
|
+
updateStreamingMessage(msgEl, content, isComplete = false) {
|
|
1313
|
+
const bodyEl = msgEl.querySelector('.message-body');
|
|
1314
|
+
if (bodyEl) {
|
|
1315
|
+
// Clear and recreate zero-md element
|
|
1316
|
+
bodyEl.innerHTML = '';
|
|
1317
|
+
const zeroMd = this.createZeroMdElement(content);
|
|
1318
|
+
bodyEl.appendChild(zeroMd);
|
|
1319
|
+
|
|
1320
|
+
if (!isComplete) {
|
|
1321
|
+
const cursor = document.createElement('span');
|
|
1322
|
+
cursor.className = 'streaming-cursor';
|
|
1323
|
+
bodyEl.appendChild(cursor);
|
|
1324
|
+
} else {
|
|
1325
|
+
this.postProcessMessage(msgEl);
|
|
1326
|
+
}
|
|
1327
|
+
this.scrollToBottom();
|
|
1328
|
+
}
|
|
1329
|
+
},
|
|
1330
|
+
|
|
1331
|
+
showError(msgEl, errorMessage) {
|
|
1332
|
+
const bodyEl = msgEl.querySelector('.message-body');
|
|
1333
|
+
if (bodyEl) {
|
|
1334
|
+
bodyEl.innerHTML += `<div class="message-error">⚠ ${escapeHtml(errorMessage)}</div>`;
|
|
1335
|
+
}
|
|
1336
|
+
},
|
|
1337
|
+
|
|
1338
|
+
createZeroMdElement(content) {
|
|
1339
|
+
// Create zero-md web component programmatically
|
|
1340
|
+
const zeroMd = document.createElement('zero-md');
|
|
1341
|
+
zeroMd.id = 'md-' + Math.random().toString(36).substr(2, 9);
|
|
1342
|
+
|
|
1343
|
+
// Create markdown script
|
|
1344
|
+
const mdScript = document.createElement('script');
|
|
1345
|
+
mdScript.type = 'text/markdown';
|
|
1346
|
+
mdScript.textContent = content;
|
|
1347
|
+
zeroMd.appendChild(mdScript);
|
|
1348
|
+
|
|
1349
|
+
// Create custom styles template
|
|
1350
|
+
const template = document.createElement('template');
|
|
1351
|
+
template.setAttribute('data-append', '');
|
|
1352
|
+
template.innerHTML = `
|
|
1353
|
+
<style>
|
|
1354
|
+
/* Integrate with chat theme */
|
|
1355
|
+
:host {
|
|
1356
|
+
display: block;
|
|
1357
|
+
color: var(--text-primary);
|
|
1358
|
+
}
|
|
1359
|
+
a {
|
|
1360
|
+
color: var(--accent-primary);
|
|
1361
|
+
text-decoration: none;
|
|
1362
|
+
}
|
|
1363
|
+
a:hover {
|
|
1364
|
+
text-decoration: underline;
|
|
1365
|
+
}
|
|
1366
|
+
code {
|
|
1367
|
+
background: var(--bg-secondary);
|
|
1368
|
+
padding: 0.2em 0.4em;
|
|
1369
|
+
border-radius: 3px;
|
|
1370
|
+
font-family: 'JetBrains Mono', monospace;
|
|
1371
|
+
}
|
|
1372
|
+
pre {
|
|
1373
|
+
background: var(--bg-secondary);
|
|
1374
|
+
padding: 1em;
|
|
1375
|
+
border-radius: 8px;
|
|
1376
|
+
overflow-x: auto;
|
|
1377
|
+
}
|
|
1378
|
+
pre code {
|
|
1379
|
+
background: none;
|
|
1380
|
+
padding: 0;
|
|
1381
|
+
}
|
|
1382
|
+
table {
|
|
1383
|
+
border-collapse: collapse;
|
|
1384
|
+
width: 100%;
|
|
1385
|
+
}
|
|
1386
|
+
th, td {
|
|
1387
|
+
border: 1px solid var(--border-color);
|
|
1388
|
+
padding: 0.5em;
|
|
1389
|
+
}
|
|
1390
|
+
th {
|
|
1391
|
+
background: var(--bg-secondary);
|
|
1392
|
+
}
|
|
1393
|
+
</style>
|
|
1394
|
+
`;
|
|
1395
|
+
zeroMd.appendChild(template);
|
|
1396
|
+
|
|
1397
|
+
return zeroMd;
|
|
1398
|
+
},
|
|
1399
|
+
|
|
1400
|
+
// Process rendered content - zero-md handles rendering automatically
|
|
1401
|
+
async postProcessMessage(msgEl) {
|
|
1402
|
+
// Wait for zero-md to finish rendering
|
|
1403
|
+
const zeroMdEls = msgEl.querySelectorAll('zero-md');
|
|
1404
|
+
if (zeroMdEls.length > 0) {
|
|
1405
|
+
await Promise.all(
|
|
1406
|
+
Array.from(zeroMdEls).map(el =>
|
|
1407
|
+
new Promise(resolve => {
|
|
1408
|
+
if (el.shadowRoot) {
|
|
1409
|
+
resolve();
|
|
1410
|
+
} else {
|
|
1411
|
+
el.addEventListener('zero-md-rendered', () => resolve(), { once: true });
|
|
1412
|
+
}
|
|
1413
|
+
})
|
|
1414
|
+
)
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
},
|
|
1418
|
+
scrollToBottom() {
|
|
1419
|
+
elements.messages.scrollTop = elements.messages.scrollHeight;
|
|
1420
|
+
},
|
|
1421
|
+
updateModelsDropdown() {
|
|
1422
|
+
elements.modelSelect.innerHTML = '';
|
|
1423
|
+
state.models.forEach((model) => {
|
|
1424
|
+
const option = document.createElement('option');
|
|
1425
|
+
option.value = model.id;
|
|
1426
|
+
option.textContent = model.id;
|
|
1427
|
+
if (model.id === state.selectedModel) {
|
|
1428
|
+
option.selected = true;
|
|
1429
|
+
}
|
|
1430
|
+
elements.modelSelect.appendChild(option);
|
|
1431
|
+
});
|
|
1432
|
+
},
|
|
1433
|
+
|
|
1434
|
+
updateAgentsDropdown() {
|
|
1435
|
+
if (!elements.agentSelect) return;
|
|
1436
|
+
elements.agentSelect.innerHTML = '';
|
|
1437
|
+
AGENTS.forEach((agent) => {
|
|
1438
|
+
const option = document.createElement('option');
|
|
1439
|
+
option.value = agent.id;
|
|
1440
|
+
option.textContent = `${agent.icon} ${agent.name}`;
|
|
1441
|
+
if (agent.id === state.selectedAgent) {
|
|
1442
|
+
option.selected = true;
|
|
1443
|
+
}
|
|
1444
|
+
elements.agentSelect.appendChild(option);
|
|
1445
|
+
});
|
|
1446
|
+
},
|
|
1447
|
+
|
|
1448
|
+
getAgentById(id) {
|
|
1449
|
+
return AGENTS.find((a) => a.id === id) || AGENTS[0];
|
|
1450
|
+
},
|
|
1451
|
+
|
|
1452
|
+
setLoading(show) {
|
|
1453
|
+
if (show) {
|
|
1454
|
+
elements.loadingOverlay.classList.remove('hidden');
|
|
1455
|
+
} else {
|
|
1456
|
+
elements.loadingOverlay.classList.add('hidden');
|
|
1457
|
+
}
|
|
1458
|
+
},
|
|
1459
|
+
|
|
1460
|
+
setStreaming(streaming) {
|
|
1461
|
+
state.isStreaming = streaming;
|
|
1462
|
+
const conversation = Conversations.getCurrent();
|
|
1463
|
+
const tokens = conversation ? getConversationTokens(conversation) : 0;
|
|
1464
|
+
const isOverLimit = tokens > MAX_CONTEXT_TOKENS;
|
|
1465
|
+
|
|
1466
|
+
elements.messageInput.disabled = streaming || isOverLimit;
|
|
1467
|
+
elements.sendBtn.disabled = streaming || isOverLimit || !elements.messageInput.value.trim();
|
|
1468
|
+
elements.modelSelect.disabled = streaming;
|
|
1469
|
+
|
|
1470
|
+
// Show/hide token limit warning
|
|
1471
|
+
this.updateTokenLimitWarning(isOverLimit);
|
|
1472
|
+
|
|
1473
|
+
if (streaming) {
|
|
1474
|
+
elements.sendBtn.innerHTML = `
|
|
1475
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
1476
|
+
<rect x="6" y="6" width="12" height="12" rx="2"></rect>
|
|
1477
|
+
</svg>
|
|
1478
|
+
`;
|
|
1479
|
+
} else {
|
|
1480
|
+
elements.sendBtn.innerHTML = `
|
|
1481
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
1482
|
+
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
1483
|
+
<polyline points="12 5 19 12 12 19"></polyline>
|
|
1484
|
+
</svg>
|
|
1485
|
+
`;
|
|
1486
|
+
}
|
|
1487
|
+
},
|
|
1488
|
+
|
|
1489
|
+
showErrorModal(title, message) {
|
|
1490
|
+
elements.errorTitle.textContent = title;
|
|
1491
|
+
elements.errorMessage.textContent = message;
|
|
1492
|
+
elements.errorModal.classList.remove('hidden');
|
|
1493
|
+
document.body.style.overflow = 'hidden';
|
|
1494
|
+
},
|
|
1495
|
+
|
|
1496
|
+
hideErrorModal() {
|
|
1497
|
+
elements.errorModal.classList.add('hidden');
|
|
1498
|
+
document.body.style.overflow = '';
|
|
1499
|
+
},
|
|
1500
|
+
|
|
1501
|
+
updateConnectionStatus(connected) {
|
|
1502
|
+
if (elements.connectionStatus) {
|
|
1503
|
+
elements.connectionStatus.className = 'connection-status' + (connected ? '' : ' disconnected');
|
|
1504
|
+
const statusText = elements.connectionStatus.querySelector('.status-text');
|
|
1505
|
+
if (statusText) {
|
|
1506
|
+
statusText.textContent = connected ? 'connected' : 'disconnected';
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
},
|
|
1510
|
+
|
|
1511
|
+
updateTokenLimitWarning(isOverLimit) {
|
|
1512
|
+
const existingWarning = document.querySelector('.token-limit-warning');
|
|
1513
|
+
if (isOverLimit) {
|
|
1514
|
+
if (!existingWarning) {
|
|
1515
|
+
const warning = document.createElement('div');
|
|
1516
|
+
warning.className = 'token-limit-warning';
|
|
1517
|
+
warning.innerHTML = `
|
|
1518
|
+
<span class="warning-icon">⚠️</span>
|
|
1519
|
+
<span>Context limit exceeded (175K tokens). Please create a new thread for best results.</span>
|
|
1520
|
+
`;
|
|
1521
|
+
const inputArea = document.querySelector('.input-area');
|
|
1522
|
+
inputArea.insertBefore(warning, inputArea.firstChild);
|
|
1523
|
+
}
|
|
1524
|
+
elements.messageInput.placeholder = 'Context limit exceeded - create new thread';
|
|
1525
|
+
} else {
|
|
1526
|
+
if (existingWarning) {
|
|
1527
|
+
existingWarning.remove();
|
|
1528
|
+
}
|
|
1529
|
+
elements.messageInput.placeholder = 'Ask anything...';
|
|
1530
|
+
}
|
|
1531
|
+
},
|
|
1532
|
+
|
|
1533
|
+
checkTokenLimit() {
|
|
1534
|
+
const conversation = Conversations.getCurrent();
|
|
1535
|
+
const tokens = conversation ? getConversationTokens(conversation) : 0;
|
|
1536
|
+
const isOverLimit = tokens > MAX_CONTEXT_TOKENS;
|
|
1537
|
+
this.updateTokenLimitWarning(isOverLimit);
|
|
1538
|
+
return isOverLimit;
|
|
1539
|
+
},
|
|
1540
|
+
|
|
1541
|
+
renderStats() {
|
|
1542
|
+
if (!elements.statsList || !state.modelStats) return;
|
|
1543
|
+
|
|
1544
|
+
const { date, models } = state.modelStats;
|
|
1545
|
+
|
|
1546
|
+
// Update date display
|
|
1547
|
+
if (elements.statsDate) {
|
|
1548
|
+
elements.statsDate.textContent = `Today: ${date}`;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Clear and render stats
|
|
1552
|
+
elements.statsList.innerHTML = '';
|
|
1553
|
+
|
|
1554
|
+
const modelIds = Object.keys(models);
|
|
1555
|
+
if (modelIds.length === 0) {
|
|
1556
|
+
elements.statsList.innerHTML = `
|
|
1557
|
+
<div class="stats-loading">
|
|
1558
|
+
<span>No models available</span>
|
|
1559
|
+
</div>
|
|
1560
|
+
`;
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
modelIds.forEach((modelId) => {
|
|
1565
|
+
const stat = models[modelId];
|
|
1566
|
+
const percentage = stat.limit > 0 ? (stat.used / stat.limit) * 100 : 0;
|
|
1567
|
+
|
|
1568
|
+
// Determine status class
|
|
1569
|
+
let barClass = '';
|
|
1570
|
+
let remainingClass = '';
|
|
1571
|
+
if (percentage >= 90) {
|
|
1572
|
+
barClass = 'danger';
|
|
1573
|
+
remainingClass = 'critical';
|
|
1574
|
+
} else if (percentage >= 70) {
|
|
1575
|
+
barClass = 'warning';
|
|
1576
|
+
remainingClass = 'low';
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
const item = document.createElement('div');
|
|
1580
|
+
item.className = 'stats-item';
|
|
1581
|
+
item.innerHTML = `
|
|
1582
|
+
<div class="stats-item-header">
|
|
1583
|
+
<span class="stats-item-name" title="${modelId}">${this.truncateModelName(modelId)}</span>
|
|
1584
|
+
<span class="stats-item-count">${stat.used}/${stat.limit}</span>
|
|
1585
|
+
</div>
|
|
1586
|
+
<div class="stats-item-bar">
|
|
1587
|
+
<div class="stats-item-bar-fill ${barClass}" style="width: ${Math.min(percentage, 100)}%"></div>
|
|
1588
|
+
</div>
|
|
1589
|
+
`;
|
|
1590
|
+
|
|
1591
|
+
elements.statsList.appendChild(item);
|
|
1592
|
+
});
|
|
1593
|
+
},
|
|
1594
|
+
|
|
1595
|
+
truncateModelName(name) {
|
|
1596
|
+
if (name.length <= 20) return name;
|
|
1597
|
+
return name.substring(0, 18) + '...';
|
|
1598
|
+
},
|
|
1599
|
+
|
|
1600
|
+
updateStatsAfterChat(modelId) {
|
|
1601
|
+
if (!state.modelStats || !state.modelStats.models[modelId]) return;
|
|
1602
|
+
|
|
1603
|
+
// Update local stats
|
|
1604
|
+
const stat = state.modelStats.models[modelId];
|
|
1605
|
+
stat.used += 1;
|
|
1606
|
+
stat.remaining = Math.max(0, stat.limit - stat.used);
|
|
1607
|
+
|
|
1608
|
+
// Re-render stats
|
|
1609
|
+
this.renderStats();
|
|
1610
|
+
},
|
|
1611
|
+
|
|
1612
|
+
setStatsLoading(loading) {
|
|
1613
|
+
if (!elements.statsList) return;
|
|
1614
|
+
|
|
1615
|
+
if (loading) {
|
|
1616
|
+
elements.statsList.innerHTML = `
|
|
1617
|
+
<div class="stats-loading">
|
|
1618
|
+
<span class="stats-loading-icon">◌</span>
|
|
1619
|
+
<span>Loading stats...</span>
|
|
1620
|
+
</div>
|
|
1621
|
+
`;
|
|
1622
|
+
}
|
|
1623
|
+
},
|
|
1624
|
+
|
|
1625
|
+
toggleRightSidebar() {
|
|
1626
|
+
if (elements.sidebarRight) {
|
|
1627
|
+
elements.sidebarRight.classList.toggle('hidden');
|
|
1628
|
+
}
|
|
1629
|
+
},
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
// ============================================
|
|
1633
|
+
// Event Handlers
|
|
1634
|
+
// ============================================
|
|
1635
|
+
function setupEventListeners() {
|
|
1636
|
+
// Send button
|
|
1637
|
+
elements.sendBtn.addEventListener('click', handleSend);
|
|
1638
|
+
|
|
1639
|
+
// Enter to send (Shift+Enter for new line)
|
|
1640
|
+
elements.messageInput.addEventListener('keydown', (e) => {
|
|
1641
|
+
// First check if FileMention wants to handle this key
|
|
1642
|
+
if (FileMention.handleKeydown(e, elements.messageInput)) {
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1647
|
+
e.preventDefault();
|
|
1648
|
+
handleSend();
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
// Auto-resize textarea and handle file mention
|
|
1653
|
+
elements.messageInput.addEventListener('input', () => {
|
|
1654
|
+
const el = elements.messageInput;
|
|
1655
|
+
el.style.height = 'auto';
|
|
1656
|
+
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
|
1657
|
+
|
|
1658
|
+
// Update char count
|
|
1659
|
+
if (el.value.length > 0) {
|
|
1660
|
+
elements.charCount.textContent = el.value.length.toLocaleString();
|
|
1661
|
+
} else {
|
|
1662
|
+
elements.charCount.textContent = '';
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// Enable/disable send button
|
|
1666
|
+
elements.sendBtn.disabled = !el.value.trim() || state.isStreaming;
|
|
1667
|
+
|
|
1668
|
+
// Handle file mention detection
|
|
1669
|
+
FileMention.handleInput(el);
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
// New chat button
|
|
1673
|
+
elements.newChatBtn.addEventListener('click', () => {
|
|
1674
|
+
Conversations.create();
|
|
1675
|
+
UI.renderConversationsList();
|
|
1676
|
+
UI.renderMessages();
|
|
1677
|
+
elements.messageInput.focus();
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
// Model selector
|
|
1681
|
+
elements.modelSelect.addEventListener('change', (e) => {
|
|
1682
|
+
state.selectedModel = e.target.value;
|
|
1683
|
+
Storage.set(STORAGE_KEYS.SETTINGS, { selectedModel: state.selectedModel });
|
|
1684
|
+
|
|
1685
|
+
const conversation = Conversations.getCurrent();
|
|
1686
|
+
if (conversation) {
|
|
1687
|
+
conversation.model = state.selectedModel;
|
|
1688
|
+
Conversations.save();
|
|
1689
|
+
}
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
// Agent selector
|
|
1693
|
+
if (elements.agentSelect) {
|
|
1694
|
+
elements.agentSelect.addEventListener('change', (e) => {
|
|
1695
|
+
state.selectedAgent = e.target.value;
|
|
1696
|
+
Storage.set(STORAGE_KEYS.SETTINGS, {
|
|
1697
|
+
...Storage.get(STORAGE_KEYS.SETTINGS),
|
|
1698
|
+
selectedAgent: state.selectedAgent,
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
const conversation = Conversations.getCurrent();
|
|
1702
|
+
if (conversation) {
|
|
1703
|
+
conversation.agent = state.selectedAgent;
|
|
1704
|
+
Conversations.save();
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// Error modal close
|
|
1710
|
+
elements.errorCloseBtn.addEventListener('click', () => {
|
|
1711
|
+
UI.hideErrorModal();
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
// Close modal on backdrop click
|
|
1715
|
+
elements.errorModal.addEventListener('click', (e) => {
|
|
1716
|
+
if (e.target === elements.errorModal || e.target.classList.contains('modal-backdrop')) {
|
|
1717
|
+
UI.hideErrorModal();
|
|
1718
|
+
}
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
// Keyboard shortcuts
|
|
1722
|
+
document.addEventListener('keydown', (e) => {
|
|
1723
|
+
// Cmd/Ctrl + N = New chat
|
|
1724
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
|
|
1725
|
+
e.preventDefault();
|
|
1726
|
+
Conversations.create();
|
|
1727
|
+
UI.renderConversationsList();
|
|
1728
|
+
UI.renderMessages();
|
|
1729
|
+
elements.messageInput.focus();
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// Escape = Close modal
|
|
1733
|
+
if (e.key === 'Escape') {
|
|
1734
|
+
UI.hideErrorModal();
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
|
|
1738
|
+
// Toggle right sidebar
|
|
1739
|
+
if (elements.toggleRightSidebarBtn) {
|
|
1740
|
+
elements.toggleRightSidebarBtn.addEventListener('click', () => {
|
|
1741
|
+
UI.toggleRightSidebar();
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// Refresh stats button
|
|
1746
|
+
if (elements.refreshStatsBtn) {
|
|
1747
|
+
elements.refreshStatsBtn.addEventListener('click', async () => {
|
|
1748
|
+
await loadStats();
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// Quick prompts
|
|
1753
|
+
if (elements.promptsList) {
|
|
1754
|
+
elements.promptsList.addEventListener('click', (e) => {
|
|
1755
|
+
const promptItem = e.target.closest('.prompt-item');
|
|
1756
|
+
if (promptItem) {
|
|
1757
|
+
const promptText = promptItem.dataset.prompt;
|
|
1758
|
+
if (promptText) {
|
|
1759
|
+
elements.messageInput.value = promptText + ' ';
|
|
1760
|
+
elements.messageInput.focus();
|
|
1761
|
+
// Trigger input event to update UI
|
|
1762
|
+
elements.messageInput.dispatchEvent(new Event('input'));
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// Theme toggle
|
|
1769
|
+
if (elements.themeToggleBtn) {
|
|
1770
|
+
elements.themeToggleBtn.addEventListener('click', () => {
|
|
1771
|
+
Theme.toggle();
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
async function loadStats() {
|
|
1777
|
+
try {
|
|
1778
|
+
UI.setStatsLoading(true);
|
|
1779
|
+
const statsResponse = await API.getStats();
|
|
1780
|
+
if (statsResponse.success && statsResponse.data) {
|
|
1781
|
+
state.modelStats = statsResponse.data;
|
|
1782
|
+
UI.renderStats();
|
|
1783
|
+
}
|
|
1784
|
+
} catch (error) {
|
|
1785
|
+
console.error('Failed to load stats:', error);
|
|
1786
|
+
if (elements.statsList) {
|
|
1787
|
+
elements.statsList.innerHTML = `
|
|
1788
|
+
<div class="stats-loading">
|
|
1789
|
+
<span>Failed to load stats</span>
|
|
1790
|
+
</div>
|
|
1791
|
+
`;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
async function handleSend() {
|
|
1797
|
+
const content = elements.messageInput.value.trim();
|
|
1798
|
+
if (!content || state.isStreaming) return;
|
|
1799
|
+
|
|
1800
|
+
// Ensure we have a conversation
|
|
1801
|
+
if (!state.currentConversationId) {
|
|
1802
|
+
Conversations.create();
|
|
1803
|
+
UI.renderConversationsList();
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// Clear input
|
|
1807
|
+
elements.messageInput.value = '';
|
|
1808
|
+
elements.messageInput.style.height = 'auto';
|
|
1809
|
+
elements.charCount.textContent = '';
|
|
1810
|
+
|
|
1811
|
+
// Get file contents if any files are mentioned
|
|
1812
|
+
let fileContents = [];
|
|
1813
|
+
let fileContextStr = '';
|
|
1814
|
+
const hasMentionedFiles = state.mentionedFiles.length > 0;
|
|
1815
|
+
if (hasMentionedFiles) {
|
|
1816
|
+
fileContents = await FileMention.getFileContents();
|
|
1817
|
+
fileContextStr = FileMention.formatFileContext(fileContents);
|
|
1818
|
+
FileMention.clearFiles(); // Clear after getting contents
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// Build full message with file context
|
|
1822
|
+
const fullMessage = fileContextStr ? content + fileContextStr : content;
|
|
1823
|
+
|
|
1824
|
+
// Display user message (show original content, not with file context)
|
|
1825
|
+
const displayMessage = hasMentionedFiles
|
|
1826
|
+
? content + `\n\n📎 _${fileContents.length} file(s) attached_`
|
|
1827
|
+
: content;
|
|
1828
|
+
|
|
1829
|
+
// Add user message
|
|
1830
|
+
Conversations.addMessage('user', displayMessage);
|
|
1831
|
+
UI.appendMessage('user', displayMessage);
|
|
1832
|
+
|
|
1833
|
+
// Check token limit before sending
|
|
1834
|
+
if (UI.checkTokenLimit()) {
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// Create assistant message placeholder with loading state
|
|
1839
|
+
const assistantMsgEl = UI.appendMessage('assistant', '', null, true);
|
|
1840
|
+
UI.setStreaming(true);
|
|
1841
|
+
|
|
1842
|
+
let fullResponse = '';
|
|
1843
|
+
|
|
1844
|
+
try {
|
|
1845
|
+
// Get history (last 10 messages for context)
|
|
1846
|
+
const conversation = Conversations.getCurrent();
|
|
1847
|
+
const history = conversation.messages.slice(-11, -1).map((m) => ({
|
|
1848
|
+
role: m.role,
|
|
1849
|
+
content: m.content,
|
|
1850
|
+
}));
|
|
1851
|
+
|
|
1852
|
+
// Get system prompt from agent
|
|
1853
|
+
const agent = UI.getAgentById(conversation.agent || state.selectedAgent);
|
|
1854
|
+
const systemPrompt = agent.systemPrompt;
|
|
1855
|
+
|
|
1856
|
+
// Stream response (send fullMessage with file context)
|
|
1857
|
+
for await (const event of API.streamChat(fullMessage, state.selectedModel, history, systemPrompt)) {
|
|
1858
|
+
if (event.type === 'chunk') {
|
|
1859
|
+
fullResponse += event.content;
|
|
1860
|
+
UI.updateStreamingMessage(assistantMsgEl, fullResponse);
|
|
1861
|
+
} else if (event.type === 'done') {
|
|
1862
|
+
UI.updateStreamingMessage(assistantMsgEl, fullResponse, true);
|
|
1863
|
+
Conversations.addMessage('assistant', fullResponse);
|
|
1864
|
+
// Update stats after successful chat
|
|
1865
|
+
UI.updateStatsAfterChat(state.selectedModel);
|
|
1866
|
+
// Update conversation list to show new token count
|
|
1867
|
+
UI.renderConversationsList();
|
|
1868
|
+
// Check if we've exceeded token limit after response
|
|
1869
|
+
UI.checkTokenLimit();
|
|
1870
|
+
} else if (event.type === 'error') {
|
|
1871
|
+
UI.showError(assistantMsgEl, event.message);
|
|
1872
|
+
if (event.code === 'ERR-WC-001') {
|
|
1873
|
+
UI.updateConnectionStatus(false);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
console.error('Chat error:', error);
|
|
1879
|
+
|
|
1880
|
+
if (error.status === 401) {
|
|
1881
|
+
UI.updateConnectionStatus(false);
|
|
1882
|
+
UI.showErrorModal('Session Expired', "Please run 'jai1 chat --web' again to start a new session.");
|
|
1883
|
+
} else {
|
|
1884
|
+
UI.showError(assistantMsgEl, error.message || 'Failed to get response');
|
|
1885
|
+
}
|
|
1886
|
+
} finally {
|
|
1887
|
+
UI.setStreaming(false);
|
|
1888
|
+
elements.messageInput.focus();
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// ============================================
|
|
1893
|
+
// Initialization
|
|
1894
|
+
// ============================================
|
|
1895
|
+
async function init() {
|
|
1896
|
+
// Initialize theme first to prevent flash
|
|
1897
|
+
Theme.init();
|
|
1898
|
+
|
|
1899
|
+
try {
|
|
1900
|
+
// Get session token from URL and store it
|
|
1901
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
1902
|
+
const urlToken = urlParams.get('session');
|
|
1903
|
+
|
|
1904
|
+
if (urlToken) {
|
|
1905
|
+
Storage.set(STORAGE_KEYS.SESSION, {
|
|
1906
|
+
token: urlToken,
|
|
1907
|
+
storedAt: new Date().toISOString(),
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
// Clean URL
|
|
1911
|
+
window.history.replaceState({}, '', window.location.pathname);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// Validate session
|
|
1915
|
+
const sessionResponse = await API.getSession();
|
|
1916
|
+
if (!sessionResponse.success) {
|
|
1917
|
+
throw new Error('Invalid session');
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
state.session = sessionResponse.data;
|
|
1921
|
+
UI.updateConnectionStatus(true);
|
|
1922
|
+
|
|
1923
|
+
// Get models
|
|
1924
|
+
const modelsResponse = await API.getModels();
|
|
1925
|
+
state.models = modelsResponse.data || [];
|
|
1926
|
+
|
|
1927
|
+
// Load settings
|
|
1928
|
+
const settings = Storage.get(STORAGE_KEYS.SETTINGS) || {};
|
|
1929
|
+
// Default to jai1-auto if available, otherwise first model
|
|
1930
|
+
const defaultModel = state.models.find(m => m.id === 'jai1-auto')?.id || state.models[0]?.id || '';
|
|
1931
|
+
state.selectedModel = settings.selectedModel || defaultModel;
|
|
1932
|
+
state.selectedAgent = settings.selectedAgent || 'default';
|
|
1933
|
+
|
|
1934
|
+
// Load conversations
|
|
1935
|
+
Conversations.load();
|
|
1936
|
+
|
|
1937
|
+
// If we have conversations, select the first one
|
|
1938
|
+
if (state.conversations.length > 0) {
|
|
1939
|
+
state.currentConversationId = state.conversations[0].id;
|
|
1940
|
+
// Restore agent from conversation
|
|
1941
|
+
const currentConv = Conversations.getCurrent();
|
|
1942
|
+
if (currentConv && currentConv.agent) {
|
|
1943
|
+
state.selectedAgent = currentConv.agent;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// Render UI
|
|
1948
|
+
UI.updateModelsDropdown();
|
|
1949
|
+
UI.updateAgentsDropdown();
|
|
1950
|
+
UI.renderConversationsList();
|
|
1951
|
+
UI.renderMessages();
|
|
1952
|
+
|
|
1953
|
+
// Setup event listeners
|
|
1954
|
+
setupEventListeners();
|
|
1955
|
+
|
|
1956
|
+
// Initialize file mention system
|
|
1957
|
+
FileMention.init();
|
|
1958
|
+
|
|
1959
|
+
// Load stats (only once at init)
|
|
1960
|
+
loadStats();
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
// Configure Mermaid if available
|
|
1964
|
+
if (typeof mermaid !== 'undefined') {
|
|
1965
|
+
mermaid.initialize({
|
|
1966
|
+
startOnLoad: false,
|
|
1967
|
+
theme: 'dark',
|
|
1968
|
+
securityLevel: 'loose'
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
if (typeof marked !== 'undefined') {
|
|
1972
|
+
marked.setOptions({
|
|
1973
|
+
breaks: true,
|
|
1974
|
+
gfm: true,
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// Hide loading
|
|
1979
|
+
UI.setLoading(false);
|
|
1980
|
+
|
|
1981
|
+
// Focus input
|
|
1982
|
+
elements.messageInput.focus();
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
console.error('Init error:', error);
|
|
1985
|
+
UI.setLoading(false);
|
|
1986
|
+
|
|
1987
|
+
if (error.status === 401 || error.message.includes('session')) {
|
|
1988
|
+
UI.showErrorModal('Session Invalid', "Please run 'jai1 chat --web' again to start a new session.");
|
|
1989
|
+
} else {
|
|
1990
|
+
UI.showErrorModal('Connection Error', 'Failed to connect to Jai1 server. Please try again.');
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
// ============================================
|
|
1996
|
+
// Utilities
|
|
1997
|
+
// ============================================
|
|
1998
|
+
function escapeHtml(text) {
|
|
1999
|
+
const div = document.createElement('div');
|
|
2000
|
+
div.textContent = text;
|
|
2001
|
+
return div.innerHTML;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// ============================================
|
|
2005
|
+
// Start
|
|
2006
|
+
// ============================================
|
|
2007
|
+
if (document.readyState === 'loading') {
|
|
2008
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
2009
|
+
} else {
|
|
2010
|
+
init();
|
|
2011
|
+
}
|
|
2012
|
+
})();
|