EmployeeAssistant 智能问答服务:基于 pgvector 与 LLM 的企业知识库助手

1. 项目简介

EmployeeAssistant 是一个面向企业员工的智能问答服务,采用原生 HTML/CSS/JavaScript 前端与 ASP.NET Core .NET 10 后端,结合 EF Core、PostgreSQL/pgvector 以及本地或组织批准的 LLM 服务,实现基于知识库的语义检索问答。系统根据员工提出的问题生成 embedding,从 PostgreSQL/pgvector 中检索最相关的知识库切块,并将检索内容、问题与引用来源一并发送给 chat 模型,通过 SSE 流式返回回答。服务同时支持健康检查、文档添加以及为未索引切块生成向量,并提供多语言界面与回答语言识别能力。

需要特别说明的是,本服务提供的回答仅作为信息参考,不能替代 HR 审批、合规调查或法律意见。

2. 技术架构

系统整体采用前后端分离架构,前端为原生 HTML/CSS/JavaScript 单页应用,后端为 ASP.NET Core .NET 10 的 Controller 层,数据访问基于 EF Core,向量存储与相似度检索由 PostgreSQL/pgvector 提供,LLM 推理使用本地或组织批准的模型服务。

  • 前端:原生 HTML/CSS/JavaScript,提供多语言界面与 SSE 流式对话体验。
  • 后端ASP.NET Core .NET 10 Controller,负责请求路由、业务编排与 SSE 响应。
  • 数据访问:EF Core 管理关系数据与实体映射。
  • 向量检索:PostgreSQL/pgvector 存储文档切块向量,执行余弦相似度检索。
  • LLM 服务:本地或组织批准的模型服务,负责生成 embedding 与回答文本。

3. 核心功能

3.1 智能问答

员工输入问题后,系统先生成问题 embedding,再从 pgvector 中检索最相关的知识库切块,最后将检索内容、原始问题和引用来源一起发送给 chat 模型,以 SSE 流式返回回答。

3.2 健康检查

服务提供健康检查接口,用于确认后端服务、数据库连接以及 LLM 服务是否可用,便于运维监控与故障排查。

3.3 文档添加与向量生成

支持向知识库添加文档,并为尚未索引的切块自动生成向量,确保新文档能够被后续检索命中。

3.4 多语言支持

界面支持多语言切换,同时系统能够识别用户提问所使用的语言,并尽量以相同语言返回回答,提升员工使用体验。

4. 数据模型与存储

系统核心数据模型围绕文档、切块与向量展开,主要实体包括文档、文档切块以及切块向量。文档实体保存原始文档元数据,切块实体保存切分后的文本内容,向量通过 pgvector 类型存储在切块对应记录中。

csharp 复制代码
using Microsoft.EntityFrameworkCore;

namespace EmployeeAssistant.Api.Data;

[Comment("企业 HR 制度知识文档及向量索引表")]
public sealed class HrDocument
{
    [Comment("文档唯一标识")]
    public long Id { get; set; }

    [Comment("制度文档标题")]
    public string Title { get; set; } = string.Empty;

    [Comment("制度文档原始内容,用于知识库检索")]
    public string Content { get; set; } = string.Empty;

    [Comment("文档创建时间")]
    public DateTime CreatedAt { get; set; }

    public ICollection<HrDocumentChunk> Chunks { get; set; } = new List<HrDocumentChunk>();
}

using Microsoft.EntityFrameworkCore;
using Pgvector;

namespace EmployeeAssistant.Api.Data;

[Comment("企业制度文档的检索切片及向量表")]
public sealed class HrDocumentChunk
{
    [Comment("切片唯一标识")]
    public long Id { get; set; }

    [Comment("所属制度文档标识")]
    public long HrDocumentId { get; set; }

    [Comment("切片在文档中的顺序")]
    public int ChunkIndex { get; set; }

    [Comment("切片文本内容")]
    public string Content { get; set; } = string.Empty;

    [Comment("由 embedding 模型生成的切片向量")]
    public Vector? Embedding { get; set; }

    [Comment("切片创建时间")]
    public DateTime CreatedAt { get; set; }

    public HrDocument Document { get; set; } = null!;
}

5. 检索与问答流程

一次完整的问答请求包含以下步骤:

  1. 接收员工问题文本。
  2. 调用 embedding 模型生成问题向量。
  3. 在 pgvector 中执行相似度检索,获取最相关的知识库切块。
  4. 将检索切块、原始问题与引用来源组装为提示词。
  5. 调用 chat 模型生成回答,并通过 SSE 流式返回给前端。

6. 关键代码示例

6.1 向量检索

csharp 复制代码
// 只检索已有向量的切片,并按余弦距离取最相关的四项作为回答上下文。
var embedding = await embeddingLlm.CreateEmbeddingAsync(request.Question, language, cancellationToken);
var queryVector = new Vector(embedding);
var chunks = await db.HrDocumentChunks
            .Include(chunk => chunk.Document)
            .Where(chunk => chunk.Embedding != null)
            .OrderBy(chunk => chunk.Embedding!.CosineDistance(queryVector))
            .Take(4)
            .AsNoTracking()
            .ToListAsync(cancellationToken);

6.2 SSE 流式返回

csharp 复制代码
// 先发送引用,再逐段发送模型输出,前端可以在回答生成期间持续渲染内容。
var sources = chunks
            .Select(chunk => new Citation(chunk.Document.Id, chunk.Document.Title))
            .DistinctBy(source => source.Id)
            .ToList();
await Response.WriteAsync($"event: sources\ndata: {JsonSerializer.Serialize(sources, SseJsonOptions)}\n\n", cancellationToken);
await Response.Body.FlushAsync(cancellationToken);

await foreach (var chunk in chatLlm.StreamChatAsync(request.Question, context, cancellationToken))
        {
            await Response.WriteAsync($"event: delta\ndata: {JsonSerializer.Serialize(new { content = chunk }, SseJsonOptions)}\n\n", cancellationToken);
            await Response.Body.FlushAsync(cancellationToken);
        }

await Response.WriteAsync("event: done\ndata: {}\n\n", cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
return new EmptyResult();

7. 部署与配置

服务部署时需配置数据库连接字符串、pgvector 扩展以及 LLM 服务地址。建议在应用启动时自动执行数据库迁移并启用 pgvector 扩展。

csharp 复制代码
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.HasPostgresExtension("vector");
}

8. 使用限制与免责声明

EmployeeAssistant 的回答基于企业知识库中的已有内容生成,仅供员工日常查询参考。系统不提供 HR 审批、合规调查或法律意见,涉及上述事项时请务必咨询对应专业部门或授权人员。

9. 详细实现

9.1 技术结构

复制代码
EmployeeAssistant.Api/
 Controllers/ API Controller、请求合约和 SSE 问答
 Data/ EF Core 模型、数据库初始化、文档切块
 Knowledge/ Markdown 知识库源文件
 Services/ embedding、chat 和本地化服务
 Resources/ 后端本地化资源
 wwwroot/ 原生前端和前端本地化资源

9.2 appsettings.json

cs 复制代码
{
  "ConnectionStrings": {
    "HrDb": "Host=localhost;Port=5432;Database=***;Username=***;Password=***;Ssl Mode=Require;Trust Server Certificate=true;Timeout=10;Command Timeout=120"
  },
  "Llm": {
    "EmbeddingBaseUrl": "http://localhost:4000",
    "EmbeddingModel": "embed.nomic:latest",
    "EmbeddingDimensions": 768,
    "ChatBaseUrl": "http://localhost:4000", 
    "ChatApiPath": "/v1/chat/completions",
    "ChatModel": "qwen3.5:latest", 
    "ChatApiKey": "***"
  },
  "AllowedHosts": "*"
}

9.3 Program.cs

cs 复制代码
using EmployeeAssistant.Api.Data;
using EmployeeAssistant.Api.Services;
using Microsoft.EntityFrameworkCore;
using Pgvector.EntityFrameworkCore;
using System.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddDbContext<HrDbContext>(options =>
    options.UseNpgsql(
        builder.Configuration.GetConnectionString("HrDb")
            ?? throw new InvalidOperationException("ConnectionStrings:HrDb must be configured."),
        npgsql => npgsql.UseVector()));
builder.Services.AddHttpClient("embedding-llm", client =>
{
    client.BaseAddress = new Uri(builder.Configuration["Llm:EmbeddingBaseUrl"] ?? "http://localhost:11434");
    client.Timeout = TimeSpan.FromMinutes(3);
});
builder.Services.AddHttpClient("chat-llm", client =>
{
    client.BaseAddress = new Uri(builder.Configuration["Llm:ChatBaseUrl"] ?? "http://localhost:11434");
    var apiKey = builder.Configuration["Llm:ChatApiKey"];
    if (!string.IsNullOrWhiteSpace(apiKey))
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
    client.Timeout = TimeSpan.FromMinutes(3);
});
builder.Services.AddSingleton<EmbeddingLlmClient>();
builder.Services.AddSingleton<ChatLlmClient>();
builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
    policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));

var app = builder.Build();
await DatabaseInitializer.InitializeAsync(app.Services);
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors();
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapControllers();
app.Run();

9.4 Controllers

9.4.1 ApiContracts.cs
cs 复制代码
namespace EmployeeAssistant.Api.Controllers;

public sealed record AskRequest(string Question, string? Language);
public sealed record DocumentRequest(string Title, string Content);
public sealed record AskResponse(string Answer, IReadOnlyList<Citation> Sources);
public sealed record Citation(long Id, string Title);
9.4.2 ChatController.cs
cs 复制代码
using EmployeeAssistant.Api.Data;
using EmployeeAssistant.Api.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Pgvector;
using Pgvector.EntityFrameworkCore;
using System.Text.Json;

namespace EmployeeAssistant.Api.Controllers;

[ApiController]
[Route("api")]
public sealed class ChatController(HrDbContext db, EmbeddingLlmClient embeddingLlm, ChatLlmClient chatLlm) : ControllerBase
{
    private static readonly JsonSerializerOptions SseJsonOptions = new(JsonSerializerDefaults.Web);

    [HttpPost("ask")]
    public async Task<IActionResult> Ask([FromBody] AskRequest request, CancellationToken cancellationToken)
    {
        var language = HrLocalization.Normalize(request.Language);
        if (string.IsNullOrWhiteSpace(request.Question))
            return BadRequest(new { message = HrLocalization.EmptyQuestion(language) });

        // 只检索已有向量的切片,并按余弦距离取最相关的四项作为回答上下文。
        var embedding = await embeddingLlm.CreateEmbeddingAsync(request.Question, language, cancellationToken);
        var queryVector = new Vector(embedding);
        var chunks = await db.HrDocumentChunks
            .Include(chunk => chunk.Document)
            .Where(chunk => chunk.Embedding != null)
            .OrderBy(chunk => chunk.Embedding!.CosineDistance(queryVector))
            .Take(4)
            .AsNoTracking()
            .ToListAsync(cancellationToken);
        var context = chunks.Count == 0
            ? HrLocalization.NoKnowledge(language)
            : string.Join("\n\n", chunks.Select((chunk, index) => $"[{index + 1}] {chunk.Document.Title}\n{chunk.Content}"));

        Response.ContentType = "text/event-stream; charset=utf-8";
        Response.Headers.CacheControl = "no-cache";
        Response.Headers.Connection = "keep-alive";

        // 先发送引用,再逐段发送模型输出,前端可以在回答生成期间持续渲染内容。
        var sources = chunks
            .Select(chunk => new Citation(chunk.Document.Id, chunk.Document.Title))
            .DistinctBy(source => source.Id)
            .ToList();
        await Response.WriteAsync($"event: sources\ndata: {JsonSerializer.Serialize(sources, SseJsonOptions)}\n\n", cancellationToken);
        await Response.Body.FlushAsync(cancellationToken);

        await foreach (var chunk in chatLlm.StreamChatAsync(request.Question, context, cancellationToken))
        {
            await Response.WriteAsync($"event: delta\ndata: {JsonSerializer.Serialize(new { content = chunk }, SseJsonOptions)}\n\n", cancellationToken);
            await Response.Body.FlushAsync(cancellationToken);
        }

        await Response.WriteAsync("event: done\ndata: {}\n\n", cancellationToken);
        await Response.Body.FlushAsync(cancellationToken);
        return new EmptyResult();
    }
}
9.4.3 DocumentsController.cs
cs 复制代码
using EmployeeAssistant.Api.Data;
using EmployeeAssistant.Api.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Pgvector;

namespace EmployeeAssistant.Api.Controllers;

[ApiController]
[Route("api")]
public sealed class DocumentsController(HrDbContext db, EmbeddingLlmClient embeddingLlm) : ControllerBase
{
    [HttpPost("documents")]
    public async Task<IActionResult> AddDocument([FromBody] DocumentRequest request, CancellationToken cancellationToken)
    {
        if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content))
            return BadRequest(new { message = "Title and content are required" });

        var document = new HrDocument { Title = request.Title.Trim(), Content = request.Content.Trim() };
        db.HrDocuments.Add(document);
        await db.SaveChangesAsync(cancellationToken);
        var chunks = DocumentChunker.Split(document.Content)
            .Select((content, index) => new HrDocumentChunk
            {
                HrDocumentId = document.Id,
                ChunkIndex = index,
                Content = content,
                CreatedAt = DateTime.UtcNow
            });
        db.HrDocumentChunks.AddRange(chunks);
        await db.SaveChangesAsync(cancellationToken);
        return Ok(new { id = document.Id, message = HrLocalization.DocumentSaved("en") });
    }

    [HttpPost("reindex")]
    public async Task<IActionResult> Reindex(CancellationToken cancellationToken)
    {
        var chunks = await db.HrDocumentChunks
            .Where(chunk => chunk.Embedding == null)
            .ToListAsync(cancellationToken);
        foreach (var chunk in chunks)
        {
            var embedding = await embeddingLlm.CreateEmbeddingAsync(chunk.Content, "en", cancellationToken);
            chunk.Embedding = new Vector(embedding);
        }
        await db.SaveChangesAsync(cancellationToken);
        return Ok(new { indexed = chunks.Count });
    }
}
9.4.4 HealthController.cs
cs 复制代码
using EmployeeAssistant.Api.Data;
using EmployeeAssistant.Api.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace EmployeeAssistant.Api.Controllers;

[ApiController]
[Route("api/health")]
public sealed class HealthController(HrDbContext db) : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> Get(CancellationToken cancellationToken)
    {
        var connected = await db.Database.CanConnectAsync(cancellationToken);
        return connected
            ? Ok(new { status = "ok", database = "connected" })
            : Problem(
                HrLocalization.DatabaseUnavailable(HrLocalization.Normalize(Request.Headers.AcceptLanguage.ToString().Split(',').FirstOrDefault())),
                statusCode: StatusCodes.Status503ServiceUnavailable);
    }
}

9.5 Data

9.5.1 DatabaseInitializer.cs
cs 复制代码
using Microsoft.EntityFrameworkCore;

namespace EmployeeAssistant.Api.Data;

public static class DatabaseInitializer
{
    public static async Task InitializeAsync(IServiceProvider services)
    {
        await using var scope = services.CreateAsyncScope();
        var db = scope.ServiceProvider.GetRequiredService<HrDbContext>();
        var environment = scope.ServiceProvider.GetRequiredService<IWebHostEnvironment>();
        await db.Database.EnsureCreatedAsync();
        await db.Database.MigrateAsync();

        var changed = false;

        var knowledgeDirectory = Path.Combine(environment.ContentRootPath, "Knowledge");
        // 启动时同步知识库文件;内容变化会删除旧切片,随后由下方逻辑重新切块。
        foreach (var knowledgePath in Directory.EnumerateFiles(knowledgeDirectory, "*.md"))
        {
            var documentTitle = Path.GetFileNameWithoutExtension(knowledgePath);
            var documentContent = await File.ReadAllTextAsync(knowledgePath);
            var existingDocument = await db.HrDocuments
                .SingleOrDefaultAsync(document => document.Title == documentTitle);

            if (existingDocument is null)
            {
                db.HrDocuments.Add(new HrDocument
                {
                    Title = documentTitle,
                    Content = documentContent,
                    CreatedAt = DateTime.UtcNow
                });
                changed = true;
            }
            else if (!string.Equals(existingDocument.Content, documentContent, StringComparison.Ordinal))
            {
                existingDocument.Content = documentContent;
                var oldChunks = await db.HrDocumentChunks
                    .Where(chunk => chunk.HrDocumentId == existingDocument.Id)
                    .ToListAsync();
                db.HrDocumentChunks.RemoveRange(oldChunks);
                changed = true;
            }
        }

        if (changed)
            await db.SaveChangesAsync();

        var documentsWithoutChunks = await db.HrDocuments
            .Where(document => !db.HrDocumentChunks.Any(chunk => chunk.HrDocumentId == document.Id))
            .ToListAsync();
        foreach (var document in documentsWithoutChunks)
        {
            var chunks = DocumentChunker.Split(document.Content);
            db.HrDocumentChunks.AddRange(chunks.Select((content, index) => new HrDocumentChunk
            {
                HrDocumentId = document.Id,
                ChunkIndex = index,
                Content = content,
                CreatedAt = DateTime.UtcNow
            }));
        }

        if (documentsWithoutChunks.Count > 0)
            await db.SaveChangesAsync();
    }
}
9.5.2 DocumentChunker.cs
cs 复制代码
namespace EmployeeAssistant.Api.Data;

public static class DocumentChunker
{
    private const int MaxChunkLength = 1200;
    private const int OverlapLength = 120;

    public static IReadOnlyList<string> Split(string content)
    {
        var paragraphs = content
            .Replace("\r\n", "\n")
            .Split("\n\n", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        var chunks = new List<string>();
        var current = string.Empty;

        foreach (var paragraph in paragraphs)
        {
            if (paragraph.Length <= MaxChunkLength)
            {
                if (current.Length + paragraph.Length + 2 <= MaxChunkLength)
                {
                    current = string.IsNullOrEmpty(current) ? paragraph : $"{current}\n\n{paragraph}";
                    continue;
                }

                AddChunk(chunks, current);
                current = paragraph;
                continue;
            }

            AddChunk(chunks, current);
            current = string.Empty;
            // 长段落无法按段落合并时使用固定窗口,并保留少量重叠以避免语义被边界截断。
            for (var offset = 0; offset < paragraph.Length; offset += MaxChunkLength - OverlapLength)
            {
                var length = Math.Min(MaxChunkLength, paragraph.Length - offset);
                chunks.Add(paragraph.Substring(offset, length));
            }
        }

        AddChunk(chunks, current);
        return chunks;
    }

    private static void AddChunk(ICollection<string> chunks, string content)
    {
        if (!string.IsNullOrWhiteSpace(content))
            chunks.Add(content.Trim());
    }
}
9.5.3 HrDbContext.cs
cs 复制代码
using Microsoft.EntityFrameworkCore;
using Pgvector.EntityFrameworkCore;

namespace EmployeeAssistant.Api.Data;

public sealed class HrDbContext(DbContextOptions<HrDbContext> options) : DbContext(options)
{
    public DbSet<HrDocument> HrDocuments => Set<HrDocument>();
    public DbSet<HrDocumentChunk> HrDocumentChunks => Set<HrDocumentChunk>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasPostgresExtension("vector");
        var document = modelBuilder.Entity<HrDocument>();
        document.ToTable("hr_documents");
        document.HasKey(item => item.Id);
        document.Property(item => item.Id)
            .HasColumnName("id")
            .ValueGeneratedOnAdd();
        document.Property(item => item.Title)
            .HasColumnName("title")
            .HasMaxLength(200)
            .IsRequired();
        document.Property(item => item.Content)
            .HasColumnName("content")
            .IsRequired();
        document.Property(item => item.CreatedAt)
            .HasColumnName("created_at");

        var chunk = modelBuilder.Entity<HrDocumentChunk>();
        chunk.ToTable("hr_document_chunks");
        chunk.HasKey(item => item.Id);
        chunk.Property(item => item.Id).HasColumnName("id").ValueGeneratedOnAdd();
        chunk.Property(item => item.HrDocumentId).HasColumnName("hr_document_id").IsRequired();
        chunk.Property(item => item.ChunkIndex).HasColumnName("chunk_index").IsRequired();
        chunk.Property(item => item.Content).HasColumnName("content").IsRequired();
        chunk.Property(item => item.Embedding).HasColumnName("embedding").HasColumnType("vector(768)");
        chunk.Property(item => item.CreatedAt).HasColumnName("created_at");
        chunk.HasOne(item => item.Document)
            .WithMany(item => item.Chunks)
            .HasForeignKey(item => item.HrDocumentId)
            .OnDelete(DeleteBehavior.Cascade);
        chunk.HasIndex(item => item.Embedding)
            .HasMethod("hnsw")
            .HasOperators("vector_cosine_ops");
    }
}
9.5.4 HrDocument.cs
cs 复制代码
using Microsoft.EntityFrameworkCore;

namespace EmployeeAssistant.Api.Data;

[Comment("企业 HR 制度知识文档及向量索引表")]
public sealed class HrDocument
{
    [Comment("文档唯一标识")]
    public long Id { get; set; }

    [Comment("制度文档标题")]
    public string Title { get; set; } = string.Empty;

    [Comment("制度文档原始内容,用于知识库检索")]
    public string Content { get; set; } = string.Empty;

    [Comment("文档创建时间")]
    public DateTime CreatedAt { get; set; }

    public ICollection<HrDocumentChunk> Chunks { get; set; } = new List<HrDocumentChunk>();
}
9.5.5 HrDocumentChunk.cs
cs 复制代码
using Microsoft.EntityFrameworkCore;
using Pgvector;

namespace EmployeeAssistant.Api.Data;

[Comment("企业制度文档的检索切片及向量表")]
public sealed class HrDocumentChunk
{
    [Comment("切片唯一标识")]
    public long Id { get; set; }

    [Comment("所属制度文档标识")]
    public long HrDocumentId { get; set; }

    [Comment("切片在文档中的顺序")]
    public int ChunkIndex { get; set; }

    [Comment("切片文本内容")]
    public string Content { get; set; } = string.Empty;

    [Comment("由 embedding 模型生成的切片向量")]
    public Vector? Embedding { get; set; }

    [Comment("切片创建时间")]
    public DateTime CreatedAt { get; set; }

    public HrDocument Document { get; set; } = null!;
}

9.6 Knowledge

...

9.7 Resources

9.7.1 localization.en.json
cs 复制代码
{"LanguageName":"English","EmptyQuestion":"The question cannot be empty","NoKnowledge":"No relevant knowledge base content is available. Clearly tell the employee that the answer cannot be confirmed and recommend contacting internal support.","DocumentSaved":"Document saved; rebuild the index","DatabaseUnavailable":"Database connection failed","EmbeddingUnavailable":"The local embedding service did not return a vector","EmbeddingEmpty":"The local embedding service returned an empty vector","EmbeddingDimensionMismatch":"The embedding has {0} dimensions, but the PG vector column requires {1}"}
9.7.2 localization.zh-CN.json
cs 复制代码
{"LanguageName":"简体中文","EmptyQuestion":"问题不能为空","NoKnowledge":"暂无可用的知识库内容,请明确告知员工无法确认,并建议联系内部支持人员。","DocumentSaved":"文档已保存,请执行重建索引","DatabaseUnavailable":"数据库连接失败","EmbeddingUnavailable":"本地 embedding 服务没有返回向量","EmbeddingEmpty":"本地 embedding 服务返回了空向量","EmbeddingDimensionMismatch":"embedding 维度为 {0},但 PG 向量列要求 {1} 维"}
9.7.3 ...

9.8 Services

9.8.1 ChatLlmClient.cs
cs 复制代码
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text.Json;

namespace EmployeeAssistant.Api.Services;

public sealed class ChatLlmClient(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{
    private readonly HttpClient _client = httpClientFactory.CreateClient("chat-llm");
    private readonly string _model = configuration["Llm:ChatModel"] ?? "dolphin-plus";
    private readonly string _apiPath = configuration["Llm:ChatApiPath"] ?? "/v1/chat/completions";

    public async IAsyncEnumerable<string> StreamChatAsync(
        string question,
        string context,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        var prompt = $"你是内部员工智能客服。只能依据提供的知识库内容回答问题。如果知识库内容不足,请明确说明无法确认,并建议联系内部支持人员。请识别知识库内容的主要语言,并使用该语言回答;只有知识库内容没有可识别语言时,才使用用户问题的语言。回答应简洁、准确。\n\n知识库内容:\n{context}\n\n员工问题:\n{question}";
        using var request = new HttpRequestMessage(HttpMethod.Post, _apiPath)
        {
            Content = JsonContent.Create(new
            {
                model = _model,
                stream = true,
                messages = new[] { new { role = "user", content = prompt } }
            })
        };
        using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
        response.EnsureSuccessStatusCode();

        // 按行解析 SSE,仅转发模型的增量文本,忽略心跳和其他事件数据。
        await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
        using var reader = new StreamReader(stream);
        while (await reader.ReadLineAsync(cancellationToken) is { } line)
        {
            if (!line.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
                continue;

            var payload = line[5..].Trim();
            if (payload == "[DONE]")
                yield break;

            using var json = JsonDocument.Parse(payload);
            if (json.RootElement.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
            {
                var delta = choices[0].GetProperty("delta");
                if (delta.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.String)
                    yield return content.GetString() ?? string.Empty;
            }
        }
    }
}
9.8.2 EmbeddingLlmClient.cs
cs 复制代码
using System.Net.Http.Json;

namespace EmployeeAssistant.Api.Services;

public sealed class EmbeddingLlmClient(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{
    private readonly HttpClient _client = httpClientFactory.CreateClient("embedding-llm");
    private readonly string _model = configuration["Llm:EmbeddingModel"] ?? "nomic-embed-text";
    private readonly int _dimensions = configuration.GetValue("Llm:EmbeddingDimensions", 768);

    public async Task<float[]> CreateEmbeddingAsync(string input, string language, CancellationToken cancellationToken)
    {
        var response = await _client.PostAsJsonAsync("/api/embed", new { model = _model, input }, cancellationToken);
        response.EnsureSuccessStatusCode();
        var payload = await response.Content.ReadFromJsonAsync<EmbeddingResponse>(cancellationToken: cancellationToken)
            ?? throw new InvalidOperationException(HrLocalization.EmbeddingUnavailable(language));
        var embedding = payload.Embeddings.FirstOrDefault()
            ?? throw new InvalidOperationException(HrLocalization.EmbeddingEmpty(language));
        if (embedding.Length != _dimensions)
            throw new InvalidOperationException(HrLocalization.EmbeddingDimensionMismatch(language, embedding.Length, _dimensions));
        return embedding;
    }

    private sealed record EmbeddingResponse(float[][] Embeddings);
}
9.8.3 Localization.cs
cs 复制代码
using System.Reflection;
using System.Text.Json;

namespace EmployeeAssistant.Api.Services;

public static class HrLocalization
{
    private static readonly Lazy<IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>>> Resources = new(LoadResources);

    public static string Normalize(string? language)
    {
        var value = language?.Trim().ToLowerInvariant() ?? string.Empty;
        return value switch
        {
            "zh-cn" or "zh-sg" or "zh" => "zh-CN",
            "zh-tw" or "zh-hk" => "zh-TW",
            "en" or "en-us" or "en-gb" => "en",
            "vi" or "vi-vn" => "vi",
            "cs" or "cs-cz" => "cs",
            "es" or "es-es" or "es-mx" => "es",
            _ => "en"
        };
    }

    public static string LanguageName(string language) => Get("LanguageName", language);
    public static string EmptyQuestion(string language) => Get("EmptyQuestion", language);
    public static string NoKnowledge(string language) => Get("NoKnowledge", language);
    public static string DocumentSaved(string language) => Get("DocumentSaved", language);
    public static string DatabaseUnavailable(string language) => Get("DatabaseUnavailable", language);
    public static string EmbeddingUnavailable(string language) => Get("EmbeddingUnavailable", language);
    public static string EmbeddingEmpty(string language) => Get("EmbeddingEmpty", language);

    public static string EmbeddingDimensionMismatch(string language, int actual, int expected) =>
        string.Format(Get("EmbeddingDimensionMismatch", language), actual, expected);

    private static string Get(string key, string language)
    {
        var normalized = Normalize(language);
        return Resources.Value.TryGetValue(key, out var translations)
            && (translations.TryGetValue(normalized, out var value) || translations.TryGetValue("en", out value))
            ? value
            : key;
    }

    private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> LoadResources()
    {
        var resources = new Dictionary<string, IReadOnlyDictionary<string, string>>();
        foreach (var language in new[] { "zh-CN", "zh-TW", "en", "vi", "cs", "es" })
        {
            var resourceName = $"EmployeeAssistant.Api.Resources.localization.{language}.json";
            using var stream = typeof(HrLocalization).Assembly.GetManifestResourceStream(resourceName)
                ?? throw new InvalidOperationException($"Embedded localization resource not found: {resourceName}");
            using var document = JsonDocument.Parse(stream);
            foreach (var item in document.RootElement.EnumerateObject())
            {
                if (!resources.TryGetValue(item.Name, out var translations))
                    translations = new Dictionary<string, string>();
                var values = translations.ToDictionary(pair => pair.Key, pair => pair.Value);
                values[language] = item.Value.GetString() ?? string.Empty;
                resources[item.Name] = values;
            }
        }
        return resources;
    }
}

9.9 wwwroot

9.9.1 locales

9.9.1.1 localization.en.json

复制代码
{"LanguageName":"English","EmptyQuestion":"The question cannot be empty","NoKnowledge":"No relevant knowledge base content is available. Clearly tell the employee that the answer cannot be confirmed and recommend contacting internal support.","DocumentSaved":"Document saved; rebuild the index","DatabaseUnavailable":"Database connection failed","EmbeddingUnavailable":"The local embedding service did not return a vector","EmbeddingEmpty":"The local embedding service returned an empty vector","EmbeddingDimensionMismatch":"The embedding has {0} dimensions, but the PG vector column requires {1}"}

9.9.1.2 localization.zh-CN.json

复制代码
{"LanguageName":"简体中文","EmptyQuestion":"问题不能为空","NoKnowledge":"暂无可用的知识库内容,请明确告知员工无法确认,并建议联系内部支持人员。","DocumentSaved":"文档已保存,请执行重建索引","DatabaseUnavailable":"数据库连接失败","EmbeddingUnavailable":"本地 embedding 服务没有返回向量","EmbeddingEmpty":"本地 embedding 服务返回了空向量","EmbeddingDimensionMismatch":"embedding 维度为 {0},但 PG 向量列要求 {1} 维"}
9.9.2 app.js
javascript 复制代码
const form = document.querySelector('#ask-form');
const question = document.querySelector('#question');
const messages = document.querySelector('#messages');
const voiceInput = document.querySelector('#voice-input');
const voiceStatus = document.querySelector('#voice-status');
const voiceToggle = document.querySelector('#voice-toggle');
const sendButton = document.querySelector('#send-button');
const languageSelect = document.querySelector('#language-select');
const languageLocales = { 'zh-CN': 'zh-CN', 'zh-TW': 'zh-TW', en: 'en-US', vi: 'vi-VN', cs: 'cs-CZ', es: 'es-ES' };

function getBrowserLanguage() {
  const browserLanguages = navigator.languages?.length ? navigator.languages : [navigator.language];
  const language = browserLanguages.find(value => Object.keys(languageLocales).some(key => value?.toLowerCase().startsWith(key.toLowerCase())))?.toLowerCase() || 'en';
  if (language.startsWith('zh-tw') || language.startsWith('zh-hk')) return 'zh-TW';
  if (language.startsWith('zh')) return 'zh-CN';
  if (language.startsWith('vi')) return 'vi';
  if (language.startsWith('cs')) return 'cs';
  if (language.startsWith('es')) return 'es';
  return 'en';
}

let uiLanguage = getBrowserLanguage();
let ui = { locale: languageLocales[uiLanguage] };
let voiceEnabled = false;
let requestRunning = false;
const microphoneIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 14a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v5a3 3 0 0 0 3 3Z"></path><path d="M19 11a7 7 0 0 1-14 0M12 18v3M8 21h8"></path></svg>';
const speakerIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4V5Z"></path><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"></path></svg>';
const speakerMutedIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4V5Z"></path><path d="m18 9-5 6M13 9l5 6"></path></svg>';
const stopIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="1"></rect></svg>';

function applyTranslations() {
  document.documentElement.lang = ui.locale;
  document.title = ui.title;
  document.querySelector('#app-title').textContent = ui.title;
  document.querySelector('#knowledge-status').textContent = ui.status;
  document.querySelector('#welcome-message').textContent = ui.welcome;
  question.placeholder = ui.placeholder;
  sendButton.querySelector('#send-label').textContent = ui.send;
  voiceInput.setAttribute('aria-label', ui.startVoice);
  voiceInput.title = ui.startVoice;
  voiceToggle.setAttribute('aria-label', ui.voiceOn);
  voiceToggle.title = ui.voiceOff;
  voiceToggle.innerHTML = voiceEnabled ? speakerIcon : speakerMutedIcon;
  languageSelect.value = uiLanguage;
  languageSelect.setAttribute('aria-label', ui.title);
  if (!recognition) {
    voiceInput.title = ui.speechUnsupported;
    setVoiceStatus(ui.speechUnsupported);
  }
  document.querySelectorAll('.suggestion').forEach((button, index) => {
    button.textContent = ui.questions[index];
    button.dataset.question = ui.questions[index];
  });
}

async function loadLanguageResource() {
  try {
    const response = await fetch(`./locales/${uiLanguage}.json`, { cache: 'no-cache' });
    if (!response.ok) return;
    const resources = await response.json();
    const profile = resources[uiLanguage] || resources;
    if (profile.locale) {
      ui = profile;
      applyTranslations();
    }
  } catch {
    // Keep the embedded fallback profile when the resource file is unavailable.
  }
}

loadLanguageResource();

languageSelect.addEventListener('change', async () => {
  if (requestRunning) return;
  uiLanguage = languageSelect.value;
  ui = { locale: languageLocales[uiLanguage] };
  try {
    const response = await fetch(`./locales/${uiLanguage}.json`, { cache: 'no-cache' });
    if (response.ok) {
      const resources = await response.json();
      ui = resources[uiLanguage] || resources;
    }
  } catch {
    // Keep the embedded fallback profile when the resource file is unavailable.
  }
  applyTranslations();
  if (recognition && !voiceInputActive) recognition.lang = ui.locale;
});

function formatRelativeTime(timestamp) {
  const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
  if (uiLanguage !== 'zh-CN' && uiLanguage !== 'zh-TW') {
    const formatter = new Intl.RelativeTimeFormat(ui.locale, { numeric: 'auto' });
    if (seconds < 60) return formatter.format(0, 'second');
    const minutes = Math.floor(seconds / 60);
    if (minutes < 60) return formatter.format(-minutes, 'minute');
    const hours = Math.floor(minutes / 60);
    if (hours < 24) return formatter.format(-hours, 'hour');
    return formatter.format(-Math.floor(hours / 24), 'day');
  }
  if (seconds < 60) return ui.relativeTime.justNow;
  const minutes = Math.floor(seconds / 60);
  if (minutes < 60) return ui.relativeTime.minutes.replace('{count}', minutes);
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return ui.relativeTime.hours.replace('{count}', hours);
  return ui.relativeTime.days.replace('{count}', Math.floor(hours / 24));
}

function refreshRelativeTimes() {
  document.querySelectorAll('time[data-timestamp]').forEach(time => {
    time.textContent = formatRelativeTime(Number(time.dataset.timestamp));
  });
}

document.querySelectorAll('time[data-timestamp]').forEach(time => {
  time.dataset.timestamp = Date.now();
});
setInterval(refreshRelativeTimes, 30000);
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognition;
let voiceInputActive = false;
let voiceStarting = false;
let voiceStartCancelled = false;
let voiceStopRequested = false;
let voiceErrorTitle = '';

function setVoiceStatus(message) {
  voiceStatus.textContent = message;
}

function voiceErrorMessage(code) {
  return ui.voiceErrors[code] || ui.voiceErrors.network;
}

async function requestMicrophone() {
  if (!window.isSecureContext && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1')
    throw new Error(ui.microphoneSecureContext);
  if (!navigator.mediaDevices?.getUserMedia)
    throw new Error(ui.microphoneUnsupported);
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  stream.getTracks().forEach(track => track.stop());
}

if (SpeechRecognition) {
  recognition = new SpeechRecognition();
  recognition.continuous = true;
  recognition.interimResults = true;
  recognition.lang = ui.locale;
  recognition.onstart = () => {
    voiceErrorTitle = '';
    voiceInputActive = true;
    voiceInput.setAttribute('aria-pressed', 'true');
    voiceInput.classList.add('recording');
    voiceInput.innerHTML = stopIcon;
    voiceInput.title = ui.stopVoice;
  };
  recognition.onresult = event => {
    const transcript = Array.from(event.results).map(result => result[0].transcript).join('');
    question.value = transcript;
    question.dispatchEvent(new Event('input', { bubbles: true }));
  };
  recognition.onend = () => {
    if (voiceInputActive && !voiceStopRequested && !requestRunning) {
      setTimeout(() => {
        if (voiceInputActive && !voiceStopRequested && !requestRunning) {
          try {
            recognition.start();
          } catch {
            voiceStopRequested = true;
          }
        }
      }, 150);
      return;
    }
    voiceInputActive = false;
    voiceInput.setAttribute('aria-pressed', 'false');
    voiceInput.classList.remove('recording');
    voiceInput.innerHTML = microphoneIcon;
    voiceInput.setAttribute('aria-label', ui.startVoice);
    voiceInput.title = voiceErrorTitle || ui.startVoice;
    if (!voiceErrorTitle) setVoiceStatus('');
    voiceErrorTitle = '';
    voiceStopRequested = false;
  };
  recognition.onerror = event => {
    if (event.error === 'no-speech' && voiceInputActive && !voiceStopRequested) return;
    voiceStopRequested = true;
    voiceErrorTitle = voiceErrorMessage(event.error);
    voiceInput.title = voiceErrorTitle;
    voiceInput.setAttribute('aria-label', voiceInput.title);
    voiceInput.classList.remove('recording');
    voiceInput.innerHTML = microphoneIcon;
    voiceInputActive = false;
    voiceInput.setAttribute('aria-pressed', 'false');
    setVoiceStatus(voiceErrorTitle);
  };
} else {
  voiceInput.disabled = true;
  voiceInput.title = ui.speechUnsupported;
  setVoiceStatus(ui.speechUnsupported);
}

function detectLanguage(text) {
  if (/\p{Script=Han}/u.test(text)) return ui.locale;
  if (/\p{Script=Hiragana}|\p{Script=Katakana}/u.test(text)) return 'ja-JP';
  if (/\p{Script=Hangul}/u.test(text)) return 'ko-KR';
  if (/\p{Script=Arabic}/u.test(text)) return 'ar-SA';
  if (/\p{Script=Cyrillic}/u.test(text)) return 'ru-RU';
  return ui.locale;
}

function speak(text, language) {
  if (!voiceEnabled) return;
  window.speechSynthesis.cancel();
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.lang = language;
  const voice = window.speechSynthesis.getVoices().find(item => item.lang.toLowerCase().startsWith(language.slice(0, 2).toLowerCase()));
  if (voice) utterance.voice = voice;
  window.speechSynthesis.speak(utterance);
}

function addMessage(text, role, sources = []) {
  const item = document.createElement('div');
  item.className = `message ${role}`;
  const sourceText = sources.length ? `<small>${ui.source}${sources.map(source => source.title).join(ui.sourceSeparator)}</small>` : '';
  item.innerHTML = `<span class="avatar">${role === 'user' ? ui.userAvatar : ui.assistantAvatar}</span><div><p>${text.replaceAll('<', '&lt;')}</p>${sourceText}<time data-timestamp="${Date.now()}">${ui.relativeTime.justNow}</time></div>`;
  messages.appendChild(item);
  item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}

function addStreamingMessage() {
  const item = document.createElement('div');
  item.className = 'message assistant';
  item.innerHTML = `<span class="avatar">${ui.assistantAvatar}</span><div><p></p><time data-timestamp="${Date.now()}">${ui.relativeTime.justNow}</time></div>`;
  messages.appendChild(item);
  item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  return { item, content: item.querySelector('p'), metadata: item.querySelector('time') };
}

function setRequestRunning(running) {
  requestRunning = running;
  question.disabled = running;
  languageSelect.disabled = running;
  voiceInput.disabled = running || !recognition;
  sendButton.disabled = running;
  sendButton.setAttribute('aria-disabled', String(running));
  form.setAttribute('aria-busy', String(running));
  form.querySelectorAll('button').forEach(button => { button.disabled = running; });
  document.querySelectorAll('[data-question]').forEach(button => { button.disabled = running; });
  if (running && voiceInputActive && recognition) {
    voiceStopRequested = true;
    recognition.stop();
  }
}

async function ask(text) {
  addMessage(text, 'user');
  question.value = '';
  setRequestRunning(true);
  const assistant = addStreamingMessage();
  let answer = '';
  try {
    const response = await fetch('./api/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question: text, language: uiLanguage }) });
    if (!response.ok) throw new Error(ui.requestFailed.replace('{status}', response.status));
    if (!response.body) throw new Error(ui.streamingUnsupported);

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    let sources = [];
    const processEvent = block => {
      let eventName = 'message';
      const data = [];
      block.split(/\r?\n/).forEach(line => {
        if (line.startsWith('event:')) eventName = line.slice(6).trim();
        if (line.startsWith('data:')) data.push(line.slice(5).trim());
      });
      if (!data.length) return;
      const payload = JSON.parse(data.join('\n'));
      if (eventName === 'sources') {
        sources = payload;
        const sourceText = document.createElement('small');
        sourceText.textContent = sources.length ? `${ui.source}${sources.map(source => source.title).join(ui.sourceSeparator)}` : '';
        if (sourceText.textContent) assistant.item.querySelector('div').appendChild(sourceText);
      } else if (eventName === 'delta') {
        answer += payload.content || '';
        assistant.content.textContent = answer;
        assistant.item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
      }
    };

    while (true) {
      const { value, done } = await reader.read();
      buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
      const events = buffer.split(/\r?\n\r?\n/);
      buffer = events.pop() || '';
      events.forEach(processEvent);
      if (done) break;
    }
    if (buffer.trim()) processEvent(buffer);
    refreshRelativeTimes();
    speak(answer, detectLanguage(text));
  } catch (error) {
    assistant.content.textContent = ui.requestError.replace('{error}', error.message);
  } finally {
    setRequestRunning(false);
  }
}

form.addEventListener('submit', event => { event.preventDefault(); if (!requestRunning && question.value.trim()) ask(question.value.trim()); });
question.addEventListener('keydown', event => {
  if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;
  event.preventDefault();
  if (!requestRunning && question.value.trim()) form.requestSubmit(sendButton);
});
document.querySelectorAll('[data-question]').forEach(button => button.addEventListener('click', () => { if (!requestRunning) ask(button.dataset.question); }));
voiceToggle.addEventListener('click', () => {
  voiceEnabled = !voiceEnabled;
  if (!voiceEnabled) window.speechSynthesis.cancel();
  voiceToggle.classList.toggle('active', voiceEnabled);
  voiceToggle.innerHTML = voiceEnabled ? speakerIcon : speakerMutedIcon;
  voiceToggle.title = voiceEnabled ? ui.voiceOn : ui.voiceOff;
  voiceToggle.setAttribute('aria-label', voiceEnabled ? ui.voiceOff : ui.voiceOn);
});
voiceInput.addEventListener('click', async () => {
  if (!recognition) return;
  if (voiceInputActive) {
    voiceStopRequested = true;
    recognition.stop();
    return;
  }
  if (voiceStarting) {
    voiceStartCancelled = true;
    voiceStopRequested = true;
    voiceStarting = false;
    voiceInputActive = false;
    voiceInput.setAttribute('aria-pressed', 'false');
    voiceInput.classList.remove('recording');
    voiceInput.innerHTML = microphoneIcon;
    voiceInput.setAttribute('aria-label', ui.startVoice);
    voiceInput.title = ui.startVoice;
    setVoiceStatus('');
    return;
  }
  recognition.lang = ui.locale;
  voiceStarting = true;
  voiceStartCancelled = false;
  voiceStopRequested = false;
  voiceInputActive = true;
  voiceInput.setAttribute('aria-pressed', 'true');
  voiceInput.classList.add('recording');
  voiceInput.innerHTML = stopIcon;
  voiceInput.setAttribute('aria-label', ui.stopVoice);
  voiceInput.title = ui.voiceStarting;
  try {
    await requestMicrophone();
    if (voiceStartCancelled) return;
    recognition.start();
  } catch (error) {
    if (voiceStartCancelled) return;
      voiceErrorTitle = error.message || ui.voiceStartFailed;
    voiceInput.title = voiceErrorTitle;
    voiceInput.setAttribute('aria-label', voiceInput.title);
    voiceInputActive = false;
    voiceInput.setAttribute('aria-pressed', 'false');
    setVoiceStatus(voiceErrorTitle);
  } finally {
    voiceStarting = false;
  }
});
9.9.3 index.html
html 复制代码
<!doctype html>
<html lang="zh-CN">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>智能客服</title>
    <link rel="stylesheet" href="./styles.css">
</head>

<body>
    <main class="app-shell">
        <header class="topbar">
            <div><span class="eyebrow">CUSTOMER EXPERIENCE</span>
                <h1 id="app-title">智能客服</h1>
            </div>
            <div class="topbar-actions"><select id="language-select" class="language-select" aria-label="选择语言" title="选择语言"><option value="zh-CN">简体中文</option><option value="zh-TW">繁體中文</option><option value="en">English</option><option value="vi">Tiếng Việt</option><option value="cs">Čeština</option><option value="es">Español</option></select><span class="status"><i></i> <span id="knowledge-status">本地知识库</span></span></div>
        </header>
        <section class="chat-panel">
            <div id="messages" class="messages">
                <div class="message assistant"><span class="avatar">AI</span>
                    <div>
                        <p id="welcome-message">你好,我是内部员工智能客服。你可以咨询制度政策、IT 支持、办公设施、薪酬福利、采购报销和出差流程。</p><time data-timestamp>刚刚</time>
                    </div>
                </div>
            </div>
                <div class="suggestions"><button class="suggestion" data-key="annualLeave" data-question="如何申请 IT 账号或权限?">如何申请 IT 账号或权限?</button><button
                    class="suggestion" data-key="sickLeave" data-question="报销需要哪些材料?">报销需要哪些材料?</button><button class="suggestion" data-key="probation" data-question="如何申请出差或办公设备?">如何申请出差或办公设备?</button>
            </div>
            <form id="ask-form" class="composer"><textarea id="question" rows="2" placeholder="输入你的问题..."
                    required></textarea><button id="voice-input" class="icon-button" type="button" aria-pressed="false"
                    aria-label="开始语音输入" title="开始语音输入"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 14a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v5a3 3 0 0 0 3 3Z"></path><path d="M19 11a7 7 0 0 1-14 0M12 18v3M8 21h8"></path></svg></button><span id="voice-status" class="voice-status" aria-live="polite"></span><button id="voice-toggle"
                    class="icon-button voice-toggle" type="button" aria-label="开启自动朗读"
                    title="自动朗读已关闭"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5 6 9H3v6h3l5 4V5Z"></path><path d="m18 9-5 6M13 9l5 6"></path></svg></button><button aria-label="发送问题" id="send-button" type="submit"><span id="send-label">发送</span> <span>↗</span></button></form>
        </section>
    </main>
    <script src="./app.js"></script>
</body>

</html>
9.9.4 styles.css
css 复制代码
:root {
    --ink: #202526;
    --muted: #718080;
    --paper: #f3f5ef;
    --white: #fff;
    --lime: #d6f36a;
    --line: #dce3d7;
    --blue: #c9e7f1
}

* {
    box-sizing: border-box
}

body {
    margin: 0;
    color: var(--ink);
    font-family: 'Trebuchet MS', 'Segoe UI', sans-serif;
    background: radial-gradient(circle at 85% 10%, #e6f1c6 0, transparent 28%), var(--paper)
}

.app-shell {
    max-width: 1080px;
    margin: auto;
    padding: 28px 42px 70px
}

.topbar {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    border-bottom: 1px solid var(--line);
    padding-bottom: 20px
}

.eyebrow,
.kicker {
    font-size: 11px;
    letter-spacing: .14em;
    font-weight: 600;
    color: #6b7d78
}

.topbar h1 {
    font: 700 24px 'Trebuchet MS', 'Segoe UI', sans-serif;
    margin: 5px 0 0
}

.topbar-actions {
    display: flex;
    align-items: center;
    gap: 10px
}

.language-select {
    min-height: 36px;
    border: 1px solid var(--line);
    border-radius: 8px;
    background: var(--white);
    color: var(--ink);
    padding: 0 10px;
    font: 12px 'Trebuchet MS', 'Segoe UI', sans-serif
}

.language-select:disabled {
    opacity: .5;
    cursor: not-allowed
}

.status {
    font-size: 12px;
    color: #61706d;
    padding: 10px 14px;
    border: 1px solid var(--line);
    border-radius: 99px;
    background: #fbfcf8
}

.status i {
    display: inline-block;
    width: 7px;
    height: 7px;
    background: #78b96b;
    border-radius: 50%;
    margin-right: 7px
}

.intro {
    padding: 68px 0 44px;
    max-width: 650px
}

.kicker {
    margin: 0 0 18px;
    color: #7a965a
}

.intro h2 {
    font: 600 clamp(38px, 6vw, 72px)/1.02 'Trebuchet MS', 'Segoe UI', sans-serif;
    letter-spacing: -.02em;
    margin: 0
}

.intro em {
    font-style: normal;
    color: #8aa43f
}

.subcopy {
    margin: 24px 0 0;
    color: var(--muted);
    font-size: 15px
}

.chat-panel {
    background: var(--white);
    border: 1px solid var(--line);
    box-shadow: 0 18px 50px #4b5c4211;
    border-radius: 12px;
    overflow: hidden
}

.messages {
    min-height: 180px;
    padding: 27px 30px
}

.message {
    display: flex;
    gap: 14px;
    max-width: 760px
}

.avatar {
    flex: none;
    width: 34px;
    height: 34px;
    border-radius: 10px;
    background: var(--ink);
    color: var(--lime);
    display: grid;
    place-items: center;
    font: 600 11px 'Trebuchet MS', 'Segoe UI', sans-serif
}

.message p {
    margin: 5px 0 8px;
    line-height: 1.6
}

.message time {
    font-size: 11px;
    color: #a0acab
}

.suggestions {
    display: flex;
    gap: 9px;
    flex-wrap: wrap;
    padding: 0 30px 20px
}

.suggestions button {
    font: 500 12px 'Trebuchet MS', 'Segoe UI', sans-serif;
    color: #53615f;
    background: #f1f6ec;
    border: 1px solid #e2eadc;
    border-radius: 99px;
    padding: 9px 13px;
    cursor: pointer
}

.suggestions button:hover {
    background: var(--lime);
    border-color: var(--lime)
}

.composer {
    display: flex;
    gap: 10px;
    background: #f5f8f3;
    border-top: 1px solid var(--line);
    padding: 16px
}

.composer textarea {
    flex: 1;
    resize: none;
    border: 0;
    background: transparent;
    outline: 0;
    color: var(--ink);
    font: 15px/1.5 'Trebuchet MS', 'Segoe UI', sans-serif;
    padding: 8px 10px
}

.composer button {
    align-self: flex-end;
    border: 0;
    border-radius: 8px;
    background: var(--ink);
    color: #fff;
    padding: 12px 18px;
    cursor: pointer;
    font-weight: 600
}

.composer button span {
    color: var(--lime);
    font-size: 17px
}

.composer .icon-button {
    flex: 0 0 46px;
    width: 46px;
    height: 46px;
    padding: 0;
    display: grid;
    place-items: center;
    border: 1px solid var(--line);
    border-radius: 50%;
    background: #edf5ed;
    color: var(--ink);
    font-size: 19px;
    line-height: 1;
    transition: background .2s, transform .2s
}

.composer .icon-button svg {
    width: 21px;
    height: 21px;
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round
}

.voice-status {
    align-self: center;
    max-width: 190px;
    color: var(--muted);
    font-size: 11px;
    line-height: 1.3
}

.composer #voice-input:hover {
    background: #d8eee1;
    transform: translateY(-1px)
}

.composer #voice-input.recording {
    background: #f4caca;
    border-color: #d88787;
    color: #9e3030;
    animation: recording-pulse 1.4s ease-in-out infinite
}

.composer #voice-toggle {
    background: #e7f0d1;
    color: #4d6b27
}

.composer #voice-toggle:hover {
    background: var(--lime);
    transform: translateY(-1px)
}

.composer #voice-toggle:not(.active) {
    background: #edf0ed;
    color: var(--muted)
}

.composer .icon-button:disabled,
.composer button:disabled {
    opacity: .45;
    cursor: not-allowed;
    pointer-events: none
}

@keyframes recording-pulse {

    0%,
    100% {
        box-shadow: 0 0 0 0 #d8878766
    }

    50% {
        box-shadow: 0 0 0 7px #d8878700
    }
}

.user {
    margin-left: auto;
    justify-content: flex-end
}

.user .avatar {
    background: var(--blue);
    color: var(--ink);
    order: 2
}

.user div {
    background: #f0f5ed;
    border-radius: 12px;
    padding: 2px 15px
}

.user p {
    margin-bottom: 5px
}

@media(max-width:620px) {
    .app-shell {
        padding: 20px 18px 40px
    }

    .intro {
        padding: 50px 0 32px
    }

    .topbar h1 {
        font-size: 20px
    }

    .status {
        padding: 8px 10px
    }

    .messages {
        padding: 22px 18px
    }

    .suggestions {
        padding: 0 18px 18px
    }

    .composer {
        padding: 12px
    }

    .composer button {
        padding: 11px 13px
    }
}

10. Demo

相关推荐
检信智能2 小时前
检信 Allemotion 多模态融合技术,解析非接触情绪采集的底层逻辑
人工智能·语音识别
RoboWizard3 小时前
固态硬盘对游戏帧数有影响吗?
人工智能
2301_768103496 小时前
AI视频创作Agent实战03:DeepSeek文案裂变与草稿版本控制
人工智能
火山引擎开发者社区6 小时前
Anker 首届黑客松挑战赛|9 月 7 日报名启动
人工智能
Dawson Zhu7 小时前
工作流与 Agent 的工程选型:从“控制权归属“看 LLM 应用架构
人工智能·语言模型·架构·aigc·agi
计算机源码社7 小时前
【大数据项目实战】基于大数据的影视内容生态综合质量分析与可视化-基于数据挖掘的影视内容类型共现与口碑聚类分析系统
大数据·人工智能·python·数据挖掘·数据分析·毕业设计·课程设计
C^h8 小时前
pytorch 适合初学者 0基础学习
人工智能·pytorch·python
Rocky Ding*8 小时前
【三年面试五年模拟】2026-09-06 拼多多 AI Agent研发岗秋招笔试4道算法题完整题解
论文阅读·人工智能·深度学习·机器学习·aigc·ai-native·拼多多
小柯南敲键盘8 小时前
跨马翻译:AI批量图片翻译工具,跨境电商视频字幕翻译与智能抠图一体搞定
人工智能·python·音视频