jp.keijiro.ai.assistant.extensions 1.1.3 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.attestation.p7m CHANGED
Binary file
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.1] - 2026-06-05
9
+
10
+ ### Changed
11
+
12
+ - Prefixed saved conversation filenames with the conversation's date and time so exported files sort chronologically.
13
+
14
+ ## [1.2.0] - 2026-06-05
15
+
16
+ ### Changed
17
+
18
+ - Reworked the Conversation Extractor to read conversations through the AI Assistant in-process API instead of parsing the relay log.
19
+ - Rebuilt the Conversation Extractor window with UI Toolkit, adding a resizable conversation list, per-conversation selection, a scrollable read-only transcript, and a toggle for including tool calls.
20
+ - Updated project and package metadata for AI Assistant 2.11.0-pre.1.
21
+
8
22
  ## [1.1.3] - 2026-05-15
9
23
 
10
24
  ### Fixed
@@ -1,227 +1,580 @@
1
1
  using System;
2
+ using System.Collections;
2
3
  using System.Collections.Generic;
3
4
  using System.IO;
5
+ using System.Linq;
6
+ using System.Reflection;
4
7
  using System.Text;
5
- using System.Text.RegularExpressions;
8
+ using System.Threading;
9
+ using System.Threading.Tasks;
6
10
  using UnityEditor;
11
+ using UnityEditor.UIElements;
7
12
  using UnityEngine;
13
+ using UnityEngine.UIElements;
8
14
 
9
15
  namespace AIAssistantExtensions {
10
16
 
17
+ // Lists the (Unity-hosted) conversations from the AI Assistant package's in-process
18
+ // API and extracts whichever one you pick. Everything in that API is internal and
19
+ // our assembly is not on its InternalsVisibleTo allowlist, so we reach it through
20
+ // reflection. The live IAssistantProvider only exists while an AI Assistant window
21
+ // is open, so that window must be open for this to work.
11
22
  class ConversationExtractorWindow : EditorWindow
12
23
  {
13
- const string RelayLogRelativePath = "Logs/relay.txt";
24
+ const string AssetDir = "Packages/jp.keijiro.ai.assistant.extensions/Editor/";
25
+ const string UxmlPath = AssetDir + "ConversationExtractorWindow.uxml";
26
+ const string RowUxmlPath = AssetDir + "ConversationRow.uxml";
27
+ const string UssPath = AssetDir + "ConversationExtractorWindow.uss";
14
28
 
15
- static readonly Regex MessageRegex = new(
16
- "\"\\$type\":\"(?<type>[^\"]+)\".*?\"message_id\":\"(?<messageId>[^\"]+)\".*?(?:\"last_message\":(?<lastMessage>true|false).*?)?\"markdown\":\"(?<markdown>(?:\\\\.|[^\"\\\\])*)\"",
17
- RegexOptions.Compiled);
29
+ const string AssistantWindowTypeName = "Unity.AI.Assistant.UI.Editor.Scripts.AssistantWindow";
30
+ const int EventTimeoutMs = 20000;
18
31
 
19
- Vector2 _scroll;
32
+ const BindingFlags InstanceMembers =
33
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
34
+
35
+ struct ConversationItem
36
+ {
37
+ public object IdBoxed; // boxed AssistantConversationId, passed straight back to the API
38
+ public string Id; // its string value, used to match the load callback
39
+ public string Title;
40
+ public long Timestamp;
41
+ public bool Favorite;
42
+ }
43
+
44
+ readonly List<ConversationItem> _conversations = new();
45
+
46
+ object _loadedConversation; // last loaded conversation, for rebuilding without re-fetching
20
47
  string _markdown;
21
- string _status;
22
- bool _includeEmpty;
48
+ string _status; // right pane: extraction lifecycle only
49
+ string _listStatus; // left pane: empty-state message
50
+ bool _busy;
51
+ bool _extracting;
52
+ bool _includeToolCalls = true;
53
+
54
+ // UI elements (resolved in CreateGUI).
55
+ VisualTreeAsset _rowTemplate;
56
+ ToolbarButton _refreshButton;
57
+ Toggle _toolCallsToggle;
58
+ ToolbarButton _copyButton;
59
+ ToolbarButton _saveButton;
60
+ Label _listHeader;
61
+ Label _emptyLabel;
62
+ ListView _listView;
63
+ TextField _preview;
64
+
65
+ // Event plumbing shared with the reflection callbacks.
66
+ Delegate _refreshedHandler;
67
+ Delegate _loadedHandler;
68
+ TaskCompletionSource<object> _refreshTcs;
69
+ TaskCompletionSource<object> _loadTcs;
70
+ string _pendingLoadId;
23
71
 
24
72
  [MenuItem("Window/AI/Conversation Extractor")]
25
73
  static void Open()
26
74
  {
27
75
  var window = GetWindow<ConversationExtractorWindow>();
28
76
  window.titleContent = new GUIContent("Conversation Extractor");
29
- window.minSize = new Vector2(480, 320);
30
- window.Extract();
77
+ window.minSize = new Vector2(640, 360);
31
78
  }
32
79
 
33
- void OnGUI()
80
+ // --- UI construction ----------------------------------------------------
81
+
82
+ void CreateGUI()
34
83
  {
35
- using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar))
84
+ var tree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
85
+ if (tree == null)
36
86
  {
37
- var includeEmpty = GUILayout.Toggle(_includeEmpty, "Include Empty", EditorStyles.toolbarButton);
38
- if (includeEmpty != _includeEmpty) _includeEmpty = includeEmpty;
87
+ rootVisualElement.Add(new Label($"Layout asset not found: {UxmlPath}"));
88
+ return;
89
+ }
39
90
 
40
- GUILayout.FlexibleSpace();
91
+ tree.CloneTree(rootVisualElement);
41
92
 
42
- if (GUILayout.Button("Extract", EditorStyles.toolbarButton))
43
- Extract();
44
- if (GUILayout.Button("Copy", EditorStyles.toolbarButton))
45
- CopyToClipboard();
46
- if (GUILayout.Button("Save", EditorStyles.toolbarButton))
47
- SaveMarkdown();
48
- }
93
+ var styleSheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
94
+ if (styleSheet != null)
95
+ rootVisualElement.styleSheets.Add(styleSheet);
96
+
97
+ _rowTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(RowUxmlPath);
98
+
99
+ _refreshButton = rootVisualElement.Q<ToolbarButton>("refresh-button");
100
+ _toolCallsToggle = rootVisualElement.Q<Toggle>("toolcalls-toggle");
101
+ _copyButton = rootVisualElement.Q<ToolbarButton>("copy-button");
102
+ _saveButton = rootVisualElement.Q<ToolbarButton>("save-button");
103
+ _listHeader = rootVisualElement.Q<Label>("list-header");
104
+ _emptyLabel = rootVisualElement.Q<Label>("empty-label");
105
+ _listView = rootVisualElement.Q<ListView>("conversation-list");
106
+ _preview = rootVisualElement.Q<TextField>("preview");
107
+
108
+ _refreshButton.clicked += () => { if (!_busy) _ = RefreshList(); };
109
+ _copyButton.clicked += CopyToClipboard;
110
+ _saveButton.clicked += SaveMarkdown;
49
111
 
50
- if (!string.IsNullOrEmpty(_status))
51
- EditorGUILayout.HelpBox(_status, MessageType.Info);
112
+ _toolCallsToggle.SetValueWithoutNotify(_includeToolCalls);
113
+ _toolCallsToggle.RegisterValueChangedCallback(evt => OnToolCallsChanged(evt.newValue));
52
114
 
53
- using var scroll = new EditorGUILayout.ScrollViewScope(_scroll);
54
- _scroll = scroll.scrollPosition;
55
- EditorGUILayout.TextArea(_markdown ?? "", GUILayout.ExpandHeight(true));
115
+ _listView.itemsSource = _conversations;
116
+ _listView.makeItem = MakeRow;
117
+ _listView.bindItem = BindRow;
118
+ _listView.selectionChanged += OnSelectionChanged;
119
+
120
+ // Show the initial hint; RefreshList drives the toolbar and list state.
121
+ UpdatePreview();
122
+ _ = RefreshList();
56
123
  }
57
124
 
58
- void Extract()
125
+ VisualElement MakeRow()
126
+ {
127
+ if (_rowTemplate != null)
128
+ return _rowTemplate.Instantiate();
129
+
130
+ var container = new VisualElement();
131
+ container.Add(new Label { name = "row-title" });
132
+ container.Add(new Label { name = "row-date" });
133
+ return container;
134
+ }
135
+
136
+ void BindRow(VisualElement element, int index)
137
+ {
138
+ var item = _conversations[index];
139
+
140
+ var title = element.Q<Label>("row-title");
141
+ // Titles can contain embedded newlines; collapse them so the row stays
142
+ // single-line (white-space:nowrap doesn't strip explicit line breaks).
143
+ title.text = (item.Favorite ? "★ " : "") + SingleLine(DisplayTitle(item.Title));
144
+
145
+ var date = element.Q<Label>("row-date");
146
+ var formatted = FormatDate(item.Timestamp);
147
+ date.text = formatted;
148
+ date.style.display = string.IsNullOrEmpty(formatted) ? DisplayStyle.None : DisplayStyle.Flex;
149
+ }
150
+
151
+ void OnSelectionChanged(IEnumerable<object> selection)
152
+ {
153
+ if (_busy) return;
154
+
155
+ var index = _listView.selectedIndex;
156
+ if (index >= 0 && index < _conversations.Count)
157
+ _ = ExtractConversation(_conversations[index]);
158
+ }
159
+
160
+ void OnToolCallsChanged(bool include)
161
+ {
162
+ _includeToolCalls = include;
163
+
164
+ // Rebuild from the already-loaded conversation; no need to re-fetch.
165
+ if (_busy || _loadedConversation == null) return;
166
+ _markdown = BuildConversationMarkdown(_loadedConversation, _includeToolCalls);
167
+ UpdateToolbar();
168
+ UpdatePreview();
169
+ }
170
+
171
+ // --- State -> UI --------------------------------------------------------
172
+
173
+ void UpdateToolbar()
59
174
  {
60
- var path = GetRelayLogPath();
61
- if (!File.Exists(path))
175
+ _refreshButton?.SetEnabled(!_busy);
176
+
177
+ var hasMarkdown = !_busy && !string.IsNullOrEmpty(_markdown);
178
+ _copyButton?.SetEnabled(hasMarkdown);
179
+ _saveButton?.SetEnabled(hasMarkdown);
180
+ }
181
+
182
+ void UpdateList()
183
+ {
184
+ if (_listView == null) return;
185
+
186
+ _listHeader.text = $"Conversations ({_conversations.Count})";
187
+ _listView.Rebuild();
188
+
189
+ var empty = _conversations.Count == 0;
190
+ _listView.style.display = empty ? DisplayStyle.None : DisplayStyle.Flex;
191
+ _emptyLabel.style.display = empty ? DisplayStyle.Flex : DisplayStyle.None;
192
+ if (empty)
193
+ _emptyLabel.text = _busy
194
+ ? "Loading..."
195
+ : (string.IsNullOrEmpty(_listStatus) ? "No conversations." : _listStatus);
196
+ }
197
+
198
+ void UpdatePreview()
199
+ {
200
+ if (_preview == null) return;
201
+
202
+ // While extracting show the status text; otherwise the extracted markdown,
203
+ // or a hint when there is none.
204
+ string content;
205
+ if (_extracting)
206
+ content = _status;
207
+ else if (!string.IsNullOrEmpty(_markdown))
208
+ content = _markdown;
209
+ else
210
+ content = string.IsNullOrEmpty(_status) ? "Select a conversation to extract." : _status;
211
+
212
+ _preview.SetValueWithoutNotify(content ?? "");
213
+ }
214
+
215
+ // --- Operations ---------------------------------------------------------
216
+
217
+ async Task RefreshList()
218
+ {
219
+ object provider = null;
220
+ _busy = true;
221
+ _listStatus = null;
222
+ UpdateToolbar();
223
+ UpdateList();
224
+
225
+ try
62
226
  {
227
+ provider = GetLiveProvider(out var error);
228
+ if (provider == null) { _listStatus = error; return; }
229
+
230
+ Subscribe(provider);
231
+
232
+ _conversations.Clear();
233
+ _conversations.AddRange((await RefreshConversations(provider))
234
+ .OrderByDescending(c => c.Timestamp));
235
+ _loadedConversation = null;
63
236
  _markdown = "";
64
- _status = $"Relay log not found: {path}";
65
- return;
237
+ _status = null;
238
+ _listView?.SetSelectionWithoutNotify(Array.Empty<int>());
66
239
  }
240
+ catch (Exception e)
241
+ {
242
+ _listStatus = $"Failed to refresh: {e.Message}";
243
+ Debug.LogException(e);
244
+ }
245
+ finally
246
+ {
247
+ Unsubscribe(provider);
248
+ _busy = false;
249
+ UpdateToolbar();
250
+ UpdateList();
251
+ UpdatePreview();
252
+ }
253
+ }
254
+
255
+ async Task ExtractConversation(ConversationItem item)
256
+ {
257
+ object provider = null;
258
+ _busy = true;
259
+ _extracting = true;
260
+ _status = $"Extracting \"{DisplayTitle(item.Title)}\"...";
261
+ UpdateToolbar();
262
+ UpdatePreview();
263
+
264
+ try
265
+ {
266
+ provider = GetLiveProvider(out var error);
267
+ if (provider == null) { _markdown = ""; _status = error; return; }
67
268
 
68
- var pairs = ExtractPairs(path);
69
- _markdown = BuildMarkdown(pairs, _includeEmpty);
70
- _status = $"Extracted {pairs.Count} conversation pair(s) from {RelayLogRelativePath}.";
269
+ Subscribe(provider);
270
+
271
+ var conversation = await LoadConversation(provider, item);
272
+ _loadedConversation = conversation;
273
+ _markdown = BuildConversationMarkdown(conversation, _includeToolCalls);
274
+ _status = null;
275
+ }
276
+ catch (Exception e)
277
+ {
278
+ _loadedConversation = null;
279
+ _markdown = "";
280
+ _status = $"Extraction failed: {e.Message}";
281
+ Debug.LogException(e);
282
+ }
283
+ finally
284
+ {
285
+ Unsubscribe(provider);
286
+ _busy = false;
287
+ _extracting = false;
288
+ UpdateToolbar();
289
+ UpdatePreview();
290
+ }
71
291
  }
72
292
 
73
293
  void CopyToClipboard()
74
294
  {
75
295
  EditorGUIUtility.systemCopyBuffer = _markdown ?? "";
76
- _status = "Copied extracted markdown to the clipboard.";
296
+ ShowNotification(new GUIContent("Copied to clipboard"));
77
297
  }
78
298
 
79
299
  void SaveMarkdown()
80
300
  {
81
- var projectRoot = GetProjectRootPath();
82
- var defaultPath = Path.Combine(projectRoot, "Logs", "conversations.md");
83
- var path = EditorUtility.SaveFilePanel("Save extracted conversations", Path.GetDirectoryName(defaultPath), Path.GetFileName(defaultPath), "md");
301
+ var projectRoot = Directory.GetParent(Application.dataPath)?.FullName ?? Directory.GetCurrentDirectory();
302
+ var index = _listView?.selectedIndex ?? -1;
303
+ var hasSelection = index >= 0 && index < _conversations.Count;
304
+ var name = hasSelection ? SanitizeFileName(_conversations[index].Title) : "conversation";
305
+ var stamp = hasSelection ? FormatFileTimestamp(_conversations[index].Timestamp) : "";
306
+ var fileName = string.IsNullOrEmpty(stamp) ? name : $"{stamp} {name}";
307
+ var defaultPath = Path.Combine(projectRoot, "Logs", $"{fileName}.md");
308
+ var path = EditorUtility.SaveFilePanel("Save extracted conversation", Path.GetDirectoryName(defaultPath), Path.GetFileName(defaultPath), "md");
84
309
  if (string.IsNullOrEmpty(path)) return;
85
310
 
86
311
  File.WriteAllText(path, _markdown ?? "", Encoding.UTF8);
87
- _status = $"Saved extracted markdown to {path}";
312
+ ShowNotification(new GUIContent($"Saved to {Path.GetFileName(path)}"));
88
313
  }
89
314
 
90
- static List<ConversationPair> ExtractPairs(string path)
315
+ // --- Live provider acquisition -----------------------------------------
316
+
317
+ static object GetLiveProvider(out string error)
91
318
  {
92
- var pairs = new List<ConversationPair>();
93
- ConversationPair currentPair = null;
94
- string activeResponseId = null;
319
+ error = null;
95
320
 
96
- var lines = File.ReadAllLines(path, Encoding.UTF8);
97
- for (var i = 0; i < lines.Length; i++)
321
+ var windowType = FindType(AssistantWindowTypeName);
322
+ if (windowType == null)
98
323
  {
99
- var parsed = ParseLine(lines[i]);
100
- if (parsed == null) continue;
324
+ error = "AI Assistant package not found. Is it installed?";
325
+ return null;
326
+ }
101
327
 
102
- if (parsed.Type == "CHAT_ACKNOWLEDGMENT_V1" && !string.IsNullOrWhiteSpace(parsed.Markdown))
103
- {
104
- if (currentPair != null && currentPair.ResponseParts.Count > 0)
105
- pairs.Add(currentPair);
328
+ var window = windowType
329
+ .GetMethod("FindExistingWindow", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)
330
+ ?.Invoke(null, null);
331
+ if (window == null)
332
+ {
333
+ error = "Open the AI Assistant window first (Window > AI > Assistant), then retry.";
334
+ return null;
335
+ }
106
336
 
107
- currentPair = new ConversationPair(parsed.Markdown.Trim(), i + 1, parsed.MessageId);
108
- activeResponseId = null;
109
- continue;
110
- }
337
+ var provider = windowType.GetProperty("AssistantInstance", InstanceMembers)?.GetValue(window);
338
+ if (provider == null)
339
+ error = "AI Assistant instance is not ready yet. Interact with the Assistant window, then retry.";
111
340
 
112
- if (parsed.Type != "CHAT_RESPONSE_V1" || currentPair == null) continue;
113
- if (string.IsNullOrEmpty(parsed.Markdown) || parsed.Markdown.StartsWith("<")) continue;
341
+ return provider;
342
+ }
114
343
 
115
- if (activeResponseId == null)
116
- {
117
- activeResponseId = parsed.MessageId;
118
- currentPair.ResponseMessageId = activeResponseId;
119
- }
344
+ // --- Event-driven calls -------------------------------------------------
120
345
 
121
- if (parsed.MessageId == activeResponseId)
122
- {
123
- currentPair.ResponseParts.Add(parsed.Markdown);
124
- currentPair.ResponseLines.Add(i + 1);
125
- }
346
+ async Task<List<ConversationItem>> RefreshConversations(object provider)
347
+ {
348
+ _refreshTcs = new TaskCompletionSource<object>();
126
349
 
127
- if (parsed.LastMessage && parsed.MessageId == activeResponseId)
350
+ var method = provider.GetType().GetMethod(
351
+ "RefreshConversationsAsync", InstanceMembers,
352
+ null, new[] { typeof(CancellationToken), typeof(bool) }, null);
353
+ if (method == null)
354
+ throw new MissingMethodException("IAssistantProvider.RefreshConversationsAsync not found.");
355
+
356
+ await (Task)method.Invoke(provider, new object[] { CancellationToken.None, false });
357
+ var infos = await WithTimeout(_refreshTcs.Task);
358
+
359
+ // The event delivers IEnumerable<AssistantConversationInfo> (a boxed struct per item).
360
+ var result = new List<ConversationItem>();
361
+ foreach (var info in (IEnumerable)infos)
362
+ {
363
+ var idBoxed = GetMember(info, "Id");
364
+ result.Add(new ConversationItem
128
365
  {
129
- pairs.Add(currentPair);
130
- currentPair = null;
131
- activeResponseId = null;
132
- }
366
+ IdBoxed = idBoxed,
367
+ Id = GetMember(idBoxed, "Value") as string,
368
+ Title = GetMember(info, "Title") as string,
369
+ Timestamp = Convert.ToInt64(GetMember(info, "LastMessageTimestamp") ?? 0L),
370
+ Favorite = GetMember(info, "IsFavorite") is true
371
+ });
133
372
  }
373
+ return result;
374
+ }
375
+
376
+ async Task<object> LoadConversation(object provider, ConversationItem item)
377
+ {
378
+ _pendingLoadId = item.Id;
379
+ _loadTcs = new TaskCompletionSource<object>();
134
380
 
135
- if (currentPair != null)
136
- pairs.Add(currentPair);
381
+ var method = provider.GetType().GetMethod("ConversationLoad", InstanceMembers);
382
+ if (method == null)
383
+ throw new MissingMethodException("IAssistantProvider.ConversationLoad not found.");
137
384
 
138
- return pairs;
385
+ await (Task)method.Invoke(provider, new[] { item.IdBoxed, CancellationToken.None });
386
+ return await WithTimeout(_loadTcs.Task);
139
387
  }
140
388
 
141
- static ParsedLine ParseLine(string line)
389
+ static async Task<object> WithTimeout(Task<object> task)
142
390
  {
143
- var match = MessageRegex.Match(line);
144
- if (!match.Success) return null;
391
+ if (await Task.WhenAny(task, Task.Delay(EventTimeoutMs)) != task)
392
+ throw new TimeoutException("Timed out waiting for the AI Assistant API to respond.");
393
+ return await task;
394
+ }
395
+
396
+ void Subscribe(object provider)
397
+ {
398
+ var type = provider.GetType();
399
+
400
+ // Bind handlers taking `object` to Action<T> events via delegate contravariance.
401
+ var refreshedEvent = type.GetEvent("ConversationsRefreshed", InstanceMembers);
402
+ _refreshedHandler = Delegate.CreateDelegate(
403
+ refreshedEvent.EventHandlerType, this,
404
+ GetType().GetMethod(nameof(OnConversationsRefreshed), InstanceMembers));
405
+ refreshedEvent.AddEventHandler(provider, _refreshedHandler);
406
+
407
+ var loadedEvent = type.GetEvent("ConversationLoaded", InstanceMembers);
408
+ _loadedHandler = Delegate.CreateDelegate(
409
+ loadedEvent.EventHandlerType, this,
410
+ GetType().GetMethod(nameof(OnConversationLoaded), InstanceMembers));
411
+ loadedEvent.AddEventHandler(provider, _loadedHandler);
412
+ }
145
413
 
146
- return new ParsedLine
147
- (
148
- match.Groups["type"].Value,
149
- match.Groups["messageId"].Value,
150
- match.Groups["lastMessage"].Value == "true",
151
- DecodeMarkdown(match.Groups["markdown"].Value)
152
- );
414
+ void Unsubscribe(object provider)
415
+ {
416
+ if (provider == null) return;
417
+
418
+ var type = provider.GetType();
419
+ if (_refreshedHandler != null)
420
+ type.GetEvent("ConversationsRefreshed", InstanceMembers)?.RemoveEventHandler(provider, _refreshedHandler);
421
+ if (_loadedHandler != null)
422
+ type.GetEvent("ConversationLoaded", InstanceMembers)?.RemoveEventHandler(provider, _loadedHandler);
423
+
424
+ _refreshedHandler = _loadedHandler = null;
425
+ }
426
+
427
+ // Reflection callbacks. May arrive on a background thread; TaskCompletionSource
428
+ // marshals the awaiting continuation back to the captured (main) context.
429
+ void OnConversationsRefreshed(object infos) => _refreshTcs?.TrySetResult(infos);
430
+
431
+ void OnConversationLoaded(object conversation)
432
+ {
433
+ var id = GetMember(GetMember(conversation, "Id"), "Value") as string;
434
+ if (_loadTcs != null && id == _pendingLoadId)
435
+ _loadTcs.TrySetResult(conversation);
153
436
  }
154
437
 
155
- static string DecodeMarkdown(string value)
156
- => JsonUtility.FromJson<JsonStringWrapper>("{\"value\":\"" + value + "\"}")?.value ?? "";
438
+ // --- Markdown building --------------------------------------------------
157
439
 
158
- static string BuildMarkdown(List<ConversationPair> pairs, bool includeEmpty)
440
+ static string BuildConversationMarkdown(object conversation, bool includeToolCalls)
159
441
  {
442
+ if (conversation == null) return "";
443
+
160
444
  var builder = new StringBuilder();
445
+ var title = GetMember(conversation, "Title") as string;
446
+ builder.Append("# ").Append(DisplayTitle(title));
161
447
 
162
- foreach (var pair in pairs)
448
+ if (GetMember(conversation, "Messages") is IEnumerable messages)
163
449
  {
164
- var response = pair.Response.Trim();
165
- if (!includeEmpty && string.IsNullOrEmpty(response)) continue;
166
-
167
- if (builder.Length > 0)
168
- builder.Append("\n\n");
450
+ foreach (var message in messages)
451
+ {
452
+ var text = ExtractMessageText(message, includeToolCalls);
453
+ if (string.IsNullOrWhiteSpace(text)) continue;
169
454
 
170
- builder.Append("## User\n\n");
171
- builder.Append(pair.Prompt);
172
- builder.Append("\n\n## Assistant\n\n");
173
- builder.Append(string.IsNullOrEmpty(response) ? "(No extracted response)" : response);
455
+ var role = GetMember(message, "Role") as string;
456
+ builder.Append("\n\n## ").Append(FormatRole(role)).Append("\n\n");
457
+ builder.Append(text.Trim());
458
+ }
174
459
  }
175
460
 
176
461
  return builder.ToString();
177
462
  }
178
463
 
179
- static string GetProjectRootPath()
180
- => Directory.GetParent(Application.dataPath)?.FullName ?? Directory.GetCurrentDirectory();
464
+ static string ExtractMessageText(object message, bool includeToolCalls)
465
+ {
466
+ if (GetMember(message, "Blocks") is not IEnumerable blocks) return "";
181
467
 
182
- static string GetRelayLogPath()
183
- => Path.Combine(GetProjectRootPath(), RelayLogRelativePath);
468
+ var parts = new List<string>();
469
+ foreach (var block in blocks)
470
+ {
471
+ var text = BlockText(block, includeToolCalls);
472
+ if (!string.IsNullOrWhiteSpace(text)) parts.Add(text.Trim());
473
+ }
474
+ return string.Join("\n\n", parts);
475
+ }
184
476
 
185
- [Serializable]
186
- class JsonStringWrapper
477
+ // Pulls text out of each block. Text-bearing blocks return their content;
478
+ // FunctionCallBlock is rendered as the tool name plus its parameters (only when
479
+ // tool calls are included). The ACP tool-call / plan blocks carry structured
480
+ // data and are skipped.
481
+ static string BlockText(object block, bool includeToolCalls)
187
482
  {
188
- public string value = "";
483
+ if (block == null) return null;
484
+ return block.GetType().Name switch
485
+ {
486
+ "PromptBlock" => GetMember(block, "Content") as string,
487
+ "AnswerBlock" => GetMember(block, "Content") as string,
488
+ "ThoughtBlock" => GetMember(block, "Content") as string,
489
+ "ErrorBlock" => GetMember(block, "Error") as string,
490
+ "InfoBlock" => GetMember(block, "Message") as string,
491
+ "FunctionCallBlock" => includeToolCalls ? FormatFunctionCall(GetMember(block, "Call")) : null,
492
+ _ => null
493
+ };
189
494
  }
190
495
 
191
- class ConversationPair
496
+ // Renders an AssistantFunctionCall (e.g. CodeEdit) as a tool name plus its
497
+ // parameters. Parameters is a Newtonsoft JObject, whose ToString() yields
498
+ // indented JSON, so no compile-time dependency on Newtonsoft is needed.
499
+ static string FormatFunctionCall(object call)
192
500
  {
193
- public readonly string Prompt;
194
- public readonly int PromptLine;
195
- public readonly string PromptMessageId;
196
- public readonly List<string> ResponseParts = new();
197
- public readonly List<int> ResponseLines = new();
198
- public string ResponseMessageId;
501
+ if (call == null) return null;
199
502
 
200
- public string Response => string.Concat(ResponseParts);
503
+ var name = GetMember(call, "FunctionId") as string;
504
+ var builder = new StringBuilder();
505
+ builder.Append("**Tool call: ").Append(string.IsNullOrEmpty(name) ? "(unknown)" : name).Append("**");
201
506
 
202
- public ConversationPair(string prompt, int promptLine, string promptMessageId)
203
- {
204
- Prompt = prompt;
205
- PromptLine = promptLine;
206
- PromptMessageId = promptMessageId;
207
- }
507
+ var parameters = GetMember(call, "Parameters")?.ToString();
508
+ if (!string.IsNullOrWhiteSpace(parameters))
509
+ builder.Append("\n\n```json\n").Append(parameters).Append("\n```");
510
+
511
+ return builder.ToString();
208
512
  }
209
513
 
210
- class ParsedLine
514
+ // --- Misc helpers -------------------------------------------------------
515
+
516
+ static string DisplayTitle(string title)
517
+ => string.IsNullOrEmpty(title) ? "(Untitled)" : title;
518
+
519
+ // Collapses CR/LF runs to single spaces so a value renders on one line.
520
+ static string SingleLine(string text)
521
+ => string.IsNullOrEmpty(text)
522
+ ? text
523
+ : System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
524
+
525
+ static string FormatRole(string role)
211
526
  {
212
- public readonly string Type;
213
- public readonly string MessageId;
214
- public readonly bool LastMessage;
215
- public readonly string Markdown;
527
+ if (string.IsNullOrEmpty(role)) return "Message";
528
+ return char.ToUpperInvariant(role[0]) + role.Substring(1);
529
+ }
530
+
531
+ static string FormatDate(long timestamp)
532
+ => TryGetLocalTime(timestamp, out var time) ? time.ToString("yyyy-MM-dd HH:mm") : "";
216
533
 
217
- public ParsedLine(string type, string messageId, bool lastMessage, string markdown)
534
+ // Date and time for filenames; colon-free so it is filesystem-safe and sortable.
535
+ static string FormatFileTimestamp(long timestamp)
536
+ => TryGetLocalTime(timestamp, out var time) ? time.ToString("yyyy-MM-dd HH-mm") : "";
537
+
538
+ static bool TryGetLocalTime(long timestamp, out DateTimeOffset time)
539
+ {
540
+ time = default;
541
+ if (timestamp <= 0) return false;
542
+ try
543
+ {
544
+ time = (timestamp > 1_000_000_000_000L
545
+ ? DateTimeOffset.FromUnixTimeMilliseconds(timestamp)
546
+ : DateTimeOffset.FromUnixTimeSeconds(timestamp)).ToLocalTime();
547
+ return true;
548
+ }
549
+ catch
218
550
  {
219
- Type = type;
220
- MessageId = messageId;
221
- LastMessage = lastMessage;
222
- Markdown = markdown;
551
+ return false;
223
552
  }
224
553
  }
554
+
555
+ static string SanitizeFileName(string name)
556
+ {
557
+ if (string.IsNullOrEmpty(name)) return "conversation";
558
+ var invalid = Path.GetInvalidFileNameChars();
559
+ var sanitized = new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim();
560
+ return string.IsNullOrEmpty(sanitized) ? "conversation" : sanitized;
561
+ }
562
+
563
+ static Type FindType(string fullName)
564
+ => AppDomain.CurrentDomain.GetAssemblies()
565
+ .Select(a => a.GetType(fullName, false))
566
+ .FirstOrDefault(t => t != null);
567
+
568
+ // Reads a public/internal field or property by name from a live object
569
+ // (works on boxed structs too).
570
+ static object GetMember(object obj, string name)
571
+ {
572
+ if (obj == null) return null;
573
+ var type = obj.GetType();
574
+ var field = type.GetField(name, InstanceMembers);
575
+ if (field != null) return field.GetValue(obj);
576
+ return type.GetProperty(name, InstanceMembers)?.GetValue(obj);
577
+ }
225
578
  }
226
579
 
227
580
  } // namespace AIAssistantExtensions
@@ -0,0 +1,80 @@
1
+ .unity-text-element {
2
+ -unity-font-definition: url("project://database/Packages/jp.keijiro.ai.assistant.extensions/Editor/NotoSansJP-Regular.ttf?fileID=12800000&guid=f7d2dd1a328194df9b71491ff2d07e2b&type=3#NotoSansJP-Regular");
3
+ }
4
+
5
+ .spacer {
6
+ flex-grow: 1;
7
+ }
8
+
9
+ .split {
10
+ flex-grow: 1;
11
+ }
12
+
13
+ #left-pane {
14
+ min-width: 150px;
15
+ }
16
+
17
+ #right-pane {
18
+ min-width: 200px;
19
+ }
20
+
21
+ .pane {
22
+ flex-grow: 1;
23
+ min-height: 0;
24
+ padding: 4px;
25
+ }
26
+
27
+ .header {
28
+ -unity-font-style: bold;
29
+ margin-top: 2px;
30
+ margin-bottom: 2px;
31
+ }
32
+
33
+ .preview-header {
34
+ flex-direction: row;
35
+ justify-content: space-between;
36
+ align-items: center;
37
+ }
38
+
39
+ .toolcalls {
40
+ margin-bottom: 2px;
41
+ }
42
+
43
+ .empty {
44
+ white-space: normal;
45
+ opacity: 0.6;
46
+ margin: 4px;
47
+ }
48
+
49
+ .list {
50
+ flex-grow: 1;
51
+ }
52
+
53
+ .preview {
54
+ flex-grow: 1;
55
+ flex-shrink: 1;
56
+ min-height: 0;
57
+ }
58
+
59
+ .preview .unity-base-text-field__input {
60
+ white-space: normal;
61
+ -unity-text-align: upper-left;
62
+ }
63
+
64
+ .row {
65
+ justify-content: center;
66
+ padding-left: 6px;
67
+ padding-right: 6px;
68
+ }
69
+
70
+ .row-title {
71
+ -unity-font-style: bold;
72
+ text-overflow: ellipsis;
73
+ overflow: hidden;
74
+ white-space: nowrap;
75
+ }
76
+
77
+ .row-date {
78
+ font-size: 10px;
79
+ opacity: 0.65;
80
+ }
@@ -0,0 +1,12 @@
1
+ fileFormatVersion: 2
2
+ guid: f6331e5dc7a84404baedc43bf6c5b522
3
+ ScriptedImporter:
4
+ internalIDToNameTable: []
5
+ externalObjects: {}
6
+ serializedVersion: 2
7
+ userData:
8
+ assetBundleName:
9
+ assetBundleVariant:
10
+ script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
11
+ disableValidation: 0
12
+ unsupportedSelectorAction: 0
@@ -0,0 +1,23 @@
1
+ <ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" editor-extension-mode="True">
2
+ <Style src="project://database/Packages/jp.keijiro.ai.assistant.extensions/Editor/ConversationExtractorWindow.uss?fileID=7433441132597879392&amp;guid=f6331e5dc7a84404baedc43bf6c5b522&amp;type=3#ConversationExtractorWindow"/>
3
+ <uie:Toolbar>
4
+ <uie:ToolbarButton name="refresh-button" text="Refresh"/>
5
+ <ui:VisualElement class="spacer"/>
6
+ <uie:ToolbarButton name="copy-button" text="Copy"/>
7
+ <uie:ToolbarButton name="save-button" text="Save"/>
8
+ </uie:Toolbar>
9
+ <ui:TwoPaneSplitView name="split" orientation="Horizontal" fixed-pane-index="0" fixed-pane-initial-dimension="240" view-data-key="split" class="split">
10
+ <ui:VisualElement name="left-pane" class="pane">
11
+ <ui:Label name="list-header" class="header"/>
12
+ <ui:ListView name="conversation-list" view-data-key="list" fixed-item-height="38" selection-type="Single" class="list"/>
13
+ <ui:Label name="empty-label" class="empty"/>
14
+ </ui:VisualElement>
15
+ <ui:VisualElement name="right-pane" class="pane">
16
+ <ui:VisualElement name="preview-header" class="preview-header">
17
+ <ui:Label text="Extracted Markdown" class="header"/>
18
+ <ui:Toggle name="toolcalls-toggle" text="Tool Calls" class="toolcalls"/>
19
+ </ui:VisualElement>
20
+ <ui:TextField name="preview" multiline="true" readonly="true" vertical-scroller-visibility="Auto" focusable="false" class="preview"/>
21
+ </ui:VisualElement>
22
+ </ui:TwoPaneSplitView>
23
+ </ui:UXML>
@@ -0,0 +1,10 @@
1
+ fileFormatVersion: 2
2
+ guid: 7a48f083d5c894030ae036790fba1881
3
+ ScriptedImporter:
4
+ internalIDToNameTable: []
5
+ externalObjects: {}
6
+ serializedVersion: 2
7
+ userData:
8
+ assetBundleName:
9
+ assetBundleVariant:
10
+ script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
@@ -0,0 +1,6 @@
1
+ <ui:UXML xmlns:ui="UnityEngine.UIElements">
2
+ <ui:VisualElement class="row">
3
+ <ui:Label name="row-title" class="row-title"/>
4
+ <ui:Label name="row-date" class="row-date"/>
5
+ </ui:VisualElement>
6
+ </ui:UXML>
@@ -0,0 +1,10 @@
1
+ fileFormatVersion: 2
2
+ guid: d67de4ce2aaaa4897b5c8a3355889f53
3
+ ScriptedImporter:
4
+ internalIDToNameTable: []
5
+ externalObjects: {}
6
+ serializedVersion: 2
7
+ userData:
8
+ assetBundleName:
9
+ assetBundleVariant:
10
+ script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
package/package.json CHANGED
@@ -1,23 +1,23 @@
1
1
  {
2
2
  "name": "jp.keijiro.ai.assistant.extensions",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "displayName": "AI Assistant Extensions",
5
5
  "description": "Custom Unity package that provides extensions for Unity AI Assistant.",
6
6
  "unity": "6000.0",
7
7
  "author": "Keijiro Takahashi",
8
8
  "dependencies": {
9
- "com.unity.ai.assistant": "2.8.0-pre.1"
9
+ "com.unity.ai.assistant": "2.11.0-pre.1"
10
10
  },
11
11
  "changelogUrl": "https://github.com/keijiro/AIA-Extensions/blob/master/CHANGELOG.md",
12
12
  "documentationUrl": "https://github.com/keijiro/AIA-Extensions",
13
13
  "licensesUrl": "https://github.com/keijiro/AIA-Extensions/blob/master/LICENSE",
14
14
  "license": "Unlicense",
15
15
  "_upm": {
16
- "changelog": "<b>Fixed</b><br>- Fixed package skill registration for AI Assistant 2.8.0-pre.1 by using the built-in package skill discovery path.<br><br><b>Changed</b><br>- Updated project and package metadata for AI Assistant 2.8.0-pre.1."
16
+ "changelog": "<b>Changed</b><br>- Prefixed saved conversation filenames with the conversation's date and time so exported files sort chronologically."
17
17
  },
18
18
  "repository": {
19
19
  "url": "git@github.com:keijiro/AIA-Extensions.git",
20
20
  "type": "git",
21
- "revision": "9ad87b2c94f8c38a28b03572585ea6de1c438e2b"
21
+ "revision": "6c89672e11d612d4416afa8f114af153fae8ec6a"
22
22
  }
23
23
  }