diff --git a/DysonNetwork.Develop/AppDatabase.cs b/DysonNetwork.Develop/AppDatabase.cs index 26faddf..13b9e9b 100644 --- a/DysonNetwork.Develop/AppDatabase.cs +++ b/DysonNetwork.Develop/AppDatabase.cs @@ -1,6 +1,7 @@ using DysonNetwork.Shared.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using NodaTime; namespace DysonNetwork.Develop; @@ -29,6 +30,35 @@ public class AppDatabase( base.OnConfiguring(optionsBuilder); } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + foreach (var entry in ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Added: + entry.Entity.CreatedAt = now; + entry.Entity.UpdatedAt = now; + break; + case EntityState.Modified: + entry.Entity.UpdatedAt = now; + break; + case EntityState.Deleted: + entry.State = EntityState.Modified; + entry.Entity.DeletedAt = now; + break; + case EntityState.Detached: + case EntityState.Unchanged: + default: + break; + } + } + + return await base.SaveChangesAsync(cancellationToken); + } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/DysonNetwork.Develop/DysonNetwork.Develop.csproj b/DysonNetwork.Develop/DysonNetwork.Develop.csproj index 13a1264..1e7db8d 100644 --- a/DysonNetwork.Develop/DysonNetwork.Develop.csproj +++ b/DysonNetwork.Develop/DysonNetwork.Develop.csproj @@ -10,7 +10,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/DysonNetwork.Drive/DysonNetwork.Drive.csproj b/DysonNetwork.Drive/DysonNetwork.Drive.csproj index f167419..d2f9654 100644 --- a/DysonNetwork.Drive/DysonNetwork.Drive.csproj +++ b/DysonNetwork.Drive/DysonNetwork.Drive.csproj @@ -13,7 +13,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/DysonNetwork.Insight/AppDatabase.cs b/DysonNetwork.Insight/AppDatabase.cs index 48b0ae2..eb22a48 100644 --- a/DysonNetwork.Insight/AppDatabase.cs +++ b/DysonNetwork.Insight/AppDatabase.cs @@ -1,5 +1,7 @@ +using DysonNetwork.Shared.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using NodaTime; namespace DysonNetwork.Insight; @@ -8,6 +10,9 @@ public class AppDatabase( IConfiguration configuration ) : DbContext(options) { + public DbSet ThinkingSequences { get; set; } + public DbSet ThinkingThoughts { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql( @@ -20,6 +25,35 @@ public class AppDatabase( base.OnConfiguring(optionsBuilder); } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + foreach (var entry in ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Added: + entry.Entity.CreatedAt = now; + entry.Entity.UpdatedAt = now; + break; + case EntityState.Modified: + entry.Entity.UpdatedAt = now; + break; + case EntityState.Deleted: + entry.State = EntityState.Modified; + entry.Entity.DeletedAt = now; + break; + case EntityState.Detached: + case EntityState.Unchanged: + default: + break; + } + } + + return await base.SaveChangesAsync(cancellationToken); + } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/DysonNetwork.Insight/DysonNetwork.Insight.csproj b/DysonNetwork.Insight/DysonNetwork.Insight.csproj index 4ce7faa..19a39e6 100644 --- a/DysonNetwork.Insight/DysonNetwork.Insight.csproj +++ b/DysonNetwork.Insight/DysonNetwork.Insight.csproj @@ -9,6 +9,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/DysonNetwork.Insight/Migrations/20251025115921_AddThinkingThought.Designer.cs b/DysonNetwork.Insight/Migrations/20251025115921_AddThinkingThought.Designer.cs new file mode 100644 index 0000000..e01006e --- /dev/null +++ b/DysonNetwork.Insight/Migrations/20251025115921_AddThinkingThought.Designer.cs @@ -0,0 +1,124 @@ +// +using System; +using System.Collections.Generic; +using DysonNetwork.Insight; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Insight.Migrations +{ + [DbContext(typeof(AppDatabase))] + [Migration("20251025115921_AddThinkingThought")] + partial class AddThinkingThought + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Topic") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("topic"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_thinking_sequences"); + + b.ToTable("thinking_sequences", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property>("Files") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("files"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("SequenceId") + .HasColumnType("uuid") + .HasColumnName("sequence_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_thinking_thoughts"); + + b.HasIndex("SequenceId") + .HasDatabaseName("ix_thinking_thoughts_sequence_id"); + + b.ToTable("thinking_thoughts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence") + .WithMany() + .HasForeignKey("SequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id"); + + b.Navigation("Sequence"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Insight/Migrations/20251025115921_AddThinkingThought.cs b/DysonNetwork.Insight/Migrations/20251025115921_AddThinkingThought.cs new file mode 100644 index 0000000..870cc26 --- /dev/null +++ b/DysonNetwork.Insight/Migrations/20251025115921_AddThinkingThought.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace DysonNetwork.Insight.Migrations +{ + /// + public partial class AddThinkingThought : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "thinking_sequences", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + topic = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: true), + account_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_thinking_sequences", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "thinking_thoughts", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + content = table.Column(type: "text", nullable: true), + files = table.Column>(type: "jsonb", nullable: false), + role = table.Column(type: "integer", nullable: false), + sequence_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_thinking_thoughts", x => x.id); + table.ForeignKey( + name: "fk_thinking_thoughts_thinking_sequences_sequence_id", + column: x => x.sequence_id, + principalTable: "thinking_sequences", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_thinking_thoughts_sequence_id", + table: "thinking_thoughts", + column: "sequence_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "thinking_thoughts"); + + migrationBuilder.DropTable( + name: "thinking_sequences"); + } + } +} diff --git a/DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs new file mode 100644 index 0000000..e3f9875 --- /dev/null +++ b/DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs @@ -0,0 +1,121 @@ +// +using System; +using System.Collections.Generic; +using DysonNetwork.Insight; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Insight.Migrations +{ + [DbContext(typeof(AppDatabase))] + partial class AppDatabaseModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Topic") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("topic"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_thinking_sequences"); + + b.ToTable("thinking_sequences", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property>("Files") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("files"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("SequenceId") + .HasColumnType("uuid") + .HasColumnName("sequence_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_thinking_thoughts"); + + b.HasIndex("SequenceId") + .HasDatabaseName("ix_thinking_thoughts_sequence_id"); + + b.ToTable("thinking_thoughts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence") + .WithMany() + .HasForeignKey("SequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id"); + + b.Navigation("Sequence"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Insight/Program.cs b/DysonNetwork.Insight/Program.cs index 083edcc..9eb461f 100644 --- a/DysonNetwork.Insight/Program.cs +++ b/DysonNetwork.Insight/Program.cs @@ -1,5 +1,6 @@ using DysonNetwork.Insight; using DysonNetwork.Insight.Startup; +using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Http; using DysonNetwork.Shared.Registry; using Microsoft.EntityFrameworkCore; @@ -16,6 +17,7 @@ builder.Services.AddAppAuthentication(); builder.Services.AddAppFlushHandlers(); builder.Services.AddAppBusinessServices(); +builder.Services.AddDysonAuth(); builder.Services.AddAccountService(); builder.Services.AddSphereService(); builder.Services.AddThinkingServices(builder.Configuration); diff --git a/DysonNetwork.Insight/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Insight/Startup/ServiceCollectionExtensions.cs index 8d3af57..f423dbe 100644 --- a/DysonNetwork.Insight/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Insight/Startup/ServiceCollectionExtensions.cs @@ -1,6 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; -using DysonNetwork.Insight.Thinking; +using DysonNetwork.Insight.Thought; using DysonNetwork.Shared.Cache; using Microsoft.SemanticKernel; using NodaTime; @@ -65,7 +65,8 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddThinkingServices(this IServiceCollection services, IConfiguration configuration) { - services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); return services; } diff --git a/DysonNetwork.Insight/Thinking/ThinkingController.cs b/DysonNetwork.Insight/Thinking/ThinkingController.cs deleted file mode 100644 index 97c3af3..0000000 --- a/DysonNetwork.Insight/Thinking/ThinkingController.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Microsoft.AspNetCore.Mvc; -using Microsoft.SemanticKernel.ChatCompletion; -using System.Text; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.Ollama; - -namespace DysonNetwork.Insight.Thinking; - -[ApiController] -[Route("/api/thinking")] -public class ThinkingController(ThinkingProvider provider) : ControllerBase -{ - public class StreamThinkingRequest - { - [Required] public string UserMessage { get; set; } = null!; - } - - [HttpPost("stream")] - public async Task ChatStream([FromBody] StreamThinkingRequest request) - { - // Set response for streaming - Response.Headers.Append("Content-Type", "text/event-stream"); - Response.StatusCode = 200; - - var kernel = provider.Kernel; - - var chatCompletionService = kernel.GetRequiredService(); - - var chatHistory = new ChatHistory( - "You're a helpful assistant on the Solar Network, a social network.\n" + - "Your name is Sn-chan, a cute sweet heart with passion for almost everything.\n" + - "\n" + - "Your aim is to helping solving questions for the users on the Solar Network.\n" + - "And the Solar Network is the social network platform you live on.\n" + - "When the user ask questions about the Solar Network (also known as SN and Solian), try use the tools you have to get latest and accurate data." - ); - chatHistory.AddUserMessage(request.UserMessage); - - // Kick off streaming generation - var accumulatedContent = new StringBuilder(); - await foreach (var chunk in chatCompletionService.GetStreamingChatMessageContentsAsync( - chatHistory, - new OllamaPromptExecutionSettings - { - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto( - options: new FunctionChoiceBehaviorOptions() - { - AllowParallelCalls = true, - AllowConcurrentInvocation = true - }) - }, - kernel: kernel - )) - { - // Write each chunk to the HTTP response as SSE - var data = chunk.Content ?? ""; - accumulatedContent.Append(data); - if (string.IsNullOrEmpty(data)) continue; - - var bytes = Encoding.UTF8.GetBytes(data); - await Response.Body.WriteAsync(bytes); - await Response.Body.FlushAsync(); - } - - // Optionally: after finishing streaming, you can save the assistant message to history. - } -} \ No newline at end of file diff --git a/DysonNetwork.Insight/Thought/README.md b/DysonNetwork.Insight/Thought/README.md new file mode 100644 index 0000000..670401a --- /dev/null +++ b/DysonNetwork.Insight/Thought/README.md @@ -0,0 +1,137 @@ +# DysonNetwork Insight Thought API + +The Thought API provides conversational AI capabilities for users of the Solar Network. It allows users to engage in chat-like conversations with an AI assistant powered by semantic kernel and connected to various tools. + +This service is handled by the Insight, when using with the Gateway, the `/api` should be replaced with `/insight` + +## Features + +- Streaming chat responses using Server-Sent Events (SSE) +- Conversation context management with sequences +- Caching for improved performance +- Authentication required for all operations + +## Endpoints + +### POST /api/thought + +Initiates or continues a chat conversation. + +#### Parameters +- `UserMessage` (string, required): The message from the user +- `SequenceId` (Guid, optional): ID of existing conversation sequence. If not provided, a new sequence is created. + +#### Response +- Content-Type: `text/event-stream` +- Streaming response with assistant messages +- Status: 401 if not authenticated +- Status: 403 if sequence doesn't belong to user + +#### Example Usage +```bash +curl -X POST "http://localhost:5000/api/thought" \ + -H "Content-Type: application/json" \ + -d '{ + "UserMessage": "Hello, how can I help with the Solar Network?", + "SequenceId": null + }' +``` + +### GET /api/thought/sequences + +Lists all thinking sequences for the authenticated user. + +#### Parameters +- `offset` (int, default 0): Number of sequences to skip for pagination +- `take` (int, default 20): Maximum number of sequences to return + +#### Response +- `200 OK`: Array of `SnThinkingSequence` +- `401 Unauthorized`: If not authenticated +- Headers: + - `X-Total`: Total number of sequences before pagination + +#### Example Usage +```bash +curl -X GET "http://localhost:5000/api/thought/sequences?take=10" +``` + +### GET /api/thought/sequences/{sequenceId} + +Retrieves all thoughts (messages) in a specific conversation sequence. + +#### Parameters +- `sequenceId` (Guid, path): ID of the sequence to retrieve + +#### Response +- `200 OK`: Array of `SnThinkingThought` ordered by creation date +- `401 Unauthorized`: If not authenticated +- `404 Not Found`: If sequence doesn't exist or doesn't belong to user + +#### Example Usage +```bash +curl -X GET "http://localhost:5000/api/thought/sequences/12345678-1234-1234-1234-123456789abc" +``` + +## Data Models + +### StreamThinkingRequest +```csharp +{ + string UserMessage, // Required + Guid? SequenceId // Optional +} +``` + +### SnThinkingSequence +```csharp +{ + Guid Id, + string? Topic, + Guid AccountId +} +``` + +### SnThinkingThought +```csharp +{ + Guid Id, + string? Content, + List Files, + ThinkingThoughtRole Role, + Guid SequenceId, + SnThinkingSequence Sequence +} +``` + +### ThinkingThoughtRole (enum) +- `Assistant` +- `User` + +## Caching + +The API uses Redis-based caching for conversation thoughts: +- Thoughts are cached for 10 minutes with group-based invalidation +- Cache is invalidated when new thoughts are added to a sequence +- Improves performance for accessing conversation history + +## Authentication + +All endpoints require authentication through the current user session. Sequence access is validated against the authenticated user's account ID. + +## Error Responses + +- `401 Unauthorized`: Authentication required +- `403 Forbidden`: Access denied (sequence ownership) +- `404 Not Found`: Resource not found + +## Streaming Details + +The POST endpoint returns a stream of assistant responses using Server-Sent Events format. Clients should handle the streaming response and display messages incrementally. + +## Implementation Notes + +- Built with ASP.NET Core and Semantic Kernel +- Uses PostgreSQL via Entity Framework Core +- Integrated with Ollama for AI completion +- Caching via Redis diff --git a/DysonNetwork.Insight/Thought/ThoughtController.cs b/DysonNetwork.Insight/Thought/ThoughtController.cs new file mode 100644 index 0000000..399e5da --- /dev/null +++ b/DysonNetwork.Insight/Thought/ThoughtController.cs @@ -0,0 +1,185 @@ +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Json; +using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Proto; +using Microsoft.AspNetCore.Mvc; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Ollama; + +namespace DysonNetwork.Insight.Thought; + +[ApiController] +[Route("/api/thought")] +public class ThoughtController(ThoughtProvider provider, ThoughtService service) : ControllerBase +{ + public class StreamThinkingRequest + { + [Required] public string UserMessage { get; set; } = null!; + public Guid? SequenceId { get; set; } + } + + [HttpPost] + public async Task Think([FromBody] StreamThinkingRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + + // Generate topic if creating new sequence + string? topic = null; + if (!request.SequenceId.HasValue) + { + // Use AI to summarize topic from user message + var summaryHistory = new ChatHistory( + "You are a helpful assistant. Summarize the following user message into a concise topic title (max 100 characters)." + ); + summaryHistory.AddUserMessage(request.UserMessage); + + var summaryResult = await provider.Kernel.GetRequiredService() + .GetChatMessageContentAsync(summaryHistory); + + topic = summaryResult.Content?.Substring(0, Math.Min(summaryResult.Content.Length, 4096)); + } + + // Handle sequence + var sequence = await service.GetOrCreateSequenceAsync(accountId, request.SequenceId, topic); + if (sequence == null) return Forbid(); // or NotFound + + // Save user thought + await service.SaveThoughtAsync(sequence, request.UserMessage, ThinkingThoughtRole.User); + + // Build chat history + var chatHistory = new ChatHistory( + "You're a helpful assistant on the Solar Network, a social network.\n" + + "Your name is Sn-chan, a cute sweet heart with passion for almost everything.\n" + + "When you talk to user, you can add some modal particles and emoticons to your response to be cute, but prevent use a lot of emojis." + + "\n" + + "Your aim is to helping solving questions for the users on the Solar Network.\n" + + "And the Solar Network is the social network platform you live on.\n" + + "When the user asks questions about the Solar Network (also known as SN and Solian), try use the tools you have to get latest and accurate data." + ); + + // Add previous thoughts (excluding the current user thought, which is the last one) + var previousThoughts = await service.GetPreviousThoughtsAsync(sequence); + var count = previousThoughts.Count; + for (var i = 0; i < count - 1; i++) + { + var thought = previousThoughts[i]; + switch (thought.Role) + { + case ThinkingThoughtRole.User: + chatHistory.AddUserMessage(thought.Content ?? ""); + break; + case ThinkingThoughtRole.Assistant: + chatHistory.AddAssistantMessage(thought.Content ?? ""); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + chatHistory.AddUserMessage(request.UserMessage); + + // Set response for streaming + Response.Headers.Append("Content-Type", "text/event-stream"); + Response.StatusCode = 200; + + var kernel = provider.Kernel; + var chatCompletionService = kernel.GetRequiredService(); + + // Kick off streaming generation + var accumulatedContent = new StringBuilder(); + await foreach (var chunk in chatCompletionService.GetStreamingChatMessageContentsAsync( + chatHistory, + new OllamaPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto( + options: new FunctionChoiceBehaviorOptions() + { + AllowParallelCalls = true, + AllowConcurrentInvocation = true + }) + }, + kernel: kernel + )) + { + // Write each chunk to the HTTP response as SSE + var data = chunk.Content ?? ""; + accumulatedContent.Append(data); + if (string.IsNullOrEmpty(data)) continue; + + var bytes = Encoding.UTF8.GetBytes(data); + await Response.Body.WriteAsync(bytes); + await Response.Body.FlushAsync(); + } + + // Save assistant thought + var savedThought = await service.SaveThoughtAsync(sequence, accumulatedContent.ToString(), ThinkingThoughtRole.Assistant); + + // Write the topic if it was newly set, then the thought object as JSON to the stream + using (var streamBuilder = new MemoryStream()) + { + await streamBuilder.WriteAsync("\n"u8.ToArray()); + if (topic != null) + { + await streamBuilder.WriteAsync(Encoding.UTF8.GetBytes($"{sequence.Topic ?? ""}\n")); + } + await streamBuilder.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(savedThought, GrpcTypeHelper.SerializerOptions))); + var outputBytes = streamBuilder.ToArray(); + await Response.Body.WriteAsync(outputBytes); + await Response.Body.FlushAsync(); + } + + // Return empty result since we're streaming + return new EmptyResult(); + } + + /// + /// Retrieves a paginated list of thinking sequences for the authenticated user. + /// + /// The number of sequences to skip for pagination. + /// The maximum number of sequences to return (default: 20). + /// + /// Returns an ActionResult containing a list of thinking sequences. + /// Includes an X-Total header with the total count of sequences before pagination. + /// + [HttpGet("sequences")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> ListSequences( + [FromQuery] int offset = 0, + [FromQuery] int take = 20 + ) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + + var (totalCount, sequences) = await service.ListSequencesAsync(accountId, offset, take); + + Response.Headers["X-Total"] = totalCount.ToString(); + + return Ok(sequences); + } + + /// + /// Retrieves the thoughts in a specific thinking sequence. + /// + /// The ID of the sequence to retrieve thoughts from. + /// + /// Returns an ActionResult containing a list of thoughts in the sequence, ordered by creation date. + /// + [HttpGet("sequences/{sequenceId:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetSequenceThoughts(Guid sequenceId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var accountId = Guid.Parse(currentUser.Id); + + var sequence = await service.GetOrCreateSequenceAsync(accountId, sequenceId); + if (sequence == null) return NotFound(); + + var thoughts = await service.GetPreviousThoughtsAsync(sequence); + + return Ok(thoughts); + } +} diff --git a/DysonNetwork.Insight/Thinking/ThinkingProvider.cs b/DysonNetwork.Insight/Thought/ThoughtProvider.cs similarity index 95% rename from DysonNetwork.Insight/Thinking/ThinkingProvider.cs rename to DysonNetwork.Insight/Thought/ThoughtProvider.cs index 4239b12..0954227 100644 --- a/DysonNetwork.Insight/Thinking/ThinkingProvider.cs +++ b/DysonNetwork.Insight/Thought/ThoughtProvider.cs @@ -1,11 +1,10 @@ +using System.Text.Json; using DysonNetwork.Shared.Proto; using Microsoft.SemanticKernel; -using Microsoft.Extensions.Configuration; -using System.Text.Json; -namespace DysonNetwork.Insight.Thinking; +namespace DysonNetwork.Insight.Thought; -public class ThinkingProvider +public class ThoughtProvider { private readonly Kernel _kernel; private readonly PostService.PostServiceClient _postClient; @@ -15,7 +14,7 @@ public class ThinkingProvider public string? ModelProviderType { get; private set; } public string? ModelDefault { get; private set; } - public ThinkingProvider( + public ThoughtProvider( IConfiguration configuration, PostService.PostServiceClient postClient, AccountService.AccountServiceClient accountClient diff --git a/DysonNetwork.Insight/Thought/ThoughtService.cs b/DysonNetwork.Insight/Thought/ThoughtService.cs new file mode 100644 index 0000000..36de5eb --- /dev/null +++ b/DysonNetwork.Insight/Thought/ThoughtService.cs @@ -0,0 +1,75 @@ +using DysonNetwork.Shared.Cache; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Insight.Thought; + +public class ThoughtService(AppDatabase db, ICacheService cache) +{ + public async Task GetOrCreateSequenceAsync(Guid accountId, Guid? sequenceId, string? topic = null) + { + if (sequenceId.HasValue) + { + var seq = await db.ThinkingSequences.FindAsync(sequenceId.Value); + if (seq == null || seq.AccountId != accountId) return null; + return seq; + } + else + { + var seq = new SnThinkingSequence { AccountId = accountId, Topic = topic }; + db.ThinkingSequences.Add(seq); + await db.SaveChangesAsync(); + return seq; + } + } + + public async Task SaveThoughtAsync(SnThinkingSequence sequence, string content, ThinkingThoughtRole role) + { + var thought = new SnThinkingThought + { + SequenceId = sequence.Id, + Content = content, + Role = role + }; + db.ThinkingThoughts.Add(thought); + await db.SaveChangesAsync(); + + // Invalidate cache for this sequence's thoughts + await cache.RemoveGroupAsync($"sequence:{sequence.Id}"); + + return thought; + } + + public async Task> GetPreviousThoughtsAsync(SnThinkingSequence sequence) + { + var cacheKey = $"thoughts:{sequence.Id}"; + var (found, cachedThoughts) = await cache.GetAsyncWithStatus>(cacheKey); + if (found && cachedThoughts != null) + { + return cachedThoughts; + } + + var thoughts = await db.ThinkingThoughts + .Where(t => t.SequenceId == sequence.Id) + .OrderBy(t => t.CreatedAt) + .ToListAsync(); + + // Cache for 10 minutes + await cache.SetWithGroupsAsync(cacheKey, thoughts, new[] { $"sequence:{sequence.Id}" }, TimeSpan.FromMinutes(10)); + + return thoughts; + } + + public async Task<(int total, List sequences)> ListSequencesAsync(Guid accountId, int offset, int take) + { + var query = db.ThinkingSequences.Where(s => s.AccountId == accountId); + var totalCount = await query.CountAsync(); + var sequences = await query + .OrderByDescending(s => s.CreatedAt) + .Skip(offset) + .Take(take) + .ToListAsync(); + + return (totalCount, sequences); + } +} diff --git a/DysonNetwork.Pass/DysonNetwork.Pass.csproj b/DysonNetwork.Pass/DysonNetwork.Pass.csproj index 7d83b3d..faa212c 100644 --- a/DysonNetwork.Pass/DysonNetwork.Pass.csproj +++ b/DysonNetwork.Pass/DysonNetwork.Pass.csproj @@ -9,7 +9,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/DysonNetwork.Ring/DysonNetwork.Ring.csproj b/DysonNetwork.Ring/DysonNetwork.Ring.csproj index 1267a5b..c66b4d6 100644 --- a/DysonNetwork.Ring/DysonNetwork.Ring.csproj +++ b/DysonNetwork.Ring/DysonNetwork.Ring.csproj @@ -16,7 +16,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/DysonNetwork.Shared/Models/ThinkingSequence.cs b/DysonNetwork.Shared/Models/ThinkingSequence.cs new file mode 100644 index 0000000..9289b9e --- /dev/null +++ b/DysonNetwork.Shared/Models/ThinkingSequence.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +namespace DysonNetwork.Shared.Models; + +public class SnThinkingSequence : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(4096)] public string? Topic { get; set; } + + public Guid AccountId { get; set; } +} + +public enum ThinkingThoughtRole +{ + Assistant, + User +} + +public class SnThinkingThought : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string? Content { get; set; } + + [Column(TypeName = "jsonb")] public List Files { get; set; } = []; + + public ThinkingThoughtRole Role { get; set; } + + public Guid SequenceId { get; set; } + [JsonIgnore] public SnThinkingSequence Sequence { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index a33198f..b2595dd 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -21,7 +21,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive