From 0d90ba8d2c2b238a3c2a31cc01d3ccd6a88c3c77 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 09:17:27 +0000 Subject: [PATCH 1/4] feat(word-plugin): add Word LLM plugin with Office.js + ASP.NET backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Backend (DocxMcp.WordAddin) - ASP.NET Minimal API with SSE streaming endpoint - ClaudeService for streaming Claude API responses - UserChangeService for tracking user changes as logical patches - CORS support for Office.js add-in ## Frontend (word-addin) - Office.js add-in with TypeScript + React + Fluent UI - WordService: wrapper around Word JavaScript API - ChangeTracker: detects user changes, sends to backend as logical patches - LlmClient: receives streaming patches via SSE, applies to Word - Task Pane UI with chat interface and patch visualization ## Architecture - User changes → logical patches (semantic: added/removed/modified/moved) - LLM changes → normal patches (JSON RFC 6902: add/replace/remove) - Real-time streaming via Server-Sent Events (SSE) https://claude.ai/code/session_01LUhRKQAbk9v2pT25K6LSke --- DocxMcp.sln | 15 + .../DocxMcp.WordAddin.csproj | 19 + src/DocxMcp.WordAddin/Models/LlmModels.cs | 182 +++++++ src/DocxMcp.WordAddin/Program.cs | 137 ++++++ .../Services/ClaudeService.cs | 319 ++++++++++++ .../Services/UserChangeService.cs | 230 +++++++++ word-addin/manifest.xml | 106 ++++ word-addin/package.json | 37 ++ word-addin/src/services/ChangeTracker.ts | 191 ++++++++ word-addin/src/services/LlmClient.ts | 243 ++++++++++ word-addin/src/services/WordService.ts | 379 +++++++++++++++ word-addin/src/services/index.ts | 3 + word-addin/src/taskpane/App.tsx | 458 ++++++++++++++++++ word-addin/src/taskpane/index.tsx | 17 + word-addin/src/taskpane/taskpane.html | 34 ++ word-addin/src/types/index.ts | 89 ++++ word-addin/tsconfig.json | 21 + word-addin/webpack.config.js | 57 +++ 18 files changed, 2537 insertions(+) create mode 100644 src/DocxMcp.WordAddin/DocxMcp.WordAddin.csproj create mode 100644 src/DocxMcp.WordAddin/Models/LlmModels.cs create mode 100644 src/DocxMcp.WordAddin/Program.cs create mode 100644 src/DocxMcp.WordAddin/Services/ClaudeService.cs create mode 100644 src/DocxMcp.WordAddin/Services/UserChangeService.cs create mode 100644 word-addin/manifest.xml create mode 100644 word-addin/package.json create mode 100644 word-addin/src/services/ChangeTracker.ts create mode 100644 word-addin/src/services/LlmClient.ts create mode 100644 word-addin/src/services/WordService.ts create mode 100644 word-addin/src/services/index.ts create mode 100644 word-addin/src/taskpane/App.tsx create mode 100644 word-addin/src/taskpane/index.tsx create mode 100644 word-addin/src/taskpane/taskpane.html create mode 100644 word-addin/src/types/index.ts create mode 100644 word-addin/tsconfig.json create mode 100644 word-addin/webpack.config.js diff --git a/DocxMcp.sln b/DocxMcp.sln index 9e123da..fd2b579 100644 --- a/DocxMcp.sln +++ b/DocxMcp.sln @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxMcp.Cli", "src\DocxMcp. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxMcp.Ui", "src\DocxMcp.Ui\DocxMcp.Ui.csproj", "{D4E5F6A7-B8C9-0123-4567-890ABCDEF012}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxMcp.WordAddin", "src\DocxMcp.WordAddin\DocxMcp.WordAddin.csproj", "{E5F6A7B8-C901-2345-6789-0ABCDEF01234}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -71,6 +73,18 @@ Global {D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Release|x64.Build.0 = Release|Any CPU {D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Release|x86.ActiveCfg = Release|Any CPU {D4E5F6A7-B8C9-0123-4567-890ABCDEF012}.Release|x86.Build.0 = Release|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Debug|x64.ActiveCfg = Debug|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Debug|x64.Build.0 = Debug|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Debug|x86.ActiveCfg = Debug|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Debug|x86.Build.0 = Debug|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Release|Any CPU.Build.0 = Release|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Release|x64.ActiveCfg = Release|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Release|x64.Build.0 = Release|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Release|x86.ActiveCfg = Release|Any CPU + {E5F6A7B8-C901-2345-6789-0ABCDEF01234}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -78,5 +92,6 @@ Global GlobalSection(NestedProjects) = preSolution {3B0B53E5-AF70-4F88-B383-04849B4CBCE0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {D4E5F6A7-B8C9-0123-4567-890ABCDEF012} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E5F6A7B8-C901-2345-6789-0ABCDEF01234} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/src/DocxMcp.WordAddin/DocxMcp.WordAddin.csproj b/src/DocxMcp.WordAddin/DocxMcp.WordAddin.csproj new file mode 100644 index 0000000..deb2171 --- /dev/null +++ b/src/DocxMcp.WordAddin/DocxMcp.WordAddin.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + docx-word-addin + DocxMcp.WordAddin + + + + + + + + + + + diff --git a/src/DocxMcp.WordAddin/Models/LlmModels.cs b/src/DocxMcp.WordAddin/Models/LlmModels.cs new file mode 100644 index 0000000..59c24ea --- /dev/null +++ b/src/DocxMcp.WordAddin/Models/LlmModels.cs @@ -0,0 +1,182 @@ +using System.Text.Json.Serialization; + +namespace DocxMcp.WordAddin.Models; + +/// +/// Request to start an LLM editing session. +/// +public sealed class LlmEditRequest +{ + /// + /// The document session ID (from docx-mcp). + /// + public required string SessionId { get; init; } + + /// + /// User's instruction for the LLM (e.g., "rewrite this paragraph more concisely"). + /// + public required string Instruction { get; init; } + + /// + /// Current document content (from Word via Office.js). + /// + public required DocumentContent Document { get; init; } + + /// + /// Optional: specific path to focus on (e.g., "/body/paragraph[2]"). + /// + public string? FocusPath { get; init; } + + /// + /// Optional: user's recent logical changes (for context). + /// + public List? RecentChanges { get; init; } +} + +/// +/// Document content sent from Word. +/// +public sealed class DocumentContent +{ + /// + /// Full text content of the document. + /// + public required string Text { get; init; } + + /// + /// Structured elements (paragraphs, headings, etc.) with IDs. + /// + public List? Elements { get; init; } + + /// + /// Current selection in Word (if any). + /// + public SelectionInfo? Selection { get; init; } +} + +/// +/// A document element with stable ID. +/// +public sealed class DocumentElement +{ + public required string Id { get; init; } + public required string Type { get; init; } + public required string Text { get; init; } + public int Index { get; init; } + public string? Style { get; init; } +} + +/// +/// Information about the current selection in Word. +/// +public sealed class SelectionInfo +{ + public required string Text { get; init; } + public int StartIndex { get; init; } + public int EndIndex { get; init; } + public string? ContainingElementId { get; init; } +} + +/// +/// A logical change detected from user edits. +/// +public sealed class LogicalChange +{ + public required string ChangeType { get; init; } // added, removed, modified, moved + public required string ElementType { get; init; } + public required string Description { get; init; } + public string? OldText { get; init; } + public string? NewText { get; init; } + public DateTime Timestamp { get; init; } +} + +/// +/// SSE event sent during LLM streaming. +/// +public sealed class LlmStreamEvent +{ + public required string Type { get; init; } + public string? Content { get; init; } + public LlmPatch? Patch { get; init; } + public string? Error { get; init; } + public LlmStreamStats? Stats { get; init; } +} + +/// +/// A patch generated by the LLM to apply to the document. +/// +public sealed class LlmPatch +{ + /// + /// Operation: add, replace, remove, move. + /// + public required string Op { get; init; } + + /// + /// Target path in the document. + /// + public required string Path { get; init; } + + /// + /// Value for add/replace operations. + /// + public object? Value { get; init; } + + /// + /// Source path for move operations. + /// + public string? From { get; init; } +} + +/// +/// Statistics at the end of streaming. +/// +public sealed class LlmStreamStats +{ + public int InputTokens { get; init; } + public int OutputTokens { get; init; } + public int PatchesGenerated { get; init; } + public double DurationMs { get; init; } +} + +/// +/// Report user's changes to the backend for context tracking. +/// +public sealed class UserChangeReport +{ + public required string SessionId { get; init; } + public required DocumentContent Before { get; init; } + public required DocumentContent After { get; init; } +} + +/// +/// Result of processing user changes into logical patches. +/// +public sealed class UserChangeResult +{ + public required List Changes { get; init; } + public required string Summary { get; init; } +} + +/// +/// JSON serialization context for AOT compatibility. +/// +[JsonSerializable(typeof(LlmEditRequest))] +[JsonSerializable(typeof(LlmStreamEvent))] +[JsonSerializable(typeof(LlmPatch))] +[JsonSerializable(typeof(UserChangeReport))] +[JsonSerializable(typeof(UserChangeResult))] +[JsonSerializable(typeof(DocumentContent))] +[JsonSerializable(typeof(DocumentElement))] +[JsonSerializable(typeof(SelectionInfo))] +[JsonSerializable(typeof(LogicalChange))] +[JsonSerializable(typeof(LlmStreamStats))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +public partial class WordAddinJsonContext : JsonSerializerContext +{ +} diff --git a/src/DocxMcp.WordAddin/Program.cs b/src/DocxMcp.WordAddin/Program.cs new file mode 100644 index 0000000..5d50c78 --- /dev/null +++ b/src/DocxMcp.WordAddin/Program.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using DocxMcp.WordAddin.Models; +using DocxMcp.WordAddin.Services; + +var builder = WebApplication.CreateSlimBuilder(args); + +// Configure JSON serialization for AOT +builder.Services.ConfigureHttpJsonOptions(o => + o.SerializerOptions.TypeInfoResolverChain.Add(WordAddinJsonContext.Default)); + +// Register services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Configure CORS for Office.js add-in +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy + .AllowAnyOrigin() // Office.js runs from various origins + .AllowAnyMethod() + .AllowAnyHeader() + .WithExposedHeaders("Content-Type"); + }); +}); + +var port = builder.Configuration.GetValue("Port", 5300); +builder.WebHost.UseUrls($"http://localhost:{port}"); + +var app = builder.Build(); + +app.UseCors(); + +// --- Health Check --- +app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "docx-word-addin" })); + +// --- LLM Streaming Endpoint (SSE) --- +app.MapPost("/api/llm/stream", async ( + HttpContext ctx, + ClaudeService claude, + UserChangeService userChanges) => +{ + // Parse request body + LlmEditRequest request; + try + { + request = await ctx.Request.ReadFromJsonAsync(WordAddinJsonContext.Default.LlmEditRequest) + ?? throw new ArgumentException("Invalid request body"); + } + catch (JsonException ex) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsJsonAsync( + new { error = $"Invalid JSON: {ex.Message}" }, + WordAddinJsonContext.Default.Options); + return; + } + + // Add recent user changes for context + request = request with + { + RecentChanges = userChanges.GetRecentChanges(request.SessionId) + }; + + // Set up SSE response + ctx.Response.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers.Connection = "keep-alive"; + + try + { + await foreach (var evt in claude.StreamPatchesAsync(request, ctx.RequestAborted)) + { + var json = JsonSerializer.Serialize(evt, WordAddinJsonContext.Default.LlmStreamEvent); + await ctx.Response.WriteAsync($"event: {evt.Type}\ndata: {json}\n\n"); + await ctx.Response.Body.FlushAsync(); + } + } + catch (OperationCanceledException) + { + // Client disconnected - this is normal + } + catch (Exception ex) + { + var errorEvt = new LlmStreamEvent + { + Type = "error", + Error = ex.Message + }; + var json = JsonSerializer.Serialize(errorEvt, WordAddinJsonContext.Default.LlmStreamEvent); + await ctx.Response.WriteAsync($"event: error\ndata: {json}\n\n"); + } +}); + +// --- User Change Tracking --- +app.MapPost("/api/changes/report", async ( + HttpContext ctx, + UserChangeService userChanges) => +{ + UserChangeReport report; + try + { + report = await ctx.Request.ReadFromJsonAsync(WordAddinJsonContext.Default.UserChangeReport) + ?? throw new ArgumentException("Invalid request body"); + } + catch (JsonException ex) + { + return Results.BadRequest(new { error = $"Invalid JSON: {ex.Message}" }); + } + + var result = userChanges.ProcessChanges(report); + return Results.Ok(result); +}); + +// --- Get Recent Changes (for debugging/UI) --- +app.MapGet("/api/changes/{sessionId}", (string sessionId, UserChangeService userChanges) => +{ + var changes = userChanges.GetRecentChanges(sessionId, 20); + return Results.Ok(new { session_id = sessionId, changes }); +}); + +// --- Clear Session Changes --- +app.MapDelete("/api/changes/{sessionId}", (string sessionId, UserChangeService userChanges) => +{ + userChanges.ClearSession(sessionId); + return Results.Ok(new { message = $"Cleared change history for session {sessionId}" }); +}); + +Console.Error.WriteLine($"docx-word-addin listening on http://localhost:{port}"); +Console.Error.WriteLine("Endpoints:"); +Console.Error.WriteLine(" POST /api/llm/stream - Stream LLM patches (SSE)"); +Console.Error.WriteLine(" POST /api/changes/report - Report user changes"); +Console.Error.WriteLine(" GET /api/changes/:id - Get recent changes"); +Console.Error.WriteLine(" GET /health - Health check"); + +app.Run(); diff --git a/src/DocxMcp.WordAddin/Services/ClaudeService.cs b/src/DocxMcp.WordAddin/Services/ClaudeService.cs new file mode 100644 index 0000000..dc66f5e --- /dev/null +++ b/src/DocxMcp.WordAddin/Services/ClaudeService.cs @@ -0,0 +1,319 @@ +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using Anthropic.SDK; +using Anthropic.SDK.Messaging; +using DocxMcp.WordAddin.Models; + +namespace DocxMcp.WordAddin.Services; + +/// +/// Service for interacting with Claude API to generate document patches. +/// +public sealed class ClaudeService +{ + private readonly AnthropicClient _client; + private readonly ILogger _logger; + + private const string MODEL = "claude-sonnet-4-20250514"; + private const int MAX_TOKENS = 4096; + + public ClaudeService(ILogger logger) + { + _logger = logger; + + var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") + ?? throw new InvalidOperationException( + "ANTHROPIC_API_KEY environment variable is not set. " + + "Get your API key from https://console.anthropic.com/"); + + _client = new AnthropicClient(apiKey); + } + + /// + /// Stream document patches from Claude based on user instruction. + /// + public async IAsyncEnumerable StreamPatchesAsync( + LlmEditRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var startTime = DateTime.UtcNow; + var patchCount = 0; + var inputTokens = 0; + var outputTokens = 0; + + var systemPrompt = BuildSystemPrompt(); + var userPrompt = BuildUserPrompt(request); + + _logger.LogInformation("Starting Claude stream for session {SessionId}", request.SessionId); + + var messages = new List + { + new(RoleType.User, userPrompt) + }; + + var parameters = new MessageParameters + { + Model = MODEL, + MaxTokens = MAX_TOKENS, + System = [new SystemMessage(systemPrompt)], + Messages = messages, + Stream = true + }; + + var patchBuffer = new StringBuilder(); + var inPatchBlock = false; + + await foreach (var evt in _client.Messages.StreamClaudeMessageAsync(parameters, cancellationToken)) + { + if (evt is ContentBlockDelta { Delta.Text: { } text }) + { + // Parse streaming text for patches + patchBuffer.Append(text); + var bufferStr = patchBuffer.ToString(); + + // Look for complete patch JSON blocks + while (TryExtractPatch(ref bufferStr, out var patchJson)) + { + patchBuffer.Clear(); + patchBuffer.Append(bufferStr); + + if (TryParsePatch(patchJson, out var patch)) + { + patchCount++; + _logger.LogDebug("Extracted patch {Count}: {Op} {Path}", + patchCount, patch.Op, patch.Path); + + yield return new LlmStreamEvent + { + Type = "patch", + Patch = patch + }; + } + } + + // Also emit raw content for UI display + yield return new LlmStreamEvent + { + Type = "content", + Content = text + }; + } + else if (evt is MessageDelta { Usage: { } usage }) + { + outputTokens = usage.OutputTokens; + } + else if (evt is MessageStart { Message.Usage: { } startUsage }) + { + inputTokens = startUsage.InputTokens; + } + } + + var duration = (DateTime.UtcNow - startTime).TotalMilliseconds; + + _logger.LogInformation( + "Claude stream completed: {Patches} patches, {InputTokens}/{OutputTokens} tokens, {Duration}ms", + patchCount, inputTokens, outputTokens, duration); + + yield return new LlmStreamEvent + { + Type = "done", + Stats = new LlmStreamStats + { + InputTokens = inputTokens, + OutputTokens = outputTokens, + PatchesGenerated = patchCount, + DurationMs = duration + } + }; + } + + private static string BuildSystemPrompt() + { + return """ + You are a document editing assistant integrated into Microsoft Word. + Your task is to modify documents based on user instructions. + + ## Output Format + + When making changes, output JSON patches in this exact format: + ```patch + {"op": "replace", "path": "/body/paragraph[0]", "value": {"type": "paragraph", "text": "New text here"}} + ``` + + Each patch must be on its own line within ```patch``` blocks. + + ## Available Operations + + - **add**: Insert a new element + ```patch + {"op": "add", "path": "/body/children/0", "value": {"type": "paragraph", "text": "New paragraph"}} + ``` + + - **replace**: Replace existing content + ```patch + {"op": "replace", "path": "/body/paragraph[id='ABC123']", "value": {"type": "paragraph", "text": "Updated text"}} + ``` + + - **remove**: Delete an element + ```patch + {"op": "remove", "path": "/body/paragraph[2]"} + ``` + + - **replace_text**: Find and replace text (preserves formatting) + ```patch + {"op": "replace_text", "path": "/body/paragraph[0]", "find": "old", "replace": "new"} + ``` + + ## Path Syntax + + - By index: `/body/paragraph[0]`, `/body/heading[1]` + - By ID: `/body/paragraph[id='ABC123']` (preferred for existing elements) + - By text: `/body/paragraph[text~='contains this']` + - Wildcards: `/body/paragraph[*]` (all paragraphs) + + ## Element Types + + - `paragraph`: Regular text + - `heading`: With `level` (1-6) + - `table`: With `headers` and `rows` + - `list`: With `items` and optional `ordered` + + ## Guidelines + + 1. Prefer `replace_text` for small text changes (preserves formatting) + 2. Use element IDs when available (more stable than indices) + 3. Make minimal changes - don't rewrite unchanged content + 4. Output patches incrementally as you determine each change + 5. Explain your reasoning briefly before each patch + """; + } + + private static string BuildUserPrompt(LlmEditRequest request) + { + var sb = new StringBuilder(); + + sb.AppendLine("## Current Document"); + sb.AppendLine(); + sb.AppendLine("```"); + sb.AppendLine(request.Document.Text); + sb.AppendLine("```"); + + if (request.Document.Elements?.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## Document Structure"); + sb.AppendLine(); + foreach (var elem in request.Document.Elements.Take(50)) + { + var text = elem.Text.Length > 100 + ? elem.Text[..100] + "..." + : elem.Text; + sb.AppendLine($"- [{elem.Index}] {elem.Type} (id={elem.Id}): \"{text}\""); + } + } + + if (request.Document.Selection is { } sel && !string.IsNullOrEmpty(sel.Text)) + { + sb.AppendLine(); + sb.AppendLine("## Current Selection"); + sb.AppendLine($"Text: \"{sel.Text}\""); + if (sel.ContainingElementId is not null) + { + sb.AppendLine($"In element: {sel.ContainingElementId}"); + } + } + + if (request.RecentChanges?.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("## User's Recent Changes"); + foreach (var change in request.RecentChanges.Take(10)) + { + sb.AppendLine($"- {change.Description}"); + } + } + + if (request.FocusPath is not null) + { + sb.AppendLine(); + sb.AppendLine($"## Focus Area: {request.FocusPath}"); + } + + sb.AppendLine(); + sb.AppendLine("## Instruction"); + sb.AppendLine(); + sb.AppendLine(request.Instruction); + + return sb.ToString(); + } + + /// + /// Try to extract a complete patch JSON from the buffer. + /// + private static bool TryExtractPatch(ref string buffer, out string patchJson) + { + patchJson = ""; + + // Look for ```patch ... ``` blocks + const string startMarker = "```patch"; + const string endMarker = "```"; + + var startIdx = buffer.IndexOf(startMarker, StringComparison.Ordinal); + if (startIdx < 0) return false; + + var contentStart = startIdx + startMarker.Length; + var endIdx = buffer.IndexOf(endMarker, contentStart, StringComparison.Ordinal); + if (endIdx < 0) return false; + + patchJson = buffer[contentStart..endIdx].Trim(); + buffer = buffer[(endIdx + endMarker.Length)..]; + + return !string.IsNullOrWhiteSpace(patchJson); + } + + /// + /// Try to parse a patch JSON string into an LlmPatch. + /// + private static bool TryParsePatch(string json, out LlmPatch patch) + { + patch = null!; + + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var op = root.GetProperty("op").GetString(); + var path = root.GetProperty("path").GetString(); + + if (op is null || path is null) return false; + + patch = new LlmPatch + { + Op = op, + Path = path, + Value = root.TryGetProperty("value", out var v) ? v.Clone() : null, + From = root.TryGetProperty("from", out var f) ? f.GetString() : null + }; + + // Handle replace_text special properties + if (op == "replace_text") + { + var find = root.TryGetProperty("find", out var findEl) ? findEl.GetString() : null; + var replace = root.TryGetProperty("replace", out var replaceEl) ? replaceEl.GetString() : null; + + patch = patch with + { + Value = new { find, replace } + }; + } + + return true; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/DocxMcp.WordAddin/Services/UserChangeService.cs b/src/DocxMcp.WordAddin/Services/UserChangeService.cs new file mode 100644 index 0000000..2392dbc --- /dev/null +++ b/src/DocxMcp.WordAddin/Services/UserChangeService.cs @@ -0,0 +1,230 @@ +using System.Text; +using DocxMcp.WordAddin.Models; + +namespace DocxMcp.WordAddin.Services; + +/// +/// Service to compute logical patches from user changes detected via Office.js. +/// These are semantic changes (added, removed, modified, moved) that help the LLM +/// understand what the user is doing. +/// +public sealed class UserChangeService +{ + private readonly ILogger _logger; + + // Store recent changes per session for context + private readonly Dictionary> _sessionChanges = new(); + private readonly Lock _lock = new(); + + public UserChangeService(ILogger logger) + { + _logger = logger; + } + + /// + /// Process a user change report and compute logical changes. + /// + public UserChangeResult ProcessChanges(UserChangeReport report) + { + var changes = ComputeLogicalChanges(report.Before, report.After); + + // Store for future context + lock (_lock) + { + if (!_sessionChanges.TryGetValue(report.SessionId, out var sessionList)) + { + sessionList = []; + _sessionChanges[report.SessionId] = sessionList; + } + + sessionList.AddRange(changes); + + // Keep only last 50 changes per session + if (sessionList.Count > 50) + { + sessionList.RemoveRange(0, sessionList.Count - 50); + } + } + + _logger.LogInformation( + "Processed {Count} logical changes for session {SessionId}", + changes.Count, report.SessionId); + + return new UserChangeResult + { + Changes = changes, + Summary = BuildSummary(changes) + }; + } + + /// + /// Get recent changes for a session (for LLM context). + /// + public List GetRecentChanges(string sessionId, int limit = 10) + { + lock (_lock) + { + if (_sessionChanges.TryGetValue(sessionId, out var changes)) + { + return changes.TakeLast(limit).Reverse().ToList(); + } + return []; + } + } + + /// + /// Clear change history for a session. + /// + public void ClearSession(string sessionId) + { + lock (_lock) + { + _sessionChanges.Remove(sessionId); + } + } + + /// + /// Compute logical changes between two document states. + /// Uses content-based matching (similar to DiffEngine in docx-mcp). + /// + private List ComputeLogicalChanges(DocumentContent before, DocumentContent after) + { + var changes = new List(); + var timestamp = DateTime.UtcNow; + + var beforeElements = before.Elements ?? []; + var afterElements = after.Elements ?? []; + + // Build lookup by ID + var beforeById = beforeElements.ToDictionary(e => e.Id); + var afterById = afterElements.ToDictionary(e => e.Id); + + // Find removed elements + foreach (var elem in beforeElements) + { + if (!afterById.ContainsKey(elem.Id)) + { + changes.Add(new LogicalChange + { + ChangeType = "removed", + ElementType = elem.Type, + Description = $"Removed {elem.Type}: \"{Truncate(elem.Text, 50)}\"", + OldText = elem.Text, + Timestamp = timestamp + }); + } + } + + // Find added elements + foreach (var elem in afterElements) + { + if (!beforeById.ContainsKey(elem.Id)) + { + changes.Add(new LogicalChange + { + ChangeType = "added", + ElementType = elem.Type, + Description = $"Added {elem.Type}: \"{Truncate(elem.Text, 50)}\"", + NewText = elem.Text, + Timestamp = timestamp + }); + } + } + + // Find modified and moved elements + foreach (var afterElem in afterElements) + { + if (beforeById.TryGetValue(afterElem.Id, out var beforeElem)) + { + // Check for content modification + if (beforeElem.Text != afterElem.Text) + { + changes.Add(new LogicalChange + { + ChangeType = "modified", + ElementType = afterElem.Type, + Description = BuildModificationDescription(beforeElem.Text, afterElem.Text), + OldText = beforeElem.Text, + NewText = afterElem.Text, + Timestamp = timestamp + }); + } + + // Check for move (position change) + if (beforeElem.Index != afterElem.Index && beforeElem.Text == afterElem.Text) + { + changes.Add(new LogicalChange + { + ChangeType = "moved", + ElementType = afterElem.Type, + Description = $"Moved {afterElem.Type} from position {beforeElem.Index} to {afterElem.Index}", + OldText = afterElem.Text, + NewText = afterElem.Text, + Timestamp = timestamp + }); + } + } + } + + return changes; + } + + /// + /// Build a human-readable description of a text modification. + /// + private static string BuildModificationDescription(string oldText, string newText) + { + // Simple heuristics for common operations + if (string.IsNullOrWhiteSpace(oldText) && !string.IsNullOrWhiteSpace(newText)) + { + return $"Added text: \"{Truncate(newText, 50)}\""; + } + + if (!string.IsNullOrWhiteSpace(oldText) && string.IsNullOrWhiteSpace(newText)) + { + return $"Cleared text (was: \"{Truncate(oldText, 50)}\")"; + } + + // Check for extension (appended text) + if (newText.StartsWith(oldText)) + { + var added = newText[oldText.Length..].Trim(); + return $"Extended text, added: \"{Truncate(added, 40)}\""; + } + + // Check for prefix (prepended text) + if (newText.EndsWith(oldText)) + { + var added = newText[..^oldText.Length].Trim(); + return $"Prepended text: \"{Truncate(added, 40)}\""; + } + + // General modification + var lengthDiff = newText.Length - oldText.Length; + var direction = lengthDiff > 0 ? "expanded" : lengthDiff < 0 ? "shortened" : "modified"; + + return $"Text {direction}: \"{Truncate(oldText, 25)}\" → \"{Truncate(newText, 25)}\""; + } + + private static string BuildSummary(List changes) + { + if (changes.Count == 0) + return "No changes detected."; + + var added = changes.Count(c => c.ChangeType == "added"); + var removed = changes.Count(c => c.ChangeType == "removed"); + var modified = changes.Count(c => c.ChangeType == "modified"); + var moved = changes.Count(c => c.ChangeType == "moved"); + + var parts = new List(); + if (added > 0) parts.Add($"{added} added"); + if (removed > 0) parts.Add($"{removed} removed"); + if (modified > 0) parts.Add($"{modified} modified"); + if (moved > 0) parts.Add($"{moved} moved"); + + return $"{changes.Count} change(s): {string.Join(", ", parts)}"; + } + + private static string Truncate(string s, int maxLen) => + s.Length <= maxLen ? s : s[..maxLen] + "..."; +} diff --git a/word-addin/manifest.xml b/word-addin/manifest.xml new file mode 100644 index 0000000..c49ba1c --- /dev/null +++ b/word-addin/manifest.xml @@ -0,0 +1,106 @@ + + + + a1b2c3d4-e5f6-7890-abcd-ef1234567890 + 1.0.0.0 + docx-mcp + en-US + + + + + + + + + + https://localhost:3000 + http://localhost:5300 + + + + + + + + + + + ReadWriteDocument + + + + + + + + <Description resid="GetStarted.Description"/> + <LearnMoreUrl resid="GetStarted.LearnMoreUrl"/> + </GetStarted> + + <FunctionFile resid="Taskpane.Url"/> + + <ExtensionPoint xsi:type="PrimaryCommandSurface"> + <OfficeTab id="TabHome"> + <Group id="LlmGroup"> + <Label resid="LlmGroup.Label"/> + <Icon> + <bt:Image size="16" resid="Icon.16x16"/> + <bt:Image size="32" resid="Icon.32x32"/> + <bt:Image size="80" resid="Icon.80x80"/> + </Icon> + + <Control xsi:type="Button" id="ShowTaskpane"> + <Label resid="ShowTaskpane.Label"/> + <Supertip> + <Title resid="ShowTaskpane.Title"/> + <Description resid="ShowTaskpane.Description"/> + </Supertip> + <Icon> + <bt:Image size="16" resid="Icon.16x16"/> + <bt:Image size="32" resid="Icon.32x32"/> + <bt:Image size="80" resid="Icon.80x80"/> + </Icon> + <Action xsi:type="ShowTaskpane"> + <TaskpaneId>LlmTaskPane</TaskpaneId> + <SourceLocation resid="Taskpane.Url"/> + </Action> + </Control> + </Group> + </OfficeTab> + </ExtensionPoint> + </DesktopFormFactor> + </Host> + </Hosts> + + <Resources> + <bt:Images> + <bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/> + <bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/> + <bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/> + </bt:Images> + + <bt:Urls> + <bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/> + <bt:Url id="GetStarted.LearnMoreUrl" DefaultValue="https://github.com/valdo404/docx-mcp"/> + </bt:Urls> + + <bt:ShortStrings> + <bt:String id="GetStarted.Title" DefaultValue="Get Started with LLM Assistant"/> + <bt:String id="LlmGroup.Label" DefaultValue="LLM"/> + <bt:String id="ShowTaskpane.Label" DefaultValue="Assistant"/> + <bt:String id="ShowTaskpane.Title" DefaultValue="Open LLM Assistant"/> + </bt:ShortStrings> + + <bt:LongStrings> + <bt:String id="GetStarted.Description" DefaultValue="Click the Assistant button to start using AI-powered document editing."/> + <bt:String id="ShowTaskpane.Description" DefaultValue="Opens the LLM Assistant panel for AI-powered document editing with real-time streaming."/> + </bt:LongStrings> + </Resources> + </VersionOverrides> +</OfficeApp> diff --git a/word-addin/package.json b/word-addin/package.json new file mode 100644 index 0000000..2269517 --- /dev/null +++ b/word-addin/package.json @@ -0,0 +1,37 @@ +{ + "name": "docx-llm-addin", + "version": "1.0.0", + "description": "Word Add-in for LLM-powered document editing with real-time patch streaming", + "main": "dist/taskpane.js", + "scripts": { + "build": "webpack --mode production", + "dev": "webpack serve --mode development", + "start": "webpack serve --mode development --open", + "lint": "eslint src --ext .ts,.tsx", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fluentui/react-components": "^9.54.0", + "@fluentui/react-icons": "^2.0.245", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/office-js": "^1.0.377", + "@types/office-runtime": "^1.0.35", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "css-loader": "^7.1.2", + "html-webpack-plugin": "^5.6.0", + "style-loader": "^4.0.0", + "ts-loader": "^9.5.1", + "typescript": "^5.4.5", + "webpack": "^5.91.0", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.0.4", + "copy-webpack-plugin": "^12.0.2" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/word-addin/src/services/ChangeTracker.ts b/word-addin/src/services/ChangeTracker.ts new file mode 100644 index 0000000..1fe4e1e --- /dev/null +++ b/word-addin/src/services/ChangeTracker.ts @@ -0,0 +1,191 @@ +/** + * ChangeTracker - Detects user changes and sends them to backend as logical patches. + * + * Uses polling + debouncing to detect changes without overwhelming the system. + * Computes a diff between snapshots and reports semantic changes. + */ + +import type { DocumentContent, UserChangeReport, LogicalChange } from '../types'; +import { wordService } from './WordService'; + +interface ChangeTrackerConfig { + backendUrl: string; + sessionId: string; + debounceMs: number; + pollIntervalMs: number; + enabled: boolean; +} + +type ChangeHandler = (changes: LogicalChange[]) => void; + +export class ChangeTracker { + private config: ChangeTrackerConfig; + private lastSnapshot: DocumentContent | null = null; + private pollTimer: number | null = null; + private debounceTimer: number | null = null; + private changeHandlers: ChangeHandler[] = []; + private isPolling = false; + + constructor(config: Partial<ChangeTrackerConfig> = {}) { + this.config = { + backendUrl: 'http://localhost:5300', + sessionId: this.generateSessionId(), + debounceMs: 500, + pollIntervalMs: 2000, + enabled: true, + ...config, + }; + } + + /** + * Get the current session ID. + */ + get sessionId(): string { + return this.config.sessionId; + } + + /** + * Start tracking changes. + */ + async start(): Promise<void> { + if (this.pollTimer) return; + + console.log('[ChangeTracker] Starting with session:', this.config.sessionId); + + // Take initial snapshot + this.lastSnapshot = await wordService.getDocumentContent(); + + // Start polling + this.pollTimer = window.setInterval(() => { + this.checkForChanges(); + }, this.config.pollIntervalMs); + } + + /** + * Stop tracking changes. + */ + stop(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + console.log('[ChangeTracker] Stopped'); + } + + /** + * Register a handler for detected changes. + */ + onChanges(handler: ChangeHandler): () => void { + this.changeHandlers.push(handler); + return () => { + this.changeHandlers = this.changeHandlers.filter((h) => h !== handler); + }; + } + + /** + * Force check for changes (useful after LLM applies patches). + */ + async forceCheck(): Promise<void> { + // Clear debounce + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + + // Update snapshot without reporting changes + this.lastSnapshot = await wordService.getDocumentContent(); + } + + /** + * Get the current document snapshot. + */ + async getCurrentSnapshot(): Promise<DocumentContent> { + return wordService.getDocumentContent(); + } + + // --- Private methods --- + + private async checkForChanges(): Promise<void> { + if (!this.config.enabled || this.isPolling) return; + + this.isPolling = true; + + try { + const currentSnapshot = await wordService.getDocumentContent(); + + // Quick check: has the text changed? + if (this.lastSnapshot && currentSnapshot.text === this.lastSnapshot.text) { + return; + } + + // Debounce the change processing + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = window.setTimeout(() => { + this.processChanges(currentSnapshot); + }, this.config.debounceMs); + } catch (error) { + console.error('[ChangeTracker] Error checking for changes:', error); + } finally { + this.isPolling = false; + } + } + + private async processChanges(currentSnapshot: DocumentContent): Promise<void> { + if (!this.lastSnapshot) { + this.lastSnapshot = currentSnapshot; + return; + } + + // Report to backend + const report: UserChangeReport = { + session_id: this.config.sessionId, + before: this.lastSnapshot, + after: currentSnapshot, + }; + + try { + const response = await fetch(`${this.config.backendUrl}/api/changes/report`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(report), + }); + + if (response.ok) { + const result = await response.json(); + const changes = result.changes as LogicalChange[]; + + if (changes.length > 0) { + console.log('[ChangeTracker] Detected changes:', result.summary); + + // Notify handlers + for (const handler of this.changeHandlers) { + try { + handler(changes); + } catch (error) { + console.error('[ChangeTracker] Handler error:', error); + } + } + } + } + } catch (error) { + console.error('[ChangeTracker] Failed to report changes:', error); + } + + // Update snapshot + this.lastSnapshot = currentSnapshot; + } + + private generateSessionId(): string { + return Math.random().toString(36).slice(2, 14); + } +} + +// Export singleton instance +export const changeTracker = new ChangeTracker(); diff --git a/word-addin/src/services/LlmClient.ts b/word-addin/src/services/LlmClient.ts new file mode 100644 index 0000000..65c3807 --- /dev/null +++ b/word-addin/src/services/LlmClient.ts @@ -0,0 +1,243 @@ +/** + * LlmClient - Handles communication with the LLM backend via SSE. + * + * Streams patches from Claude and applies them to Word in real-time. + */ + +import type { LlmEditRequest, LlmStreamEvent, LlmPatch, DocumentContent } from '../types'; +import { wordService } from './WordService'; +import { changeTracker } from './ChangeTracker'; + +interface LlmClientConfig { + backendUrl: string; + autoApplyPatches: boolean; + onContent?: (content: string) => void; + onPatch?: (patch: LlmPatch, applied: boolean) => void; + onDone?: (stats: LlmStreamEvent['stats']) => void; + onError?: (error: string) => void; +} + +export class LlmClient { + private config: LlmClientConfig; + private abortController: AbortController | null = null; + private pendingPatches: LlmPatch[] = []; + private fullContent = ''; + + constructor(config: Partial<LlmClientConfig> = {}) { + this.config = { + backendUrl: 'http://localhost:5300', + autoApplyPatches: true, + ...config, + }; + } + + /** + * Send an instruction to the LLM and stream patches back. + */ + async streamEdit(instruction: string): Promise<void> { + // Cancel any existing stream + this.cancel(); + + this.abortController = new AbortController(); + this.pendingPatches = []; + this.fullContent = ''; + + // Get current document state + const document = await wordService.getDocumentContent(); + + const request: LlmEditRequest = { + session_id: changeTracker.sessionId, + instruction, + document, + recent_changes: [], // Will be filled by backend from stored history + }; + + console.log('[LlmClient] Starting stream for:', instruction.slice(0, 50)); + + try { + const response = await fetch(`${this.config.backendUrl}/api/llm/stream`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + signal: this.abortController.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${await response.text()}`); + } + + if (!response.body) { + throw new Error('No response body'); + } + + await this.processStream(response.body); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + console.log('[LlmClient] Stream cancelled'); + return; + } + + const message = error instanceof Error ? error.message : String(error); + console.error('[LlmClient] Stream error:', message); + this.config.onError?.(message); + } + } + + /** + * Cancel the current stream. + */ + cancel(): void { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + /** + * Get pending patches that haven't been applied yet. + */ + getPendingPatches(): LlmPatch[] { + return [...this.pendingPatches]; + } + + /** + * Apply all pending patches to the document. + */ + async applyPendingPatches(): Promise<{ applied: number; errors: string[] }> { + const patches = [...this.pendingPatches]; + this.pendingPatches = []; + + const result = await wordService.applyPatches(patches); + + // Update change tracker to avoid detecting our own changes + await changeTracker.forceCheck(); + + return result; + } + + /** + * Get the full content streamed so far. + */ + getFullContent(): string { + return this.fullContent; + } + + // --- Private methods --- + + private async processStream(body: ReadableStream<Uint8Array>): Promise<void> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }); + + // Process complete SSE events + const events = this.parseSSEEvents(buffer); + buffer = events.remaining; + + for (const event of events.complete) { + await this.handleEvent(event); + } + } + } finally { + reader.releaseLock(); + } + } + + private parseSSEEvents(buffer: string): { complete: SSEEvent[]; remaining: string } { + const events: SSEEvent[] = []; + const lines = buffer.split('\n'); + let currentEvent: Partial<SSEEvent> = {}; + let remaining = ''; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if we have an incomplete event at the end + if (i === lines.length - 1 && !line.endsWith('\n') && buffer.indexOf('\n\n') === -1) { + remaining = lines.slice(i).join('\n'); + break; + } + + if (line.startsWith('event:')) { + currentEvent.type = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + currentEvent.data = line.slice(5).trim(); + } else if (line === '' && currentEvent.type && currentEvent.data) { + events.push(currentEvent as SSEEvent); + currentEvent = {}; + } + } + + return { complete: events, remaining }; + } + + private async handleEvent(sse: SSEEvent): Promise<void> { + let event: LlmStreamEvent; + + try { + event = JSON.parse(sse.data) as LlmStreamEvent; + } catch { + console.warn('[LlmClient] Failed to parse SSE data:', sse.data); + return; + } + + switch (event.type) { + case 'content': + if (event.content) { + this.fullContent += event.content; + this.config.onContent?.(event.content); + } + break; + + case 'patch': + if (event.patch) { + console.log('[LlmClient] Received patch:', event.patch.op, event.patch.path); + + if (this.config.autoApplyPatches) { + const result = await wordService.applyPatch(event.patch); + this.config.onPatch?.(event.patch, result.success); + + if (!result.success) { + console.warn('[LlmClient] Patch failed:', result.error); + } + } else { + this.pendingPatches.push(event.patch); + this.config.onPatch?.(event.patch, false); + } + } + break; + + case 'done': + console.log('[LlmClient] Stream complete:', event.stats); + this.config.onDone?.(event.stats); + + // Update change tracker after all patches applied + await changeTracker.forceCheck(); + break; + + case 'error': + console.error('[LlmClient] Server error:', event.error); + this.config.onError?.(event.error ?? 'Unknown error'); + break; + } + } +} + +interface SSEEvent { + type: string; + data: string; +} + +// Export factory function +export function createLlmClient(config?: Partial<LlmClientConfig>): LlmClient { + return new LlmClient(config); +} diff --git a/word-addin/src/services/WordService.ts b/word-addin/src/services/WordService.ts new file mode 100644 index 0000000..8cee86c --- /dev/null +++ b/word-addin/src/services/WordService.ts @@ -0,0 +1,379 @@ +/** + * WordService - Wrapper around Office.js Word API + * + * Provides a clean interface for: + * - Reading document content with element IDs + * - Applying patches from the LLM + * - Tracking changes via events + */ + +import type { DocumentContent, DocumentElement, SelectionInfo, LlmPatch } from '../types'; + +// Simple ID generator (8-char hex) +let idCounter = 0; +function generateId(): string { + idCounter++; + return (Date.now() + idCounter).toString(16).toUpperCase().slice(-8); +} + +// Map to store element IDs (persisted per session) +const elementIdMap = new Map<string, string>(); + +export class WordService { + private isInitialized = false; + + /** + * Initialize the Word API context. + */ + async initialize(): Promise<void> { + if (this.isInitialized) return; + + await Office.onReady(); + this.isInitialized = true; + console.log('[WordService] Initialized'); + } + + /** + * Get the full document content with element IDs. + */ + async getDocumentContent(): Promise<DocumentContent> { + await this.initialize(); + + return Word.run(async (context) => { + const body = context.document.body; + body.load('text'); + + const paragraphs = body.paragraphs; + paragraphs.load('items'); + + await context.sync(); + + // Load each paragraph's details + const elements: DocumentElement[] = []; + + for (let i = 0; i < paragraphs.items.length; i++) { + const para = paragraphs.items[i]; + para.load(['text', 'style', 'isListItem']); + } + + await context.sync(); + + for (let i = 0; i < paragraphs.items.length; i++) { + const para = paragraphs.items[i]; + const text = para.text.trim(); + + // Skip empty paragraphs + if (!text) continue; + + // Generate or retrieve stable ID + const contentKey = `${i}:${text.slice(0, 50)}`; + let id = elementIdMap.get(contentKey); + if (!id) { + id = generateId(); + elementIdMap.set(contentKey, id); + } + + // Determine type based on style + const styleName = para.style?.toLowerCase() || ''; + let type: DocumentElement['type'] = 'paragraph'; + + if (styleName.includes('heading') || styleName.startsWith('titre')) { + type = 'heading'; + } else if (para.isListItem) { + type = 'list'; + } + + elements.push({ + id, + type, + text, + index: i, + style: para.style, + }); + } + + return { + text: body.text, + elements, + selection: await this.getSelectionInternal(context), + }; + }); + } + + /** + * Get the current selection. + */ + async getSelection(): Promise<SelectionInfo | undefined> { + await this.initialize(); + + return Word.run(async (context) => { + return this.getSelectionInternal(context); + }); + } + + private async getSelectionInternal(context: Word.RequestContext): Promise<SelectionInfo | undefined> { + const selection = context.document.getSelection(); + selection.load('text'); + + try { + await context.sync(); + + if (!selection.text || !selection.text.trim()) { + return undefined; + } + + return { + text: selection.text, + start_index: 0, // Office.js doesn't give us character indices easily + end_index: selection.text.length, + }; + } catch { + return undefined; + } + } + + /** + * Apply a single LLM patch to the document. + */ + async applyPatch(patch: LlmPatch): Promise<{ success: boolean; error?: string }> { + await this.initialize(); + + try { + await Word.run(async (context) => { + const body = context.document.body; + + switch (patch.op) { + case 'add': + await this.applyAdd(context, body, patch); + break; + + case 'replace': + await this.applyReplace(context, body, patch); + break; + + case 'remove': + await this.applyRemove(context, body, patch); + break; + + case 'replace_text': + await this.applyReplaceText(context, body, patch); + break; + + case 'move': + // Move is complex - implement as remove + add + console.warn('[WordService] Move operation not fully implemented'); + break; + + default: + throw new Error(`Unknown patch operation: ${patch.op}`); + } + + await context.sync(); + }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error('[WordService] Patch failed:', message); + return { success: false, error: message }; + } + } + + /** + * Apply multiple patches in sequence. + */ + async applyPatches(patches: LlmPatch[]): Promise<{ applied: number; errors: string[] }> { + const errors: string[] = []; + let applied = 0; + + for (const patch of patches) { + const result = await this.applyPatch(patch); + if (result.success) { + applied++; + } else if (result.error) { + errors.push(result.error); + } + } + + return { applied, errors }; + } + + /** + * Insert text at the current cursor position. + */ + async insertTextAtCursor(text: string): Promise<void> { + await this.initialize(); + + await Word.run(async (context) => { + const selection = context.document.getSelection(); + selection.insertText(text, Word.InsertLocation.replace); + await context.sync(); + }); + } + + /** + * Insert text at the end of the document. + */ + async appendText(text: string): Promise<void> { + await this.initialize(); + + await Word.run(async (context) => { + const body = context.document.body; + body.insertText(text, Word.InsertLocation.end); + await context.sync(); + }); + } + + /** + * Register a handler for paragraph changes. + */ + async onParagraphChanged(handler: () => void): Promise<() => void> { + await this.initialize(); + + // Note: Word.js paragraph events are in preview + // For now, we'll use polling as a fallback + const interval = setInterval(handler, 2000); + + return () => clearInterval(interval); + } + + // --- Private patch application methods --- + + private async applyAdd( + context: Word.RequestContext, + body: Word.Body, + patch: LlmPatch + ): Promise<void> { + const value = patch.value as { type?: string; text?: string; level?: number } | undefined; + if (!value?.text) { + throw new Error('Add operation requires value.text'); + } + + // Parse path to get index + const index = this.parseIndexFromPath(patch.path); + + if (index !== null && index === 0) { + // Insert at beginning + body.insertParagraph(value.text, Word.InsertLocation.start); + } else { + // Insert at end (simplification - proper index handling would need more work) + const para = body.insertParagraph(value.text, Word.InsertLocation.end); + + // Apply heading style if specified + if (value.type === 'heading' && value.level) { + para.style = `Heading ${value.level}`; + } + } + + await context.sync(); + } + + private async applyReplace( + context: Word.RequestContext, + body: Word.Body, + patch: LlmPatch + ): Promise<void> { + const value = patch.value as { text?: string } | undefined; + if (!value?.text) { + throw new Error('Replace operation requires value.text'); + } + + // Parse path to find the paragraph + const index = this.parseIndexFromPath(patch.path); + const id = this.parseIdFromPath(patch.path); + + const paragraphs = body.paragraphs; + paragraphs.load('items'); + await context.sync(); + + let targetPara: Word.Paragraph | null = null; + + if (index !== null && index < paragraphs.items.length) { + targetPara = paragraphs.items[index]; + } else if (id) { + // Find by ID (search through our map) + for (let i = 0; i < paragraphs.items.length; i++) { + const para = paragraphs.items[i]; + para.load('text'); + await context.sync(); + + const contentKey = `${i}:${para.text.trim().slice(0, 50)}`; + if (elementIdMap.get(contentKey) === id) { + targetPara = para; + break; + } + } + } + + if (targetPara) { + // Clear and insert new text + targetPara.clear(); + targetPara.insertText(value.text, Word.InsertLocation.start); + } else { + throw new Error(`Could not find element at path: ${patch.path}`); + } + } + + private async applyRemove( + context: Word.RequestContext, + body: Word.Body, + patch: LlmPatch + ): Promise<void> { + const index = this.parseIndexFromPath(patch.path); + + const paragraphs = body.paragraphs; + paragraphs.load('items'); + await context.sync(); + + if (index !== null && index < paragraphs.items.length) { + const para = paragraphs.items[index]; + para.delete(); + } else { + throw new Error(`Could not find element at path: ${patch.path}`); + } + } + + private async applyReplaceText( + context: Word.RequestContext, + body: Word.Body, + patch: LlmPatch + ): Promise<void> { + const value = patch.value as { find?: string; replace?: string } | undefined; + if (!value?.find || value.replace === undefined) { + throw new Error('replace_text requires value.find and value.replace'); + } + + // Use Word's search and replace + const searchResults = body.search(value.find, { matchCase: false, matchWholeWord: false }); + searchResults.load('items'); + await context.sync(); + + if (searchResults.items.length > 0) { + // Replace first occurrence + searchResults.items[0].insertText(value.replace, Word.InsertLocation.replace); + } + } + + // --- Path parsing helpers --- + + private parseIndexFromPath(path: string): number | null { + // Match patterns like /body/paragraph[0] or /body/children/0 + const indexMatch = path.match(/\[(\d+)\]/) || path.match(/\/(\d+)$/); + if (indexMatch) { + return parseInt(indexMatch[1], 10); + } + return null; + } + + private parseIdFromPath(path: string): string | null { + // Match patterns like /body/paragraph[id='ABC123'] + const idMatch = path.match(/\[id='([^']+)'\]/); + if (idMatch) { + return idMatch[1]; + } + return null; + } +} + +// Export singleton instance +export const wordService = new WordService(); diff --git a/word-addin/src/services/index.ts b/word-addin/src/services/index.ts new file mode 100644 index 0000000..b6ab684 --- /dev/null +++ b/word-addin/src/services/index.ts @@ -0,0 +1,3 @@ +export { wordService, WordService } from './WordService'; +export { changeTracker, ChangeTracker } from './ChangeTracker'; +export { createLlmClient, LlmClient } from './LlmClient'; diff --git a/word-addin/src/taskpane/App.tsx b/word-addin/src/taskpane/App.tsx new file mode 100644 index 0000000..707da93 --- /dev/null +++ b/word-addin/src/taskpane/App.tsx @@ -0,0 +1,458 @@ +import * as React from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + makeStyles, + tokens, + Button, + Textarea, + Card, + CardHeader, + Text, + Badge, + Spinner, + Divider, + Switch, + Tooltip, +} from '@fluentui/react-components'; +import { + SendRegular, + StopRegular, + SettingsRegular, + DocumentRegular, + CheckmarkCircleRegular, + ErrorCircleRegular, + ArrowSyncRegular, +} from '@fluentui/react-icons'; + +import { wordService } from '../services/WordService'; +import { changeTracker } from '../services/ChangeTracker'; +import { createLlmClient, LlmClient } from '../services/LlmClient'; +import type { LlmPatch, LogicalChange } from '../types'; + +const useStyles = makeStyles({ + container: { + display: 'flex', + flexDirection: 'column', + height: '100%', + padding: tokens.spacingHorizontalM, + gap: tokens.spacingVerticalM, + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + title: { + fontWeight: tokens.fontWeightSemibold, + fontSize: tokens.fontSizeBase400, + }, + content: { + flex: 1, + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + overflowY: 'auto', + }, + inputArea: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, + buttonRow: { + display: 'flex', + gap: tokens.spacingHorizontalS, + }, + message: { + padding: tokens.spacingVerticalS, + borderRadius: tokens.borderRadiusMedium, + }, + userMessage: { + backgroundColor: tokens.colorBrandBackground2, + alignSelf: 'flex-end', + maxWidth: '85%', + }, + assistantMessage: { + backgroundColor: tokens.colorNeutralBackground3, + alignSelf: 'flex-start', + maxWidth: '85%', + }, + patchCard: { + backgroundColor: tokens.colorNeutralBackground1, + border: `1px solid ${tokens.colorNeutralStroke1}`, + }, + patchSuccess: { + borderLeftColor: tokens.colorPaletteGreenBorder1, + borderLeftWidth: '3px', + }, + patchFailed: { + borderLeftColor: tokens.colorPaletteRedBorder1, + borderLeftWidth: '3px', + }, + changeItem: { + padding: tokens.spacingVerticalXS, + fontSize: tokens.fontSizeBase200, + color: tokens.colorNeutralForeground2, + }, + statusBar: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: tokens.spacingVerticalXS, + fontSize: tokens.fontSizeBase200, + color: tokens.colorNeutralForeground3, + }, + settings: { + padding: tokens.spacingVerticalM, + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, +}); + +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + patches?: { patch: LlmPatch; success: boolean }[]; + timestamp: Date; +} + +export const App: React.FC = () => { + const styles = useStyles(); + const [messages, setMessages] = useState<Message[]>([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [showSettings, setShowSettings] = useState(false); + const [autoApply, setAutoApply] = useState(true); + const [trackChanges, setTrackChanges] = useState(true); + const [recentChanges, setRecentChanges] = useState<LogicalChange[]>([]); + const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected'>('disconnected'); + + const llmClientRef = useRef<LlmClient | null>(null); + const contentScrollRef = useRef<HTMLDivElement>(null); + + // Initialize services + useEffect(() => { + const init = async () => { + try { + await wordService.initialize(); + setConnectionStatus('connected'); + + // Set up change tracking + if (trackChanges) { + changeTracker.onChanges((changes) => { + setRecentChanges((prev) => [...changes, ...prev].slice(0, 10)); + }); + changeTracker.start(); + } + } catch (error) { + console.error('Failed to initialize:', error); + setConnectionStatus('disconnected'); + } + }; + + init(); + + return () => { + changeTracker.stop(); + }; + }, [trackChanges]); + + // Scroll to bottom when messages change + useEffect(() => { + if (contentScrollRef.current) { + contentScrollRef.current.scrollTop = contentScrollRef.current.scrollHeight; + } + }, [messages]); + + const handleSend = useCallback(async () => { + if (!input.trim() || isLoading) return; + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: input.trim(), + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMessage]); + setInput(''); + setIsLoading(true); + + // Create assistant message placeholder + const assistantId = (Date.now() + 1).toString(); + const assistantMessage: Message = { + id: assistantId, + role: 'assistant', + content: '', + patches: [], + timestamp: new Date(), + }; + setMessages((prev) => [...prev, assistantMessage]); + + // Create LLM client with handlers + llmClientRef.current = createLlmClient({ + autoApplyPatches: autoApply, + onContent: (content) => { + setMessages((prev) => + prev.map((m) => + m.id === assistantId ? { ...m, content: m.content + content } : m + ) + ); + }, + onPatch: (patch, success) => { + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, patches: [...(m.patches || []), { patch, success }] } + : m + ) + ); + }, + onDone: () => { + setIsLoading(false); + llmClientRef.current = null; + }, + onError: (error) => { + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, content: m.content + `\n\n**Error:** ${error}` } + : m + ) + ); + setIsLoading(false); + llmClientRef.current = null; + }, + }); + + try { + await llmClientRef.current.streamEdit(userMessage.content); + } catch (error) { + console.error('Stream failed:', error); + setIsLoading(false); + } + }, [input, isLoading, autoApply]); + + const handleCancel = useCallback(() => { + if (llmClientRef.current) { + llmClientRef.current.cancel(); + llmClientRef.current = null; + setIsLoading(false); + } + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + if (showSettings) { + return ( + <div className={styles.container}> + <div className={styles.header}> + <Text className={styles.title}>Settings</Text> + <Button + appearance="subtle" + icon={<DocumentRegular />} + onClick={() => setShowSettings(false)} + /> + </div> + + <div className={styles.settings}> + <Switch + checked={autoApply} + onChange={(_, data) => setAutoApply(data.checked)} + label="Auto-apply patches" + /> + <Text size={200} style={{ color: tokens.colorNeutralForeground3 }}> + When enabled, LLM patches are applied to the document immediately. + When disabled, you can review patches before applying. + </Text> + + <Divider /> + + <Switch + checked={trackChanges} + onChange={(_, data) => { + setTrackChanges(data.checked); + if (data.checked) { + changeTracker.start(); + } else { + changeTracker.stop(); + } + }} + label="Track user changes" + /> + <Text size={200} style={{ color: tokens.colorNeutralForeground3 }}> + When enabled, your edits are tracked and sent to the LLM as context + (logical patches). + </Text> + + <Divider /> + + <Text size={200}> + Session ID: <code>{changeTracker.sessionId}</code> + </Text> + </div> + </div> + ); + } + + return ( + <div className={styles.container}> + {/* Header */} + <div className={styles.header}> + <Text className={styles.title}>LLM Assistant</Text> + <div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}> + <Badge + appearance="filled" + color={connectionStatus === 'connected' ? 'success' : 'danger'} + size="small" + /> + <Button + appearance="subtle" + icon={<SettingsRegular />} + onClick={() => setShowSettings(true)} + /> + </div> + </div> + + {/* Recent user changes */} + {recentChanges.length > 0 && ( + <Card size="small"> + <CardHeader + header={ + <Text size={200} weight="semibold"> + Your Recent Changes + </Text> + } + action={ + <Tooltip content="Clear history" relationship="label"> + <Button + appearance="subtle" + size="small" + icon={<ArrowSyncRegular />} + onClick={() => setRecentChanges([])} + /> + </Tooltip> + } + /> + <div style={{ maxHeight: '80px', overflowY: 'auto' }}> + {recentChanges.slice(0, 3).map((change, i) => ( + <div key={i} className={styles.changeItem}> + {change.description} + </div> + ))} + </div> + </Card> + )} + + {/* Messages */} + <div className={styles.content} ref={contentScrollRef}> + {messages.length === 0 && ( + <div style={{ textAlign: 'center', padding: '20px', color: tokens.colorNeutralForeground3 }}> + <DocumentRegular style={{ fontSize: '32px', marginBottom: '8px' }} /> + <Text block> + Describe what you want to change in your document, and I'll help you edit it. + </Text> + </div> + )} + + {messages.map((message) => ( + <div + key={message.id} + className={`${styles.message} ${ + message.role === 'user' ? styles.userMessage : styles.assistantMessage + }`} + > + <Text>{message.content || (isLoading && message.role === 'assistant' ? '...' : '')}</Text> + + {/* Patches */} + {message.patches && message.patches.length > 0 && ( + <div style={{ marginTop: '8px' }}> + <Divider /> + <Text size={200} weight="semibold" style={{ marginTop: '4px' }}> + Patches ({message.patches.length}) + </Text> + {message.patches.map((p, i) => ( + <Card + key={i} + size="small" + className={`${styles.patchCard} ${ + p.success ? styles.patchSuccess : styles.patchFailed + }`} + style={{ marginTop: '4px' }} + > + <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}> + {p.success ? ( + <CheckmarkCircleRegular style={{ color: tokens.colorPaletteGreenForeground1 }} /> + ) : ( + <ErrorCircleRegular style={{ color: tokens.colorPaletteRedForeground1 }} /> + )} + <Text size={200}> + <code>{p.patch.op}</code> {p.patch.path} + </Text> + </div> + </Card> + ))} + </div> + )} + </div> + ))} + + {isLoading && ( + <div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px' }}> + <Spinner size="tiny" /> + <Text size={200}>Generating...</Text> + </div> + )} + </div> + + {/* Input */} + <div className={styles.inputArea}> + <Textarea + placeholder="Describe your edit... (e.g., 'Make the first paragraph more concise')" + value={input} + onChange={(_, data) => setInput(data.value)} + onKeyDown={handleKeyDown} + disabled={isLoading} + resize="vertical" + style={{ minHeight: '60px' }} + /> + <div className={styles.buttonRow}> + {isLoading ? ( + <Button + appearance="secondary" + icon={<StopRegular />} + onClick={handleCancel} + > + Cancel + </Button> + ) : ( + <Button + appearance="primary" + icon={<SendRegular />} + onClick={handleSend} + disabled={!input.trim()} + > + Send + </Button> + )} + </div> + </div> + + {/* Status bar */} + <div className={styles.statusBar}> + <Text size={100}> + {trackChanges ? 'Tracking changes' : 'Change tracking off'} + </Text> + <Text size={100}> + {autoApply ? 'Auto-apply on' : 'Manual apply'} + </Text> + </div> + </div> + ); +}; diff --git a/word-addin/src/taskpane/index.tsx b/word-addin/src/taskpane/index.tsx new file mode 100644 index 0000000..5f66766 --- /dev/null +++ b/word-addin/src/taskpane/index.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { App } from './App'; + +// Wait for Office.js to be ready +Office.onReady(() => { + const container = document.getElementById('root'); + if (container) { + const root = createRoot(container); + root.render( + <FluentProvider theme={webLightTheme}> + <App /> + </FluentProvider> + ); + } +}); diff --git a/word-addin/src/taskpane/taskpane.html b/word-addin/src/taskpane/taskpane.html new file mode 100644 index 0000000..c59e0db --- /dev/null +++ b/word-addin/src/taskpane/taskpane.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>LLM Document Assistant + + + + + + + +
+
+

Loading LLM Assistant...

+
+
+ + diff --git a/word-addin/src/types/index.ts b/word-addin/src/types/index.ts new file mode 100644 index 0000000..3dbb734 --- /dev/null +++ b/word-addin/src/types/index.ts @@ -0,0 +1,89 @@ +/** + * Types shared between the Word add-in and the backend. + */ + +/** Document element with stable ID */ +export interface DocumentElement { + id: string; + type: 'paragraph' | 'heading' | 'table' | 'list'; + text: string; + index: number; + style?: string; +} + +/** Current selection in Word */ +export interface SelectionInfo { + text: string; + start_index: number; + end_index: number; + containing_element_id?: string; +} + +/** Document content sent to the backend */ +export interface DocumentContent { + text: string; + elements?: DocumentElement[]; + selection?: SelectionInfo; +} + +/** Logical change detected from user edits */ +export interface LogicalChange { + change_type: 'added' | 'removed' | 'modified' | 'moved'; + element_type: string; + description: string; + old_text?: string; + new_text?: string; + timestamp?: string; +} + +/** Request to start LLM editing */ +export interface LlmEditRequest { + session_id: string; + instruction: string; + document: DocumentContent; + focus_path?: string; + recent_changes?: LogicalChange[]; +} + +/** Patch generated by the LLM */ +export interface LlmPatch { + op: 'add' | 'replace' | 'remove' | 'move' | 'replace_text'; + path: string; + value?: unknown; + from?: string; +} + +/** SSE event from the LLM stream */ +export interface LlmStreamEvent { + type: 'content' | 'patch' | 'done' | 'error'; + content?: string; + patch?: LlmPatch; + error?: string; + stats?: { + input_tokens: number; + output_tokens: number; + patches_generated: number; + duration_ms: number; + }; +} + +/** Report user changes to backend */ +export interface UserChangeReport { + session_id: string; + before: DocumentContent; + after: DocumentContent; +} + +/** Result of processing user changes */ +export interface UserChangeResult { + changes: LogicalChange[]; + summary: string; +} + +/** Configuration for the add-in */ +export interface AddinConfig { + backendUrl: string; + sessionId: string; + autoTrack: boolean; + debounceMs: number; +} diff --git a/word-addin/tsconfig.json b/word-addin/tsconfig.json new file mode 100644 index 0000000..833fe49 --- /dev/null +++ b/word-addin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true, + "jsx": "react-jsx", + "types": ["office-js", "office-runtime"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/word-addin/webpack.config.js b/word-addin/webpack.config.js new file mode 100644 index 0000000..0f459dc --- /dev/null +++ b/word-addin/webpack.config.js @@ -0,0 +1,57 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); + +const isProduction = process.env.NODE_ENV === 'production'; + +module.exports = { + entry: { + taskpane: './src/taskpane/index.tsx', + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: '[name].js', + clean: true, + }, + resolve: { + extensions: ['.ts', '.tsx', '.js', '.jsx'], + }, + module: { + rules: [ + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + }, + ], + }, + plugins: [ + new HtmlWebpackPlugin({ + template: './src/taskpane/taskpane.html', + filename: 'taskpane.html', + chunks: ['taskpane'], + }), + new CopyWebpackPlugin({ + patterns: [ + { from: 'manifest.xml', to: 'manifest.xml' }, + { from: 'assets', to: 'assets', noErrorOnMissing: true }, + ], + }), + ], + devServer: { + static: { + directory: path.join(__dirname, 'dist'), + }, + port: 3000, + https: true, // Required for Office.js add-ins + headers: { + 'Access-Control-Allow-Origin': '*', + }, + hot: true, + }, + devtool: isProduction ? 'source-map' : 'eval-source-map', +}; From 23c6eaecab4434546dd55a499311131e8d9b760a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Feb 2026 11:28:05 +0000 Subject: [PATCH 2/4] feat(doccy): rebrand Word plugin as Doccy - your friendly document assistant - Like Clippy, but actually helpful! - Updated manifest with new branding and Clippy-style welcome message - Added paperclip emoji throughout UI - Renamed package and assembly to 'doccy' "It looks like you're writing a document. Would you like help with that?" https://claude.ai/code/session_01LUhRKQAbk9v2pT25K6LSke --- src/DocxMcp.WordAddin/DocxMcp.WordAddin.csproj | 2 +- src/DocxMcp.WordAddin/Program.cs | 10 +++++----- word-addin/manifest.xml | 16 ++++++++-------- word-addin/package.json | 4 ++-- word-addin/src/taskpane/App.tsx | 15 +++++++++------ 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/DocxMcp.WordAddin/DocxMcp.WordAddin.csproj b/src/DocxMcp.WordAddin/DocxMcp.WordAddin.csproj index deb2171..8e80ac8 100644 --- a/src/DocxMcp.WordAddin/DocxMcp.WordAddin.csproj +++ b/src/DocxMcp.WordAddin/DocxMcp.WordAddin.csproj @@ -4,7 +4,7 @@ net10.0 enable enable - docx-word-addin + doccy DocxMcp.WordAddin diff --git a/src/DocxMcp.WordAddin/Program.cs b/src/DocxMcp.WordAddin/Program.cs index 5d50c78..cd5af94 100644 --- a/src/DocxMcp.WordAddin/Program.cs +++ b/src/DocxMcp.WordAddin/Program.cs @@ -33,7 +33,7 @@ app.UseCors(); // --- Health Check --- -app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "docx-word-addin" })); +app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "doccy" })); // --- LLM Streaming Endpoint (SSE) --- app.MapPost("/api/llm/stream", async ( @@ -127,11 +127,11 @@ await ctx.Response.WriteAsJsonAsync( return Results.Ok(new { message = $"Cleared change history for session {sessionId}" }); }); -Console.Error.WriteLine($"docx-word-addin listening on http://localhost:{port}"); +Console.Error.WriteLine($"📎 Doccy backend listening on http://localhost:{port}"); Console.Error.WriteLine("Endpoints:"); -Console.Error.WriteLine(" POST /api/llm/stream - Stream LLM patches (SSE)"); +Console.Error.WriteLine(" POST /api/llm/stream - Stream LLM patches (SSE)"); Console.Error.WriteLine(" POST /api/changes/report - Report user changes"); -Console.Error.WriteLine(" GET /api/changes/:id - Get recent changes"); -Console.Error.WriteLine(" GET /health - Health check"); +Console.Error.WriteLine(" GET /api/changes/:id - Get recent changes"); +Console.Error.WriteLine(" GET /health - Health check"); app.Run(); diff --git a/word-addin/manifest.xml b/word-addin/manifest.xml index c49ba1c..d8ead0d 100644 --- a/word-addin/manifest.xml +++ b/word-addin/manifest.xml @@ -10,8 +10,8 @@ 1.0.0.0 docx-mcp en-US - - + + @@ -91,15 +91,15 @@ - - - - + + + + - - + +
diff --git a/word-addin/package.json b/word-addin/package.json index 2269517..c1eb040 100644 --- a/word-addin/package.json +++ b/word-addin/package.json @@ -1,7 +1,7 @@ { - "name": "docx-llm-addin", + "name": "doccy", "version": "1.0.0", - "description": "Word Add-in for LLM-powered document editing with real-time patch streaming", + "description": "Doccy - Your friendly document assistant. Like Clippy, but smarter!", "main": "dist/taskpane.js", "scripts": { "build": "webpack --mode production", diff --git a/word-addin/src/taskpane/App.tsx b/word-addin/src/taskpane/App.tsx index 707da93..3c0828f 100644 --- a/word-addin/src/taskpane/App.tsx +++ b/word-addin/src/taskpane/App.tsx @@ -253,7 +253,7 @@ export const App: React.FC = () => { return (
- Settings + 📎 Doccy Settings