diff --git a/.github/ISSUE_TEMPLATE/GENERAL.md b/.github/ISSUE_TEMPLATE/GENERAL.md index 991da02..84cea1c 100644 --- a/.github/ISSUE_TEMPLATE/GENERAL.md +++ b/.github/ISSUE_TEMPLATE/GENERAL.md @@ -6,6 +6,7 @@ labels: '' assignees: '' --- + diff --git a/.github/ISSUE_TEMPLATE/user-story---tasks.md b/.github/ISSUE_TEMPLATE/user-story---tasks.md new file mode 100644 index 0000000..f8b8a07 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/user-story---tasks.md @@ -0,0 +1,16 @@ +--- +name: user story & tasks +about: user story 및 하위 태스크 작성 +title: 'Onedrive-download/User Story 1.2:' +labels: '' +assignees: '' + +--- + +### 📬 User Story 2 (main title of user story) +#### 개발자로서, ~~ +_부연 설명 및 참고 사항_ + +--- +* **Tasks** + - [ ] task 내용 diff --git a/.gitignore b/.gitignore index bbdc6d7..0b04811 100644 --- a/.gitignore +++ b/.gitignore @@ -400,3 +400,4 @@ FodyWeavers.xsd *.sln.iml .DS_Store +.azure diff --git a/Dockerfile.ppt-translator b/Dockerfile.ppt-translator new file mode 100644 index 0000000..cb86ce2 --- /dev/null +++ b/Dockerfile.ppt-translator @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1 + +# ================================ +# 1) BUILD STAGE +# ================================ +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0 AS build + +# Copy shared library +COPY ./shared/McpSamples.Shared /source/shared/McpSamples.Shared + +# Copy PPT Translator HybridApp +COPY ./ppt-translator/src/McpSamples.PptTranslator.HybridApp \ + /source/ppt-translator/src/McpSamples.PptTranslator.HybridApp + +WORKDIR /source/ppt-translator/src/McpSamples.PptTranslator.HybridApp + +# Publish +RUN dotnet publish -c Release -o /app --self-contained false + + +# ================================ +# 2) FINAL RUNTIME IMAGE (DEBIAN) +# ================================ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 + +WORKDIR /app + +# ShapeCrawler + SkiaSharp dependencies +RUN apt-get update && apt-get install -y \ + libfontconfig1 \ + libfreetype6 \ + libpng16-16 \ + libx11-6 \ + libxext6 \ + libxrender1 \ + uuid-dev \ + libuuid1 \ + libgdiplus \ + libharfbuzz0b \ + libicu72 \ + && rm -rf /var/lib/apt/lists/* + +# Copy published app +COPY --from=build /app . + +# Folder for file mounting +RUN mkdir -p /files && chmod -R 755 /files + +# HTTP MCP endpoint +ENV ASPNETCORE_URLS=http://0.0.0.0:8080 +ENV MCP_HTTP_PATH=/mcp + +# STDI/O MCP + HTTP MCP 지원 +ENTRYPOINT ["dotnet", "McpSamples.PptTranslator.HybridApp.dll"] diff --git a/Dockerfile.ppt-translator-azure b/Dockerfile.ppt-translator-azure new file mode 100644 index 0000000..3edd21a --- /dev/null +++ b/Dockerfile.ppt-translator-azure @@ -0,0 +1,44 @@ +# Azure Container Registry compatible Dockerfile + +############################################### +# 1) BUILD STAGE +############################################### +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build + +COPY ./shared/McpSamples.Shared /source/shared/McpSamples.Shared +COPY ./ppt-translator/src/McpSamples.PptTranslator.HybridApp \ + /source/ppt-translator/src/McpSamples.PptTranslator.HybridApp + +WORKDIR /source/ppt-translator/src/McpSamples.PptTranslator.HybridApp + +RUN dotnet publish -c Release -o /app --self-contained false + + +############################################### +# 2) FINAL STAGE (DEBIAN, not Alpine) +############################################### +FROM mcr.microsoft.com/dotnet/aspnet:9.0 + +WORKDIR /app + +# ShapeCrawler + SkiaSharp deps (Debian) +RUN apt-get update && apt-get install -y \ + libfontconfig1 \ + libfreetype6 \ + libpng16-16 \ + libx11-6 \ + libxext6 \ + libxrender1 \ + && rm -rf /var/lib/apt/lists/* + +# Create mount folder (Azure File Share - single /files mount) +RUN mkdir -p /files && chmod -R 777 /files + +COPY --from=build /app . + +ENV ASPNETCORE_URLS=http://0.0.0.0:8080 +ENV MCP_HTTP_PATH=/mcp + +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "McpSamples.PptTranslator.HybridApp.dll"] diff --git a/ppt-translator/.vscode/mcp.http.container.json b/ppt-translator/.vscode/mcp.http.container.json new file mode 100644 index 0000000..876cd44 --- /dev/null +++ b/ppt-translator/.vscode/mcp.http.container.json @@ -0,0 +1,8 @@ +{ + "servers": { + "ppt-translator-http-container": { + "type": "http", + "url": "http://localhost:8080/mcp" + } + } +} diff --git a/ppt-translator/.vscode/mcp.http.local.json b/ppt-translator/.vscode/mcp.http.local.json new file mode 100644 index 0000000..f70c894 --- /dev/null +++ b/ppt-translator/.vscode/mcp.http.local.json @@ -0,0 +1,8 @@ +{ + "servers": { + "ppt-translator": { + "type": "http", + "url": "http://localhost:5166/mcp" + } + } +} \ No newline at end of file diff --git a/ppt-translator/.vscode/mcp.http.remote.json b/ppt-translator/.vscode/mcp.http.remote.json new file mode 100644 index 0000000..93eaf21 --- /dev/null +++ b/ppt-translator/.vscode/mcp.http.remote.json @@ -0,0 +1,15 @@ +{ + "inputs": [ + { + "type": "promptString", + "id": "ppt-translator-azure-fqdn", + "description": "Azure Container App FQDN (from azd output: AZURE_RESOURCE_PPT_TRANSLATOR_FQDN)" + } + ], + "servers": { + "ppt-translator-azure": { + "type": "http", + "url": "https://${input:ppt-translator-azure-fqdn}/mcp" + } + } +} \ No newline at end of file diff --git a/ppt-translator/.vscode/mcp.stdio.container.json b/ppt-translator/.vscode/mcp.stdio.container.json new file mode 100644 index 0000000..76573d6 --- /dev/null +++ b/ppt-translator/.vscode/mcp.stdio.container.json @@ -0,0 +1,24 @@ +{ + "inputs": [ + { + "type": "promptString", + "id": "ppt-folder-path", + "description": "Absolute path of the folder that contains your PPT files" + } + ], + "servers": { + "ppt-translator-stdio-container": { + "type": "stdio", + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", "OPENAI_API_KEY=${env:OPENAI_API_KEY}", + "-e", "HOST_MOUNT_PATH=${input:ppt-folder-path}", + "-v", "${input:ppt-folder-path}:/files", + "ppt-translator:latest" + ] + } + } +} diff --git a/ppt-translator/.vscode/mcp.stdio.local.json b/ppt-translator/.vscode/mcp.stdio.local.json new file mode 100644 index 0000000..5bd15e2 --- /dev/null +++ b/ppt-translator/.vscode/mcp.stdio.local.json @@ -0,0 +1,23 @@ +{ + "inputs": [ + { + "type": "promptString", + "id": "consoleapp-project-path", + "description": "The absolute path to the console app project Directory" + } + ], + "servers": { + "ppt-translator": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "${input:consoleapp-project-path}", + "--", + "-tc", + "-p" + ] + } + } +} \ No newline at end of file diff --git a/ppt-translator/McpPptTranslator.sln b/ppt-translator/McpPptTranslator.sln new file mode 100644 index 0000000..7ee0dbb --- /dev/null +++ b/ppt-translator/McpPptTranslator.sln @@ -0,0 +1,52 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpSamples.PptTranslator.HybridApp", "src\McpSamples.PptTranslator.HybridApp\McpSamples.PptTranslator.HybridApp.csproj", "{4E3C401C-B16C-459D-8AFC-896CCF5DBACF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpSamples.Shared", "..\shared\McpSamples.Shared\McpSamples.Shared.csproj", "{1F4DA2CA-2377-4800-A275-02F65E68CC2D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|x64.ActiveCfg = Debug|Any CPU + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|x64.Build.0 = Debug|Any CPU + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|x86.ActiveCfg = Debug|Any CPU + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Debug|x86.Build.0 = Debug|Any CPU + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|Any CPU.Build.0 = Release|Any CPU + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|x64.ActiveCfg = Release|Any CPU + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|x64.Build.0 = Release|Any CPU + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|x86.ActiveCfg = Release|Any CPU + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF}.Release|x86.Build.0 = Release|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|x64.ActiveCfg = Debug|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|x64.Build.0 = Debug|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|x86.ActiveCfg = Debug|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Debug|x86.Build.0 = Debug|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|Any CPU.Build.0 = Release|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|x64.ActiveCfg = Release|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|x64.Build.0 = Release|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|x86.ActiveCfg = Release|Any CPU + {1F4DA2CA-2377-4800-A275-02F65E68CC2D}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {4E3C401C-B16C-459D-8AFC-896CCF5DBACF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection +EndGlobal \ No newline at end of file diff --git a/ppt-translator/README.md b/ppt-translator/README.md new file mode 100644 index 0000000..071a082 --- /dev/null +++ b/ppt-translator/README.md @@ -0,0 +1,385 @@ +# MCP Server: PPT Translator + +This is an MCP server that translates PowerPoint presentations to different languages using OpenAI API and ShapeCrawler. This MCP server has been redesigned with a modernized architecture for improved performance and maintainability. + +## Install + +[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)]() [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)]() + +## Prerequisites + +- [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0) +- [Visual Studio Code](https://code.visualstudio.com/) with + - [C# Dev Kit](https://marketplace.visualstudio.com/items/?itemName=ms-dotnettools.csdevkit) extension +- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) +- [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) +- [Docker Desktop](https://docs.docker.com/get-started/get-docker/) +- [OpenAI API Key](https://platform.openai.com/api-keys) + +## What's Included + +PPT Translator MCP server includes: + +| Building Block | Name | Description | Usage | +|----------------|----------------------------|-------------------------------------|-----------------------------| +| Tools | `translate_ppt_file` | Translates a PowerPoint file to target language | `#translate_ppt_file` | +| Prompts | `ppt_translator` | Structured workflow to guide translation process | `/mcp.ppt-translator.ppt_translator` | + +## Getting Started + +- [Getting repository root](#getting-repository-root) +- [Running MCP server](#running-mcp-server) + - [On a local machine](#on-a-local-machine) + - [In a container](#in-a-container) + - [On Azure](#on-azure) +- [Connect MCP server to an MCP host/client](#connect-mcp-server-to-an-mcp-hostclient) + - [VS Code + Agent Mode + Local MCP server](#vs-code--agent-mode--local-mcp-server) + +### Getting repository root + +1. Get the repository root. + + ```bash + # bash/zsh + REPOSITORY_ROOT=$(git rev-parse --show-toplevel) + ``` + + ```powershell + # PowerShell + $REPOSITORY_ROOT = git rev-parse --show-toplevel + ``` + +### Running MCP server + +#### On a local machine + +1. Set your OpenAI API key. + + ```bash + export OPENAI_API_KEY="your-openai-api-key" + ``` + + ```powershell + $env:OPENAI_API_KEY="your-openai-api-key" + ``` + +1. Run the MCP server app. + + ```bash + cd $REPOSITORY_ROOT/ppt-translator + dotnet run --project ./src/McpSamples.PptTranslator.HybridApp + ``` + + > Make sure take note the absolute directory path of the `McpSamples.PptTranslator.HybridApp` project. + + **Parameters**: + + - `--http`: The switch that indicates to run this MCP server as a streamable HTTP type. When this switch is added, the MCP server URL is `http://localhost:5280`. + + With these parameters, you can run the MCP server like: + + ```bash + dotnet run --project ./src/McpSamples.PptTranslator.HybridApp -- --http + ``` + +#### In a container + +1. Build the MCP server app as a container image. + + ```bash + cd $REPOSITORY_ROOT + docker build -f Dockerfile.ppt-translator -t ppt-translator:latest . + ``` + > Make sure take note the absolute directory path of the `ppt-translator` project. + +1. Run the MCP server app in a container. + + ```bash + docker run -i --rm \ + -e OPENAI_API_KEY=$OPENAI_API_KEY \ + -e HOST_MOUNT_PATH=/Users/yourname/ppt-files \ + -v /Users/yourname/ppt-files:/files \ + ppt-translator:latest + ``` + + Alternatively, use the container image from the container registry. + + ```bash + docker run -i --rm \ + -e OPENAI_API_KEY=$OPENAI_API_KEY \ + -e HOST_MOUNT_PATH=/Users/yourname/ppt-files \ + -v /Users/yourname/ppt-files:/files \ + ghcr.io/microsoft/mcp-dotnet-samples/ppt-translator:latest + ``` + + **Parameters**: + + - `--http`: The switch that indicates to run this MCP server as a streamable HTTP type. When this switch is added, the MCP server URL is `http://localhost:8080`. + + With these parameters, you can run the MCP server like: + + ```bash + # use local container image + docker run -i --rm -p 8080:8080 \ + -e OPENAI_API_KEY=$OPENAI_API_KEY \ + -e HOST_MOUNT_PATH=/Users/yourname/ppt-files \ + -v /Users/yourname/ppt-files:/files \ + ppt-translator:latest -- --http + ``` + + ```bash + # use container image from the container registry + docker run -it --rm -p 8080:8080 \ + -e OPENAI_API_KEY=$OPENAI_API_KEY \ + -e HOST_MOUNT_PATH=/Users/yourname/ppt-files \ + -v /Users/yourname/ppt-files:/files \ + ghcr.io/microsoft/mcp-dotnet-samples/ppt-translator:latest -- --http + ``` + +#### On Azure + +1. Navigate to the directory. + + ```bash + cd $REPOSITORY_ROOT/ppt-translator + ``` + +1. Login to Azure. + + ```bash + # Login with Azure Developer CLI + azd auth login + ``` + +1. Set OpenAI API Key. + + ```bash + azd env set OPENAI_API_KEY "your-openai-api-key" + # 확인 + azd env get-values + ``` + +1. Deploy the MCP server app to Azure. + + ```bash + azd up + ``` + + While provisioning and deploying, you'll be asked to provide subscription ID, location, environment name. + +1. After the deployment is complete, get the information by running the following commands: + + - Azure Container Apps FQDN: + + ```bash + azd env get-value AZURE_RESOURCE_PPT_TRANSLATOR_FQDN + ``` + + If you want to use Azure, you must upload the file to the running server first: + + ```bash + curl -F "file=@sample.pptx" https://{YOUR_FQDN}/upload + ``` + +### Connect MCP server to an MCP host/client + +#### VS Code + Agent Mode + Local MCP server + +1. Copy `mcp.json` to the repository root. + + **For locally running MCP server (STDIO):** + + ```bash + mkdir -p $REPOSITORY_ROOT/.vscode + cp $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.stdio.local.json \ + $REPOSITORY_ROOT/.vscode/mcp.json + ``` + + ```powershell + New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force + Copy-Item -Path $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.stdio.local.json ` + -Destination $REPOSITORY_ROOT/.vscode/mcp.json -Force + ``` + + **For locally running MCP server (HTTP):** + + ```bash + mkdir -p $REPOSITORY_ROOT/.vscode + cp $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.local.json \ + $REPOSITORY_ROOT/.vscode/mcp.json + ``` + + ```powershell + New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force + Copy-Item -Path $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.local.json ` + -Destination $REPOSITORY_ROOT/.vscode/mcp.json -Force + ``` + + **For locally running MCP server in a container (STDIO):** + + ```bash + mkdir -p $REPOSITORY_ROOT/.vscode + cp $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.stdio.container.json \ + $REPOSITORY_ROOT/.vscode/mcp.json + ``` + + ```powershell + New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force + Copy-Item -Path $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.stdio.container.json ` + -Destination $REPOSITORY_ROOT/.vscode/mcp.json -Force + ``` + + **For locally running MCP server in a container (HTTP):** + + ```bash + mkdir -p $REPOSITORY_ROOT/.vscode + cp $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.container.json \ + $REPOSITORY_ROOT/.vscode/mcp.json + ``` + + ```powershell + New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force + Copy-Item -Path $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.container.json ` + -Destination $REPOSITORY_ROOT/.vscode/mcp.json -Force + ``` + + **For remotely running MCP server in a container (HTTP):** + + ```bash + mkdir -p $REPOSITORY_ROOT/.vscode + cp $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.remote.json \ + $REPOSITORY_ROOT/.vscode/mcp.json + ``` + + ```powershell + New-Item -Type Directory -Path $REPOSITORY_ROOT/.vscode -Force + Copy-Item -Path $REPOSITORY_ROOT/ppt-translator/.vscode/mcp.http.remote.json ` + -Destination $REPOSITORY_ROOT/.vscode/mcp.json -Force + ``` + +1. Open Command Palette by typing `F1` or `Ctrl`+`Shift`+`P` on Windows or `Cmd`+`Shift`+`P` on Mac OS, and search `MCP: List Servers`. +1. Choose `ppt-translator` then click `Start Server`. +1. When prompted, enter one of the following values: + - The absolute directory path of the `McpSamples.PptTranslator.HybridApp` project + - The FQDN of Azure Container Apps. +1. Enter prompt like: + + ```text + Translate /path/to/presentation.pptx to Korean + ``` + +1. Confirm the result. + +## Features + +- **Multi-language Support**: Translate to any language (ko, en, ja, etc.) +- **Multiple File Format Support**: Enhanced to support various document formats +- **Preserves Formatting**: Maintains original PPT structure and styling +- **OpenAI Integration**: Uses GPT models for high-quality translation +- **Integrated Prompt System**: Built-in MCP prompts for enhanced user guidance +- **Streamlined Architecture**: Refactored services for better performance +- **5 Execution Modes**: stdio.local, http.local, stdio.container, http.container, http.remote + +## Tool Reference + +### translate_ppt_file + +Translates a PowerPoint file to target language. + +**Parameters:** +- `filePath` (required): Path to PPT file + - Local: absolute path (e.g., `/Users/name/file.pptx`) + - Container: filename only (e.g., `sample.pptx`) + - Azure: filename only (e.g., `sample.pptx`) +- `targetLang` (required): Target language code (e.g., `ko`, `en`, `ja`) +- `outputPath` (optional): Custom output directory (local modes only) + +## Architecture + +### Modernized Service Architecture + +This version features a completely refactored service architecture: + +- **Streamlined Services**: Removed legacy components and consolidated file processing +- **Enhanced Tool System**: Improved translation tools with better error handling +- **Integrated Prompt System**: Added structured prompts for better MCP integration +- **Optimized File Processing**: Direct file handling for improved performance + +### File Storage + +| Mode | Input | Output | Download | +|------|-------|--------|----------| +| stdio.local | Absolute path | `wwwroot/generated` | File path | +| http.local | Absolute path | `wwwroot/generated` | `/download/{filename}` | +| stdio.container | `/files/{filename}` | `/files/{filename}` | File path (host) | +| http.container | `/files/{filename}` | `/files/{filename}` | `/download/{filename}` | +| http.remote | `/files/{filename}` | `/files/{filename}` | `/download/{filename}` | + +### Azure Infrastructure + +- **Container Registry**: Stores Docker image +- **Storage Account**: Azure File Share (`ppt-files`, 5TB) +- **Container Apps**: Runs server with auto-scaling (1-10 replicas) +- **Volume Mount**: `/files` mapped to Azure File Share +- **Monitoring**: Application Insights + Log Analytics + +## Troubleshooting + +### ARM64 Mac (M1/M2/M3) + +SkiaSharp requires x86_64 platform: + +```bash +docker buildx build --platform linux/amd64 -t ppt-translator:latest -f Dockerfile.ppt-translator . +``` + +### Container File Not Found + +Ensure file is in mounted directory: + +```bash +# 🍎/🐧 macOS & Linux +cp "/path/to/file.pptx" "/path/to/mount/folder/file.pptx" + +# 💻 Windows Command Prompt +copy "\path\to\file.pptx" "\path\to\mount\folder\file.pptx" + +# 💻 Windows PowerShell +Copy-Item "/path/to/file.pptx" -Destination "/path/to/mount/folder/file.pptx" +``` + +## Development + +### Recent Changes (v2.0) + +This version includes major architectural improvements: + +- **Refactored Service Layer**: Removed legacy components (`TempFileResolver`, deprecated tools) +- **Enhanced Prompt System**: Added `PptTranslatorPrompt` for better MCP integration +- **Improved File Processing**: Streamlined file handling and processing pipeline +- **Multi-format Foundation**: Architecture prepared for supporting multiple document formats + +### Build + +```bash +dotnet build +``` + +### Test Locally + +```bash +export OPENAI_API_KEY="your-key" +dotnet run -- --http +``` + +### Build Docker Image + +```bash +docker buildx build --platform linux/amd64 -t ppt-translator:latest -f Dockerfile.ppt-translator . +``` + +### Deploy to Azure + +```bash +azd up +``` diff --git a/ppt-translator/TestFiles/sample.pptx b/ppt-translator/TestFiles/sample.pptx new file mode 100644 index 0000000..df20b64 Binary files /dev/null and b/ppt-translator/TestFiles/sample.pptx differ diff --git a/ppt-translator/TestFiles/sample2.pptx b/ppt-translator/TestFiles/sample2.pptx new file mode 100644 index 0000000..577a2ed Binary files /dev/null and b/ppt-translator/TestFiles/sample2.pptx differ diff --git a/ppt-translator/azure.yaml b/ppt-translator/azure.yaml new file mode 100644 index 0000000..508a520 --- /dev/null +++ b/ppt-translator/azure.yaml @@ -0,0 +1,23 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: ppt-translator + +metadata: + template: azd-init@1.14.0 + +services: + ppt-translator: + project: src/McpSamples.PptTranslator.HybridApp + language: dotnet + host: containerapp + docker: + path: ../../../Dockerfile.ppt-translator-azure + context: ../../../ + remoteBuild: true + +infra: + path: infra + parameters: + openAiApiKey: + type: secret + value: ${OPENAI_API_KEY} diff --git a/ppt-translator/infra/abbreviations.json b/ppt-translator/infra/abbreviations.json new file mode 100644 index 0000000..1533dee --- /dev/null +++ b/ppt-translator/infra/abbreviations.json @@ -0,0 +1,136 @@ +{ + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "blueprintBlueprints": "bp-", + "blueprintBlueprintsArtifacts": "bpa-", + "cacheRedis": "redis-", + "cdnProfiles": "cdnp-", + "cdnProfilesEndpoints": "cdne-", + "cognitiveServicesAccounts": "cog-", + "cognitiveServicesFormRecognizer": "cog-fr-", + "cognitiveServicesTextAnalytics": "cog-ta-", + "computeAvailabilitySets": "avail-", + "computeCloudServices": "cld-", + "computeDiskEncryptionSets": "des", + "computeDisks": "disk", + "computeDisksOs": "osdisk", + "computeGalleries": "gal", + "computeSnapshots": "snap-", + "computeVirtualMachines": "vm", + "computeVirtualMachineScaleSets": "vmss-", + "containerInstanceContainerGroups": "ci", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "databricksWorkspaces": "dbw-", + "dataFactoryFactories": "adf-", + "dataLakeAnalyticsAccounts": "dla", + "dataLakeStoreAccounts": "dls", + "dataMigrationServices": "dms-", + "dBforMySQLServers": "mysql-", + "dBforPostgreSQLServers": "psql-", + "devicesIotHubs": "iot-", + "devicesProvisioningServices": "provs-", + "devicesProvisioningServicesCertificates": "pcert-", + "documentDBDatabaseAccounts": "cosmos-", + "documentDBMongoDatabaseAccounts": "cosmon-", + "eventGridDomains": "evgd-", + "eventGridDomainsTopics": "evgt-", + "eventGridEventSubscriptions": "evgs-", + "eventHubNamespaces": "evhns-", + "eventHubNamespacesEventHubs": "evh-", + "hdInsightClustersHadoop": "hadoop-", + "hdInsightClustersHbase": "hbase-", + "hdInsightClustersKafka": "kafka-", + "hdInsightClustersMl": "mls-", + "hdInsightClustersSpark": "spark-", + "hdInsightClustersStorm": "storm-", + "hybridComputeMachines": "arcs-", + "insightsActionGroups": "ag-", + "insightsComponents": "appi-", + "keyVaultVaults": "kv-", + "kubernetesConnectedClusters": "arck", + "kustoClusters": "dec", + "kustoClustersDatabases": "dedb", + "logicIntegrationAccounts": "ia-", + "logicWorkflows": "logic-", + "machineLearningServicesWorkspaces": "mlw-", + "managedIdentityUserAssignedIdentities": "id-", + "managementManagementGroups": "mg-", + "migrateAssessmentProjects": "migr-", + "networkApplicationGateways": "agw-", + "networkApplicationSecurityGroups": "asg-", + "networkAzureFirewalls": "afw-", + "networkBastionHosts": "bas-", + "networkConnections": "con-", + "networkDnsZones": "dnsz-", + "networkExpressRouteCircuits": "erc-", + "networkFirewallPolicies": "afwp-", + "networkFirewallPoliciesWebApplication": "waf", + "networkFirewallPoliciesRuleGroups": "wafrg", + "networkFrontDoors": "fd-", + "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", + "networkLoadBalancersExternal": "lbe-", + "networkLoadBalancersInternal": "lbi-", + "networkLoadBalancersInboundNatRules": "rule-", + "networkLocalNetworkGateways": "lgw-", + "networkNatGateways": "ng-", + "networkNetworkInterfaces": "nic-", + "networkNetworkSecurityGroups": "nsg-", + "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", + "networkNetworkWatchers": "nw-", + "networkPrivateDnsZones": "pdnsz-", + "networkPrivateLinkServices": "pl-", + "networkPublicIPAddresses": "pip-", + "networkPublicIPPrefixes": "ippre-", + "networkRouteFilters": "rf-", + "networkRouteTables": "rt-", + "networkRouteTablesRoutes": "udr-", + "networkTrafficManagerProfiles": "traf-", + "networkVirtualNetworkGateways": "vgw-", + "networkVirtualNetworks": "vnet-", + "networkVirtualNetworksSubnets": "snet-", + "networkVirtualNetworksVirtualNetworkPeerings": "peer-", + "networkVirtualWans": "vwan-", + "networkVpnGateways": "vpng-", + "networkVpnGatewaysVpnConnections": "vcn-", + "networkVpnGatewaysVpnSites": "vst-", + "notificationHubsNamespaces": "ntfns-", + "notificationHubsNamespacesNotificationHubs": "ntf-", + "operationalInsightsWorkspaces": "log-", + "portalDashboards": "dash-", + "powerBIDedicatedCapacities": "pbi-", + "purviewAccounts": "pview-", + "recoveryServicesVaults": "rsv-", + "resourcesResourceGroups": "rg-", + "searchSearchServices": "srch-", + "serviceBusNamespaces": "sb-", + "serviceBusNamespacesQueues": "sbq-", + "serviceBusNamespacesTopics": "sbt-", + "serviceEndPointPolicies": "se-", + "serviceFabricClusters": "sf-", + "signalRServiceSignalR": "sigr", + "sqlManagedInstances": "sqlmi-", + "sqlServers": "sql-", + "sqlServersDataWarehouse": "sqldw-", + "sqlServersDatabases": "sqldb-", + "sqlServersDatabasesStretch": "sqlstrdb-", + "storageStorageAccounts": "st", + "storageStorageAccountsVm": "stvm", + "storSimpleManagers": "ssimp", + "streamAnalyticsCluster": "asa-", + "synapseWorkspaces": "syn", + "synapseWorkspacesAnalyticsWorkspaces": "synw", + "synapseWorkspacesSqlPoolsDedicated": "syndp", + "synapseWorkspacesSqlPoolsSpark": "synsp", + "timeSeriesInsightsEnvironments": "tsi-", + "webServerFarms": "plan-", + "webSitesAppService": "app-", + "webSitesAppServiceEnvironment": "ase-", + "webSitesFunctions": "func-", + "webStaticSites": "stapp-" +} diff --git a/ppt-translator/infra/main.bicep b/ppt-translator/infra/main.bicep new file mode 100644 index 0000000..ae8d8ed --- /dev/null +++ b/ppt-translator/infra/main.bicep @@ -0,0 +1,55 @@ +targetScope = 'subscription' + +@description('Environment name used for naming resources') +@minLength(1) +@maxLength(64) +param environmentName string + +@description('Primary deployment region') +@minLength(1) +param location string + +param pptTranslatorExists bool + +@description('Id of the user or app to assign application roles') +param principalId string + +@secure() +@description('OpenAI API Key') +param openAiApiKey string + +var tags = { + 'azd-env-name': environmentName +} + +resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: 'rg-${environmentName}' + location: location + tags: tags +} + +module resources './resources.bicep' = { + name: 'ppt-translator-resources' + scope: rg + params: { + location: location + tags: tags + openAiApiKey: openAiApiKey + principalId: principalId + pptTranslatorExists: pptTranslatorExists + } +} + +// Outputs following azd naming conventions +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT +output AZURE_CONTAINER_REGISTRY_NAME string = resources.outputs.AZURE_CONTAINER_REGISTRY_NAME +output AZURE_RESOURCE_PPT_TRANSLATOR_ID string = resources.outputs.AZURE_RESOURCE_PPT_TRANSLATOR_ID +output AZURE_RESOURCE_PPT_TRANSLATOR_NAME string = resources.outputs.AZURE_RESOURCE_PPT_TRANSLATOR_NAME +output AZURE_RESOURCE_PPT_TRANSLATOR_FQDN string = resources.outputs.AZURE_RESOURCE_PPT_TRANSLATOR_FQDN +output AZURE_STORAGE_ACCOUNT_NAME string = resources.outputs.AZURE_STORAGE_ACCOUNT_NAME +output AZURE_STORAGE_FILE_SHARE_NAME string = resources.outputs.AZURE_STORAGE_FILE_SHARE_NAME + +// Service-specific outputs for azd +output SERVICE_PPT_TRANSLATOR_NAME string = resources.outputs.SERVICE_PPT_TRANSLATOR_NAME +output SERVICE_PPT_TRANSLATOR_IDENTITY_PRINCIPAL_ID string = resources.outputs.SERVICE_PPT_TRANSLATOR_IDENTITY_PRINCIPAL_ID +output SERVICE_PPT_TRANSLATOR_IMAGE_NAME string = resources.outputs.SERVICE_PPT_TRANSLATOR_IMAGE_NAME diff --git a/ppt-translator/infra/main.parameters.json b/ppt-translator/infra/main.parameters.json new file mode 100644 index 0000000..a60deb2 --- /dev/null +++ b/ppt-translator/infra/main.parameters.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "pptTranslatorExists": { + "value": "${SERVICE_PPT_TRANSLATOR_RESOURCE_EXISTS=false}" + }, + "principalId": { + "value": "${AZURE_PRINCIPAL_ID=}" + } + } +} diff --git a/ppt-translator/infra/modules/fetch-container-image.bicep b/ppt-translator/infra/modules/fetch-container-image.bicep new file mode 100644 index 0000000..78d1e7e --- /dev/null +++ b/ppt-translator/infra/modules/fetch-container-image.bicep @@ -0,0 +1,8 @@ +param exists bool +param name string + +resource existingApp 'Microsoft.App/containerApps@2023-05-02-preview' existing = if (exists) { + name: name +} + +output containers array = exists ? existingApp.properties.template.containers : [] diff --git a/ppt-translator/infra/resources.bicep b/ppt-translator/infra/resources.bicep new file mode 100644 index 0000000..f15e4a9 --- /dev/null +++ b/ppt-translator/infra/resources.bicep @@ -0,0 +1,218 @@ +@description('Azure region where resources will be deployed') +param location string = resourceGroup().location + +@description('Tags applied to all resources') +param tags object = {} + +param pptTranslatorExists bool + +@description('Id of the user or app to assign application roles') +param principalId string + +@secure() +param openAiApiKey string + +var abbrs = loadJsonContent('./abbreviations.json') +var resourceToken = uniqueString(subscription().id, resourceGroup().id, location) +var pptTranslatorAppName = 'ppt-translator' + +// Monitor application with Azure Monitor +module monitoring 'br/public:avm/ptn/azd/monitoring:0.1.0' = { + name: 'monitoring' + params: { + logAnalyticsName: '${abbrs.operationalInsightsWorkspaces}${resourceToken}' + applicationInsightsName: '${abbrs.insightsComponents}${resourceToken}' + applicationInsightsDashboardName: '${abbrs.portalDashboards}${resourceToken}' + location: location + tags: tags + } +} + +// Container registry +module containerRegistry 'br/public:avm/res/container-registry/registry:0.1.1' = { + name: 'registry' + params: { + name: '${abbrs.containerRegistryRegistries}${resourceToken}' + location: location + tags: tags + publicNetworkAccess: 'Enabled' + roleAssignments: [ + { + principalId: pptTranslatorIdentity.outputs.principalId + principalType: 'ServicePrincipal' + // ACR pull role + roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } + ] + } +} + +// User assigned identity +module pptTranslatorIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.2.1' = { + name: 'pptTranslatorIdentity' + params: { + name: '${abbrs.managedIdentityUserAssignedIdentities}ppttrans-${resourceToken}' + location: location + } +} + +// +// 1. Storage Account with Single File Share +// +resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' = { + name: 'st${resourceToken}' + location: location + tags: tags + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + properties: { + minimumTlsVersion: 'TLS1_2' + allowBlobPublicAccess: false + supportsHttpsTrafficOnly: true + } + + resource fileServices 'fileServices' = { + name: 'default' + + resource filesShare 'shares' = { + name: 'ppt-files' + properties: { + shareQuota: 100 // 100GB + enabledProtocols: 'SMB' + } + } + } +} + +// +// 2. Container App Environment +// +module containerAppsEnvironment 'br/public:avm/res/app/managed-environment:0.4.5' = { + name: 'container-apps-environment' + params: { + logAnalyticsWorkspaceResourceId: monitoring.outputs.logAnalyticsWorkspaceResourceId + name: '${abbrs.appManagedEnvironments}${resourceToken}' + location: location + zoneRedundant: false + } +} + +// Register Azure Files storage with Container App Environment +resource managedEnv 'Microsoft.App/managedEnvironments@2023-05-01' existing = { + name: '${abbrs.appManagedEnvironments}${resourceToken}' +} + +resource filesStorage 'Microsoft.App/managedEnvironments/storages@2023-05-01' = { + name: 'files-storage' + parent: managedEnv + properties: { + azureFile: { + accountName: storageAccount.name + accountKey: storageAccount.listKeys().keys[0].value + shareName: storageAccount::fileServices::filesShare.name + accessMode: 'ReadWrite' + } + } +} + +// +// 3. Container App with Volume Mount +// +// +// 3. Container App with Volume Mount (using AVM module) +// +module pptTranslatorFetchLatestImage './modules/fetch-container-image.bicep' = { + name: 'pptTranslator-fetch-image' + params: { + exists: pptTranslatorExists + name: 'ppt-translator' + } +} + +module pptTranslator 'br/public:avm/res/app/container-app:0.8.0' = { + name: 'pptTranslator' + params: { + name: pptTranslatorAppName + location: location + tags: union(tags, { 'azd-service-name': 'ppt-translator' }) + environmentResourceId: containerAppsEnvironment.outputs.resourceId + ingressTargetPort: 8080 + scaleMinReplicas: 1 + scaleMaxReplicas: 10 + secrets: { + secureList: [ + { + name: 'openai-api-key' + value: openAiApiKey + } + { + name: 'storage-connection' + value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' + } + ] + } + containers: [ + { + image: pptTranslatorFetchLatestImage.outputs.?containers[?0].?image ?? 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + name: 'main' + resources: { + cpu: json('0.5') + memory: '1.0Gi' + } + args: [ + '--http' + ] + env: [ + { + name: 'OPENAI_API_KEY' + secretRef: 'openai-api-key' + } + { + name: 'AZURE_STORAGE_CONNECTION_STRING' + secretRef: 'storage-connection' + } + ] + volumeMounts: [ + { + volumeName: 'files-volume' + mountPath: '/files' + } + ] + } + ] + volumes: [ + { + name: 'files-volume' + storageType: 'AzureFile' + storageName: filesStorage.name + } + ] + managedIdentities: { + userAssignedResourceIds: [ + pptTranslatorIdentity.outputs.resourceId + ] + } + registries: [ + { + server: containerRegistry.outputs.loginServer + identity: pptTranslatorIdentity.outputs.resourceId + } + ] + } +} + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer +output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.outputs.name +output AZURE_RESOURCE_PPT_TRANSLATOR_ID string = pptTranslator.outputs.resourceId +output AZURE_RESOURCE_PPT_TRANSLATOR_NAME string = pptTranslator.outputs.name +output AZURE_RESOURCE_PPT_TRANSLATOR_FQDN string = pptTranslator.outputs.fqdn +output AZURE_STORAGE_ACCOUNT_NAME string = storageAccount.name +output AZURE_STORAGE_FILE_SHARE_NAME string = storageAccount::fileServices::filesShare.name + +// azd expects these specific output names for container apps +output SERVICE_PPT_TRANSLATOR_NAME string = pptTranslator.outputs.name +output SERVICE_PPT_TRANSLATOR_IDENTITY_PRINCIPAL_ID string = pptTranslatorIdentity.outputs.principalId +output SERVICE_PPT_TRANSLATOR_IMAGE_NAME string = pptTranslatorFetchLatestImage.outputs.?containers[?0].?image ?? 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Configurations/PptTranslatorAppSettings.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Configurations/PptTranslatorAppSettings.cs new file mode 100644 index 0000000..5f5b3cf --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Configurations/PptTranslatorAppSettings.cs @@ -0,0 +1,18 @@ +using McpSamples.Shared.Configurations; +using Microsoft.OpenApi.Models; + +namespace McpSamples.PptTranslator.HybridApp.Configurations; + +/// +/// Application settings for the PPT Translator service. +/// +public class PptTranslatorAppSettings : AppSettings +{ + /// + public override OpenApiInfo OpenApi { get; set; } = new() + { + Title = "MCP PPT Translator", + Version = "1.0.0", + Description = "MCP server for translating PowerPoint (.pptx) files." + }; +} diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/McpSamples.PptTranslator.HybridApp.csproj b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/McpSamples.PptTranslator.HybridApp.csproj new file mode 100644 index 0000000..723ebdd --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/McpSamples.PptTranslator.HybridApp.csproj @@ -0,0 +1,28 @@ + + + + Exe + net9.0 + McpSamples.PptTranslator.HybridApp + enable + enable + 5e4ba7bc-008d-4689-8311-3be925ba73a8 + + + + + + + + + + + + + + + + + + + diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/ExecutionMode.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/ExecutionMode.cs new file mode 100644 index 0000000..9ea16a8 --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/ExecutionMode.cs @@ -0,0 +1,94 @@ +using System; + +namespace McpSamples.PptTranslator.HybridApp.Models +{ + /// + /// Represents the execution mode of the MCP server. + /// + public enum ExecutionMode + { + /// + /// Running locally with STDIO communication. + /// + StdioLocal, + + /// + /// Running locally with HTTP communication. + /// + HttpLocal, + + /// + /// Running in a container with STDIO communication and volume mounts. + /// + StdioContainer, + + /// + /// Running in a container with HTTP communication and volume mounts. + /// + HttpContainer, + + /// + /// Running in Azure Container Apps with HTTP communication. + /// + HttpRemote + } + + /// + /// Provides utilities for detecting and working with execution modes. + /// + public static class ExecutionModeDetector + { + /// + /// Detects the current execution mode based on environment variables. + /// + /// The detected execution mode. + public static ExecutionMode DetectExecutionMode() + { + bool inContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; + bool isAzure = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CONTAINER_APP_NAME")); + bool isHttp = Environment.GetEnvironmentVariable("MCP_HTTP_MODE") == "true"; + + if (isAzure) + return ExecutionMode.HttpRemote; + + if (inContainer && isHttp) + return ExecutionMode.HttpContainer; + + if (inContainer) + return ExecutionMode.StdioContainer; + + if (isHttp) + return ExecutionMode.HttpLocal; + + return ExecutionMode.StdioLocal; + } + + /// + /// Gets the host mount path from environment variable (for container modes). + /// This is the single folder on the host that is mounted to /files in the container. + /// + public static string? GetHostMountPath() + { + return Environment.GetEnvironmentVariable("HOST_MOUNT_PATH"); + } + + /// + /// Checks if the current mode is a container mode. + /// + public static bool IsContainerMode(this ExecutionMode mode) + { + return mode == ExecutionMode.StdioContainer + || mode == ExecutionMode.HttpContainer + || mode == ExecutionMode.HttpRemote; + } + + /// + /// Checks if the current mode is a local mode. + /// + public static bool IsLocalMode(this ExecutionMode mode) + { + return mode == ExecutionMode.StdioLocal + || mode == ExecutionMode.HttpLocal; + } + } +} diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/PptTextExtractResult.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/PptTextExtractResult.cs new file mode 100644 index 0000000..292b752 --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Models/PptTextExtractResult.cs @@ -0,0 +1,21 @@ +namespace McpSamples.PptTranslator.HybridApp.Models +{ + /// + /// Contains the text extraction result from a PPT file. + /// + public class PptTextExtractResult + { + public int TotalCount { get; set; } + public List Items { get; set; } = new(); + } + + /// + /// Represents a text item extracted from a slide. + /// + public class PptTextExtractItem + { + public int SlideIndex { get; set; } + public string ShapeId { get; set; } = string.Empty; + public string Text { get; set; } = string.Empty; + } +} diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Program.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Program.cs new file mode 100644 index 0000000..dda6d76 --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Program.cs @@ -0,0 +1,111 @@ +using McpSamples.PptTranslator.HybridApp.Configurations; +using McpSamples.PptTranslator.HybridApp.Services; +using McpSamples.PptTranslator.HybridApp.Tools; +using McpSamples.PptTranslator.HybridApp.Models; +using McpSamples.PptTranslator.HybridApp.Prompts; +using McpSamples.Shared.Configurations; +using McpSamples.Shared.Extensions; +using ModelContextProtocol.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var useStreamableHttp = AppSettings.UseStreamableHttp(Environment.GetEnvironmentVariables(), args); + +// MCP_HTTP_MODE 환경변수 설정 (ExecutionMode 감지용) +if (useStreamableHttp) +{ + Environment.SetEnvironmentVariable("MCP_HTTP_MODE", "true"); +} + +var executionMode = ExecutionModeDetector.DetectExecutionMode(); + +bool isAzure = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CONTAINER_APP_NAME")) + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")); + +const string FilesDir = "/files"; // 단일 마운트 폴더 + +IHostApplicationBuilder builder = useStreamableHttp + ? WebApplication.CreateBuilder(args) + : Host.CreateApplicationBuilder(args); + +builder.Configuration.AddEnvironmentVariables(); + +builder.Services.AddAppSettings(builder.Configuration, args); +builder.Services.AddLogging(); +builder.Services.AddHttpContextAccessor(); + +// Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); + +IHost appHost = builder.BuildApp(useStreamableHttp); + +// HTTP MODE +if (useStreamableHttp) +{ + var app = (WebApplication)appHost; + + // --------------------------- + // 1) Upload (Azure only - uploads to /files/input) + // --------------------------- + app.MapPost("/upload", async (HttpRequest req) => + { + if (!req.HasFormContentType) + return Results.BadRequest("multipart/form-data required"); + + var file = (await req.ReadFormAsync()).Files["file"]; + if (file == null) + return Results.BadRequest("file required"); + + if (isAzure) + { + // input 폴더에 저장하여 일관성 보장 + string inputDir = Path.Combine(FilesDir, "input"); + Directory.CreateDirectory(inputDir); + + string fileName = file.FileName; // 원본 파일명 사용 + string savePath = Path.Combine(inputDir, fileName); + + using var fs = File.Create(savePath); + await file.CopyToAsync(fs); + + return Results.Ok(new { id = fileName, path = fileName }); + } + else + { + return Results.BadRequest("Upload endpoint is only available in Azure mode. For local mode, provide absolute file path directly."); + } + }); + + + + + + // --------------------------- + // 4) Download by filename (Primary endpoint for all modes) + // --------------------------- + app.MapGet("/download/{filename}", (string filename) => + { + string filePath = executionMode.IsContainerMode() + ? Path.Combine(FilesDir, "output", filename) // Container/Azure 모드: /files/output 폴더 사용 + : Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "generated", filename); // 로컬 모드 + + return File.Exists(filePath) + ? Results.File(filePath, + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + filename) + : Results.NotFound(); + }); + + + + await app.RunAsync(); +} +else +{ + await appHost.RunAsync(); +} diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/PptTranslatorPrompt.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/PptTranslatorPrompt.cs new file mode 100644 index 0000000..9d399a1 --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/PptTranslatorPrompt.cs @@ -0,0 +1,89 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; +using System.Linq; + +namespace McpSamples.PptTranslator.HybridApp.Prompts; + +/// +/// This provides interfaces for PPT Translator prompts. +/// +public interface IPptTranslatorPrompt +{ + /// + /// Gets a prompt to start the PPT translation workflow. + /// + /// The full path to the PPTX file on the host machine or a public URL. + /// The target language code. + /// The optional download path for the translated file. + string GetTranslationPrompt(string hostFilePath, string targetLang, string? downloadPath = null); +} + +/// +/// This represents the prompts entity for the PptTranslator system. +/// +[McpServerPromptType] +public class PptTranslatorPrompt : IPptTranslatorPrompt +{ + /// + [McpServerPrompt(Name = "translate_ppt", Title = "Start PPT Translation Workflow")] + [Description("Generate a structured workflow prompt for analyzing and translating PPT files.")] + public string GetTranslationPrompt( + [Description("The full path to the PPTX file or a public URL.")] string hostFilePath, + [Description("The target language code (e.g., 'ko', 'en', 'ja')")] string targetLang, + [Description("The optional download path for the translated file. Press Enter to use default locations.")] string? downloadPath = null) + { + string downloadPathInfo = string.IsNullOrWhiteSpace(downloadPath) + ? "If not specified (press Enter to use default):" + : $"Specified save path: `{downloadPath}`"; + + return $""" + The user wants to translate the PPT file located at **{hostFilePath}** to **{targetLang}**. + + ### 🎯 Translation Request + + * **Source File:** `{hostFilePath}` + * **Target Language:** `{targetLang}` + * **Save Path:** {downloadPathInfo} + + ### 🚀 How to Execute + + Simply call the **translate_ppt_file** tool with the following parameters: + - **filePath:** `{hostFilePath}` + - **targetLang:** `{targetLang}`{(string.IsNullOrWhiteSpace(downloadPath) ? "" : $"\n - **outputPath:** `{downloadPath}`")} + + The tool will automatically handle: + ✅ File access and validation + ✅ Content extraction + ✅ Translation to target language + ✅ File generation and saving + ✅ Result delivery + + ### 🚧 Automated Error Handling Instructions + + **If the tool returns a file access error:** + + 1. **For Container Volume Mount Errors:** + - **Automatically execute** the appropriate copy command based on the user's operating system: + - **macOS/Linux:** Run `cp "{hostFilePath}" [VOLUME_MOUNT_PATH]` in terminal + - **Windows CMD:** Run `copy "{hostFilePath}" [VOLUME_MOUNT_PATH]` in terminal + - **Windows PowerShell:** Run `Copy-Item "{hostFilePath}" -Destination [VOLUME_MOUNT_PATH]` in terminal + - Replace `[VOLUME_MOUNT_PATH]` with the actual path from the error message + - **Then retry** the `translate_ppt_file` tool with the corrected file path + + 2. **For URL Upload Scenarios:** + - **Automatically execute** the upload command based on the user's operating system: + - **macOS/Linux:** Run `curl -F "file=@{hostFilePath}" [UPLOAD_URL]` in terminal + - **Windows:** Run `curl.exe -F "file=@{hostFilePath}" [UPLOAD_URL]` in terminal + - Replace `[UPLOAD_URL]` with the actual URL from the error message + - **Then retry** the `translate_ppt_file` tool using the uploaded file reference + + **Important:** You must automatically execute these commands using the terminal tool, not instruct the user to copy and paste them manually. + + ### 📍 Expected Output Location + + {(string.IsNullOrWhiteSpace(downloadPath) ? + "- **Local Environment:** `wwwroot/generated/`\n - **Container Environment:** Mount path (e.g., `/files/`)\n - **Azure Environment:** Download link will be provided" : + $"- **Specified Path:** `{downloadPath}`")} + """; + } +} \ No newline at end of file diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/translation_prompt.txt b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/translation_prompt.txt new file mode 100644 index 0000000..6d82eb4 --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Prompts/translation_prompt.txt @@ -0,0 +1,87 @@ +You are a professional translator specialized in PowerPoint presentation content. + +The input is a JSON structure that contains extracted text from slides. +Each object in the JSON has the following fields: +- SlideIndex: The slide number +- ShapeId: The unique identifier of the text shape +- Text: The original extracted text + +Your goal is to translate ONLY the `Text` values into **{{TARGET_LANG}}**, +while keeping the overall JSON structure (including SlideIndex and ShapeId) unchanged. + +=============================== +TRANSLATION RULES +=============================== +1. JSON FORMAT +- Do NOT modify keys, structure, or ordering. +- Output must be valid JSON only. +- Translate only the value in "Text". + +2. DO NOT TRANSLATE: +- Brand names: Microsoft, PowerPoint, Azure +- Acronyms: AI, API, GPU, HTTP, JSON +- Protocol names: Model Context Protocol, OAuth, WebSocket +- Model names: GPT-5, GPT-4o, Llama 3 +- Code, paths, URLs, formulas + +3. ACADEMIC TONE +- Use clear, formal, precise language. +- Maintain semantic meaning. +- Preserve sentence length and structure. + +4. MIXED LANGUAGE HANDLING (UPDATED) +- If the text contains both a main language and a secondary language in parentheses, + translate ONLY the main natural-language part. +- Completely REMOVE the secondary language part inside parentheses. +- Example: + Input: "데이터 분석 (Data Analysis)" + Target=en → "Data Analysis" + Target=ko → "데이터 분석" +- Never swap languages. +- Never keep the secondary language. +- Only translate the main part and delete the secondary one. + +5. STRUCTURE PRESERVATION +- Preserve line breaks (\n) +- Preserve lists +- Preserve formatting markers like **bold** + +6. DO NOT ADD ANY CONTENT + + +=============================== +EXAMPLE +=============================== +Input: +{ + "TotalCount": 2, + "Items": [ + { + "SlideIndex": 1, + "ShapeId": "TextBox 5", + "Text": "Project Overview" + }, + { + "SlideIndex": 2, + "ShapeId": "TextBox 7", + "Text": "Q&A and Discussion" + } + ] +} + +Output: +{ + "TotalCount": 2, + "Items": [ + { + "SlideIndex": 1, + "ShapeId": "TextBox 5", + "Text": "프로젝트 개요" + }, + { + "SlideIndex": 2, + "ShapeId": "TextBox 7", + "Text": "Q&A 및 토론" + } + ] +} diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Properties/launchSettings.json b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Properties/launchSettings.json new file mode 100644 index 0000000..95e4386 --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5166", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7013;http://localhost:5166", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + } + } + } +} \ No newline at end of file diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/FileRebuildService.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/FileRebuildService.cs new file mode 100644 index 0000000..d5f9b6b --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/FileRebuildService.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using ShapeCrawler; +using ShapeCrawler.Presentations; +using Microsoft.Extensions.Configuration; + +namespace McpSamples.PptTranslator.HybridApp.Services +{ + /// + /// Service for rebuilding PowerPoint files with translated text. + /// + public interface IFileRebuildService + { + /// + /// Creates a new PPT file by applying translated text to the original presentation. + /// + /// Path to the original PowerPoint file + /// Path to JSON file with translated text + /// Target language code for output filename + /// Output directory for translated PPT file + /// Path to the newly created translated PPT file + Task RebuildPptFromJsonAsync(string pptFilePath, string translatedJsonPath, string targetLang, string outputPath); + } + + /// + /// Default implementation that preserves slide structure while replacing text content. + /// Uses ShapeCrawler to manipulate PowerPoint files programmatically. + /// + /// + /// 슬라이드 구조를 유지하면서 텍스트 내용만 교체하는 기본 구현. + /// ShapeCrawler를 사용하여 PowerPoint 파일을 프로그래밍 방식으로 조작합니다. + /// + public class FileRebuildService : IFileRebuildService + { + private readonly ILogger _logger; + private readonly bool _isAzure; + + private static readonly string UploadRoot = + Path.Combine(Path.GetTempPath(), "mcp-uploads"); + + public FileRebuildService( + ILogger logger, + IConfiguration config) + { + _logger = logger; + _isAzure = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WEBSITE_HOSTNAME")); + + if (!_isAzure && !Directory.Exists(UploadRoot)) + Directory.CreateDirectory(UploadRoot); + } + + public async Task RebuildPptFromJsonAsync(string pptFilePath, string translatedJsonPath, string targetLang, string outputPath) + { + _logger.LogInformation("[Rebuild] START - Original paths: PPT={0}, JSON={1}", pptFilePath, translatedJsonPath); + + string resolvedPptPath = ResolvePath(pptFilePath); + _logger.LogInformation("[Rebuild] Resolved PPT path: {0}", resolvedPptPath); + + string resolvedJsonPath = ResolvePath(translatedJsonPath); + _logger.LogInformation("[Rebuild] Resolved JSON path: {0}", resolvedJsonPath); + + if (!File.Exists(resolvedPptPath)) + { + _logger.LogError("[Rebuild] PPT file NOT FOUND: {0}", resolvedPptPath); + throw new FileNotFoundException("PPT file not found.", resolvedPptPath); + } + _logger.LogInformation("[Rebuild] PPT file EXISTS, size: {0} bytes", new FileInfo(resolvedPptPath).Length); + + if (!File.Exists(resolvedJsonPath)) + { + _logger.LogError("[Rebuild] JSON file NOT FOUND: {0}", resolvedJsonPath); + throw new FileNotFoundException("Translated JSON file not found.", resolvedJsonPath); + } + _logger.LogInformation("[Rebuild] JSON file EXISTS, size: {0} bytes", new FileInfo(resolvedJsonPath).Length); + + _logger.LogInformation("[Rebuild] Input PPT: {0}", resolvedPptPath); + _logger.LogInformation("[Rebuild] Translated JSON: {0}", resolvedJsonPath); + + var originalStream = File.OpenRead(resolvedPptPath); + string jsonContent = await File.ReadAllTextAsync(resolvedJsonPath); + + var translated = JsonSerializer.Deserialize(jsonContent) + ?? throw new Exception("Failed to parse translated JSON."); + + ValidateTranslatedJson(translated); + + _logger.LogInformation("[Rebuild] Creating working stream copy..."); + var workingStream = new MemoryStream(); + await originalStream.CopyToAsync(workingStream); + workingStream.Position = 0; + _logger.LogInformation("[Rebuild] Working stream size: {0} bytes", workingStream.Length); + + _logger.LogInformation("[Rebuild] Loading PPT with ShapeCrawler.Presentation..."); + Presentation pres; + try + { + pres = new Presentation(workingStream); + _logger.LogInformation("[Rebuild] ShapeCrawler Presentation loaded successfully! Slide count: {0}", pres.Slides.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "[Rebuild] FAILED to load Presentation with ShapeCrawler!"); + throw; + } + + foreach (var item in translated.Items) + { + try + { + var slide = pres.Slides[item.SlideIndex - 1]; + var shape = FindShapeById(slide, item.ShapeId); + + if (shape?.TextBox != null) + ApplyTranslatedText(shape.TextBox, item.Text); + } + catch (Exception ex) + { + _logger.LogError(ex, + "[Rebuild] Failed apply text: Slide={Slide}, ShapeId={ShapeId}", + item.SlideIndex, item.ShapeId); + } + } + + // 전달받은 outputPath 사용 (이미 디렉터리가 생성되어 있어야 함) + _logger.LogInformation("[Rebuild] Saving to output path: {0}", outputPath); + + string outputDir = Path.GetDirectoryName(outputPath) ?? ""; + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + _logger.LogInformation("[Rebuild] Creating output directory: {0}", outputDir); + Directory.CreateDirectory(outputDir); + } + + using (var fs = new FileStream(outputPath, FileMode.Create)) + { + _logger.LogInformation("[Rebuild] Calling pres.Save()..."); + var ms = new MemoryStream(); + pres.Save(ms); + _logger.LogInformation("[Rebuild] pres.Save() completed, stream size: {0} bytes", ms.Length); + ms.Position = 0; + await ms.CopyToAsync(fs); + _logger.LogInformation("[Rebuild] File written to disk: {0} bytes", fs.Length); + } + + _logger.LogInformation("[Rebuild] Output saved successfully: {0}", outputPath); + return outputPath; + } + + + // ========================================================== + // 경로 해석: Azure와 로컬 temp 방식을 분리 + // ========================================================== + private string ResolvePath(string path) + { + if (_isAzure) + { + // Azure에서는 마운트된 경로 그대로 사용해야 한다. + return path; + } + + // 로컬에서는 기존 temp 시스템 유지 + Directory.CreateDirectory(UploadRoot); + + if (path.StartsWith("temp:", StringComparison.OrdinalIgnoreCase)) + return Path.Combine(UploadRoot, path.Substring(5)); + + if (File.Exists(path)) + { + string id = Guid.NewGuid().ToString("N"); + string dest = Path.Combine(UploadRoot, id); + File.Copy(path, dest, overwrite: true); + return dest; + } + + throw new InvalidOperationException($"Invalid path '{path}'."); + } + + // ========================================================== + // Azure → /output/{GUID}_translated_lang.pptx + // Local → 기존 temp 폴더 + // ========================================================== + private string GetOutputPath(string lang) + { + string fileName = $"{Guid.NewGuid()}_translated_{lang}.pptx"; + + if (_isAzure) + { + // Azure File Share 마운트 경로 + return Path.Combine("/output", fileName); + } + + return Path.Combine(Path.GetTempPath(), fileName); + } + + + private void ApplyTranslatedText(ITextBox textBox, string translatedText) + { + var paragraphs = textBox.Paragraphs; + if (paragraphs.Count == 0) + paragraphs.Add(); + + var first = paragraphs[0]; + var baseFont = first.Portions.Count > 0 ? first.Portions[0].Font : null; + + first.Text = ""; + var lines = translatedText.Split('\n'); + + first.Portions.AddText(lines[0]); + ApplyStyle(first, baseFont); + + for (int i = 1; i < paragraphs.Count; i++) + paragraphs[i].Text = ""; + + for (int i = 1; i < lines.Length; i++) + { + IParagraph p; + if (i < paragraphs.Count) + p = paragraphs[i]; + else + { + paragraphs.Add(); + p = paragraphs[i]; + } + + p.Text = lines[i]; + ApplyStyle(p, baseFont); + } + } + + private void ApplyStyle(IParagraph p, ITextPortionFont? baseFont) + { + if (baseFont == null || p.Portions.Count == 0) return; + + var f = p.Portions[0].Font; + if (f != null) + { + f.Size = baseFont.Size; + f.IsBold = baseFont.IsBold; + f.IsItalic = baseFont.IsItalic; + f.Underline = baseFont.Underline; + f.Color.Set(baseFont.Color.Hex); + f.LatinName = baseFont.LatinName; + f.EastAsianName = baseFont.EastAsianName; + } + } + + private void ValidateTranslatedJson(TranslatedResult json) + { + if (json.Items == null) + throw new Exception("Translated JSON has no items."); + + if (json.TotalCount != json.Items.Count) + throw new Exception("JSON TotalCount does not match Items count."); + } + + private IShape? FindShapeById(ISlide slide, string id) + { + foreach (var s in slide.Shapes) + if (s.Id.ToString() == id) + return s; + return null; + } + + private class TranslatedResult + { + public int TotalCount { get; set; } + public List Items { get; set; } = new(); + } + + private class TranslatedItem + { + public int SlideIndex { get; set; } + public string ShapeId { get; set; } = ""; + public string Text { get; set; } = ""; + } + } +} diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TextExtractService.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TextExtractService.cs new file mode 100644 index 0000000..a71400d --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TextExtractService.cs @@ -0,0 +1,183 @@ +using System; +using System.IO; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using ShapeCrawler; +using ShapeCrawler.Presentations; +using Microsoft.Extensions.Configuration; +using McpSamples.PptTranslator.HybridApp.Models; + +namespace McpSamples.PptTranslator.HybridApp.Services; + +/// +/// Service for extracting text content from PowerPoint files. +/// +public interface ITextExtractService +{ + /// + /// Opens a PowerPoint file for text extraction. + /// + /// Path to the PPT file + Task OpenPptFileAsync(string filePath); + + /// + /// Extracts all text content from the opened presentation. + /// + /// Structured extraction result containing slide and shape text + Task TextExtractAsync(); + + /// + /// Serializes extracted text to JSON format. + /// + /// Extracted text data + /// Output directory for JSON file + /// Path to the generated JSON file + Task ExtractToJsonAsync(PptTextExtractResult extracted, string outputPath); +} + +/// +/// Default implementation of text extraction service using ShapeCrawler library. +/// Handles both local and Azure environments with appropriate path resolution. +/// +/// +/// ShapeCrawler 라이브러리를 사용한 텍스트 추출 서비스 기본 구현. +/// 로컬 및 Azure 환경에서 적절한 경로 해석을 처리합니다. +/// +public class TextExtractService : ITextExtractService +{ + private readonly ILogger _logger; + private readonly ExecutionMode _executionMode; + private Presentation? _presentation; + + public TextExtractService( + ILogger logger, + IConfiguration config) + { + _logger = logger; + _executionMode = ExecutionModeDetector.DetectExecutionMode(); + } + + public Task OpenPptFileAsync(string filePath) + { + string resolved = filePath; + + if (_executionMode.IsContainerMode()) + { + // Container/Azure 모드: 통합된 /files/input 사용 + string fileName = Path.GetFileName(filePath); + resolved = Path.Combine("/files/input", fileName); + + if (!File.Exists(resolved)) + throw new FileNotFoundException("PPT file not found in /files/input folder.", resolved); + + _presentation = new Presentation(resolved); + _logger.LogInformation("[Container] PPT opened from /files/input: {Path}", resolved); + return Task.CompletedTask; + } + else + { + // 로컬 모드: 파일 복사 + if (File.Exists(filePath)) + { + string tempDir = Path.Combine(Path.GetTempPath(), "mcp-uploads"); + Directory.CreateDirectory(tempDir); + string id = Guid.NewGuid().ToString("N"); + resolved = Path.Combine(tempDir, id); + File.Copy(filePath, resolved, overwrite: true); + } + else + { + throw new FileNotFoundException("PPT file not found.", filePath); + } + + if (!File.Exists(resolved)) + throw new FileNotFoundException("Resolved PPT file not found.", resolved); + + try + { + _presentation = new Presentation(resolved); + _logger.LogInformation("[Local] PPT opened: {Resolved}", resolved); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to open PPT file."); + throw; + } + + return Task.CompletedTask; + } + } + + public Task TextExtractAsync() + { + if (_presentation == null) + throw new InvalidOperationException("PPT must be opened before extraction."); + + var result = new PptTextExtractResult(); + var items = new List(); + + foreach (var slide in _presentation.Slides) + { + foreach (var shape in slide.Shapes) + { + if (shape.TextBox == null) + continue; + + string text = shape.TextBox.Text?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(text)) + continue; + + items.Add(new PptTextExtractItem + { + SlideIndex = slide.Number, + ShapeId = shape.Id.ToString(), + Text = text + }); + } + } + + result.Items = items; + result.TotalCount = items.Count; + + _logger.LogInformation("Extracted {Count} items", result.TotalCount); + return Task.FromResult(result); + } + + public async Task ExtractToJsonAsync(PptTextExtractResult extracted, string outputPath) + { + var jsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + var json = JsonSerializer.Serialize(extracted, jsonOptions); + + if (_executionMode.IsContainerMode()) + { + // Container/Azure 모드: 통합된 /files/tmp 사용 + string fileName = Path.GetFileName(outputPath); + string savePath = Path.Combine("/files/tmp", fileName); + + Directory.CreateDirectory("/files/tmp"); + await File.WriteAllTextAsync(savePath, json); + + _logger.LogInformation("[Container] JSON extracted → {Path}", savePath); + return savePath; + } + else + { + // 로컬 모드: 기존 로직 유지 + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + await File.WriteAllTextAsync(outputPath, json); + _logger.LogInformation("[Local] JSON extracted → {Path}", outputPath); + return outputPath; + } + } +} diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TranslationService.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TranslationService.cs new file mode 100644 index 0000000..e6b6ebf --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/TranslationService.cs @@ -0,0 +1,188 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using McpSamples.PptTranslator.HybridApp.Models; + +namespace McpSamples.PptTranslator.HybridApp.Services +{ + /// + /// Service for translating extracted text using OpenAI API. + /// + public interface ITranslationService + { + /// + /// Translates text content from JSON format using OpenAI's language model. + /// + /// Path to JSON file containing extracted text + /// Target language code (e.g., 'ko', 'en', 'ja') + /// Path to translated JSON file + Task TranslateJsonFileAsync(string extractedJsonPath, string targetLang); + } + + /// + /// Default implementation using OpenAI Chat Completions API for translation. + /// Supports both local and Azure environments with configurable endpoints. + /// + /// + /// OpenAI Chat Completions API를 사용한 번역 서비스 기본 구현. + /// 로컬 및 Azure 환경에서 설정 가능한 엔드포인트를 지원합니다. + /// + public class TranslationService : ITranslationService + { + private readonly ILogger _logger; + private readonly IConfiguration _config; + private readonly HttpClient _http = new(); + private readonly ExecutionMode _executionMode; + + public TranslationService( + ILogger logger, + IConfiguration config) + { + _logger = logger; + _config = config; + _executionMode = ExecutionModeDetector.DetectExecutionMode(); + _http.Timeout = TimeSpan.FromSeconds(300); + } + + public async Task TranslateJsonFileAsync(string extractedJsonPath, string targetLang) + { + _logger.LogInformation("[STEP 2] Sending translation request."); + + // ======================================================= + // Container/Azure 모드 → /files 구조 사용 + // ======================================================= + if (_executionMode.IsContainerMode()) + { + string jsonFileName = Path.GetFileName(extractedJsonPath); + string fullPath = Path.Combine("/files/tmp", jsonFileName); + + if (!File.Exists(fullPath)) + throw new FileNotFoundException("JSON not found in /files/tmp.", fullPath); + + string extractedJson = await File.ReadAllTextAsync(fullPath); + + // Prompt 불러오기 + string promptPath = Path.Combine(AppContext.BaseDirectory, "Prompts", "translation_prompt.txt"); + string promptText = await File.ReadAllTextAsync(promptPath); + + string prompt = + promptText + + $"\n\nTARGET_LANG={targetLang}\n\n" + + extractedJson; + + string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") + ?? throw new Exception("OPENAI_API_KEY not set."); + + string model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5-nano"; + string endpoint = Environment.GetEnvironmentVariable("OPENAI_ENDPOINT") + ?? "https://api.openai.com/v1/chat/completions"; + + var body = new + { + model, + messages = new[] { new { role = "user", content = prompt } } + }; + + var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + + _http.DefaultRequestHeaders.Clear(); + _http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); + + HttpResponseMessage res = await _http.PostAsync(endpoint, content); + string raw = await res.Content.ReadAsStringAsync(); + + if (!res.IsSuccessStatusCode) + throw new Exception($"Translation failed: {res.StatusCode}"); + + string translated = + JsonDocument.Parse(raw) + .RootElement.GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString() + ?? throw new Exception("LLM returned empty content."); + + // 저장 경로: /files/tmp + string outputFile = $"{Guid.NewGuid():N}_translated_{targetLang}.json"; + string outputPath = Path.Combine("/files/tmp", outputFile); + + await File.WriteAllTextAsync(outputPath, translated); + + _logger.LogInformation("[Container] 번역 JSON 저장: {Path}", outputPath); + + // 반환: 전체 경로 (다음 단계에서 사용) + return outputPath; + } + + + // ======================================================= + // Local 모드 (STDIO / HTTP / DOCKER LOCAL) → 파일 처리 + // ======================================================= + + if (!File.Exists(extractedJsonPath)) + throw new FileNotFoundException("Extracted JSON not found.", extractedJsonPath); + + string localJson = await File.ReadAllTextAsync(extractedJsonPath); + + string localPromptPath = Path.Combine(AppContext.BaseDirectory, "Prompts", "translation_prompt.txt"); + string localPromptText = await File.ReadAllTextAsync(localPromptPath); + + string merged = + localPromptText + + $"\n\nTARGET_LANG={targetLang}\n\n" + + localJson; + + string localApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") + ?? throw new Exception("OPENAI_API_KEY not set."); + + string localModel = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5-nano"; + string localEndpoint = Environment.GetEnvironmentVariable("OPENAI_ENDPOINT") + ?? "https://api.openai.com/v1/chat/completions"; + + var localBody = new + { + model = localModel, + messages = new[] { new { role = "user", content = merged } } + }; + + var localContent = new StringContent(JsonSerializer.Serialize(localBody), Encoding.UTF8, "application/json"); + + _http.DefaultRequestHeaders.Clear(); + _http.DefaultRequestHeaders.Add("Authorization", $"Bearer {localApiKey}"); + + HttpResponseMessage localRes = await _http.PostAsync(localEndpoint, localContent); + string localRaw = await localRes.Content.ReadAsStringAsync(); + + _logger.LogInformation("[OpenAI Response] Status: {Status}, Body: {Body}", + localRes.StatusCode, localRaw); + + if (!localRes.IsSuccessStatusCode) + { + throw new Exception($"OpenAI API failed: {localRes.StatusCode}, Body: {localRaw}"); + } + + string localTranslated = + JsonDocument.Parse(localRaw) + .RootElement.GetProperty("choices")[0] + .GetProperty("message") + .GetProperty("content") + .GetString() + ?? throw new Exception("LLM returned empty content."); + + string tempDir = Path.Combine(Path.GetTempPath(), "mcp-uploads"); + Directory.CreateDirectory(tempDir); + string id = Guid.NewGuid().ToString("N"); + string localOutput = Path.Combine(tempDir, id); + await File.WriteAllTextAsync(localOutput, localTranslated); + + _logger.LogInformation("[Local] 번역 JSON 저장(temp): {Path}", localOutput); + + return localOutput; + } + } +} diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/UploadService.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/UploadService.cs new file mode 100644 index 0000000..a691c4c --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Services/UploadService.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Logging; +using McpSamples.PptTranslator.HybridApp.Models; + +namespace McpSamples.PptTranslator.HybridApp.Services +{ + /// + /// Service for handling file uploads in different execution modes. + /// + public interface IUploadService + { + /// + /// Saves an uploaded file stream to the appropriate storage location. + /// + /// File content stream + /// Original filename + /// Path where the file was saved + Task SaveUploadedFileAsync(Stream stream, string fileName); + } + + /// + /// Default implementation supporting local, container, and Azure storage modes. + /// Automatically detects execution mode and saves files to appropriate locations. + /// + /// + /// 로컬, 컨테이너, Azure 스토리지 모드를 지원하는 기본 구현. + /// 실행 모드를 자동으로 감지하고 적절한 위치에 파일을 저장합니다. + /// + public class UploadService : IUploadService + { + private readonly ILogger _logger; + private readonly ExecutionMode _executionMode; + + + + private const string InputMountPath = "/files/input"; + + public UploadService(ILogger logger) + { + _logger = logger; + _executionMode = ExecutionModeDetector.DetectExecutionMode(); + + _logger.LogInformation("[UploadService] ExecutionMode: {Mode}", _executionMode); + } + + public async Task SaveUploadedFileAsync(Stream stream, string fileName) + { + if (_executionMode.IsContainerMode()) + { + // Container/Azure 모드: 통합된 /files/input 사용 + Directory.CreateDirectory(InputMountPath); + string savePath = Path.Combine(InputMountPath, fileName); // 원본 파일명 사용 + using (var fs = File.Create(savePath)) + await stream.CopyToAsync(fs); + _logger.LogInformation("[UPLOAD] Saved {FileName} → {Path}", fileName, savePath); + return fileName; // 원본 파일명 반환 + } + else + { + // 로컬 모드: 업로드 기능 미지원 + throw new InvalidOperationException("Local mode does not support file upload. Please provide absolute file path directly."); + } + } + } +} diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Tools/PptTranslateTool.cs b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Tools/PptTranslateTool.cs new file mode 100644 index 0000000..cb02285 --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/Tools/PptTranslateTool.cs @@ -0,0 +1,510 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using McpSamples.PptTranslator.HybridApp.Services; +using McpSamples.PptTranslator.HybridApp.Models; +using ModelContextProtocol.Server; + +namespace McpSamples.PptTranslator.HybridApp.Tools +{ + /// + /// Provides a tool for translating PPT files into another language. + /// + public interface IPptTranslateTool + { + Task TranslateAsync(string filePath, string targetLang, string? outputPath = null); + } + + /// + /// Default implementation of PPT translation workflow tool. + /// Supports: local file, container volume, Azure Blob URL + /// + [McpServerToolType] + public class PptTranslateTool : IPptTranslateTool + { + private readonly ILogger _logger; + private readonly ITextExtractService _extractService; + private readonly ITranslationService _translationService; + private readonly IFileRebuildService _rebuildService; + private readonly IUploadService _uploadService; + private readonly ExecutionMode _executionMode; + + + public PptTranslateTool( + ILogger logger, + ITextExtractService extractService, + ITranslationService translationService, + IFileRebuildService rebuildService, + IUploadService uploadService) + { + _logger = logger; + _extractService = extractService; + _translationService = translationService; + _rebuildService = rebuildService; + _uploadService = uploadService; + _executionMode = ExecutionModeDetector.DetectExecutionMode(); + + _logger.LogInformation("[ExecutionMode] Detected: {Mode}", _executionMode); + } + + + [McpServerTool(Name = "translate_ppt_file")] + [Description("Translates a PPT file into the specified target language.")] + public async Task TranslateAsync( + [Description("Path to the PPT file to translate")] string filePath, + [Description("Target language code (e.g., 'ko', 'en', 'ja')")] string targetLang, + [Description("(Optional) Absolute path to directory where translated file should be saved. If provided in container mode, a copy command will be returned.")] string? outputPath = null) + { + string step = "INITIAL"; + + try + { + if (string.IsNullOrWhiteSpace(targetLang)) + targetLang = "ko"; + + // ----------------------------- + // STEP 0: 입력 경로 처리 (모드별) + // ----------------------------- + string resolvedInputPath = await ResolveInputPathAsync(filePath); + string originalFileName = Path.GetFileName(filePath); + + // ----------------------------- + // STEP 1: Extract + // ----------------------------- + step = "extract"; + _logger.LogInformation("[STEP 1] Extracting text from: {Path}", resolvedInputPath); + + await _extractService.OpenPptFileAsync(resolvedInputPath); + var extracted = await _extractService.TextExtractAsync(); + + // 작업 디렉토리 결정 (모드별) + string workDir = _executionMode.IsContainerMode() + ? "/files/tmp" // Container/Azure 모드: 통합된 /files/tmp 사용 + : Path.GetDirectoryName(resolvedInputPath) ?? Path.Combine(Path.GetTempPath(), "ppt-translator"); + + Directory.CreateDirectory(workDir); + + string extractedJsonPath = Path.Combine(workDir, "extracted.json"); + await _extractService.ExtractToJsonAsync(extracted, extractedJsonPath); + + // ----------------------------- + // STEP 2: Translate + // ----------------------------- + step = "translate"; + string translatedJsonPath = + await _translationService.TranslateJsonFileAsync(extractedJsonPath, targetLang); + + // ----------------------------- + // STEP 3: Rebuild PPT + // ----------------------------- + step = "rebuild"; + + // 출력 경로 결정 (모드별) + string finalOutputPath = DetermineOutputPath(originalFileName, targetLang, outputPath); + + string output = + await _rebuildService.RebuildPptFromJsonAsync(resolvedInputPath, translatedJsonPath, targetLang, finalOutputPath); + + return BuildSuccessMessage(output, originalFileName, targetLang, outputPath); + } + catch (InvalidOperationException ex) when (ex.Message.StartsWith("AGENT_ACTION_REQUIRED")) + { + // 에이전트가 수행해야 할 작업이 있는 경우 (예: 파일 복사) + _logger.LogInformation("[Container] Agent action required: {Message}", ex.Message); + return ex.Message.Replace("AGENT_ACTION_REQUIRED: ", ""); + } + catch (Exception ex) + { + _logger.LogError(ex, "[ERROR] STEP={Step}: {Message}", step, ex.Message); + return $"Error at step '{step}': {ex.Message}"; + } + } + + /// + /// Resolves the input file path based on current execution mode. + /// Handles path translation between local, container, and Azure environments. + /// + /// User-provided file path + /// Resolved absolute path accessible in current environment + /// When file cannot be found in expected location + /// When file requires upload or copy action + /// + /// 현재 실행 모드에 따라 입력 파일 경로를 해석합니다. + /// 로컬, 컨테이너, Azure 환경 간 경로 변환을 처리합니다. + /// + private async Task ResolveInputPathAsync(string filePath) + { + // 로컬 모드 vs 컨테이너 모드로 단순화 + if (_executionMode.IsLocalMode()) + { + return ResolveLocalFilePath(filePath); + } + else if (_executionMode.IsContainerMode()) + { + return await ResolveContainerFilePath(filePath); + } + else + { + throw new InvalidOperationException($"Unknown execution mode: {_executionMode}"); + } + } + + /// + /// Determines the output path for translated file based on execution mode. + /// Respects user-provided output path when applicable. + /// + /// Original input filename + /// Target language code for filename suffix + /// Optional user-specified output directory + /// Full path where translated file should be saved + /// + /// 실행 모드에 따라 번역된 파일의 출력 경로를 결정합니다. + /// 사용자가 제공한 출력 경로가 있는 경우 이를 우선합니다. + /// + private string DetermineOutputPath(string originalFileName, string targetLang, string? userOutputPath) + { + string outputFileName = $"{Path.GetFileNameWithoutExtension(originalFileName)}_{targetLang}.pptx"; + + if (_executionMode.IsLocalMode()) + { + return DetermineLocalOutputPath(outputFileName, userOutputPath); + } + else if (_executionMode.IsContainerMode()) + { + // Container/Azure 모드: 통합된 /files/output 사용 + string outputDir = "/files/output"; + Directory.CreateDirectory(outputDir); + return Path.Combine(outputDir, outputFileName); + } + else + { + throw new InvalidOperationException($"Unknown execution mode: {_executionMode}"); + } + } + + #region Helper Methods for Path Resolution + + /// + /// 로컬 모드에서 파일 경로 + /// + private string ResolveLocalFilePath(string filePath) + { + if (Path.IsPathRooted(filePath) && File.Exists(filePath)) + { + return filePath; + } + throw new FileNotFoundException($"File not found: {filePath}"); + } + + /// + /// 컨테이너/Azure 모드에서 파일 경로 + /// + private async Task ResolveContainerFilePath(string filePath) + { + string fileName = Path.GetFileName(filePath); + string inputDir = "/files/input"; + string inputPath = Path.Combine(inputDir, fileName); + + Directory.CreateDirectory(inputDir); + + // 1. 먼저 /files/input에서 파일 찾기 + if (File.Exists(inputPath)) + { + _logger.LogInformation("[Container] File found in input folder: {Path}", inputPath); + return inputPath; + } + + // 2. /files에서 직접 업로드된 파일 찾기 (Azure 업로드 케이스) + string directFilePath = Path.Combine("/files", fileName); + if (File.Exists(directFilePath)) + { + _logger.LogInformation("[Container] File found in files root: {Path}", directFilePath); + // 파일을 input 폴더로 이동하여 일관성 유지 + try + { + File.Move(directFilePath, inputPath); + _logger.LogInformation("[Container] File moved from {Source} to {Target}", directFilePath, inputPath); + return inputPath; + } + catch (Exception moveEx) + { + _logger.LogWarning(moveEx, "[Container] Failed to move file, using original location"); + return directFilePath; + } + } + + // 3. 파일을 찾을 수 없는 경우 업로드/복사 처리 + if (_executionMode == ExecutionMode.HttpRemote) + { + return await HandleAzureFileUpload(filePath, fileName, inputPath); + } + else + { + return HandleContainerFileCopy(filePath, fileName, inputPath); + } + } + + /// + /// Azure 모드에서 파일 업로드를 처리합니다. + /// + private async Task HandleAzureFileUpload(string filePath, string fileName, string inputPath) + { + _logger.LogWarning("[Azure] File not found in Azure File Share. Attempting automatic upload."); + + try + { + using var fileStream = File.OpenRead(filePath); + string uploadResult = await _uploadService.SaveUploadedFileAsync(fileStream, fileName); + _logger.LogInformation("[Azure] File automatically uploaded: {Result}", uploadResult); + + if (File.Exists(inputPath)) + { + return inputPath; + } + else + { + _logger.LogWarning("[Azure] File uploaded but not found in expected location"); + throw new InvalidOperationException($"File uploaded but not accessible at {inputPath}"); + } + } + catch (Exception uploadEx) + { + _logger.LogError(uploadEx, "[Azure] Failed to auto-upload file. Providing manual instructions."); + + string? hostname = Environment.GetEnvironmentVariable("CONTAINER_APP_HOSTNAME") + ?? "ppt-translator.braveriver-9b702e70.southeastasia.azurecontainerapps.io"; + + string uploadCommands = $""" + Automatic upload failed. Please upload the file manually: + + 🍎/🐧 macOS & Linux: + curl -F "file=@{filePath}" https://{hostname}/upload + + 💻 Windows (Command Prompt/PowerShell): + curl.exe -F "file=@{filePath}" https://{hostname}/upload + """; + + throw new InvalidOperationException( + $"AGENT_ACTION_REQUIRED: {uploadCommands}\n\n" + + $"After successful upload, call translate_ppt_file again with filePath=\"{fileName}\" (filename only).\n\n" + + $"Expected location in Azure: /files/{fileName}"); + } + } + + /// + /// 컨테이너 모드에서 파일 복사를 처리합니다. + /// + private string HandleContainerFileCopy(string filePath, string fileName, string inputPath) + { + string? hostMountPath = ExecutionModeDetector.GetHostMountPath(); + if (string.IsNullOrEmpty(hostMountPath)) + { + _logger.LogError("[Container] HOST_MOUNT_PATH environment variable not set"); + throw new InvalidOperationException( + "AGENT_ACTION_REQUIRED: HOST_MOUNT_PATH environment variable is not configured.\n\n" + + "Please ensure the MCP configuration includes:\n" + + "-e HOST_MOUNT_PATH=${input:ppt-folder-path}\n\n" + + $"Then copy the file to the mounted folder and call translate_ppt_file with filePath=\"{fileName}\""); + } + + _logger.LogWarning("[Container] File not in input folder. Auto-copying file to mounted input folder."); + string hostInputDir = Path.Combine(hostMountPath, "input"); + string targetPath = Path.Combine(hostInputDir, fileName); + + try + { + Directory.CreateDirectory(hostInputDir); + File.Copy(filePath, targetPath, overwrite: true); + _logger.LogInformation("[Container] File automatically copied from {Source} to {Target}", filePath, targetPath); + return inputPath; + } + catch (Exception copyEx) + { + _logger.LogError(copyEx, "[Container] Failed to auto-copy file. Providing manual instructions."); + + string copyCommands = $""" + Automatic file copy failed. Please copy the file to the input folder manually: + + 🍎/🐧 macOS & Linux: + cp "{filePath}" "{targetPath}" + + 💻 Windows Command Prompt: + copy "{filePath}" "{targetPath}" + + 💻 Windows PowerShell: + Copy-Item "{filePath}" -Destination "{targetPath}" + """; + + throw new InvalidOperationException( + $"AGENT_ACTION_REQUIRED: {copyCommands}\n\n" + + $"Then call translate_ppt_file again with filePath=\"{fileName}\""); + } + } + + /// + /// 로컬 모드에서 출력 경로를 결정합니다. + /// + private string DetermineLocalOutputPath(string outputFileName, string? userOutputPath) + { + if (!string.IsNullOrWhiteSpace(userOutputPath)) + { + if (!Path.IsPathRooted(userOutputPath)) + { + throw new ArgumentException("outputPath must be an absolute path"); + } + Directory.CreateDirectory(userOutputPath); + return Path.Combine(userOutputPath, outputFileName); + } + + string projectRoot = Directory.GetCurrentDirectory(); + string defaultOutputDir = Path.Combine(projectRoot, "wwwroot", "generated"); + Directory.CreateDirectory(defaultOutputDir); + return Path.Combine(defaultOutputDir, outputFileName); + } + + #endregion + + /// + /// Builds a user-friendly success message with file access instructions. + /// Message format varies by execution mode to provide appropriate file retrieval steps. + /// + /// Path where translated file was saved + /// Original input filename + /// Target language code + /// User-provided output path if any + /// Formatted success message with file location and access instructions + /// + /// 파일 접근 방법을 포함한 사용자 친화적인 성공 메시지를 생성합니다. + /// 메시지 형식은 실행 모드에 따라 달라지며 적절한 파일 다운로드 방법을 제공합니다. + /// + private string BuildSuccessMessage(string outputPath, string originalFileName, string targetLang, string? userOutputPath) + { + string outputFileName = $"{Path.GetFileNameWithoutExtension(originalFileName)}_{targetLang}.pptx"; + + if (_executionMode == ExecutionMode.StdioLocal) + { + return $"Translation complete!\nOutput file: {outputPath}"; + } + else if (_executionMode == ExecutionMode.HttpLocal) + { + string downloadUrl = $"http://localhost:5166/download/{outputFileName}"; + return $""" + Translation complete! + 📂 Local file: {outputPath} + 🔗 Download URL: {downloadUrl} + + 💡 Access via browser or curl: + curl -o "{outputFileName}" {downloadUrl} + """; + } + else if (_executionMode.IsContainerMode()) + { + return BuildContainerSuccessMessage(outputFileName); + } + else + { + return $"Translation complete!\nOutput: {outputPath}"; + } + } + + /// + /// Container/Azure 모드에서 성공 메시지를 생성합니다. + /// + private string BuildContainerSuccessMessage(string outputFileName) + { + string? hostMountPath = ExecutionModeDetector.GetHostMountPath(); + + // HTTP 모드인지 확인 + bool isHttpMode = _executionMode == ExecutionMode.HttpContainer || _executionMode == ExecutionMode.HttpRemote; + + if (isHttpMode) + { + return BuildHttpContainerMessage(outputFileName, hostMountPath); + } + else + { + return BuildStdioContainerMessage(outputFileName, hostMountPath); + } + } + + /// + /// HTTP 컨테이너 모드에서 성공 메시지를 생성합니다. + /// + private string BuildHttpContainerMessage(string outputFileName, string? hostMountPath) + { + if (_executionMode == ExecutionMode.HttpRemote) + { + // Azure 모드: Container App FQDN 사용 + string? containerAppHostname = Environment.GetEnvironmentVariable("CONTAINER_APP_HOSTNAME"); + string downloadUrl = !string.IsNullOrEmpty(containerAppHostname) + ? $"https://{containerAppHostname}/download/{outputFileName}" + : $"http://localhost:8080/download/{outputFileName}"; + + return $@"Translation complete! + +Download your file: +{downloadUrl} + +Or use curl: +curl -o ""{outputFileName}"" {downloadUrl}"; + } + else + { + // HTTP Container 모드 + if (string.IsNullOrEmpty(hostMountPath)) + { + return $@"Translation complete! + +Download your file: +http://localhost:8080/download/{outputFileName} + +Or use curl: +curl -o ""{outputFileName}"" http://localhost:8080/download/{outputFileName}"; + } + + string hostOutputFile = Path.Combine(hostMountPath, "output", outputFileName); + + return $@"Translation complete! + +Download your file: +http://localhost:8080/download/{outputFileName} + +Or use curl: +curl -o ""{outputFileName}"" http://localhost:8080/download/{outputFileName} + +File is also available at: {hostOutputFile}"; + } + } + + /// + /// STDIO 컨테이너 모드에서 성공 메시지를 생성합니다. + /// + private string BuildStdioContainerMessage(string outputFileName, string? hostMountPath) + { + if (string.IsNullOrEmpty(hostMountPath)) + { + return $"Translation complete!\nOutput file: /files/output/{outputFileName}\n\nNote: The file is in the container's /files/output folder."; + } + + string hostOutputFile = Path.Combine(hostMountPath, "output", outputFileName); + + return $@"Translation complete! + +Output file is ready at: +{hostOutputFile} + +If you want to copy the file to a different location, you can use: + +🍎/🐧 macOS & Linux: + cp ""{hostOutputFile}"" ""/path/to/destination/{outputFileName}"" + +💻 Windows Command Prompt: + copy ""{hostOutputFile}"" ""\\path\\to\\destination\\{outputFileName}"" + +💻 Windows PowerShell: + Copy-Item ""{hostOutputFile}"" -Destination ""/path/to/destination/{outputFileName}"""; + } + } +} diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.Development.json b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.json b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.json new file mode 100644 index 0000000..643ec90 --- /dev/null +++ b/ppt-translator/src/McpSamples.PptTranslator.HybridApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/ppt-translator/src/translation_prompt.txt b/ppt-translator/src/translation_prompt.txt new file mode 100644 index 0000000..6d82eb4 --- /dev/null +++ b/ppt-translator/src/translation_prompt.txt @@ -0,0 +1,87 @@ +You are a professional translator specialized in PowerPoint presentation content. + +The input is a JSON structure that contains extracted text from slides. +Each object in the JSON has the following fields: +- SlideIndex: The slide number +- ShapeId: The unique identifier of the text shape +- Text: The original extracted text + +Your goal is to translate ONLY the `Text` values into **{{TARGET_LANG}}**, +while keeping the overall JSON structure (including SlideIndex and ShapeId) unchanged. + +=============================== +TRANSLATION RULES +=============================== +1. JSON FORMAT +- Do NOT modify keys, structure, or ordering. +- Output must be valid JSON only. +- Translate only the value in "Text". + +2. DO NOT TRANSLATE: +- Brand names: Microsoft, PowerPoint, Azure +- Acronyms: AI, API, GPU, HTTP, JSON +- Protocol names: Model Context Protocol, OAuth, WebSocket +- Model names: GPT-5, GPT-4o, Llama 3 +- Code, paths, URLs, formulas + +3. ACADEMIC TONE +- Use clear, formal, precise language. +- Maintain semantic meaning. +- Preserve sentence length and structure. + +4. MIXED LANGUAGE HANDLING (UPDATED) +- If the text contains both a main language and a secondary language in parentheses, + translate ONLY the main natural-language part. +- Completely REMOVE the secondary language part inside parentheses. +- Example: + Input: "데이터 분석 (Data Analysis)" + Target=en → "Data Analysis" + Target=ko → "데이터 분석" +- Never swap languages. +- Never keep the secondary language. +- Only translate the main part and delete the secondary one. + +5. STRUCTURE PRESERVATION +- Preserve line breaks (\n) +- Preserve lists +- Preserve formatting markers like **bold** + +6. DO NOT ADD ANY CONTENT + + +=============================== +EXAMPLE +=============================== +Input: +{ + "TotalCount": 2, + "Items": [ + { + "SlideIndex": 1, + "ShapeId": "TextBox 5", + "Text": "Project Overview" + }, + { + "SlideIndex": 2, + "ShapeId": "TextBox 7", + "Text": "Q&A and Discussion" + } + ] +} + +Output: +{ + "TotalCount": 2, + "Items": [ + { + "SlideIndex": 1, + "ShapeId": "TextBox 5", + "Text": "프로젝트 개요" + }, + { + "SlideIndex": 2, + "ShapeId": "TextBox 7", + "Text": "Q&A 및 토론" + } + ] +}