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.
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
- aline_ai-0.3.0.dist-info/RECORD +41 -0
- aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
- realign/__init__.py +32 -1
- realign/cli.py +203 -19
- realign/commands/__init__.py +2 -2
- realign/commands/clean.py +149 -0
- realign/commands/config.py +1 -1
- realign/commands/export_shares.py +1785 -0
- realign/commands/hide.py +112 -24
- realign/commands/import_history.py +873 -0
- realign/commands/init.py +104 -217
- realign/commands/mirror.py +131 -0
- realign/commands/pull.py +101 -0
- realign/commands/push.py +155 -245
- realign/commands/review.py +216 -54
- realign/commands/session_utils.py +139 -4
- realign/commands/share.py +965 -0
- realign/commands/status.py +559 -0
- realign/commands/sync.py +91 -0
- realign/commands/undo.py +423 -0
- realign/commands/watcher.py +805 -0
- realign/config.py +21 -10
- realign/file_lock.py +3 -1
- realign/hash_registry.py +310 -0
- realign/hooks.py +115 -411
- realign/logging_config.py +2 -2
- realign/mcp_server.py +263 -549
- realign/mcp_watcher.py +997 -139
- realign/mirror_utils.py +322 -0
- realign/prompts/__init__.py +21 -0
- realign/prompts/presets.py +238 -0
- realign/redactor.py +168 -16
- realign/tracker/__init__.py +9 -0
- realign/tracker/git_tracker.py +1123 -0
- realign/watcher_daemon.py +115 -0
- aline_ai-0.2.6.dist-info/RECORD +0 -28
- aline_ai-0.2.6.dist-info/entry_points.txt +0 -5
- realign/commands/auto_commit.py +0 -242
- realign/commands/commit.py +0 -379
- realign/commands/search.py +0 -449
- realign/commands/show.py +0 -416
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {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
|