Phase 20I – Story Intelligence Infrastructure
This commit is contained in:
parent
a2b3fb2d03
commit
7d93f833b4
@ -11,7 +11,8 @@ namespace PlotLine.Controllers;
|
||||
public sealed class AdminController(
|
||||
ICurrentUserService currentUser,
|
||||
IFeatureRequestService featureRequests,
|
||||
IEmailService emails) : Controller
|
||||
IEmailService emails,
|
||||
IStoryIntelligenceDiagnosticsService storyIntelligenceDiagnostics) : Controller
|
||||
{
|
||||
[HttpGet("")]
|
||||
[HttpGet("index")]
|
||||
@ -29,6 +30,12 @@ public sealed class AdminController(
|
||||
return View();
|
||||
}
|
||||
|
||||
[HttpGet("story-intelligence-diagnostics")]
|
||||
public async Task<IActionResult> StoryIntelligenceDiagnostics(CancellationToken cancellationToken)
|
||||
{
|
||||
return View(await storyIntelligenceDiagnostics.GetDiagnosticsAsync(cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("feature-requests")]
|
||||
public async Task<IActionResult> FeatureRequests(string? status, string? appArea, string? importance, string? search, string? sort)
|
||||
{
|
||||
|
||||
@ -64,3 +64,39 @@ public sealed class StoryIntelligenceJobProgress
|
||||
public bool IsActive { get; init; }
|
||||
public bool IsCompleted { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceOptions
|
||||
{
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public decimal Temperature { get; init; } = 0.2m;
|
||||
public int MaxOutputTokens { get; init; } = 4000;
|
||||
public int TimeoutSeconds { get; init; } = 120;
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceRun
|
||||
{
|
||||
public Guid RunId { get; init; } = Guid.NewGuid();
|
||||
public int ProjectId { get; init; }
|
||||
public int BookId { get; init; }
|
||||
public DateTime StartedUtc { get; init; } = DateTime.UtcNow;
|
||||
public DateTime? CompletedUtc { get; init; }
|
||||
public string Status { get; init; } = "Pending";
|
||||
public string PromptVersion { get; init; } = string.Empty;
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public int? InputTokens { get; init; }
|
||||
public int? OutputTokens { get; init; }
|
||||
public decimal? EstimatedCost { get; init; }
|
||||
public bool Cancelled { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceClientResult
|
||||
{
|
||||
public string RawResponseText { get; init; } = string.Empty;
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public TimeSpan Duration { get; init; }
|
||||
public int? InputTokens { get; init; }
|
||||
public int? OutputTokens { get; init; }
|
||||
public decimal? EstimatedCost { get; init; }
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ public class Program
|
||||
options.MaximumReceiveMessageSize = 1024 * 1024;
|
||||
});
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddDistributedMemoryCache();
|
||||
builder.Services.AddSession(options =>
|
||||
{
|
||||
@ -40,6 +41,7 @@ public class Program
|
||||
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
|
||||
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("Stripe"));
|
||||
builder.Services.Configure<StorageSettings>(builder.Configuration.GetSection("Storage"));
|
||||
builder.Services.Configure<StoryIntelligenceOptions>(builder.Configuration.GetSection("OpenAI"));
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
@ -181,6 +183,11 @@ public class Program
|
||||
builder.Services.AddScoped<IOnboardingService, OnboardingService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceService, StoryIntelligenceService>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceProvider, StubStoryIntelligenceProvider>();
|
||||
builder.Services.AddSingleton<IStoryPromptRepository, StoryPromptRepository>();
|
||||
builder.Services.AddSingleton<IStoryPromptBuilder, StoryPromptBuilder>();
|
||||
builder.Services.AddSingleton<IStoryPromptVersionService, StoryPromptVersionService>();
|
||||
builder.Services.AddHttpClient<IStoryIntelligenceClient, StoryIntelligenceClient>();
|
||||
builder.Services.AddScoped<IStoryIntelligenceDiagnosticsService, StoryIntelligenceDiagnosticsService>();
|
||||
builder.Services.AddScoped<IProjectAccessService, ProjectAccessService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
builder.Services.AddScoped<IStripeBillingService, StripeBillingService>();
|
||||
|
||||
309
PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs
Normal file
309
PlotLine/Services/StoryIntelligenceOpenAIInfrastructure.cs
Normal file
@ -0,0 +1,309 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IStoryPromptRepository
|
||||
{
|
||||
Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IStoryPromptBuilder
|
||||
{
|
||||
string BuildPrompt(string promptTemplate, string sceneContextJson, string sceneText);
|
||||
}
|
||||
|
||||
public interface IStoryPromptVersionService
|
||||
{
|
||||
string GetPromptVersion(string fileName);
|
||||
}
|
||||
|
||||
public interface IStoryIntelligenceClient
|
||||
{
|
||||
Task<StoryIntelligenceClientResult> ExecutePromptAsync(string completedPrompt, string promptVersion, CancellationToken cancellationToken);
|
||||
StoryIntelligenceClientConfigurationStatus GetConfigurationStatus();
|
||||
}
|
||||
|
||||
public interface IStoryIntelligenceDiagnosticsService
|
||||
{
|
||||
Task<StoryIntelligenceDiagnosticsViewModel> GetDiagnosticsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class StoryPromptRepository(
|
||||
IWebHostEnvironment environment,
|
||||
IMemoryCache cache,
|
||||
ILogger<StoryPromptRepository> logger) : IStoryPromptRepository
|
||||
{
|
||||
private const string PromptFolder = "Docs/AI";
|
||||
|
||||
public async Task<string> LoadPromptAsync(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)
|
||||
|| fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
|
||||
|| fileName.Contains(Path.DirectorySeparatorChar)
|
||||
|| fileName.Contains(Path.AltDirectorySeparatorChar))
|
||||
{
|
||||
throw new InvalidOperationException("Prompt filename is invalid.");
|
||||
}
|
||||
|
||||
var cacheKey = $"story-prompt:{fileName}";
|
||||
if (cache.TryGetValue(cacheKey, out string? cachedPrompt) && !string.IsNullOrWhiteSpace(cachedPrompt))
|
||||
{
|
||||
return cachedPrompt;
|
||||
}
|
||||
|
||||
var promptPath = Path.Combine(environment.ContentRootPath, PromptFolder, fileName);
|
||||
if (!File.Exists(promptPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Story Intelligence prompt file '{fileName}' was not found in /Docs/AI/.", promptPath);
|
||||
}
|
||||
|
||||
var prompt = await File.ReadAllTextAsync(promptPath, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(prompt))
|
||||
{
|
||||
throw new InvalidOperationException($"Story Intelligence prompt file '{fileName}' is empty.");
|
||||
}
|
||||
|
||||
cache.Set(cacheKey, prompt, TimeSpan.FromMinutes(15));
|
||||
logger.LogInformation("Loaded Story Intelligence prompt {PromptFile} from {PromptPath}.", fileName, promptPath);
|
||||
return prompt;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StoryPromptBuilder : IStoryPromptBuilder
|
||||
{
|
||||
public string BuildPrompt(string promptTemplate, string sceneContextJson, string sceneText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(promptTemplate))
|
||||
{
|
||||
throw new InvalidOperationException("Prompt template is empty.");
|
||||
}
|
||||
|
||||
return promptTemplate
|
||||
.Replace("{{SCENE_CONTEXT_JSON}}", sceneContextJson ?? string.Empty, StringComparison.Ordinal)
|
||||
.Replace("{{SCENE_TEXT}}", sceneText ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StoryPromptVersionService : IStoryPromptVersionService
|
||||
{
|
||||
public string GetPromptVersion(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
{
|
||||
throw new InvalidOperationException("Prompt filename is required.");
|
||||
}
|
||||
|
||||
var safeName = Path.GetFileName(fileName);
|
||||
return Path.GetFileNameWithoutExtension(safeName);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<StoryIntelligenceOptions> options,
|
||||
ILogger<StoryIntelligenceClient> logger) : IStoryIntelligenceClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly StoryIntelligenceOptions settings = options.Value;
|
||||
|
||||
public StoryIntelligenceClientConfigurationStatus GetConfigurationStatus()
|
||||
=> new()
|
||||
{
|
||||
ApiKeyConfigured = !string.IsNullOrWhiteSpace(settings.ApiKey),
|
||||
Model = settings.Model,
|
||||
Temperature = settings.Temperature,
|
||||
MaxOutputTokens = settings.MaxOutputTokens,
|
||||
TimeoutSeconds = settings.TimeoutSeconds,
|
||||
ClientConstructed = true,
|
||||
ConnectionStatus = "Not checked. Diagnostics do not call OpenAI."
|
||||
};
|
||||
|
||||
public async Task<StoryIntelligenceClientResult> ExecutePromptAsync(string completedPrompt, string promptVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.ApiKey))
|
||||
{
|
||||
throw new InvalidOperationException("OpenAI API key is not configured. Add OpenAI:ApiKey to User Secrets or environment variables.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.Model))
|
||||
{
|
||||
throw new InvalidOperationException("OpenAI model is not configured. Set OpenAI:Model in configuration.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(completedPrompt))
|
||||
{
|
||||
throw new InvalidOperationException("Completed prompt is empty.");
|
||||
}
|
||||
|
||||
var requestBody = JsonSerializer.Serialize(new
|
||||
{
|
||||
model = settings.Model,
|
||||
input = completedPrompt,
|
||||
temperature = settings.Temperature,
|
||||
max_output_tokens = settings.MaxOutputTokens
|
||||
}, JsonOptions);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var responseText = await SendWithRetryAsync(requestBody, cancellationToken);
|
||||
stopwatch.Stop();
|
||||
logger.LogInformation(
|
||||
"Story Intelligence prompt executed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
|
||||
promptVersion,
|
||||
settings.Model,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true);
|
||||
|
||||
return new StoryIntelligenceClientResult
|
||||
{
|
||||
RawResponseText = responseText,
|
||||
Model = settings.Model,
|
||||
Duration = stopwatch.Elapsed
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
logger.LogError(
|
||||
ex,
|
||||
"Story Intelligence prompt failed. PromptVersion={PromptVersion} Model={Model} DurationMs={DurationMs} InputTokens={InputTokens} OutputTokens={OutputTokens} EstimatedCost={EstimatedCost} Success={Success}",
|
||||
promptVersion,
|
||||
settings.Model,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> SendWithRetryAsync(string requestBody, CancellationToken cancellationToken)
|
||||
{
|
||||
const int maxAttempts = 3;
|
||||
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/responses")
|
||||
{
|
||||
Content = new StringContent(requestBody, Encoding.UTF8, "application/json")
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey);
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, settings.TimeoutSeconds)));
|
||||
using var response = await httpClient.SendAsync(request, timeout.Token);
|
||||
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return responseText;
|
||||
}
|
||||
|
||||
if (!ShouldRetry(response.StatusCode) || attempt == maxAttempts)
|
||||
{
|
||||
throw new InvalidOperationException($"OpenAI request failed with {(int)response.StatusCode} {response.StatusCode}: {TrimForLog(responseText)}");
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested && attempt < maxAttempts)
|
||||
{
|
||||
await DelayForRetryAsync(attempt, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
catch (HttpRequestException) when (attempt < maxAttempts)
|
||||
{
|
||||
await DelayForRetryAsync(attempt, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
await DelayForRetryAsync(attempt, cancellationToken);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("OpenAI request failed after retry attempts.");
|
||||
}
|
||||
|
||||
private static bool ShouldRetry(HttpStatusCode statusCode)
|
||||
=> statusCode is HttpStatusCode.RequestTimeout
|
||||
or HttpStatusCode.TooManyRequests
|
||||
or HttpStatusCode.InternalServerError
|
||||
or HttpStatusCode.BadGateway
|
||||
or HttpStatusCode.ServiceUnavailable
|
||||
or HttpStatusCode.GatewayTimeout;
|
||||
|
||||
private static Task DelayForRetryAsync(int attempt, CancellationToken cancellationToken)
|
||||
=> Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)), cancellationToken);
|
||||
|
||||
private static string TrimForLog(string value)
|
||||
=> string.IsNullOrWhiteSpace(value) || value.Length <= 500 ? value : value[..500];
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceDiagnosticsService(
|
||||
IStoryPromptRepository prompts,
|
||||
IStoryPromptVersionService versions,
|
||||
IStoryPromptBuilder builder,
|
||||
IStoryIntelligenceClient client) : IStoryIntelligenceDiagnosticsService
|
||||
{
|
||||
private const string ScenePromptFile = "Scene-Prompt-V1.md";
|
||||
|
||||
public async Task<StoryIntelligenceDiagnosticsViewModel> GetDiagnosticsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var clientStatus = client.GetConfigurationStatus();
|
||||
var model = new StoryIntelligenceDiagnosticsViewModel
|
||||
{
|
||||
Model = clientStatus.Model,
|
||||
ApiKeyConfigured = clientStatus.ApiKeyConfigured,
|
||||
ClientConstructed = clientStatus.ClientConstructed,
|
||||
ConnectionStatus = clientStatus.ConnectionStatus,
|
||||
PromptFileName = ScenePromptFile,
|
||||
PromptVersion = versions.GetPromptVersion(ScenePromptFile),
|
||||
Temperature = clientStatus.Temperature,
|
||||
MaxOutputTokens = clientStatus.MaxOutputTokens,
|
||||
TimeoutSeconds = clientStatus.TimeoutSeconds
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var prompt = await prompts.LoadPromptAsync(ScenePromptFile, cancellationToken);
|
||||
model.PromptRepositoryHealthy = true;
|
||||
model.ScenePromptLoaded = true;
|
||||
model.ScenePromptLength = prompt.Length;
|
||||
var completed = builder.BuildPrompt(prompt, "{\"sceneId\":0}", "Diagnostic scene text.");
|
||||
model.RuntimeSubstitutionWorks = completed.Contains("\"sceneId\":0", StringComparison.Ordinal)
|
||||
&& completed.Contains("Diagnostic scene text.", StringComparison.Ordinal)
|
||||
&& !completed.Contains("{{SCENE_CONTEXT_JSON}}", StringComparison.Ordinal)
|
||||
&& !completed.Contains("{{SCENE_TEXT}}", StringComparison.Ordinal);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
model.PromptRepositoryHealthy = false;
|
||||
model.ScenePromptLoaded = false;
|
||||
model.ErrorMessage = ex.Message;
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceClientConfigurationStatus
|
||||
{
|
||||
public bool ApiKeyConfigured { get; init; }
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public decimal Temperature { get; init; }
|
||||
public int MaxOutputTokens { get; init; }
|
||||
public int TimeoutSeconds { get; init; }
|
||||
public bool ClientConstructed { get; init; }
|
||||
public string ConnectionStatus { get; init; } = string.Empty;
|
||||
}
|
||||
@ -78,6 +78,24 @@ public sealed class AdminDashboardViewModel
|
||||
public FeatureRequestAdminSummary FeatureRequestSummary { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StoryIntelligenceDiagnosticsViewModel
|
||||
{
|
||||
public string Model { get; set; } = string.Empty;
|
||||
public decimal Temperature { get; set; }
|
||||
public int MaxOutputTokens { get; set; }
|
||||
public int TimeoutSeconds { get; set; }
|
||||
public bool ApiKeyConfigured { get; set; }
|
||||
public bool ClientConstructed { get; set; }
|
||||
public string ConnectionStatus { get; set; } = string.Empty;
|
||||
public bool PromptRepositoryHealthy { get; set; }
|
||||
public string PromptFileName { get; set; } = string.Empty;
|
||||
public bool ScenePromptLoaded { get; set; }
|
||||
public int ScenePromptLength { get; set; }
|
||||
public string PromptVersion { get; set; } = string.Empty;
|
||||
public bool RuntimeSubstitutionWorks { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AdminFeatureRequestFilter
|
||||
{
|
||||
public string? Status { get; set; }
|
||||
|
||||
@ -76,9 +76,12 @@
|
||||
</article>
|
||||
<article class="project-card">
|
||||
<div class="project-card__body">
|
||||
<p class="eyebrow">Placeholder</p>
|
||||
<h2>Users</h2>
|
||||
<p class="project-card__description">User management tools will be added later.</p>
|
||||
<p class="eyebrow">Story Intelligence</p>
|
||||
<h2>AI Diagnostics</h2>
|
||||
<p class="project-card__description">Check prompt loading and OpenAI configuration without making API calls.</p>
|
||||
</div>
|
||||
<div class="project-card__actions">
|
||||
<a class="btn btn-primary btn-sm" asp-action="StoryIntelligenceDiagnostics">Open</a>
|
||||
</div>
|
||||
</article>
|
||||
<article class="project-card">
|
||||
|
||||
98
PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml
Normal file
98
PlotLine/Views/Admin/StoryIntelligenceDiagnostics.cshtml
Normal file
@ -0,0 +1,98 @@
|
||||
@model StoryIntelligenceDiagnosticsViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Story Intelligence Diagnostics";
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Admin utility</p>
|
||||
<h1>Story Intelligence Diagnostics</h1>
|
||||
<p class="lead-text">Configuration and prompt loading checks. This page does not call OpenAI.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="mb-3" aria-label="Breadcrumb">
|
||||
<a asp-controller="Projects" asp-action="Index">Projects</a>
|
||||
<span class="muted"> / </span>
|
||||
<a asp-action="Index">Admin</a>
|
||||
<span class="muted"> / Story Intelligence Diagnostics</span>
|
||||
</nav>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-warning">@Model.ErrorMessage</div>
|
||||
}
|
||||
|
||||
<section class="edit-panel">
|
||||
<h2>OpenAI configuration</h2>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<p class="eyebrow">Model</p>
|
||||
<h3 class="mb-0">@Model.Model</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<p class="eyebrow">API key</p>
|
||||
<h3 class="mb-0">@StatusText(Model.ApiKeyConfigured)</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<p class="eyebrow">Client</p>
|
||||
<h3 class="mb-0">@StatusText(Model.ClientConstructed)</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="mt-3 mb-0">
|
||||
<dt>Temperature</dt>
|
||||
<dd>@Model.Temperature</dd>
|
||||
<dt>Max output tokens</dt>
|
||||
<dd>@Model.MaxOutputTokens.ToString("N0")</dd>
|
||||
<dt>Timeout</dt>
|
||||
<dd>@Model.TimeoutSeconds seconds</dd>
|
||||
<dt>Connection status</dt>
|
||||
<dd>@Model.ConnectionStatus</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="edit-panel">
|
||||
<h2>Prompt repository</h2>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<p class="eyebrow">Repository</p>
|
||||
<h3 class="mb-0">@StatusText(Model.PromptRepositoryHealthy)</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<p class="eyebrow">Scene prompt</p>
|
||||
<h3 class="mb-0">@StatusText(Model.ScenePromptLoaded)</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<p class="eyebrow">Prompt version</p>
|
||||
<h3 class="mb-0">@Model.PromptVersion</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="border rounded p-3 h-100">
|
||||
<p class="eyebrow">Substitution</p>
|
||||
<h3 class="mb-0">@StatusText(Model.RuntimeSubstitutionWorks)</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="mt-3 mb-0">
|
||||
<dt>Prompt file</dt>
|
||||
<dd>@Model.PromptFileName</dd>
|
||||
<dt>Prompt length</dt>
|
||||
<dd>@Model.ScenePromptLength.ToString("N0") characters</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@functions {
|
||||
private static string StatusText(bool value) => value ? "Configured" : "Missing";
|
||||
}
|
||||
@ -19,6 +19,12 @@
|
||||
"Storage": {
|
||||
"UploadsRoot": ""
|
||||
},
|
||||
"OpenAI": {
|
||||
"Model": "gpt-5",
|
||||
"Temperature": 0.2,
|
||||
"MaxOutputTokens": 4000,
|
||||
"TimeoutSeconds": 120
|
||||
},
|
||||
"Site": {
|
||||
"PublicBaseUrl": "https://plotdirector.com"
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user