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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.attestation.p7m CHANGED
Binary file
package/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ 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.0] - 2026-06-05
9
+
10
+ ### Changed
11
+
12
+ - Reworked the Conversation Extractor to read conversations through the AI Assistant in-process API instead of parsing the relay log.
13
+ - 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.
14
+ - Updated project and package metadata for AI Assistant 2.11.0-pre.1.
15
+
8
16
  ## [1.1.3] - 2026-05-15
9
17
 
10
18
  ### Fixed
@@ -1,227 +1,571 @@
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;
111
+
112
+ _toolCallsToggle.SetValueWithoutNotify(_includeToolCalls);
113
+ _toolCallsToggle.RegisterValueChangedCallback(evt => OnToolCallsChanged(evt.newValue));
114
+
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();
123
+ }
124
+
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 --------------------------------------------------------
49
172
 
50
- if (!string.IsNullOrEmpty(_status))
51
- EditorGUILayout.HelpBox(_status, MessageType.Info);
173
+ void UpdateToolbar()
174
+ {
175
+ _refreshButton?.SetEnabled(!_busy);
176
+
177
+ var hasMarkdown = !_busy && !string.IsNullOrEmpty(_markdown);
178
+ _copyButton?.SetEnabled(hasMarkdown);
179
+ _saveButton?.SetEnabled(hasMarkdown);
180
+ }
52
181
 
53
- using var scroll = new EditorGUILayout.ScrollViewScope(_scroll);
54
- _scroll = scroll.scrollPosition;
55
- EditorGUILayout.TextArea(_markdown ?? "", GUILayout.ExpandHeight(true));
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);
56
196
  }
57
197
 
58
- void Extract()
198
+ void UpdatePreview()
59
199
  {
60
- var path = GetRelayLogPath();
61
- if (!File.Exists(path))
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>());
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();
66
252
  }
253
+ }
67
254
 
68
- var pairs = ExtractPairs(path);
69
- _markdown = BuildMarkdown(pairs, _includeEmpty);
70
- _status = $"Extracted {pairs.Count} conversation pair(s) from {RelayLogRelativePath}.";
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; }
268
+
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 name = index >= 0 && index < _conversations.Count
304
+ ? SanitizeFileName(_conversations[index].Title)
305
+ : "conversation";
306
+ var defaultPath = Path.Combine(projectRoot, "Logs", $"{name}.md");
307
+ var path = EditorUtility.SaveFilePanel("Save extracted conversation", Path.GetDirectoryName(defaultPath), Path.GetFileName(defaultPath), "md");
84
308
  if (string.IsNullOrEmpty(path)) return;
85
309
 
86
310
  File.WriteAllText(path, _markdown ?? "", Encoding.UTF8);
87
- _status = $"Saved extracted markdown to {path}";
311
+ ShowNotification(new GUIContent($"Saved to {Path.GetFileName(path)}"));
88
312
  }
89
313
 
90
- static List<ConversationPair> ExtractPairs(string path)
314
+ // --- Live provider acquisition -----------------------------------------
315
+
316
+ static object GetLiveProvider(out string error)
91
317
  {
92
- var pairs = new List<ConversationPair>();
93
- ConversationPair currentPair = null;
94
- string activeResponseId = null;
318
+ error = null;
95
319
 
96
- var lines = File.ReadAllLines(path, Encoding.UTF8);
97
- for (var i = 0; i < lines.Length; i++)
320
+ var windowType = FindType(AssistantWindowTypeName);
321
+ if (windowType == null)
98
322
  {
99
- var parsed = ParseLine(lines[i]);
100
- if (parsed == null) continue;
323
+ error = "AI Assistant package not found. Is it installed?";
324
+ return null;
325
+ }
101
326
 
102
- if (parsed.Type == "CHAT_ACKNOWLEDGMENT_V1" && !string.IsNullOrWhiteSpace(parsed.Markdown))
103
- {
104
- if (currentPair != null && currentPair.ResponseParts.Count > 0)
105
- pairs.Add(currentPair);
327
+ var window = windowType
328
+ .GetMethod("FindExistingWindow", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)
329
+ ?.Invoke(null, null);
330
+ if (window == null)
331
+ {
332
+ error = "Open the AI Assistant window first (Window > AI > Assistant), then retry.";
333
+ return null;
334
+ }
106
335
 
107
- currentPair = new ConversationPair(parsed.Markdown.Trim(), i + 1, parsed.MessageId);
108
- activeResponseId = null;
109
- continue;
110
- }
336
+ var provider = windowType.GetProperty("AssistantInstance", InstanceMembers)?.GetValue(window);
337
+ if (provider == null)
338
+ error = "AI Assistant instance is not ready yet. Interact with the Assistant window, then retry.";
111
339
 
112
- if (parsed.Type != "CHAT_RESPONSE_V1" || currentPair == null) continue;
113
- if (string.IsNullOrEmpty(parsed.Markdown) || parsed.Markdown.StartsWith("<")) continue;
340
+ return provider;
341
+ }
114
342
 
115
- if (activeResponseId == null)
116
- {
117
- activeResponseId = parsed.MessageId;
118
- currentPair.ResponseMessageId = activeResponseId;
119
- }
343
+ // --- Event-driven calls -------------------------------------------------
120
344
 
121
- if (parsed.MessageId == activeResponseId)
122
- {
123
- currentPair.ResponseParts.Add(parsed.Markdown);
124
- currentPair.ResponseLines.Add(i + 1);
125
- }
345
+ async Task<List<ConversationItem>> RefreshConversations(object provider)
346
+ {
347
+ _refreshTcs = new TaskCompletionSource<object>();
348
+
349
+ var method = provider.GetType().GetMethod(
350
+ "RefreshConversationsAsync", InstanceMembers,
351
+ null, new[] { typeof(CancellationToken), typeof(bool) }, null);
352
+ if (method == null)
353
+ throw new MissingMethodException("IAssistantProvider.RefreshConversationsAsync not found.");
354
+
355
+ await (Task)method.Invoke(provider, new object[] { CancellationToken.None, false });
356
+ var infos = await WithTimeout(_refreshTcs.Task);
126
357
 
127
- if (parsed.LastMessage && parsed.MessageId == activeResponseId)
358
+ // The event delivers IEnumerable<AssistantConversationInfo> (a boxed struct per item).
359
+ var result = new List<ConversationItem>();
360
+ foreach (var info in (IEnumerable)infos)
361
+ {
362
+ var idBoxed = GetMember(info, "Id");
363
+ result.Add(new ConversationItem
128
364
  {
129
- pairs.Add(currentPair);
130
- currentPair = null;
131
- activeResponseId = null;
132
- }
365
+ IdBoxed = idBoxed,
366
+ Id = GetMember(idBoxed, "Value") as string,
367
+ Title = GetMember(info, "Title") as string,
368
+ Timestamp = Convert.ToInt64(GetMember(info, "LastMessageTimestamp") ?? 0L),
369
+ Favorite = GetMember(info, "IsFavorite") is true
370
+ });
133
371
  }
372
+ return result;
373
+ }
374
+
375
+ async Task<object> LoadConversation(object provider, ConversationItem item)
376
+ {
377
+ _pendingLoadId = item.Id;
378
+ _loadTcs = new TaskCompletionSource<object>();
134
379
 
135
- if (currentPair != null)
136
- pairs.Add(currentPair);
380
+ var method = provider.GetType().GetMethod("ConversationLoad", InstanceMembers);
381
+ if (method == null)
382
+ throw new MissingMethodException("IAssistantProvider.ConversationLoad not found.");
137
383
 
138
- return pairs;
384
+ await (Task)method.Invoke(provider, new[] { item.IdBoxed, CancellationToken.None });
385
+ return await WithTimeout(_loadTcs.Task);
139
386
  }
140
387
 
141
- static ParsedLine ParseLine(string line)
388
+ static async Task<object> WithTimeout(Task<object> task)
142
389
  {
143
- var match = MessageRegex.Match(line);
144
- if (!match.Success) return null;
390
+ if (await Task.WhenAny(task, Task.Delay(EventTimeoutMs)) != task)
391
+ throw new TimeoutException("Timed out waiting for the AI Assistant API to respond.");
392
+ return await task;
393
+ }
145
394
 
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
- );
395
+ void Subscribe(object provider)
396
+ {
397
+ var type = provider.GetType();
398
+
399
+ // Bind handlers taking `object` to Action<T> events via delegate contravariance.
400
+ var refreshedEvent = type.GetEvent("ConversationsRefreshed", InstanceMembers);
401
+ _refreshedHandler = Delegate.CreateDelegate(
402
+ refreshedEvent.EventHandlerType, this,
403
+ GetType().GetMethod(nameof(OnConversationsRefreshed), InstanceMembers));
404
+ refreshedEvent.AddEventHandler(provider, _refreshedHandler);
405
+
406
+ var loadedEvent = type.GetEvent("ConversationLoaded", InstanceMembers);
407
+ _loadedHandler = Delegate.CreateDelegate(
408
+ loadedEvent.EventHandlerType, this,
409
+ GetType().GetMethod(nameof(OnConversationLoaded), InstanceMembers));
410
+ loadedEvent.AddEventHandler(provider, _loadedHandler);
153
411
  }
154
412
 
155
- static string DecodeMarkdown(string value)
156
- => JsonUtility.FromJson<JsonStringWrapper>("{\"value\":\"" + value + "\"}")?.value ?? "";
413
+ void Unsubscribe(object provider)
414
+ {
415
+ if (provider == null) return;
157
416
 
158
- static string BuildMarkdown(List<ConversationPair> pairs, bool includeEmpty)
417
+ var type = provider.GetType();
418
+ if (_refreshedHandler != null)
419
+ type.GetEvent("ConversationsRefreshed", InstanceMembers)?.RemoveEventHandler(provider, _refreshedHandler);
420
+ if (_loadedHandler != null)
421
+ type.GetEvent("ConversationLoaded", InstanceMembers)?.RemoveEventHandler(provider, _loadedHandler);
422
+
423
+ _refreshedHandler = _loadedHandler = null;
424
+ }
425
+
426
+ // Reflection callbacks. May arrive on a background thread; TaskCompletionSource
427
+ // marshals the awaiting continuation back to the captured (main) context.
428
+ void OnConversationsRefreshed(object infos) => _refreshTcs?.TrySetResult(infos);
429
+
430
+ void OnConversationLoaded(object conversation)
159
431
  {
432
+ var id = GetMember(GetMember(conversation, "Id"), "Value") as string;
433
+ if (_loadTcs != null && id == _pendingLoadId)
434
+ _loadTcs.TrySetResult(conversation);
435
+ }
436
+
437
+ // --- Markdown building --------------------------------------------------
438
+
439
+ static string BuildConversationMarkdown(object conversation, bool includeToolCalls)
440
+ {
441
+ if (conversation == null) return "";
442
+
160
443
  var builder = new StringBuilder();
444
+ var title = GetMember(conversation, "Title") as string;
445
+ builder.Append("# ").Append(DisplayTitle(title));
161
446
 
162
- foreach (var pair in pairs)
447
+ if (GetMember(conversation, "Messages") is IEnumerable messages)
163
448
  {
164
- var response = pair.Response.Trim();
165
- if (!includeEmpty && string.IsNullOrEmpty(response)) continue;
166
-
167
- if (builder.Length > 0)
168
- builder.Append("\n\n");
449
+ foreach (var message in messages)
450
+ {
451
+ var text = ExtractMessageText(message, includeToolCalls);
452
+ if (string.IsNullOrWhiteSpace(text)) continue;
169
453
 
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);
454
+ var role = GetMember(message, "Role") as string;
455
+ builder.Append("\n\n## ").Append(FormatRole(role)).Append("\n\n");
456
+ builder.Append(text.Trim());
457
+ }
174
458
  }
175
459
 
176
460
  return builder.ToString();
177
461
  }
178
462
 
179
- static string GetProjectRootPath()
180
- => Directory.GetParent(Application.dataPath)?.FullName ?? Directory.GetCurrentDirectory();
463
+ static string ExtractMessageText(object message, bool includeToolCalls)
464
+ {
465
+ if (GetMember(message, "Blocks") is not IEnumerable blocks) return "";
181
466
 
182
- static string GetRelayLogPath()
183
- => Path.Combine(GetProjectRootPath(), RelayLogRelativePath);
467
+ var parts = new List<string>();
468
+ foreach (var block in blocks)
469
+ {
470
+ var text = BlockText(block, includeToolCalls);
471
+ if (!string.IsNullOrWhiteSpace(text)) parts.Add(text.Trim());
472
+ }
473
+ return string.Join("\n\n", parts);
474
+ }
184
475
 
185
- [Serializable]
186
- class JsonStringWrapper
476
+ // Pulls text out of each block. Text-bearing blocks return their content;
477
+ // FunctionCallBlock is rendered as the tool name plus its parameters (only when
478
+ // tool calls are included). The ACP tool-call / plan blocks carry structured
479
+ // data and are skipped.
480
+ static string BlockText(object block, bool includeToolCalls)
187
481
  {
188
- public string value = "";
482
+ if (block == null) return null;
483
+ return block.GetType().Name switch
484
+ {
485
+ "PromptBlock" => GetMember(block, "Content") as string,
486
+ "AnswerBlock" => GetMember(block, "Content") as string,
487
+ "ThoughtBlock" => GetMember(block, "Content") as string,
488
+ "ErrorBlock" => GetMember(block, "Error") as string,
489
+ "InfoBlock" => GetMember(block, "Message") as string,
490
+ "FunctionCallBlock" => includeToolCalls ? FormatFunctionCall(GetMember(block, "Call")) : null,
491
+ _ => null
492
+ };
189
493
  }
190
494
 
191
- class ConversationPair
495
+ // Renders an AssistantFunctionCall (e.g. CodeEdit) as a tool name plus its
496
+ // parameters. Parameters is a Newtonsoft JObject, whose ToString() yields
497
+ // indented JSON, so no compile-time dependency on Newtonsoft is needed.
498
+ static string FormatFunctionCall(object call)
192
499
  {
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;
500
+ if (call == null) return null;
199
501
 
200
- public string Response => string.Concat(ResponseParts);
502
+ var name = GetMember(call, "FunctionId") as string;
503
+ var builder = new StringBuilder();
504
+ builder.Append("**Tool call: ").Append(string.IsNullOrEmpty(name) ? "(unknown)" : name).Append("**");
201
505
 
202
- public ConversationPair(string prompt, int promptLine, string promptMessageId)
203
- {
204
- Prompt = prompt;
205
- PromptLine = promptLine;
206
- PromptMessageId = promptMessageId;
207
- }
506
+ var parameters = GetMember(call, "Parameters")?.ToString();
507
+ if (!string.IsNullOrWhiteSpace(parameters))
508
+ builder.Append("\n\n```json\n").Append(parameters).Append("\n```");
509
+
510
+ return builder.ToString();
208
511
  }
209
512
 
210
- class ParsedLine
513
+ // --- Misc helpers -------------------------------------------------------
514
+
515
+ static string DisplayTitle(string title)
516
+ => string.IsNullOrEmpty(title) ? "(Untitled)" : title;
517
+
518
+ // Collapses CR/LF runs to single spaces so a value renders on one line.
519
+ static string SingleLine(string text)
520
+ => string.IsNullOrEmpty(text)
521
+ ? text
522
+ : System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
523
+
524
+ static string FormatRole(string role)
211
525
  {
212
- public readonly string Type;
213
- public readonly string MessageId;
214
- public readonly bool LastMessage;
215
- public readonly string Markdown;
526
+ if (string.IsNullOrEmpty(role)) return "Message";
527
+ return char.ToUpperInvariant(role[0]) + role.Substring(1);
528
+ }
216
529
 
217
- public ParsedLine(string type, string messageId, bool lastMessage, string markdown)
530
+ static string FormatDate(long timestamp)
531
+ {
532
+ if (timestamp <= 0) return "";
533
+ try
534
+ {
535
+ var time = timestamp > 1_000_000_000_000L
536
+ ? DateTimeOffset.FromUnixTimeMilliseconds(timestamp)
537
+ : DateTimeOffset.FromUnixTimeSeconds(timestamp);
538
+ return time.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
539
+ }
540
+ catch
218
541
  {
219
- Type = type;
220
- MessageId = messageId;
221
- LastMessage = lastMessage;
222
- Markdown = markdown;
542
+ return "";
223
543
  }
224
544
  }
545
+
546
+ static string SanitizeFileName(string name)
547
+ {
548
+ if (string.IsNullOrEmpty(name)) return "conversation";
549
+ var invalid = Path.GetInvalidFileNameChars();
550
+ var sanitized = new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim();
551
+ return string.IsNullOrEmpty(sanitized) ? "conversation" : sanitized;
552
+ }
553
+
554
+ static Type FindType(string fullName)
555
+ => AppDomain.CurrentDomain.GetAssemblies()
556
+ .Select(a => a.GetType(fullName, false))
557
+ .FirstOrDefault(t => t != null);
558
+
559
+ // Reads a public/internal field or property by name from a live object
560
+ // (works on boxed structs too).
561
+ static object GetMember(object obj, string name)
562
+ {
563
+ if (obj == null) return null;
564
+ var type = obj.GetType();
565
+ var field = type.GetField(name, InstanceMembers);
566
+ if (field != null) return field.GetValue(obj);
567
+ return type.GetProperty(name, InstanceMembers)?.GetValue(obj);
568
+ }
225
569
  }
226
570
 
227
571
  } // 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.0",
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>- Reworked the Conversation Extractor to read conversations through the AI Assistant in-process API instead of parsing the relay log.<br>- 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.<br>- Updated project and package metadata for AI Assistant 2.11.0-pre.1."
17
17
  },
18
18
  "repository": {
19
19
  "url": "git@github.com:keijiro/AIA-Extensions.git",
20
20
  "type": "git",
21
- "revision": "9ad87b2c94f8c38a28b03572585ea6de1c438e2b"
21
+ "revision": "6a623e5a6d39c382845c3eb447fc3199bda740b3"
22
22
  }
23
23
  }