aline-ai 0.2.6__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
  2. aline_ai-0.3.0.dist-info/RECORD +41 -0
  3. aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
  4. realign/__init__.py +32 -1
  5. realign/cli.py +203 -19
  6. realign/commands/__init__.py +2 -2
  7. realign/commands/clean.py +149 -0
  8. realign/commands/config.py +1 -1
  9. realign/commands/export_shares.py +1785 -0
  10. realign/commands/hide.py +112 -24
  11. realign/commands/import_history.py +873 -0
  12. realign/commands/init.py +104 -217
  13. realign/commands/mirror.py +131 -0
  14. realign/commands/pull.py +101 -0
  15. realign/commands/push.py +155 -245
  16. realign/commands/review.py +216 -54
  17. realign/commands/session_utils.py +139 -4
  18. realign/commands/share.py +965 -0
  19. realign/commands/status.py +559 -0
  20. realign/commands/sync.py +91 -0
  21. realign/commands/undo.py +423 -0
  22. realign/commands/watcher.py +805 -0
  23. realign/config.py +21 -10
  24. realign/file_lock.py +3 -1
  25. realign/hash_registry.py +310 -0
  26. realign/hooks.py +115 -411
  27. realign/logging_config.py +2 -2
  28. realign/mcp_server.py +263 -549
  29. realign/mcp_watcher.py +997 -139
  30. realign/mirror_utils.py +322 -0
  31. realign/prompts/__init__.py +21 -0
  32. realign/prompts/presets.py +238 -0
  33. realign/redactor.py +168 -16
  34. realign/tracker/__init__.py +9 -0
  35. realign/tracker/git_tracker.py +1123 -0
  36. realign/watcher_daemon.py +115 -0
  37. aline_ai-0.2.6.dist-info/RECORD +0 -28
  38. aline_ai-0.2.6.dist-info/entry_points.txt +0 -5
  39. realign/commands/auto_commit.py +0 -242
  40. realign/commands/commit.py +0 -379
  41. realign/commands/search.py +0 -449
  42. realign/commands/show.py +0 -416
  43. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
  44. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1785 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Export shares command - Export selected commits' chat history to JSON files.
4
+
5
+ This allows users to select specific commits and extract their chat history changes
6
+ into standalone JSON files for sharing.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import secrets
14
+ import hashlib
15
+ import base64
16
+ from collections import defaultdict
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import List, Dict, Optional, Tuple
20
+
21
+ from .review import get_unpushed_commits, UnpushedCommit
22
+ from .hide import parse_commit_indices
23
+ from ..logging_config import setup_logger
24
+
25
+ logger = setup_logger('realign.commands.export_shares', 'export_shares.log')
26
+
27
+ # Try to import cryptography
28
+ try:
29
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
30
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
31
+ from cryptography.hazmat.primitives import hashes
32
+ from cryptography.hazmat.backends import default_backend
33
+ CRYPTO_AVAILABLE = True
34
+ except ImportError:
35
+ CRYPTO_AVAILABLE = False
36
+ logger.warning("cryptography package not available, interactive mode disabled")
37
+
38
+ # Try to import httpx
39
+ try:
40
+ import httpx
41
+ HTTPX_AVAILABLE = True
42
+ except ImportError:
43
+ HTTPX_AVAILABLE = False
44
+ logger.warning("httpx package not available, interactive mode disabled")
45
+
46
+
47
+ def get_line_timestamp(line_json: dict) -> datetime:
48
+ """
49
+ 从 JSON 对象中提取时间戳。
50
+
51
+ Args:
52
+ line_json: JSON 对象
53
+
54
+ Returns:
55
+ datetime 对象,如果没有 timestamp 则返回 datetime.min
56
+ """
57
+ if 'timestamp' in line_json:
58
+ ts_str = line_json['timestamp']
59
+ # 处理 ISO 格式: "2025-12-07T17:54:42.618Z"
60
+ return datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
61
+ return datetime.min # 没有时间戳的放在最前面
62
+
63
+
64
+ def get_session_id(line_json: dict) -> Optional[str]:
65
+ """
66
+ 从 JSON 对象中提取 session ID。
67
+
68
+ Args:
69
+ line_json: JSON 对象
70
+
71
+ Returns:
72
+ session ID 字符串,如果没有则返回 None
73
+ """
74
+ return line_json.get('sessionId')
75
+
76
+
77
+ def extract_messages_from_commit(
78
+ commit: UnpushedCommit,
79
+ repo_root: Path
80
+ ) -> Dict[str, List[Tuple[datetime, dict]]]:
81
+ """
82
+ 从单个 commit 提取所有新增消息,按 session ID 分组。
83
+
84
+ Args:
85
+ commit: UnpushedCommit 对象
86
+ repo_root: shadow git 仓库路径
87
+
88
+ Returns:
89
+ 字典: {session_id: [(timestamp, json_object), ...]}
90
+ """
91
+ logger.info(f"Extracting messages from commit {commit.hash}")
92
+ session_messages = defaultdict(list)
93
+
94
+ for session_file, line_ranges in commit.session_additions.items():
95
+ logger.debug(f"Processing session file: {session_file}")
96
+
97
+ # 获取文件内容
98
+ result = subprocess.run(
99
+ ["git", "show", f"{commit.full_hash}:{session_file}"],
100
+ cwd=repo_root,
101
+ capture_output=True,
102
+ text=True
103
+ )
104
+
105
+ if result.returncode != 0:
106
+ logger.warning(f"Failed to get content for {session_file}")
107
+ continue
108
+
109
+ lines = result.stdout.split('\n')
110
+
111
+ # 提取新增行
112
+ for start, end in line_ranges:
113
+ for line_num in range(start, end + 1):
114
+ if line_num <= len(lines):
115
+ line = lines[line_num - 1].strip()
116
+
117
+ if not line or "[REDACTED]" in line:
118
+ continue
119
+
120
+ try:
121
+ json_obj = json.loads(line)
122
+
123
+ # 跳过已被标记为 redacted 的内容
124
+ if json_obj.get("redacted"):
125
+ continue
126
+
127
+ session_id = get_session_id(json_obj)
128
+ if session_id:
129
+ timestamp = get_line_timestamp(json_obj)
130
+ session_messages[session_id].append((timestamp, json_obj))
131
+ logger.debug(f"Extracted message from session {session_id}")
132
+
133
+ except json.JSONDecodeError as e:
134
+ logger.warning(f"Failed to parse JSON at line {line_num}: {e}")
135
+ continue
136
+
137
+ logger.info(f"Extracted {sum(len(msgs) for msgs in session_messages.values())} messages from {len(session_messages)} sessions")
138
+ return session_messages
139
+
140
+
141
+ def merge_messages_from_commits(
142
+ selected_commits: List[UnpushedCommit],
143
+ repo_root: Path
144
+ ) -> Dict[str, List[dict]]:
145
+ """
146
+ 合并所有选中 commits 的消息,按 session ID 分组并排序。
147
+
148
+ Args:
149
+ selected_commits: 选中的 commits 列表
150
+ repo_root: shadow git 仓库路径
151
+
152
+ Returns:
153
+ 字典: {session_id: [sorted_json_objects]}
154
+ """
155
+ logger.info(f"Merging messages from {len(selected_commits)} commits")
156
+ all_session_messages = defaultdict(list)
157
+
158
+ # 按时间顺序处理 commits(从旧到新)
159
+ for commit in reversed(selected_commits):
160
+ commit_messages = extract_messages_from_commit(commit, repo_root)
161
+
162
+ for session_id, messages in commit_messages.items():
163
+ all_session_messages[session_id].extend(messages)
164
+
165
+ # 排序和去重
166
+ result = {}
167
+ for session_id, messages_with_ts in all_session_messages.items():
168
+ # 按时间戳排序
169
+ sorted_messages = sorted(messages_with_ts, key=lambda x: x[0])
170
+
171
+ # 去重(基于 JSON 字符串)
172
+ seen = set()
173
+ unique_messages = []
174
+ for ts, msg in sorted_messages:
175
+ msg_str = json.dumps(msg, sort_keys=True, ensure_ascii=False)
176
+ if msg_str not in seen:
177
+ seen.add(msg_str)
178
+ unique_messages.append(msg)
179
+
180
+ result[session_id] = unique_messages
181
+ logger.info(f"Session {session_id}: {len(unique_messages)} unique messages")
182
+
183
+ return result
184
+
185
+
186
+ def save_export_file(
187
+ session_messages: Dict[str, List[dict]],
188
+ output_dir: Path,
189
+ username: str
190
+ ) -> Path:
191
+ """
192
+ 保存导出文件。
193
+
194
+ Args:
195
+ session_messages: {session_id: [messages]}
196
+ output_dir: 输出目录
197
+ username: 用户名
198
+
199
+ Returns:
200
+ 导出文件路径
201
+ """
202
+ logger.info(f"Saving export file to {output_dir}")
203
+
204
+ # 创建输出目录
205
+ output_dir.mkdir(parents=True, exist_ok=True)
206
+
207
+ # 生成文件名: username_timestamp.json
208
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
209
+ filename = f"{username}_{timestamp}.json"
210
+ output_path = output_dir / filename
211
+
212
+ # 构建导出数据
213
+ export_data = {
214
+ "username": username,
215
+ "time": datetime.now().isoformat(),
216
+ "sessions": [
217
+ {
218
+ "session_id": session_id,
219
+ "messages": messages
220
+ }
221
+ for session_id, messages in session_messages.items()
222
+ ]
223
+ }
224
+
225
+ # 写入文件
226
+ with open(output_path, 'w', encoding='utf-8') as f:
227
+ json.dump(export_data, f, indent=2, ensure_ascii=False)
228
+
229
+ logger.info(f"Export file saved to {output_path}")
230
+ return output_path
231
+
232
+
233
+ def display_commits_for_selection(commits: List[UnpushedCommit]) -> None:
234
+ """
235
+ 显示 commits 供用户选择。
236
+
237
+ Args:
238
+ commits: UnpushedCommit 列表
239
+ """
240
+ print(f"\n📋 Available commits ({len(commits)}):\n")
241
+
242
+ for commit in commits:
243
+ # Display format: [index] hash - message
244
+ print(f" [{commit.index}] {commit.hash} - {commit.message}")
245
+
246
+ # Show user request if available
247
+ if commit.user_request:
248
+ request_preview = commit.user_request[:60]
249
+ if len(commit.user_request) > 60:
250
+ request_preview += "..."
251
+ print(f" └─ {request_preview}")
252
+
253
+ print()
254
+
255
+
256
+ def export_shares_command(
257
+ indices: Optional[str] = None,
258
+ username: Optional[str] = None,
259
+ repo_root: Optional[Path] = None,
260
+ output_dir: Optional[Path] = None
261
+ ) -> int:
262
+ """
263
+ Main entry point for export shares command.
264
+
265
+ Allows users to select commits and export their chat history to JSON files.
266
+
267
+ Args:
268
+ indices: Commit indices to export (e.g., "1,3,5-7"). If None, prompts user.
269
+ username: Username for the export. If None, uses system username.
270
+ repo_root: Path to user's project root (auto-detected if None)
271
+ output_dir: Custom output directory. If None, uses ~/.aline/{project}/share/
272
+
273
+ Returns:
274
+ 0 on success, 1 on error
275
+ """
276
+ logger.info("======== Export shares command started ========")
277
+
278
+ # Auto-detect user project root if not provided
279
+ if repo_root is None:
280
+ try:
281
+ result = subprocess.run(
282
+ ["git", "rev-parse", "--show-toplevel"],
283
+ capture_output=True,
284
+ text=True,
285
+ check=True
286
+ )
287
+ repo_root = Path(result.stdout.strip())
288
+ logger.debug(f"Detected user project root: {repo_root}")
289
+ except subprocess.CalledProcessError:
290
+ print("Error: Not in a git repository", file=sys.stderr)
291
+ logger.error("Not in a git repository")
292
+ return 1
293
+
294
+ # Get shadow git repository path
295
+ from .. import get_realign_dir
296
+ shadow_dir = get_realign_dir(repo_root)
297
+ shadow_git = shadow_dir
298
+
299
+ # Verify shadow git exists
300
+ if not shadow_git.exists():
301
+ print(f"Error: ReAlign repository not found at {shadow_git}", file=sys.stderr)
302
+ print("Run 'aline init' first to initialize the repository.", file=sys.stderr)
303
+ logger.error(f"Shadow git not found at {shadow_git}")
304
+ return 1
305
+
306
+ # Check if it's a git repository
307
+ git_dir = shadow_git / '.git'
308
+ if not git_dir.exists():
309
+ print(f"Error: {shadow_git} is not a git repository", file=sys.stderr)
310
+ print("Run 'aline init' first to initialize the repository.", file=sys.stderr)
311
+ logger.error(f"No .git found in {shadow_git}")
312
+ return 1
313
+
314
+ logger.info(f"Using shadow git repository: {shadow_git}")
315
+
316
+ # Get username
317
+ if username is None:
318
+ username = os.environ.get('USER') or os.environ.get('USERNAME') or 'user'
319
+
320
+ logger.debug(f"Using username: {username}")
321
+
322
+ # Set output directory
323
+ if output_dir is None:
324
+ output_dir = shadow_dir / "share"
325
+
326
+ logger.debug(f"Output directory: {output_dir}")
327
+
328
+ # Get all unpushed commits from shadow git
329
+ try:
330
+ all_commits = get_unpushed_commits(shadow_git)
331
+ except Exception as e:
332
+ print(f"Error: Failed to get commits: {e}", file=sys.stderr)
333
+ logger.error(f"Failed to get commits: {e}", exc_info=True)
334
+ return 1
335
+
336
+ if not all_commits:
337
+ print("No unpushed commits found. Nothing to export.", file=sys.stderr)
338
+ logger.info("No unpushed commits found")
339
+ return 1
340
+
341
+ # Get commit selection
342
+ if indices is None:
343
+ # Interactive mode: display commits and prompt user
344
+ display_commits_for_selection(all_commits)
345
+
346
+ print("Enter commit indices to export (e.g., '1,3,5-7' or 'all'):")
347
+ indices_input = input("Indices: ").strip()
348
+
349
+ if not indices_input:
350
+ print("No commits selected. Exiting.")
351
+ logger.info("No commits selected by user")
352
+ return 0
353
+ else:
354
+ indices_input = indices
355
+
356
+ # Parse indices
357
+ try:
358
+ if indices_input.lower() == "all":
359
+ indices_list = [c.index for c in all_commits]
360
+ else:
361
+ indices_list = parse_commit_indices(indices_input)
362
+ except ValueError as e:
363
+ print(f"Error: Invalid indices format: {e}", file=sys.stderr)
364
+ logger.error(f"Invalid indices format: {e}")
365
+ return 1
366
+
367
+ # Validate indices
368
+ max_index = len(all_commits)
369
+ invalid_indices = [i for i in indices_list if i < 1 or i > max_index]
370
+ if invalid_indices:
371
+ print(f"Error: Invalid indices (out of range 1-{max_index}): {invalid_indices}", file=sys.stderr)
372
+ logger.error(f"Invalid indices: {invalid_indices}")
373
+ return 1
374
+
375
+ # Get selected commits
376
+ selected_commits = [c for c in all_commits if c.index in indices_list]
377
+
378
+ logger.info(f"Selected {len(selected_commits)} commit(s) to export")
379
+
380
+ # Merge messages from commits
381
+ print(f"\n🔄 Extracting chat history from {len(selected_commits)} commit(s)...")
382
+ try:
383
+ session_messages = merge_messages_from_commits(selected_commits, shadow_git)
384
+ except Exception as e:
385
+ print(f"\nError: Failed to extract messages: {e}", file=sys.stderr)
386
+ logger.error(f"Failed to extract messages: {e}", exc_info=True)
387
+ return 1
388
+
389
+ if not session_messages:
390
+ print("\nWarning: No chat history found in selected commits.", file=sys.stderr)
391
+ logger.warning("No chat history found in selected commits")
392
+ return 1
393
+
394
+ # Save export file
395
+ try:
396
+ output_path = save_export_file(session_messages, output_dir, username)
397
+ except Exception as e:
398
+ print(f"\nError: Failed to save export file: {e}", file=sys.stderr)
399
+ logger.error(f"Failed to save export file: {e}", exc_info=True)
400
+ return 1
401
+
402
+ # Success message
403
+ total_messages = sum(len(msgs) for msgs in session_messages.values())
404
+ print(f"\n✅ Successfully exported {len(session_messages)} session(s)")
405
+ print(f"📁 Export file: {output_path}")
406
+ print(f"📊 Total messages: {total_messages}")
407
+ print()
408
+
409
+ logger.info(f"======== Export shares command completed: {output_path} ========")
410
+ return 0
411
+
412
+
413
+ def encrypt_conversation_data(data: dict, password: str) -> dict:
414
+ """
415
+ 使用 AES-256-GCM 加密对话数据
416
+
417
+ Args:
418
+ data: 要加密的数据字典
419
+ password: 加密密码
420
+
421
+ Returns:
422
+ 包含加密数据的字典: {encrypted_data, salt, nonce, password_hash}
423
+ """
424
+ if not CRYPTO_AVAILABLE:
425
+ raise RuntimeError("cryptography package not installed. Run: pip install cryptography")
426
+
427
+ # 生成盐值和随机数
428
+ salt = os.urandom(32)
429
+ nonce = os.urandom(12)
430
+
431
+ # 密钥派生
432
+ kdf = PBKDF2HMAC(
433
+ algorithm=hashes.SHA256(),
434
+ length=32,
435
+ salt=salt,
436
+ iterations=1000,
437
+ backend=default_backend()
438
+ )
439
+ key = kdf.derive(password.encode())
440
+
441
+ # 加密数据
442
+ cipher = Cipher(algorithms.AES(key), modes.GCM(nonce), backend=default_backend())
443
+ encryptor = cipher.encryptor()
444
+
445
+ json_data = json.dumps(data, ensure_ascii=False).encode('utf-8')
446
+ ciphertext = encryptor.update(json_data) + encryptor.finalize()
447
+
448
+ # 添加认证标签
449
+ ciphertext_with_tag = ciphertext + encryptor.tag
450
+
451
+ # 计算密码 hash
452
+ password_hash = hashlib.sha256(password.encode()).hexdigest()
453
+
454
+ return {
455
+ "encrypted_data": base64.b64encode(ciphertext_with_tag).decode('ascii'),
456
+ "salt": base64.b64encode(salt).decode('ascii'),
457
+ "nonce": base64.b64encode(nonce).decode('ascii'),
458
+ "password_hash": password_hash
459
+ }
460
+
461
+
462
+ def upload_to_backend(
463
+ encrypted_payload: dict,
464
+ metadata: dict,
465
+ backend_url: str
466
+ ) -> dict:
467
+ """
468
+ 上传加密数据到后端服务器
469
+
470
+ Args:
471
+ encrypted_payload: 加密后的数据
472
+ metadata: 元数据
473
+ backend_url: 后端 URL
474
+
475
+ Returns:
476
+ 包含 share_id 和 share_url 的字典
477
+ """
478
+ if not HTTPX_AVAILABLE:
479
+ raise RuntimeError("httpx package not installed. Run: pip install httpx")
480
+
481
+ try:
482
+ response = httpx.post(
483
+ f"{backend_url}/api/share/create",
484
+ json={
485
+ "encrypted_payload": encrypted_payload,
486
+ "metadata": metadata
487
+ },
488
+ timeout=30.0
489
+ )
490
+ response.raise_for_status()
491
+ return response.json()
492
+ except httpx.HTTPError as e:
493
+ logger.error(f"Upload failed: {e}")
494
+ raise RuntimeError(f"Failed to upload to server: {e}")
495
+
496
+
497
+ def clean_text_for_prompt(text: str) -> str:
498
+ """
499
+ 清理文本中的控制字符,使其适合在 LLM prompt 中使用
500
+
501
+ Args:
502
+ text: 原始文本
503
+
504
+ Returns:
505
+ 清理后的文本
506
+ """
507
+ # 替换控制字符为空格或删除
508
+ import re
509
+ # 保留换行符和制表符,删除其他控制字符
510
+ text = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', text)
511
+ # 将多个连续空格合并为一个
512
+ text = re.sub(r'\s+', ' ', text)
513
+ return text.strip()
514
+
515
+
516
+ def generate_ui_metadata_with_llm(
517
+ conversation_data: dict,
518
+ selected_commits: List,
519
+ provider: str = "auto",
520
+ preset_id: str = "default"
521
+ ) -> Tuple[Optional[dict], Optional[dict]]:
522
+ """
523
+ 使用 LLM 根据对话内容生成个性化的 UI 元数据
524
+
525
+ Args:
526
+ conversation_data: 对话数据字典 {username, time, sessions}
527
+ selected_commits: 选中的 UnpushedCommit 列表
528
+ provider: LLM provider ("auto", "claude", "openai")
529
+ preset_id: Prompt preset ID,用于调整生成风格
530
+
531
+ Returns:
532
+ Tuple[ui_metadata, debug_info]
533
+ - ui_metadata: UI 元数据字典,包含 title, welcome, description, preset_questions
534
+ - debug_info: {system_prompt, user_prompt, response_text, provider} 或 None
535
+ 如果生成失败则都返回 None
536
+ """
537
+ logger.info(f"Generating UI metadata with LLM (preset: {preset_id})")
538
+
539
+ # 构建对话内容摘要
540
+ sessions = conversation_data.get("sessions", [])
541
+ total_messages = sum(len(s.get("messages", [])) for s in sessions)
542
+
543
+ # 提取 commit 中的 LLM summary 和 user request
544
+ commit_summaries = []
545
+ user_requests = []
546
+
547
+ for commit in selected_commits:
548
+ if commit.llm_summary and commit.llm_summary.strip():
549
+ # 清理控制字符
550
+ cleaned_summary = clean_text_for_prompt(commit.llm_summary)
551
+ commit_summaries.append(cleaned_summary)
552
+ if commit.user_request and commit.user_request.strip():
553
+ # 清理控制字符并截取前300字符
554
+ cleaned_request = clean_text_for_prompt(commit.user_request[:300])
555
+ user_requests.append(cleaned_request)
556
+
557
+ # 如果没有 commit summary,回退到提取消息样本
558
+ if not commit_summaries:
559
+ logger.warning("No commit summaries found, falling back to message samples")
560
+ user_messages = []
561
+ assistant_messages = []
562
+
563
+ for session in sessions[:5]: # 只看前5个session
564
+ for msg in session.get("messages", [])[:10]: # 每个session前10条消息
565
+ content = msg.get("content", "")
566
+ if isinstance(content, str) and content.strip():
567
+ # 清理控制字符并截取
568
+ cleaned_content = clean_text_for_prompt(content[:200])
569
+ if msg.get("role") == "user":
570
+ user_messages.append(cleaned_content)
571
+ elif msg.get("role") == "assistant":
572
+ assistant_messages.append(cleaned_content)
573
+
574
+ # 根据 preset_id 定制 system_prompt
575
+ preset_configs = {
576
+ "default": {
577
+ "role_description": "a general-purpose conversation assistant",
578
+ "title_style": "a neutral, descriptive summary of the topic",
579
+ "welcome_tone": "friendly and informative, with a brief overview of the conversation",
580
+ "description_focus": "what information can be found and how the assistant can help",
581
+ "question_angles": [
582
+ "high-level summary",
583
+ "technical or implementation details",
584
+ "decision-making or reasoning",
585
+ "results, impact, or follow-up"
586
+ ]
587
+ },
588
+ "work-report": {
589
+ "role_description": "a professional work report agent representing the user to colleagues/managers",
590
+ "title_style": "a professional, achievement-oriented summary (e.g., 'Progress on Project X', 'Completed Tasks for Week Y')",
591
+ "welcome_tone": "professional and confident, highlighting accomplishments and progress",
592
+ "description_focus": "what work was done, what value was created, and how the assistant represents the user's contributions",
593
+ "question_angles": [
594
+ "overall progress and achievements",
595
+ "technical solutions implemented",
596
+ "challenges overcome and decisions made",
597
+ "next steps and impact on project goals"
598
+ ]
599
+ },
600
+ "knowledge-agent": {
601
+ "role_description": "a knowledge-sharing agent representing the user's deep thinking as founder/architect/author",
602
+ "title_style": "a thought-provoking, conceptual title (e.g., 'Design Philosophy of Feature X', 'Architectural Decisions for System Y')",
603
+ "welcome_tone": "insightful and educational, emphasizing the thinking process and context behind decisions",
604
+ "description_focus": "the knowledge and insights shared, the reasoning behind decisions, and how the assistant helps others understand the user's thought process",
605
+ "question_angles": [
606
+ "core concepts and philosophy",
607
+ "design rationale and trade-offs",
608
+ "key insights and learning",
609
+ "practical implications and applications"
610
+ ]
611
+ },
612
+ "personality-analyzer": {
613
+ "role_description": "a personality analysis assistant that understands the user's characteristics based on conversation",
614
+ "title_style": "an analytical, personality-focused title (e.g., 'Minhao's Working Style Analysis', 'Communication Pattern Insights')",
615
+ "welcome_tone": "analytical yet friendly, introducing what aspects of personality can be explored",
616
+ "description_focus": "what personality traits, working styles, and communication patterns can be discovered from the conversation",
617
+ "question_angles": [
618
+ "overall personality traits and characteristics",
619
+ "working style and approach to problem-solving",
620
+ "communication patterns and preferences",
621
+ "strengths, growth areas, and unique qualities"
622
+ ]
623
+ }
624
+ }
625
+
626
+ # 获取 preset 配置,如果没有则使用默认
627
+ preset_config = preset_configs.get(preset_id, preset_configs["default"])
628
+
629
+ # 构建 LLM prompt
630
+ system_prompt = f"""You are a conversation interface copy generator for {preset_config['role_description']}.
631
+
632
+ Your task is to analyze a given conversation history and generate
633
+ personalized UI copy for a chat-based assistant that helps users
634
+ understand and explore that conversation.
635
+
636
+ Return the result strictly in JSON format:
637
+
638
+ {{
639
+ "title": "A highly abstract and concise title (30–50 characters) that is {preset_config['title_style']}.",
640
+
641
+ "welcome": "A welcome message (60-120 characters) that is {preset_config['welcome_tone']}.
642
+ The message should provide context about what this conversation contains and set the right tone.",
643
+
644
+ "description": "A clear description (30-80 characters) that explains {preset_config['description_focus']}.",
645
+
646
+ "preset_questions": [
647
+ "Question 1: About {preset_config['question_angles'][0]} (15–30 characters)",
648
+ "Question 2: About {preset_config['question_angles'][1]} (15–30 characters)",
649
+ "Question 3: About {preset_config['question_angles'][2]} (15–30 characters)",
650
+ "Question 4: About {preset_config['question_angles'][3]} (15–30 characters)"
651
+ ]
652
+ }}
653
+
654
+ Requirements:
655
+ 1. The title must align with the preset's purpose and tone.
656
+ 2. The welcome message should match the preset's role and create appropriate expectations.
657
+ 3. The description should clearly explain the assistant's specific purpose for this preset.
658
+ 4. Preset questions must be based on the actual conversation content, concrete, and useful from the specified angles.
659
+ 5. All text must be in English or Chinese, depending on the conversation language.
660
+ 6. Output JSON only. Do not include any additional explanation or text."""
661
+
662
+ # 构建 user prompt - 优先使用 commit summaries
663
+ if commit_summaries:
664
+ user_prompt = f"""Analyze the following conversation history and generate UI copy:
665
+
666
+ Number of sessions: {len(sessions)}
667
+ Total messages: {total_messages}
668
+ Number of commits included: {len(commit_summaries)}
669
+
670
+ LLM summaries from each commit:
671
+ {chr(10).join(f"{i+1}. {summary}" for i, summary in enumerate(commit_summaries))}
672
+
673
+ User's main requests:
674
+ {chr(10).join(f"- {req}" for req in user_requests[:10]) if user_requests else "None"}
675
+
676
+ Return the UI copy in JSON format."""
677
+ else:
678
+ # 回退到使用消息样本
679
+ user_prompt = f"""Analyze the following conversation history and generate UI copy:
680
+
681
+ Number of sessions: {len(sessions)}
682
+ Total messages: {total_messages}
683
+
684
+ User message samples:
685
+ {chr(10).join(user_messages[:10])}
686
+
687
+ Assistant reply samples:
688
+ {chr(10).join(assistant_messages[:10])}
689
+
690
+ Return the UI copy in JSON format."""
691
+
692
+ # 尝试调用 LLM
693
+ anthropic_key = os.getenv("ANTHROPIC_API_KEY")
694
+ openai_key = os.getenv("OPENAI_API_KEY")
695
+
696
+ try_claude = provider in ("auto", "claude")
697
+ try_openai = provider in ("auto", "openai")
698
+
699
+ # 尝试 Claude
700
+ if try_claude and anthropic_key:
701
+ print(" → Generating UI metadata with Anthropic Claude...", file=sys.stderr)
702
+ try:
703
+ import anthropic
704
+ client = anthropic.Anthropic(api_key=anthropic_key)
705
+
706
+ response = client.messages.create(
707
+ model="claude-3-5-haiku-20241022",
708
+ max_tokens=1000,
709
+ temperature=0.7,
710
+ system=system_prompt,
711
+ messages=[{"role": "user", "content": user_prompt}]
712
+ )
713
+
714
+ response_text = response.content[0].text.strip()
715
+ logger.debug(f"Claude response: {response_text}")
716
+
717
+ # 解析 JSON
718
+ json_str = response_text
719
+ if "```json" in response_text:
720
+ json_start = response_text.find("```json") + 7
721
+ json_end = response_text.find("```", json_start)
722
+ if json_end != -1:
723
+ json_str = response_text[json_start:json_end].strip()
724
+ elif "```" in response_text:
725
+ json_start = response_text.find("```") + 3
726
+ json_end = response_text.find("```", json_start)
727
+ if json_end != -1:
728
+ json_str = response_text[json_start:json_end].strip()
729
+
730
+ # 清理控制字符,避免 JSON 解析错误
731
+ import re
732
+ # 移除非法的控制字符,但保留合法的空白字符(空格、制表符、换行)
733
+ json_str = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', json_str)
734
+
735
+ ui_metadata = json.loads(json_str)
736
+ print(" ✅ UI metadata generated successfully with Claude", file=sys.stderr)
737
+
738
+ # Return with debug info
739
+ debug_info = {
740
+ "system_prompt": system_prompt,
741
+ "user_prompt": user_prompt,
742
+ "response_text": response_text,
743
+ "provider": "claude"
744
+ }
745
+ return ui_metadata, debug_info
746
+
747
+ except ImportError:
748
+ if provider == "claude":
749
+ print(" ❌ Anthropic package not installed", file=sys.stderr)
750
+ return None, None
751
+ print(" ⚠️ Anthropic package not installed, trying OpenAI...", file=sys.stderr)
752
+ except Exception as e:
753
+ logger.error(f"Claude API error: {e}", exc_info=True)
754
+ if provider == "claude":
755
+ print(f" ❌ Claude API error: {e}", file=sys.stderr)
756
+ return None, None
757
+ print(f" ⚠️ Claude failed, trying OpenAI...", file=sys.stderr)
758
+
759
+ # 尝试 OpenAI
760
+ if try_openai and openai_key:
761
+ print(" → Generating UI metadata with OpenAI...", file=sys.stderr)
762
+ try:
763
+ import openai
764
+ client = openai.OpenAI(api_key=openai_key)
765
+
766
+ response = client.chat.completions.create(
767
+ model="gpt-3.5-turbo",
768
+ messages=[
769
+ {"role": "system", "content": system_prompt},
770
+ {"role": "user", "content": user_prompt}
771
+ ],
772
+ max_tokens=1000,
773
+ temperature=0.7
774
+ )
775
+
776
+ response_text = response.choices[0].message.content.strip()
777
+ logger.debug(f"OpenAI response: {response_text}")
778
+
779
+ # 解析 JSON
780
+ json_str = response_text
781
+ if "```json" in response_text:
782
+ json_start = response_text.find("```json") + 7
783
+ json_end = response_text.find("```", json_start)
784
+ if json_end != -1:
785
+ json_str = response_text[json_start:json_end].strip()
786
+ elif "```" in response_text:
787
+ json_start = response_text.find("```") + 3
788
+ json_end = response_text.find("```", json_start)
789
+ if json_end != -1:
790
+ json_str = response_text[json_start:json_end].strip()
791
+
792
+ # 清理控制字符,避免 JSON 解析错误
793
+ import re
794
+ # 移除非法的控制字符,但保留合法的空白字符(空格、制表符、换行)
795
+ json_str = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', json_str)
796
+
797
+ ui_metadata = json.loads(json_str)
798
+ print(" ✅ UI metadata generated successfully with OpenAI", file=sys.stderr)
799
+
800
+ # Return with debug info
801
+ debug_info = {
802
+ "system_prompt": system_prompt,
803
+ "user_prompt": user_prompt,
804
+ "response_text": response_text,
805
+ "provider": "openai"
806
+ }
807
+ return ui_metadata, debug_info
808
+
809
+ except ImportError:
810
+ print(" ❌ OpenAI package not installed", file=sys.stderr)
811
+ return None, None
812
+ except Exception as e:
813
+ logger.error(f"OpenAI API error: {e}", exc_info=True)
814
+ print(f" ❌ OpenAI API error: {e}", file=sys.stderr)
815
+ return None, None
816
+
817
+ # 没有可用的 API
818
+ logger.warning("No LLM API keys available for UI metadata generation")
819
+ print(" ⚠️ No LLM API keys configured, using default UI text", file=sys.stderr)
820
+ return None, None
821
+
822
+
823
+ def save_export_log(
824
+ conversation_data: dict,
825
+ llm_prompts: Optional[dict],
826
+ llm_response: Optional[str],
827
+ username: str,
828
+ shadow_dir: Path
829
+ ) -> Path:
830
+ """
831
+ 保存导出日志
832
+
833
+ Args:
834
+ conversation_data: 分享的对话数据
835
+ llm_prompts: LLM prompts字典 {system_prompt, user_prompt}
836
+ llm_response: LLM的原始回答
837
+ username: 用户名
838
+ shadow_dir: ReAlign目录 (~/.aline/{project}/)
839
+
840
+ Returns:
841
+ 保存的日志文件路径
842
+ """
843
+ # 创建日志目录
844
+ log_dir = shadow_dir / "share" / "export_logs"
845
+ log_dir.mkdir(parents=True, exist_ok=True)
846
+
847
+ # 生成文件名
848
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
849
+ log_file = log_dir / f"{username}_{timestamp}.log.json"
850
+
851
+ # 构建日志内容
852
+ log_content = {
853
+ "export_time": datetime.now().isoformat(),
854
+ "username": username,
855
+ "conversation_data": conversation_data,
856
+ "llm_generation": {
857
+ "prompts": llm_prompts,
858
+ "response": llm_response
859
+ } if llm_prompts else None
860
+ }
861
+
862
+ # 保存
863
+ with open(log_file, 'w', encoding='utf-8') as f:
864
+ json.dump(log_content, f, indent=2, ensure_ascii=False)
865
+
866
+ return log_file
867
+
868
+
869
+ def display_selection_statistics(
870
+ selected_commits: List[UnpushedCommit],
871
+ session_messages: Dict[str, List[dict]]
872
+ ) -> None:
873
+ """
874
+ 显示选择的统计信息
875
+
876
+ Args:
877
+ selected_commits: 选中的commits
878
+ session_messages: 合并后的session消息
879
+ """
880
+ try:
881
+ from rich.console import Console
882
+ from rich.panel import Panel
883
+ except ImportError:
884
+ # Fallback to plain text if rich is not available
885
+ print(f"\n📊 Selection Summary:")
886
+ print(f" Commits: {len(selected_commits)}")
887
+ print(f" Sessions: {len(session_messages)}")
888
+ total_messages = sum(len(msgs) for msgs in session_messages.values())
889
+ print(f" Messages: {total_messages}")
890
+ return
891
+
892
+ console = Console()
893
+
894
+ # 计算统计信息
895
+ total_sessions = len(session_messages)
896
+ total_messages = sum(len(msgs) for msgs in session_messages.values())
897
+
898
+ # 消息角色分布
899
+ user_messages = 0
900
+ assistant_messages = 0
901
+ other_messages = 0
902
+
903
+ for messages in session_messages.values():
904
+ for msg in messages:
905
+ role = msg.get('role', 'unknown')
906
+ if role == 'user':
907
+ user_messages += 1
908
+ elif role == 'assistant':
909
+ assistant_messages += 1
910
+ else:
911
+ other_messages += 1
912
+
913
+ # 时间范围
914
+ all_timestamps = []
915
+ for messages in session_messages.values():
916
+ for msg in messages:
917
+ if 'timestamp' in msg:
918
+ ts_str = msg['timestamp']
919
+ try:
920
+ ts = datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
921
+ all_timestamps.append(ts)
922
+ except:
923
+ pass
924
+
925
+ time_info = ""
926
+ if all_timestamps:
927
+ earliest = min(all_timestamps)
928
+ latest = max(all_timestamps)
929
+ duration = latest - earliest
930
+
931
+ time_info = f"""
932
+ [bold]Time Range:[/bold]
933
+ {earliest.strftime('%Y-%m-%d %H:%M')} → {latest.strftime('%Y-%m-%d %H:%M')}
934
+ Duration: {duration.days}d {duration.seconds//3600}h {(duration.seconds%3600)//60}m
935
+ """
936
+
937
+ # 构建统计文本
938
+ stats_text = f"""[bold cyan]Commits Selected:[/bold cyan] {len(selected_commits)}
939
+
940
+ [bold green]Sessions:[/bold green] {total_sessions}
941
+
942
+ [bold yellow]Messages:[/bold yellow] {total_messages}
943
+ ├─ User: {user_messages}
944
+ ├─ Assistant: {assistant_messages}"""
945
+
946
+ if other_messages > 0:
947
+ stats_text += f"\n └─ Other: {other_messages}"
948
+
949
+ if time_info:
950
+ stats_text += "\n" + time_info
951
+
952
+ panel = Panel(
953
+ stats_text,
954
+ title="[bold]📊 Selection Summary[/bold]",
955
+ border_style="cyan",
956
+ padding=(1, 2)
957
+ )
958
+ console.print(panel)
959
+
960
+
961
+ def display_session_preview(
962
+ session_messages: Dict[str, List[dict]],
963
+ max_sessions: int = 10
964
+ ) -> None:
965
+ """
966
+ 显示session预览
967
+
968
+ Args:
969
+ session_messages: {session_id: [messages]}
970
+ max_sessions: 最多显示的session数量
971
+ """
972
+ try:
973
+ from rich.console import Console
974
+ from rich.table import Table
975
+ except ImportError:
976
+ # Fallback to plain text
977
+ print(f"\n📝 Sessions to be shared ({len(session_messages)} total):")
978
+ for i, (session_id, messages) in enumerate(list(session_messages.items())[:max_sessions], 1):
979
+ print(f" {i}. {session_id[-25:] if len(session_id) > 25 else session_id} ({len(messages)} messages)")
980
+ if len(session_messages) > max_sessions:
981
+ print(f" ... and {len(session_messages) - max_sessions} more sessions")
982
+ return
983
+
984
+ console = Console()
985
+
986
+ total_sessions = len(session_messages)
987
+ showing = min(total_sessions, max_sessions)
988
+
989
+ table = Table(
990
+ title=f"📝 Session Preview (showing {showing} of {total_sessions})"
991
+ )
992
+ table.add_column("#", style="dim", width=4)
993
+ table.add_column("Session ID", style="cyan", width=25)
994
+ table.add_column("Msgs", justify="right", width=6)
995
+ table.add_column("First User Message", style="dim", no_wrap=False)
996
+
997
+ for i, (session_id, messages) in enumerate(
998
+ list(session_messages.items())[:max_sessions], 1
999
+ ):
1000
+ # 获取第一条用户消息
1001
+ first_msg_preview = "[No user messages]"
1002
+ for msg in messages[:10]:
1003
+ if msg.get('role') == 'user':
1004
+ content = msg.get('content', '')
1005
+ if isinstance(content, str) and content.strip():
1006
+ first_msg_preview = content[:60]
1007
+ if len(content) > 60:
1008
+ first_msg_preview += "..."
1009
+ break
1010
+
1011
+ # 截断session ID用于显示
1012
+ session_display = session_id[-25:] if len(session_id) > 25 else session_id
1013
+
1014
+ table.add_row(
1015
+ str(i),
1016
+ session_display,
1017
+ str(len(messages)),
1018
+ first_msg_preview
1019
+ )
1020
+
1021
+ if total_sessions > max_sessions:
1022
+ table.add_row(
1023
+ "...",
1024
+ f"[{total_sessions - max_sessions} more sessions]",
1025
+ "...",
1026
+ "..."
1027
+ )
1028
+
1029
+ console.print(table)
1030
+
1031
+
1032
+ def save_preview_json(
1033
+ conversation_data: dict,
1034
+ username: str
1035
+ ) -> Path:
1036
+ """
1037
+ 保存预览JSON到临时位置
1038
+
1039
+ Args:
1040
+ conversation_data: 完整的对话数据
1041
+ username: 用户名(用于文件名)
1042
+
1043
+ Returns:
1044
+ Path: 保存的预览文件路径
1045
+ """
1046
+ import tempfile
1047
+
1048
+ # 创建临时目录
1049
+ temp_dir = Path(tempfile.gettempdir()) / "aline_previews"
1050
+ temp_dir.mkdir(exist_ok=True, parents=True)
1051
+
1052
+ # 生成带时间戳的文件名
1053
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
1054
+ preview_path = temp_dir / f"{username}_preview_{timestamp}.json"
1055
+
1056
+ # 保存并格式化
1057
+ with open(preview_path, 'w', encoding='utf-8') as f:
1058
+ json.dump(conversation_data, f, indent=2, ensure_ascii=False)
1059
+
1060
+ return preview_path
1061
+
1062
+
1063
+ def display_ui_metadata_preview(ui_metadata: dict) -> None:
1064
+ """
1065
+ 显示UI metadata预览
1066
+
1067
+ Args:
1068
+ ui_metadata: UI metadata字典
1069
+ """
1070
+ try:
1071
+ from rich.console import Console
1072
+ from rich.panel import Panel
1073
+ except ImportError:
1074
+ # Fallback to plain text
1075
+ print("\n🎨 Generated UI Content:")
1076
+ print(f" Title: {ui_metadata.get('title', '[Not generated]')}")
1077
+ print(f" Welcome: {ui_metadata.get('welcome', '[Not generated]')}")
1078
+ print(f" Description: {ui_metadata.get('description', '[Not generated]')}")
1079
+ questions = ui_metadata.get('preset_questions', [])
1080
+ if questions:
1081
+ print(" Preset Questions:")
1082
+ for i, q in enumerate(questions, 1):
1083
+ print(f" {i}. {q}")
1084
+ return
1085
+
1086
+ console = Console()
1087
+
1088
+ console.print("\n[bold cyan]🎨 Generated UI Content[/bold cyan]\n")
1089
+
1090
+ # Title
1091
+ console.print(Panel(
1092
+ ui_metadata.get('title', '[Not generated]'),
1093
+ title="[bold]Title[/bold]",
1094
+ border_style="green",
1095
+ padding=(1, 2)
1096
+ ))
1097
+
1098
+ # Welcome message
1099
+ console.print(Panel(
1100
+ ui_metadata.get('welcome', '[Not generated]'),
1101
+ title="[bold]Welcome Message[/bold]",
1102
+ border_style="blue",
1103
+ padding=(1, 2)
1104
+ ))
1105
+
1106
+ # Description
1107
+ console.print(Panel(
1108
+ ui_metadata.get('description', '[Not generated]'),
1109
+ title="[bold]Description[/bold]",
1110
+ border_style="yellow",
1111
+ padding=(1, 2)
1112
+ ))
1113
+
1114
+ # Preset questions
1115
+ questions = ui_metadata.get('preset_questions', [])
1116
+ if questions:
1117
+ questions_text = "\n".join(f"{i+1}. {q}" for i, q in enumerate(questions))
1118
+ else:
1119
+ questions_text = "[No questions generated]"
1120
+
1121
+ console.print(Panel(
1122
+ questions_text,
1123
+ title="[bold]Preset Questions[/bold]",
1124
+ border_style="magenta",
1125
+ padding=(1, 2)
1126
+ ))
1127
+
1128
+
1129
+ def prompt_ui_metadata_editing(ui_metadata: dict) -> dict:
1130
+ """
1131
+ 提示用户确认或编辑UI metadata
1132
+
1133
+ Args:
1134
+ ui_metadata: 生成的metadata
1135
+
1136
+ Returns:
1137
+ dict: 更新后的metadata
1138
+ """
1139
+ try:
1140
+ from rich.console import Console
1141
+ from rich.prompt import Prompt, Confirm
1142
+ except ImportError:
1143
+ # Fallback: just return original if rich is not available
1144
+ print("\n💡 Rich library not available for interactive editing. Using generated content.")
1145
+ return ui_metadata
1146
+
1147
+ console = Console()
1148
+
1149
+ # 询问是否要编辑
1150
+ console.print()
1151
+ if not Confirm.ask(
1152
+ "[yellow]Would you like to review and edit this content?[/yellow]",
1153
+ default=False
1154
+ ):
1155
+ console.print("[green]✓ Using generated content as-is[/green]")
1156
+ return ui_metadata
1157
+
1158
+ console.print("\n[dim]Press Enter to keep current value, or type new value:[/dim]\n")
1159
+
1160
+ edited = {}
1161
+
1162
+ # Title
1163
+ console.print(f"[bold cyan]Title:[/bold cyan]")
1164
+ console.print(f" Current: {ui_metadata.get('title', '')}")
1165
+ new_title = Prompt.ask(" New value", default="")
1166
+ edited['title'] = new_title if new_title else ui_metadata.get('title', '')
1167
+
1168
+ # Welcome
1169
+ console.print(f"\n[bold cyan]Welcome Message:[/bold cyan]")
1170
+ console.print(f" Current: {ui_metadata.get('welcome', '')}")
1171
+ new_welcome = Prompt.ask(" New value", default="")
1172
+ edited['welcome'] = new_welcome if new_welcome else ui_metadata.get('welcome', '')
1173
+
1174
+ # Description
1175
+ console.print(f"\n[bold cyan]Description:[/bold cyan]")
1176
+ console.print(f" Current: {ui_metadata.get('description', '')}")
1177
+ new_desc = Prompt.ask(" New value", default="")
1178
+ edited['description'] = new_desc if new_desc else ui_metadata.get('description', '')
1179
+
1180
+ # Questions
1181
+ questions = ui_metadata.get('preset_questions', [])
1182
+ edited['preset_questions'] = []
1183
+
1184
+ console.print(f"\n[bold cyan]Preset Questions:[/bold cyan]")
1185
+ for i, q in enumerate(questions, 1):
1186
+ console.print(f" Question {i}: {q}")
1187
+ new_q = Prompt.ask(f" New value", default="")
1188
+ edited['preset_questions'].append(new_q if new_q else q)
1189
+
1190
+ console.print("\n[green]✓ Content updated[/green]")
1191
+ return edited
1192
+
1193
+
1194
+ def display_share_result(
1195
+ share_url: str,
1196
+ password: str,
1197
+ expiry_days: int,
1198
+ max_views: int,
1199
+ admin_token: Optional[str] = None
1200
+ ) -> None:
1201
+ """
1202
+ 显示分享创建结果
1203
+
1204
+ Args:
1205
+ share_url: 分享URL
1206
+ password: 加密密码
1207
+ expiry_days: 过期天数
1208
+ max_views: 最大浏览次数
1209
+ admin_token: 管理员token(可选)
1210
+ """
1211
+ try:
1212
+ from rich.console import Console
1213
+ from rich.panel import Panel
1214
+ from rich.text import Text
1215
+ except ImportError:
1216
+ # Fallback to plain text
1217
+ print("\n╭────────────────────────────────────────╮")
1218
+ print("│ ✅ Share Created Successfully! │")
1219
+ print("╰────────────────────────────────────────╯\n")
1220
+ print(f"🔗 Share URL: {share_url}")
1221
+ print(f"🔑 Password: {password}")
1222
+ print(f"📅 Expires: {expiry_days} days")
1223
+ print(f"👁️ Max views: {max_views}")
1224
+ if admin_token:
1225
+ print(f"\n📊 Admin token: {admin_token}")
1226
+ print(f" View stats at: {share_url}/stats?token={admin_token}")
1227
+ print("\n💡 Share this URL and password with your team!")
1228
+ return
1229
+
1230
+ console = Console()
1231
+
1232
+ # 成功标题
1233
+ console.print("\n")
1234
+ success_text = Text("✅ Share Created Successfully!", style="bold green")
1235
+ success_panel = Panel(
1236
+ success_text,
1237
+ border_style="green",
1238
+ padding=(1, 4)
1239
+ )
1240
+ console.print(success_panel)
1241
+
1242
+ # 分享详情
1243
+ share_info = f"""[bold cyan]🔗 Share URL:[/bold cyan]
1244
+ {share_url}
1245
+
1246
+ [bold yellow]🔑 Password:[/bold yellow]
1247
+ {password}
1248
+
1249
+ [bold blue]📅 Expires:[/bold blue] {expiry_days} days from now
1250
+ [bold magenta]👁️ Max Views:[/bold magenta] {max_views} views"""
1251
+
1252
+ info_panel = Panel(
1253
+ share_info,
1254
+ title="[bold white]Share Details[/bold white]",
1255
+ border_style="cyan",
1256
+ padding=(1, 2)
1257
+ )
1258
+ console.print(info_panel)
1259
+
1260
+ # Admin token (如果有)
1261
+ if admin_token:
1262
+ admin_info = f"""[bold]Admin Token:[/bold]
1263
+ {admin_token}
1264
+
1265
+ [bold]Stats URL:[/bold]
1266
+ {share_url}/stats?token={admin_token}
1267
+
1268
+ [dim]Use this to view access statistics and manage the share[/dim]"""
1269
+
1270
+ admin_panel = Panel(
1271
+ admin_info,
1272
+ title="[bold yellow]Admin Access[/bold yellow]",
1273
+ border_style="yellow",
1274
+ padding=(1, 2)
1275
+ )
1276
+ console.print(admin_panel)
1277
+
1278
+ # 使用说明
1279
+ instructions = """[bold green]How to Share:[/bold green]
1280
+
1281
+ 1. Copy the URL and password above
1282
+ 2. Send them to your team (separately for security)
1283
+ 3. Recipients can access the interactive chatbot
1284
+ 4. They'll need the password to decrypt
1285
+
1286
+ [dim]💡 Tip: Send URL via Slack, password via DM[/dim]"""
1287
+
1288
+ instructions_panel = Panel(
1289
+ instructions,
1290
+ title="[bold white]Next Steps[/bold white]",
1291
+ border_style="green",
1292
+ padding=(1, 2)
1293
+ )
1294
+ console.print(instructions_panel)
1295
+ console.print()
1296
+
1297
+
1298
+ def export_shares_interactive_command(
1299
+ indices: Optional[str] = None,
1300
+ password: Optional[str] = None,
1301
+ expiry_days: int = 7,
1302
+ max_views: int = 100,
1303
+ enable_preview: bool = True,
1304
+ backend_url: Optional[str] = None,
1305
+ repo_root: Optional[Path] = None,
1306
+ preset: Optional[str] = None,
1307
+ enable_mcp: bool = True
1308
+ ) -> int:
1309
+ """
1310
+ 交互式导出对话历史并生成分享链接
1311
+
1312
+ Args:
1313
+ indices: Commit indices to export
1314
+ password: 加密密码 (如果为 None 则自动生成)
1315
+ expiry_days: 过期天数
1316
+ max_views: 最大访问次数
1317
+ enable_preview: 是否启用UI预览和编辑 (默认: True)
1318
+ backend_url: 后端服务器 URL
1319
+ repo_root: 项目根目录
1320
+ preset: Prompt preset ID (如果为 None 则交互式选择)
1321
+ enable_mcp: 是否启用MCP agent-to-agent通信 (默认: True)
1322
+
1323
+ Returns:
1324
+ 0 on success, 1 on error
1325
+ """
1326
+ logger.info("======== Interactive export shares command started ========")
1327
+
1328
+ # Check dependencies
1329
+ if not CRYPTO_AVAILABLE:
1330
+ print("❌ Error: cryptography package not installed", file=sys.stderr)
1331
+ print("Install it with: pip install cryptography", file=sys.stderr)
1332
+ return 1
1333
+
1334
+ if not HTTPX_AVAILABLE:
1335
+ print("❌ Error: httpx package not installed", file=sys.stderr)
1336
+ print("Install it with: pip install httpx", file=sys.stderr)
1337
+ return 1
1338
+
1339
+ # Get backend URL
1340
+ if backend_url is None:
1341
+ # Try to load from config
1342
+ from ..config import ReAlignConfig
1343
+ if repo_root is None:
1344
+ try:
1345
+ result = subprocess.run(
1346
+ ["git", "rev-parse", "--show-toplevel"],
1347
+ capture_output=True,
1348
+ text=True,
1349
+ check=True
1350
+ )
1351
+ repo_root = Path(result.stdout.strip())
1352
+ except subprocess.CalledProcessError:
1353
+ repo_root = Path.cwd()
1354
+
1355
+ config = ReAlignConfig.load()
1356
+ backend_url = config.share_backend_url
1357
+
1358
+ print("\n╭────────────────────────────────────────╮")
1359
+ print("│ ReAlign Interactive Share Export │")
1360
+ print("╰────────────────────────────────────────╯\n")
1361
+
1362
+ # Step 1: Select commits (reuse existing logic)
1363
+ if repo_root is None:
1364
+ try:
1365
+ result = subprocess.run(
1366
+ ["git", "rev-parse", "--show-toplevel"],
1367
+ capture_output=True,
1368
+ text=True,
1369
+ check=True
1370
+ )
1371
+ repo_root = Path(result.stdout.strip())
1372
+ except subprocess.CalledProcessError:
1373
+ print("Error: Not in a git repository", file=sys.stderr)
1374
+ return 1
1375
+
1376
+ from .. import get_realign_dir
1377
+ shadow_dir = get_realign_dir(repo_root)
1378
+ shadow_git = shadow_dir
1379
+
1380
+ if not shadow_git.exists() or not (shadow_git / '.git').exists():
1381
+ print(f"Error: ReAlign not initialized. Run 'aline init' first.", file=sys.stderr)
1382
+ return 1
1383
+
1384
+ # Get commits
1385
+ try:
1386
+ all_commits = get_unpushed_commits(shadow_git)
1387
+ except Exception as e:
1388
+ print(f"Error: Failed to get commits: {e}", file=sys.stderr)
1389
+ return 1
1390
+
1391
+ if not all_commits:
1392
+ print("No unpushed commits found. Nothing to export.", file=sys.stderr)
1393
+ return 1
1394
+
1395
+ # Display and select commits
1396
+ if indices is None:
1397
+ display_commits_for_selection(all_commits)
1398
+ print("Enter commit indices to export (e.g., '1,3,5-7' or 'all'):")
1399
+ indices = input("Indices: ").strip()
1400
+
1401
+ if not indices:
1402
+ print("No commits selected. Exiting.")
1403
+ return 0
1404
+
1405
+ # Parse indices
1406
+ try:
1407
+ if indices.lower() == "all":
1408
+ indices_list = [c.index for c in all_commits]
1409
+ else:
1410
+ indices_list = parse_commit_indices(indices)
1411
+ except ValueError as e:
1412
+ print(f"Error: Invalid indices format: {e}", file=sys.stderr)
1413
+ return 1
1414
+
1415
+ # Get selected commits
1416
+ selected_commits = [c for c in all_commits if c.index in indices_list]
1417
+
1418
+ # Step 2: Extract messages
1419
+ print(f"\n🔄 Extracting chat history from {len(selected_commits)} commit(s)...")
1420
+ try:
1421
+ session_messages = merge_messages_from_commits(selected_commits, shadow_git)
1422
+ except Exception as e:
1423
+ print(f"\nError: Failed to extract messages: {e}", file=sys.stderr)
1424
+ return 1
1425
+
1426
+ if not session_messages:
1427
+ print("\nWarning: No chat history found in selected commits.", file=sys.stderr)
1428
+ return 1
1429
+
1430
+ # Build conversation data
1431
+ username = os.environ.get('USER') or os.environ.get('USERNAME') or 'anonymous'
1432
+ conversation_data = {
1433
+ "username": username,
1434
+ "time": datetime.now().isoformat(),
1435
+ "sessions": [
1436
+ {
1437
+ "session_id": session_id,
1438
+ "messages": messages
1439
+ }
1440
+ for session_id, messages in session_messages.items()
1441
+ ]
1442
+ }
1443
+
1444
+ total_messages = sum(len(msgs) for msgs in session_messages.values())
1445
+ print(f"✅ Extracted {len(session_messages)} session(s) with {total_messages} messages")
1446
+
1447
+ # NEW: Display selection statistics
1448
+ print()
1449
+ display_selection_statistics(selected_commits, session_messages)
1450
+
1451
+ # NEW: Display session preview
1452
+ print()
1453
+ display_session_preview(session_messages)
1454
+
1455
+ # NEW: Save preview JSON
1456
+ try:
1457
+ preview_path = save_preview_json(conversation_data, username)
1458
+ try:
1459
+ from rich.console import Console
1460
+ console = Console()
1461
+ console.print(f"\n💾 Preview saved: [cyan]{preview_path}[/cyan]")
1462
+ console.print(" You can inspect the full data before encryption.\n")
1463
+ except ImportError:
1464
+ print(f"\n💾 Preview saved: {preview_path}")
1465
+ print(" You can inspect the full data before encryption.\n")
1466
+ except Exception as e:
1467
+ logger.warning(f"Failed to save preview JSON: {e}")
1468
+ # Don't fail the export if preview save fails
1469
+
1470
+ # Step 3: Select prompt preset
1471
+ from ..prompts import get_all_presets, get_preset_by_id, display_preset_menu, prompt_for_custom_instructions
1472
+
1473
+ selected_preset = None
1474
+ custom_instructions = ""
1475
+
1476
+ if preset:
1477
+ # Use preset specified via command line
1478
+ selected_preset = get_preset_by_id(preset)
1479
+ if not selected_preset:
1480
+ print(f"\n❌ Error: Preset '{preset}' not found", file=sys.stderr)
1481
+ print("Available presets:", file=sys.stderr)
1482
+ all_presets = get_all_presets()
1483
+ for p in all_presets:
1484
+ print(f" - {p.id}: {p.name}", file=sys.stderr)
1485
+ return 1
1486
+ print(f"\n✓ Using preset: {selected_preset.name} ({selected_preset.id})")
1487
+ else:
1488
+ # Interactive preset selection
1489
+ all_presets = get_all_presets()
1490
+ print(display_preset_menu(all_presets))
1491
+
1492
+ while True:
1493
+ try:
1494
+ selection = input("Enter preset number or ID [1]: ").strip()
1495
+ if not selection:
1496
+ selection = "1"
1497
+
1498
+ # Try parsing as index first
1499
+ try:
1500
+ idx = int(selection)
1501
+ from ..prompts import get_preset_by_index
1502
+ selected_preset = get_preset_by_index(idx)
1503
+ if not selected_preset:
1504
+ print(f"Invalid index. Please enter 1-{len(all_presets)}")
1505
+ continue
1506
+ except ValueError:
1507
+ # Not a number, try as ID
1508
+ selected_preset = get_preset_by_id(selection)
1509
+ if not selected_preset:
1510
+ print(f"Invalid preset ID: {selection}")
1511
+ continue
1512
+
1513
+ print(f"\n✓ Selected: {selected_preset.name}")
1514
+ break
1515
+
1516
+ except KeyboardInterrupt:
1517
+ print("\n\nExport cancelled.")
1518
+ return 0
1519
+
1520
+ # Collect custom instructions if allowed
1521
+ if selected_preset.allow_custom_instructions:
1522
+ custom_instructions = prompt_for_custom_instructions(selected_preset)
1523
+
1524
+ # Step 4: Generate UI metadata with LLM
1525
+ print("\n🤖 Generating personalized UI content...")
1526
+ from ..config import ReAlignConfig
1527
+ config = ReAlignConfig.load()
1528
+ ui_metadata, llm_debug_info = generate_ui_metadata_with_llm(
1529
+ conversation_data,
1530
+ selected_commits,
1531
+ provider=config.llm_provider,
1532
+ preset_id=selected_preset.id
1533
+ )
1534
+
1535
+ # NEW: Display and optionally edit UI metadata
1536
+ if ui_metadata:
1537
+ display_ui_metadata_preview(ui_metadata)
1538
+
1539
+ # Only prompt for editing if enable_preview is True
1540
+ if enable_preview:
1541
+ try:
1542
+ ui_metadata = prompt_ui_metadata_editing(ui_metadata)
1543
+
1544
+ # Show final version
1545
+ try:
1546
+ from rich.console import Console
1547
+ console = Console()
1548
+ console.print("\n[bold green]✅ Final UI Content:[/bold green]")
1549
+ except ImportError:
1550
+ print("\n✅ Final UI Content:")
1551
+ display_ui_metadata_preview(ui_metadata)
1552
+ except KeyboardInterrupt:
1553
+ # User cancelled during editing
1554
+ try:
1555
+ from rich.prompt import Confirm
1556
+ print("\n")
1557
+ if Confirm.ask("[yellow]Cancel export?[/yellow]", default=False):
1558
+ print("Export cancelled.")
1559
+ return 0
1560
+ else:
1561
+ print("Continuing with generated content...")
1562
+ except ImportError:
1563
+ print("\nContinuing with generated content...")
1564
+
1565
+ # Add UI metadata with preset information
1566
+ if ui_metadata is None:
1567
+ ui_metadata = {}
1568
+ ui_metadata["prompt_preset"] = {
1569
+ "id": selected_preset.id,
1570
+ "name": selected_preset.name,
1571
+ "custom_instructions": custom_instructions
1572
+ }
1573
+ conversation_data["ui_metadata"] = ui_metadata
1574
+
1575
+ # Debug logging
1576
+ logger.info(f"Added preset to ui_metadata: id={selected_preset.id}, custom_instructions={custom_instructions[:100] if custom_instructions else 'None'}")
1577
+ print(f"\n🔍 Debug: Preset info added - ID: {selected_preset.id}, Custom instructions: '{custom_instructions[:50]}{'...' if len(custom_instructions) > 50 else ''}'", file=sys.stderr)
1578
+ else:
1579
+ # Use default UI metadata if LLM generation failed
1580
+ try:
1581
+ from rich.console import Console
1582
+ console = Console()
1583
+ console.print("[yellow]⚠️ Using default UI content[/yellow]")
1584
+ except ImportError:
1585
+ print("⚠️ Using default UI content")
1586
+ # Still add preset information
1587
+ conversation_data["ui_metadata"] = {
1588
+ "prompt_preset": {
1589
+ "id": selected_preset.id,
1590
+ "name": selected_preset.name,
1591
+ "custom_instructions": custom_instructions
1592
+ }
1593
+ }
1594
+
1595
+ # Add MCP instructions if enabled
1596
+ if enable_mcp:
1597
+ conversation_data["ui_metadata"]["mcp_instructions"] = {
1598
+ "tool_name": "ask_shared_conversation",
1599
+ "usage": "Local AI agents can install the aline MCP server and use the 'ask_shared_conversation' tool to query this conversation programmatically.",
1600
+ "installation": {
1601
+ "step1": "Install aline: pip install aline",
1602
+ "step2": "Add to claude_desktop_config.json:",
1603
+ "config": {
1604
+ "mcpServers": {
1605
+ "aline": {
1606
+ "command": "aline-mcp"
1607
+ }
1608
+ }
1609
+ },
1610
+ "step3": "Restart Claude Desktop"
1611
+ },
1612
+ "example_usage": "Ask your local Claude agent: 'Use the ask_shared_conversation tool to query this URL with question: ...'"
1613
+ }
1614
+ logger.info("MCP instructions added to ui_metadata")
1615
+
1616
+ # NEW: Save export log
1617
+ try:
1618
+ log_file = save_export_log(
1619
+ conversation_data=conversation_data,
1620
+ llm_prompts={
1621
+ "system_prompt": llm_debug_info["system_prompt"],
1622
+ "user_prompt": llm_debug_info["user_prompt"]
1623
+ } if llm_debug_info else None,
1624
+ llm_response=llm_debug_info["response_text"] if llm_debug_info else None,
1625
+ username=username,
1626
+ shadow_dir=shadow_git
1627
+ )
1628
+ logger.info(f"Export log saved to: {log_file}")
1629
+ try:
1630
+ from rich.console import Console
1631
+ console = Console()
1632
+ console.print(f"\n📝 Export log saved: [cyan]{log_file}[/cyan]\n")
1633
+ except ImportError:
1634
+ print(f"\n📝 Export log saved: {log_file}\n")
1635
+ except Exception as e:
1636
+ logger.warning(f"Failed to save export log: {e}")
1637
+ # Don't fail the export if log save fails
1638
+
1639
+ # Step 4: Ask if user wants password protection
1640
+ use_password = True # Default
1641
+ if password is None:
1642
+ try:
1643
+ from rich.prompt import Confirm
1644
+ print()
1645
+ use_password = Confirm.ask(
1646
+ "[yellow]🔐 Would you like to protect this share with a password?[/yellow]",
1647
+ default=False
1648
+ )
1649
+ except ImportError:
1650
+ # Fallback: ask with plain input
1651
+ print("\n🔐 Would you like to protect this share with a password? (y/N): ", end='')
1652
+ response = input().strip().lower()
1653
+ use_password = (response == 'y' or response == 'yes')
1654
+
1655
+ # Step 5: Generate password or skip encryption
1656
+ encrypted_payload = None
1657
+ if use_password:
1658
+ if password is None:
1659
+ # Generate a random password
1660
+ password = secrets.token_urlsafe(16)
1661
+ print(f"\n🔐 Generated password: {password}")
1662
+ print("⚠️ Save this password - you'll need it to access the share!")
1663
+
1664
+ # Encrypt data
1665
+ print("\n🔒 Encrypting conversation data...")
1666
+ try:
1667
+ encrypted_payload = encrypt_conversation_data(conversation_data, password)
1668
+ except Exception as e:
1669
+ print(f"\nError: Failed to encrypt data: {e}", file=sys.stderr)
1670
+ logger.error(f"Encryption failed: {e}", exc_info=True)
1671
+ return 1
1672
+
1673
+ print("✅ Encryption complete")
1674
+ else:
1675
+ print("\n📂 No password protection - data will be accessible to anyone with the link")
1676
+ password = None
1677
+
1678
+ # Step 6: Upload to backend
1679
+ print(f"\n📤 Uploading to {backend_url}...")
1680
+
1681
+ metadata = {
1682
+ "username": username,
1683
+ "expiry_days": expiry_days,
1684
+ "max_views": max_views
1685
+ }
1686
+
1687
+ try:
1688
+ # Prepare payload based on whether encryption was used
1689
+ if encrypted_payload:
1690
+ payload = {"encrypted_payload": encrypted_payload, "metadata": metadata}
1691
+ else:
1692
+ # No encryption - send conversation data directly
1693
+ payload = {"conversation_data": conversation_data, "metadata": metadata}
1694
+
1695
+ response = httpx.post(
1696
+ f"{backend_url}/api/share/create",
1697
+ json=payload,
1698
+ timeout=30.0
1699
+ )
1700
+ response.raise_for_status()
1701
+ result = response.json()
1702
+ except Exception as e:
1703
+ print(f"\n❌ Upload failed: {e}", file=sys.stderr)
1704
+ logger.error(f"Upload failed: {e}", exc_info=True)
1705
+ return 1
1706
+
1707
+ # Step 7: Display success with beautiful formatting
1708
+ display_share_result(
1709
+ share_url=result['share_url'],
1710
+ password=password,
1711
+ expiry_days=expiry_days,
1712
+ max_views=max_views,
1713
+ admin_token=result.get('admin_token')
1714
+ )
1715
+
1716
+ # Display MCP setup instructions if enabled
1717
+ if enable_mcp:
1718
+ try:
1719
+ from rich.console import Console
1720
+ from rich.panel import Panel
1721
+ console = Console()
1722
+
1723
+ mcp_instructions = f"""[bold green]🤖 MCP Access Enabled![/bold green]
1724
+
1725
+ This share can be queried by AI agents using the Model Context Protocol.
1726
+
1727
+ [bold]Installation:[/bold]
1728
+
1729
+ 1. Install aline (if not already installed):
1730
+ [cyan]pip install aline[/cyan]
1731
+
1732
+ 2. Add to Claude Desktop config:
1733
+ [dim]~/.config/Claude/claude_desktop_config.json (Linux/Mac)
1734
+ or %APPDATA%/Claude/claude_desktop_config.json (Windows)[/dim]
1735
+
1736
+ {{
1737
+ "mcpServers": {{
1738
+ "aline": {{
1739
+ "command": "aline-mcp"
1740
+ }}
1741
+ }}
1742
+ }}
1743
+
1744
+ 3. Restart Claude Desktop
1745
+
1746
+ [bold]Usage Example:[/bold]
1747
+
1748
+ In Claude Desktop, say:
1749
+ [cyan]"Use the ask_shared_conversation tool to query this URL:
1750
+ {result['share_url']}
1751
+
1752
+ Question: What were the main topics discussed?"[/cyan]
1753
+
1754
+ {f'[yellow]Password: {password}[/yellow]' if password else '[dim](No password required)[/dim]'}
1755
+
1756
+ [dim]💡 Tip: Agents can now directly query this conversation without human intervention![/dim]"""
1757
+
1758
+ mcp_panel = Panel(
1759
+ mcp_instructions,
1760
+ title="[bold magenta]Agent-to-Agent Communication[/bold magenta]",
1761
+ border_style="magenta",
1762
+ padding=(1, 2)
1763
+ )
1764
+ console.print()
1765
+ console.print(mcp_panel)
1766
+ console.print()
1767
+
1768
+ except ImportError:
1769
+ # Fallback to plain text
1770
+ print("\n" + "="*60)
1771
+ print("🤖 MCP ACCESS ENABLED")
1772
+ print("="*60)
1773
+ print("\nThis share can be queried by AI agents programmatically.")
1774
+ print("\nSetup:")
1775
+ print("1. pip install aline")
1776
+ print("2. Add to claude_desktop_config.json:")
1777
+ print(' {"mcpServers": {"aline": {"command": "aline-mcp"}}}')
1778
+ print("3. Restart Claude Desktop")
1779
+ print(f"\nShare URL: {result['share_url']}")
1780
+ if password:
1781
+ print(f"Password: {password}")
1782
+ print("\n" + "="*60 + "\n")
1783
+
1784
+ logger.info(f"======== Interactive export completed: {result['share_url']} ========")
1785
+ return 0