diff --git a/Dockerfile b/Dockerfile index ddbf640b..e69e9bcd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -FROM --platform=$BUILDPLATFORM node:24-bookworm-slim AS frontend-builder +FROM node:24-bookworm-slim AS frontend-builder WORKDIR /src/frontend @@ -30,7 +30,7 @@ RUN --mount=type=cache,id=next-cache,target=/src/frontend/.next/cache \ pnpm build -FROM --platform=$TARGETPLATFORM golang:1.26-bookworm AS backend-builder +FROM golang:1.26-bookworm AS backend-builder WORKDIR /src/backend @@ -58,7 +58,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -o /out/deeix-chat ./cmd/server -FROM --platform=$BUILDPLATFORM debian:bookworm-slim AS runtime-deps +FROM debian:bookworm-slim AS runtime-deps RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates tzdata \ diff --git a/VERSION b/VERSION index 53a75d67..b0032849 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.6 +0.2.7 diff --git a/backend/README.md b/backend/README.md index 2c1f4367..b0a61bf4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -224,6 +224,7 @@ OCR 引擎配置由后台文件设置管理,当前支持 RapidOCR、Tesseract 模型能力 JSON 支持: - `defaultOptions`:写入用户侧默认参数 JSON,并作为请求参数来源。 +- `lockedOptionPaths`:声明不可由用户覆盖的参数路径;对应值仍从 `defaultOptions` 读取,后端发送前会恢复为管理员默认值。 - `optionControls`:定义用户参数配置对话框的可视化控件,不会单独传给上游。 - `nativeToolKeys`:定义当前模型允许的厂商官方原生工具,例如 OpenAI、xAI、Google 和 Anthropic 的原生搜索、代码执行或图片生成能力。 - `image.stream`:仅对图像类模型能力生效;未配置时保持默认流式,显式写 `false` 时关闭图像流式调用。 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b52044e3..6c9c85d3 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -9,7 +9,7 @@ import ( ) // @title DEEIX Chat API -// @version 0.2.6 +// @version 0.2.7 // @description DEEIX Chat 后端 API 文档 // @BasePath /api/v1 // @securityDefinitions.apikey BearerAuth diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 78ebf55a..a9ca1b99 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -15805,7 +15805,7 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "0.2.6", + Version: "0.2.7", Host: "", BasePath: "/api/v1", Schemes: []string{}, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index d7e3653a..2e6cd87a 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -4,7 +4,7 @@ "description": "DEEIX Chat 后端 API 文档", "title": "DEEIX Chat API", "contact": {}, - "version": "0.2.6" + "version": "0.2.7" }, "basePath": "/api/v1", "paths": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 401f87b7..09b9d5b3 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -4620,7 +4620,7 @@ info: contact: {} description: DEEIX Chat 后端 API 文档 title: DEEIX Chat API - version: "0.2.6" + version: "0.2.7" paths: /admin/announcements: get: diff --git a/backend/go.mod b/backend/go.mod index c3909709..ad862a84 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,73 +4,74 @@ go 1.26 require ( github.com/asg017/sqlite-vec-go-bindings v0.1.6 - github.com/aws/aws-sdk-go-v2 v1.41.7 - github.com/aws/aws-sdk-go-v2/config v1.32.17 - github.com/aws/aws-sdk-go-v2/credentials v1.19.16 - github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 - github.com/aws/smithy-go v1.25.1 - github.com/coregx/gxpdf v0.7.0 + github.com/aws/aws-sdk-go-v2 v1.42.0 + github.com/aws/aws-sdk-go-v2/config v1.32.25 + github.com/aws/aws-sdk-go-v2/credentials v1.19.24 + github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 + github.com/aws/smithy-go v1.27.2 + github.com/coregx/gxpdf v0.8.2 github.com/extrame/xls v0.0.1 - github.com/gin-gonic/gin v1.10.0 - github.com/go-playground/validator/v10 v10.20.0 + github.com/gin-gonic/gin v1.12.0 + github.com/go-playground/validator/v10 v10.30.3 github.com/go-redis/redis/extra/redisotel/v8 v8.11.5 github.com/go-redis/redis/v8 v8.11.5 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 - github.com/mmonterroca/docxgo/v2 v2.3.0 + github.com/mmonterroca/docxgo/v2 v2.4.0 github.com/oschwald/maxminddb-golang v1.13.1 - github.com/pdfcpu/pdfcpu v0.11.1 + github.com/pdfcpu/pdfcpu v0.13.0 github.com/swaggo/files v1.0.1 - github.com/swaggo/gin-swagger v1.6.0 - github.com/swaggo/swag v1.16.4 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 github.com/xuri/excelize/v2 v2.10.1 - go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.52.0 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 - go.opentelemetry.io/otel v1.40.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 - go.opentelemetry.io/otel/sdk v1.40.0 - go.opentelemetry.io/otel/trace v1.40.0 - go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.50.0 - golang.org/x/image v0.39.0 - golang.org/x/sync v0.20.0 - golang.org/x/text v0.36.0 + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + go.uber.org/zap v1.28.0 + golang.org/x/crypto v0.53.0 + golang.org/x/image v0.42.0 + golang.org/x/sync v0.21.0 + golang.org/x/text v0.38.0 gopkg.in/yaml.v3 v3.0.1 - gorm.io/driver/postgres v1.5.11 - gorm.io/driver/sqlite v1.5.7 - gorm.io/gorm v1.25.12 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect - github.com/bytedance/sonic v1.11.6 // indirect - github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.1 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect - github.com/cloudwego/base64x v0.1.5 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/extrame/ole2 v0.0.0-20160812065207-d69429661ad7 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect @@ -80,47 +81,52 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-redis/redis/extra/rediscmd/v8 v8.11.5 // indirect - github.com/goccy/go-json v0.10.2 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hhrutter/lzw v1.0.0 // indirect - github.com/hhrutter/pkcs7 v0.2.0 // indirect - github.com/hhrutter/tiff v1.0.2 // indirect + github.com/hhrutter/pkcs7 v0.2.2 // indirect + github.com/hhrutter/tiff v1.0.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.5.5 // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.6 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect github.com/richardlehane/mscfb v1.0.6 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/arch v0.17.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/tools v0.43.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + golang.org/x/arch v0.27.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 02a4bc67..749fef2f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,47 +6,48 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/asg017/sqlite-vec-go-bindings v0.1.6 h1:Nx0jAzyS38XpkKznJ9xQjFXz2X9tI7KqjwVxV8RNoww= github.com/asg017/sqlite-vec-go-bindings v0.1.6/go.mod h1:A8+cTt/nKFsYCQF6OgzSNpKZrzNo5gQsXBTfsXHXY0Q= -github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= -github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY= -github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= -github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= -github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU= -github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= -github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= -github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= -github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= -github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls= +github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= +github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= +github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk= +github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= +github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -55,14 +56,12 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= -github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= -github.com/coregx/gxpdf v0.7.0 h1:73ALALyBGcFMhWshKvF8e9a6RcF74htVdmVWMDja4wA= -github.com/coregx/gxpdf v0.7.0/go.mod h1:ziVrCi95jVy1UY/XglSgEexgU0mbiY0pptkhbVgCbCI= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/coregx/gxpdf v0.8.2 h1:k2eRx0HL8xqJJjwZmiDh6VFVcnDeyQHeehsjLycPJc4= +github.com/coregx/gxpdf v0.8.2/go.mod h1:ziVrCi95jVy1UY/XglSgEexgU0mbiY0pptkhbVgCbCI= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -78,14 +77,14 @@ github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSw github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -107,8 +106,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= github.com/go-redis/redis/extra/rediscmd/v8 v8.11.5 h1:ftG8tp8SG81xyuL2woNEx5t2RZ8mOJuC2+tumi+/NR8= github.com/go-redis/redis/extra/rediscmd/v8 v8.11.5/go.mod h1:s9f/6bSbS5r/jC2ozpWhWZ2GsoHDNf6iL+kZKnZnasc= github.com/go-redis/redis/extra/redisotel/v8 v8.11.5 h1:BqyYJgvdSr2S/6O2l7zmCj26ocUTxDLgagsGIRfkS+Q= @@ -116,10 +115,12 @@ github.com/go-redis/redis/extra/redisotel/v8 v8.11.5/go.mod h1:LlDT9RRdBgOrMGvFj github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -142,24 +143,24 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= -github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I= -github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= -github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8= -github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw= +github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8= +github.com/hhrutter/pkcs7 v0.2.2/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= +github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0= +github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= -github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -168,10 +169,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -185,14 +184,14 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mmonterroca/docxgo/v2 v2.3.0 h1:0WTg5FKGEa2kW+YO7jPhlK5F4lNKGMVK2QvFZMHVa+Q= -github.com/mmonterroca/docxgo/v2 v2.3.0/go.mod h1:Yc3h7M+sssxGMz51qQ8EHbfDb2NyleLbS+lZLH9Y0us= +github.com/mmonterroca/docxgo/v2 v2.4.0 h1:1sGxTkaR1vpcYDlTdokkTX2wvNZaMtqCjTeDbyxHtH0= +github.com/mmonterroca/docxgo/v2 v2.4.0/go.mod h1:Yc3h7M+sssxGMz51qQ8EHbfDb2NyleLbS+lZLH9Y0us= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -215,14 +214,18 @@ github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= -github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas= -github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pdfcpu/pdfcpu v0.13.0 h1:7maI7K0w4pJsgX9u7eeCsK4+An/+xEVkJwAwyd7/n3M= +github.com/pdfcpu/pdfcpu v0.13.0/go.mod h1:Pz8elxcY3MHc3W65HeeDbuSBvsq+OK+enMVdBsvKCj4= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= @@ -232,27 +235,29 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= -github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= -github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= -github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= -github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= @@ -261,58 +266,65 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= +go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.52.0 h1:vkioc4XBfqnZZ7u40wK3Kgbjj9JYkvW6FY1ghmM/Shk= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.52.0/go.mod h1:vsyxiwPzPlijgouF1SRZRGqbuHod8fV6+MRCH7ltxDE= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/contrib/propagators/b3 v1.27.0 h1:IjgxbomVrV9za6bRi8fWCNXENs0co37SZedQilP2hm0= -go.opentelemetry.io/contrib/propagators/b3 v1.27.0/go.mod h1:Dv9obQz25lCisDvvs4dy28UPh974CxkahRDUPsY7y9E= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 h1:u5gsfBL8t1Km4ROhQKAs0cA0t9CzUE7nfkASj/UjAtI= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0/go.mod h1:W6FFYCZQuntC5hxVesXpu7Ppd9sT0a84njildAijc+k= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/contrib/propagators/b3 v1.44.0 h1:1IFH4oFKK8KupzIelCl3u+bkxpGRps1oWRjQI2+TTWs= +go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro= go.opentelemetry.io/otel v1.4.1/go.mod h1:StM6F/0fSwpd8dKWDCdRr7uRvEPYdW0hBSlbdTiUde4= go.opentelemetry.io/otel v1.5.0/go.mod h1:Jm/m+rNp/z0eqJc74H7LPwQ3G87qkU/AnnAydAjSAHk= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.4.1/go.mod h1:NBwHDgDIBYjwK2WNu1OPgsIc2IJzmBXNnvIJxJc8BpE= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.4.1/go.mod h1:iYEVbroFCNut9QkwEczV9vMRPHNKSSwYZjulEtsmhFc= go.opentelemetry.io/otel/trace v1.5.0/go.mod h1:sq55kfhjXYr1zVSyexg0w1mpa03AYXR5eyTkB9NPPdE= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= -golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= +golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= -golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY= +golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -323,14 +335,14 @@ golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -350,9 +362,8 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -361,26 +372,26 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -408,11 +419,9 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= -gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= -gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= -gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= -gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= -gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index cde910c2..0dfbc0e9 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -27,6 +27,7 @@ import ( apprag "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/rag" appruntime "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/runtime" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/settings" + appskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/skill" appsystemevent "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/systemevent" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/user" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/usersettings" @@ -46,6 +47,7 @@ import ( memoryrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/memory" promptpresetrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/promptpreset" settingsrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/settings" + skillrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/skill" systemeventrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/systemevent" userrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/user" usersettingsrepo "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/postgres/usersettings" @@ -61,6 +63,7 @@ import ( memoryhttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/memory" promptpresethttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/promptpreset" settingshttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/settings" + skillhttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/skill" userhttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/user" usersettingshttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/usersettings" "github.com/gin-gonic/gin" @@ -274,6 +277,12 @@ func NewApp() (*App, error) { promptPresetService.SetAuditWriter(auditService) promptPresetHandler := promptpresethttp.NewHandler(promptPresetService) promptPresetModule := promptpresethttp.NewModule(promptPresetHandler) + skillRepo := skillrepo.NewRepo(db) + skillService := appskill.NewService(skillRepo) + skillService.SetAuditWriter(auditService) + conversationService.SetSkillResolver(skillService) + skillHandler := skillhttp.NewHandler(skillService) + skillModule := skillhttp.NewModule(skillHandler) hc := newHealthChecker(db, cfg.CacheDriver, redisClient) rateLimiter := buildRateLimiter(cfg, redisClient, memoryCache) @@ -288,6 +297,7 @@ func NewApp() (*App, error) { Admin: adminModule, Announcement: announcementModule, PromptPreset: promptPresetModule, + Skill: skillModule, Settings: settingsModule, UserSettings: userSettingsModule, User: userModule, diff --git a/backend/internal/application/admin/service.go b/backend/internal/application/admin/service.go index a0023cfe..9a0748d1 100644 --- a/backend/internal/application/admin/service.go +++ b/backend/internal/application/admin/service.go @@ -26,6 +26,7 @@ import ( type userService interface { ListUsers(ctx context.Context, page int, pageSize int) ([]domainuser.User, int64, error) + ListLatestSessionActivityByUserIDs(ctx context.Context, userIDs []uint) (map[uint]time.Time, error) CountSuperAdmins(ctx context.Context) (int64, error) CreateUser( ctx context.Context, @@ -201,7 +202,11 @@ func (s *Service) ListUsers(ctx context.Context, page int, pageSize int) ([]user // BuildUserView 构建单个用户的前端展示视图。 func (s *Service) BuildUserView(ctx context.Context, item domainuser.User) (userview.UserView, error) { if s.subscriptionResolver == nil { - return s.applyTwoFactorView(ctx, userview.FromUser(item, nil)) + view, err := s.applyTwoFactorView(ctx, userview.FromUser(item, nil)) + if err != nil { + return userview.UserView{}, err + } + return s.applyLastActiveView(ctx, view) } mode, err := s.subscriptionResolver.GetBillingMode(ctx) @@ -222,7 +227,11 @@ func (s *Service) BuildUserView(ctx context.Context, item domainuser.User) (user Status: account.Status, }) } - return s.applyTwoFactorView(ctx, view) + view, err = s.applyTwoFactorView(ctx, view) + if err != nil { + return userview.UserView{}, err + } + return s.applyLastActiveView(ctx, view) } subscription, err := s.subscriptionResolver.GetCurrentSubscriptionSnapshot(ctx, item.ID, time.Now()) @@ -230,16 +239,24 @@ func (s *Service) BuildUserView(ctx context.Context, item domainuser.User) (user return userview.UserView{}, err } if subscription == nil { - return s.applyTwoFactorView(ctx, userview.FromUser(item, nil)) + view, err := s.applyTwoFactorView(ctx, userview.FromUser(item, nil)) + if err != nil { + return userview.UserView{}, err + } + return s.applyLastActiveView(ctx, view) } - return s.applyTwoFactorView(ctx, userview.FromUser(item, &userview.SubscriptionState{ + view, err := s.applyTwoFactorView(ctx, userview.FromUser(item, &userview.SubscriptionState{ PlanID: subscription.PlanID, PlanName: subscription.PlanName, Tier: subscription.Tier, Status: subscription.Status, ExpiresAt: subscription.ExpiresAt, })) + if err != nil { + return userview.UserView{}, err + } + return s.applyLastActiveView(ctx, view) } // BuildUserViews 批量构建用户展示视图。 @@ -257,7 +274,7 @@ func (s *Service) BuildUserViews(ctx context.Context, items []domainuser.User) ( } results = append(results, view) } - return results, nil + return s.applyLastActiveViews(ctx, results) } userIDs := make([]uint, 0, len(items)) @@ -286,7 +303,7 @@ func (s *Service) BuildUserViews(ctx context.Context, items []domainuser.User) ( } results = append(results, view) } - return results, nil + return s.applyLastActiveViews(ctx, results) } subscriptions, err := s.subscriptionResolver.ListCurrentSubscriptionSnapshots(ctx, userIDs, time.Now()) @@ -318,7 +335,38 @@ func (s *Service) BuildUserViews(ctx context.Context, items []domainuser.User) ( results = append(results, view) } - return results, nil + return s.applyLastActiveViews(ctx, results) +} + +func (s *Service) applyLastActiveView(ctx context.Context, view userview.UserView) (userview.UserView, error) { + activities, err := s.userService.ListLatestSessionActivityByUserIDs(ctx, []uint{view.ID}) + if err != nil { + return userview.UserView{}, err + } + if value, ok := activities[view.ID]; ok { + return userview.WithLastActiveAt(view, &value), nil + } + return view, nil +} + +func (s *Service) applyLastActiveViews(ctx context.Context, views []userview.UserView) ([]userview.UserView, error) { + if len(views) == 0 { + return views, nil + } + userIDs := make([]uint, 0, len(views)) + for _, view := range views { + userIDs = append(userIDs, view.ID) + } + activities, err := s.userService.ListLatestSessionActivityByUserIDs(ctx, userIDs) + if err != nil { + return nil, err + } + for index, view := range views { + if value, ok := activities[view.ID]; ok { + views[index] = userview.WithLastActiveAt(view, &value) + } + } + return views, nil } func (s *Service) applyTwoFactorView(ctx context.Context, view userview.UserView) (userview.UserView, error) { diff --git a/backend/internal/application/admin/service_test.go b/backend/internal/application/admin/service_test.go index edb35715..2403c2c1 100644 --- a/backend/internal/application/admin/service_test.go +++ b/backend/internal/application/admin/service_test.go @@ -193,6 +193,10 @@ func (s *adminUserServiceFake) ListUsers(context.Context, int, int) ([]domainuse return nil, 0, nil } +func (s *adminUserServiceFake) ListLatestSessionActivityByUserIDs(context.Context, []uint) (map[uint]time.Time, error) { + return map[uint]time.Time{}, nil +} + func (s *adminUserServiceFake) CountSuperAdmins(context.Context) (int64, error) { if s.superAdminCount != nil { return *s.superAdminCount, nil diff --git a/backend/internal/application/auth/registration.go b/backend/internal/application/auth/registration.go index 3b42a05b..7ee6ab2b 100644 --- a/backend/internal/application/auth/registration.go +++ b/backend/internal/application/auth/registration.go @@ -841,7 +841,7 @@ func (s *Service) CompleteEmailChange(ctx context.Context, userID uint, newEmail func normalizeRegistrationEmail(raw string) (string, error) { normalized := strings.ToLower(strings.TrimSpace(raw)) - if normalized == "" || len(normalized) > 128 { + if normalized == "" || len(normalized) > 128 || containsEmailControlCharacter(normalized) { return "", fmt.Errorf("invalid email") } parsed, err := mail.ParseAddress(normalized) @@ -851,6 +851,12 @@ func normalizeRegistrationEmail(raw string) (string, error) { return normalized, nil } +func containsEmailControlCharacter(value string) bool { + return strings.ContainsFunc(value, func(r rune) bool { + return r == 0 || r == '\r' || r == '\n' + }) +} + func validateEmailRegistrationPolicy(cfg config.Config, email string) error { local, domain, ok := strings.Cut(email, "@") if !ok || local == "" || domain == "" { @@ -1153,17 +1159,21 @@ func (s *Service) sendEmailVerificationCode(to string, code string, template ver if err != nil { return fmt.Errorf("smtp from is invalid") } + normalizedTo, err := normalizeRegistrationEmail(to) + if err != nil { + return err + } addr := net.JoinHostPort(strings.TrimSpace(cfg.SMTPHost), fmt.Sprintf("%d", cfg.SMTPPort)) var auth smtp.Auth if strings.TrimSpace(cfg.SMTPUsername) != "" || strings.TrimSpace(cfg.SMTPPassword) != "" { auth = smtp.PlainAuth("", strings.TrimSpace(cfg.SMTPUsername), strings.TrimSpace(cfg.SMTPPassword), strings.TrimSpace(cfg.SMTPHost)) } - message := buildVerificationEmailMessage(parsedFrom.String(), to, code, template, publicAssetURL(cfg.PublicWebBaseURL, "logo.svg")) - if err := sendSMTPMail(addr, strings.TrimSpace(cfg.SMTPHost), cfg.SMTPPort, auth, parsedFrom.Address, []string{to}, []byte(message)); err != nil { + message := buildVerificationEmailMessage(parsedFrom.String(), normalizedTo, code, template, publicAssetURL(cfg.PublicWebBaseURL, "logo.svg")) + if err := sendSMTPMail(addr, strings.TrimSpace(cfg.SMTPHost), cfg.SMTPPort, auth, parsedFrom.Address, []string{normalizedTo}, []byte(message)); err != nil { s.warn("email_verification_send_failed", zap.String("label", strings.TrimSpace(logLabel)), - zap.String("email", to), + zap.String("email", normalizedTo), zap.Error(err), ) return err diff --git a/backend/internal/application/conversation/errs.go b/backend/internal/application/conversation/errs.go index 6f07c9c9..6fd41461 100644 --- a/backend/internal/application/conversation/errs.go +++ b/backend/internal/application/conversation/errs.go @@ -43,6 +43,12 @@ var ( ErrTooManyMessageFiles = errors.New("too many message files") // ErrTooManySelectedTools 单条消息选择的 MCP 工具数超限。 ErrTooManySelectedTools = errors.New("too many selected tools") + // ErrTooManySelectedSkills 单条消息选择的 Skill 数超限。 + ErrTooManySelectedSkills = errors.New("too many selected skills") + // ErrSkillNotFound 技能不存在或当前用户不可用。 + ErrSkillNotFound = errors.New("skill not found") + // ErrInvalidSkillUse 技能使用入参不合法。 + ErrInvalidSkillUse = errors.New("invalid skill use") // ErrInvalidMessageBranch 消息分支参数无效。 ErrInvalidMessageBranch = errors.New("invalid message branch") // ErrInvalidMessageContent 消息内容不合法。 @@ -65,6 +71,8 @@ var ( ErrUpstreamRequestFailed = errors.New("upstream request failed") // ErrUpstreamEmptyResponse 上游返回空响应。 ErrUpstreamEmptyResponse = errors.New("upstream returned empty response") + // ErrToolRunFinalAnswerMissing 工具循环结束后上游仍未产出最终回答。 + ErrToolRunFinalAnswerMissing = errors.New("tool run ended without a final answer") // ErrMessageGenerationCanceled 用户主动停止生成。 ErrMessageGenerationCanceled = errors.New("message generation canceled") // ErrInvalidMediaGenerationTask 媒体生成任务类型或输入不合法。 diff --git a/backend/internal/application/conversation/model_option_policy.go b/backend/internal/application/conversation/model_option_policy.go index 204d6d7f..f8af93cd 100644 --- a/backend/internal/application/conversation/model_option_policy.go +++ b/backend/internal/application/conversation/model_option_policy.go @@ -39,10 +39,6 @@ type modelOptionPolicyConfig struct { } func filterModelOptions(options map[string]interface{}, protocol string, cfg modelOptionPolicyConfig) map[string]interface{} { - if len(options) == 0 { - return nil - } - mode := strings.TrimSpace(cfg.Mode) if mode == "" { mode = modelOptionPolicyAllowlist @@ -52,8 +48,16 @@ func filterModelOptions(options map[string]interface{}, protocol string, cfg mod } protocolKey := modelOptionPolicyProtocolKey(protocol) - nativeTools := nativeProviderToolsFromOption(protocolKey, options["tools"], cfg.ModelCapabilitiesJSON) - policyOptions := cloneModelOptionMap(options) + defaultOptions := modelCapabilityDefaultOptions(cfg.ModelCapabilitiesJSON) + policyOptions := mergeModelOptionDefaults( + defaultOptions, + options, + modelCapabilityLockedOptionPaths(cfg.ModelCapabilitiesJSON), + ) + if len(policyOptions) == 0 { + return nil + } + nativeTools := nativeProviderToolsFromOption(protocolKey, policyOptions["tools"], cfg.ModelCapabilitiesJSON) delete(policyOptions, "tools") denied := append([][]string{}, hardDeniedModelOptionPaths...) @@ -85,6 +89,59 @@ func filterModelOptions(options map[string]interface{}, protocol string, cfg mod return filtered } +// modelCapabilityDefaultOptions 提取管理员在模型能力 JSON 中声明的默认请求参数。 +func modelCapabilityDefaultOptions(raw string) map[string]interface{} { + value := strings.TrimSpace(raw) + if value == "" { + return nil + } + var config struct { + DefaultOptions map[string]interface{} `json:"defaultOptions"` + } + if err := json.Unmarshal([]byte(value), &config); err != nil { + return nil + } + return cloneModelOptionMap(config.DefaultOptions) +} + +func modelCapabilityLockedOptionPaths(raw string) [][]string { + value := strings.TrimSpace(raw) + if value == "" { + return nil + } + var config struct { + LockedOptionPaths []string `json:"lockedOptionPaths"` + } + if err := json.Unmarshal([]byte(value), &config); err != nil { + return nil + } + paths := make([][]string, 0, len(config.LockedOptionPaths)) + for _, value := range config.LockedOptionPaths { + if path := splitModelOptionPath(value); len(path) > 0 { + paths = append(paths, path) + } + } + return paths +} + +// mergeModelOptionDefaults 以能力默认值为基础合并本次显式参数,并对锁定路径恢复默认值。 +func mergeModelOptionDefaults(defaults map[string]interface{}, options map[string]interface{}, lockedPaths [][]string) map[string]interface{} { + merged := cloneModelOptionMap(defaults) + if merged == nil { + merged = make(map[string]interface{}, len(options)) + } + mergeModelOptionMap(merged, options) + for _, path := range lockedPaths { + if value, ok := readModelOptionPath(defaults, path); ok { + writeModelOptionPath(merged, path, cloneModelOptionValue(value)) + } + } + if len(merged) == 0 { + return nil + } + return merged +} + // nativeProviderToolsFromOption 将用户 options.tools 收敛为当前协议允许的官方原生工具。 // 普通参数白名单不处理 tools,避免用户通过自由 JSON 绕过官方工具控制。 func nativeProviderToolsFromOption(protocolKey string, raw interface{}, capabilitiesJSON string) []map[string]interface{} { diff --git a/backend/internal/application/conversation/model_option_policy_test.go b/backend/internal/application/conversation/model_option_policy_test.go index 0e86244c..562f6584 100644 --- a/backend/internal/application/conversation/model_option_policy_test.go +++ b/backend/internal/application/conversation/model_option_policy_test.go @@ -68,6 +68,128 @@ func TestFilterModelOptionsAllowlistUsesDefaultAndProtocolPaths(t *testing.T) { } } +func TestFilterModelOptionsAppliesCapabilityDefaultOptions(t *testing.T) { + filtered := filterModelOptions(map[string]interface{}{ + "reasoning": map[string]interface{}{ + "effort": "high", + }, + }, llm.AdapterOpenAIResponses, modelOptionPolicyConfig{ + Mode: modelOptionPolicyAllowlist, + AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(), + DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(), + ModelCapabilitiesJSON: `{ + "defaultOptions": { + "reasoning": {"effort": "medium", "summary": "auto"}, + "text": {"verbosity": "low"}, + "model": "blocked" + } + }`, + }) + + reasoning := filtered["reasoning"].(map[string]interface{}) + if reasoning["effort"] != "high" || reasoning["summary"] != "auto" { + t.Fatalf("expected explicit option to override default and default summary to remain, got %#v", reasoning) + } + text := filtered["text"].(map[string]interface{}) + if text["verbosity"] != "low" { + t.Fatalf("expected default text verbosity to pass, got %#v", filtered) + } + if _, ok := filtered["model"]; ok { + t.Fatalf("expected hard-denied default option to be removed, got %#v", filtered) + } +} + +func TestFilterModelOptionsAppliesLockedCapabilityDefaultOptions(t *testing.T) { + filtered := filterModelOptions(map[string]interface{}{ + "reasoning": map[string]interface{}{ + "effort": "high", + }, + "text": map[string]interface{}{ + "verbosity": "high", + }, + }, llm.AdapterOpenAIResponses, modelOptionPolicyConfig{ + Mode: modelOptionPolicyAllowlist, + AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(), + DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(), + ModelCapabilitiesJSON: `{ + "defaultOptions": { + "reasoning": {"effort": "low"}, + "text": {"verbosity": "low"}, + "previous_response_id": "resp_blocked" + }, + "lockedOptionPaths": ["reasoning.effort", "text.verbosity", "previous_response_id"] + }`, + }) + + reasoning := filtered["reasoning"].(map[string]interface{}) + if reasoning["effort"] != "low" { + t.Fatalf("expected locked default reasoning effort to override explicit option, got %#v", reasoning) + } + text := filtered["text"].(map[string]interface{}) + if text["verbosity"] != "low" { + t.Fatalf("expected locked default text verbosity to override explicit option, got %#v", text) + } + if _, ok := filtered["previous_response_id"]; ok { + t.Fatalf("expected hard-denied locked default option to be removed, got %#v", filtered) + } +} + +func TestFilterModelOptionsOnlyInjectsDefaultToolsFromDefaultOptions(t *testing.T) { + allowedOnly := filterModelOptions(nil, llm.AdapterOpenAIResponses, modelOptionPolicyConfig{ + Mode: modelOptionPolicyAllowlist, + AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(), + DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(), + ModelCapabilitiesJSON: `{"nativeToolKeys":["openai.web_search_preview"]}`, + }) + if _, ok := allowedOnly["tools"]; ok { + t.Fatalf("expected nativeToolKeys to allow but not inject tools, got %#v", allowedOnly) + } + + withDefault := filterModelOptions(nil, llm.AdapterOpenAIResponses, modelOptionPolicyConfig{ + Mode: modelOptionPolicyAllowlist, + AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(), + DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(), + ModelCapabilitiesJSON: `{ + "defaultOptions": { + "tools": [{"type": "web_search_preview", "search_context_size": "low"}] + } + }`, + }) + tools, ok := withDefault["tools"].([]map[string]interface{}) + if !ok || len(tools) != 1 { + t.Fatalf("expected default tool to be injected through capability defaults, got %#v", withDefault) + } + if tools[0]["type"] != "web_search_preview" || tools[0]["search_context_size"] != "low" { + t.Fatalf("expected default tool parameters to pass, got %#v", tools[0]) + } +} + +func TestFilterModelOptionsInjectsLockedDefaultToolsThroughCapabilities(t *testing.T) { + filtered := filterModelOptions(map[string]interface{}{ + "tools": []interface{}{ + map[string]interface{}{"type": "web_search_preview", "search_context_size": "high"}, + }, + }, llm.AdapterOpenAIResponses, modelOptionPolicyConfig{ + Mode: modelOptionPolicyAllowlist, + AllowedPathsJSON: config.DefaultModelOptionAllowedPathsJSON(), + DeniedPathsJSON: config.DefaultModelOptionDeniedPathsJSON(), + ModelCapabilitiesJSON: `{ + "defaultOptions": { + "tools": [{"type": "web_search_preview", "search_context_size": "low"}] + }, + "lockedOptionPaths": ["tools"] + }`, + }) + + tools, ok := filtered["tools"].([]map[string]interface{}) + if !ok || len(tools) != 1 { + t.Fatalf("expected locked default tool to be injected through capabilities, got %#v", filtered) + } + if tools[0]["type"] != "web_search_preview" || tools[0]["search_context_size"] != "low" { + t.Fatalf("expected locked default tool to override explicit tool parameters, got %#v", tools[0]) + } +} + func TestFilterModelOptionsRejectsUnsupportedOpenAIServiceTier(t *testing.T) { for _, serviceTier := range []string{"auto", "scale", "unknown"} { t.Run(serviceTier, func(t *testing.T) { diff --git a/backend/internal/application/conversation/prompt_plan.go b/backend/internal/application/conversation/prompt_plan.go index 5f0501c4..3313a902 100644 --- a/backend/internal/application/conversation/prompt_plan.go +++ b/backend/internal/application/conversation/prompt_plan.go @@ -7,6 +7,7 @@ import ( appstorage "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/objectstorage" domainconversation "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" ) @@ -19,6 +20,7 @@ const ( PromptBlockStableContext PromptBlockKind = "stable_context" PromptBlockHistoricalEvidence PromptBlockKind = "historical_evidence" PromptBlockDynamicContext PromptBlockKind = "dynamic_context" + PromptBlockSkillContext PromptBlockKind = "skill_context" PromptBlockToolGuidance PromptBlockKind = "tool_guidance" PromptBlockTranscript PromptBlockKind = "transcript" ) @@ -57,6 +59,7 @@ type promptPlanInput struct { BaseMessages []llm.Message StableAttachments []AttachmentInput DynamicContext userContextInput + SkillPrompts *skillPrompts ToolRuntime selectedToolRuntime Config config.Config StoreProvider appstorage.Provider @@ -127,6 +130,24 @@ func buildPromptPlan(ctx context.Context, input promptPlanInput) PromptPlan { } } + before = len(messages) + messages = injectSkillPrompts(messages, input.SkillPrompts) + if len(messages) > before && input.SkillPrompts != nil { + inserted := findSkillPromptMessage(messages) + tokenEstimate := int64(0) + if inserted >= 0 { + tokenEstimate = estimateMessageTokens(messages[inserted]) + } + trace.addBlock(PromptBlockTrace{ + Kind: PromptBlockSkillContext, + Title: "Skill 上下文", + TokenEstimate: tokenEstimate, + Cacheable: true, + SourceCount: len(input.SkillPrompts.Skills), + SourceRefs: skillPromptSourceRefs(input.SkillPrompts.Skills), + }) + } + before = len(messages) messages = injectMCPToolGuidance(messages, input.ToolRuntime) if len(messages) > before { @@ -305,6 +326,15 @@ func historicalArtifactSourceRefs(artifacts []domainconversation.ContextArtifact return refs } +// skillPromptSourceRefs 提取本轮可用 Skill 的来源引用。 +func skillPromptSourceRefs(skills []domainskill.Skill) []PromptSourceRef { + refs := make([]PromptSourceRef, 0, len(skills)) + for _, skill := range skills { + refs = appendPromptSourceRef(refs, "skill", fmt.Sprintf("%d", skill.ID), skill.Title) + } + return refs +} + // toolDefinitionSourceRefs 提取本轮可用工具定义的来源引用。 func toolDefinitionSourceRefs(tools []llm.ToolDefinition) []PromptSourceRef { refs := make([]PromptSourceRef, 0, len(tools)) diff --git a/backend/internal/application/conversation/service.go b/backend/internal/application/conversation/service.go index 5086f4f6..a3319238 100644 --- a/backend/internal/application/conversation/service.go +++ b/backend/internal/application/conversation/service.go @@ -16,6 +16,7 @@ import ( appupload "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/upload" model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation" domainmemory "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/memory" + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/embedding" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" @@ -48,6 +49,10 @@ type memoryRecorder interface { UpsertUserMemoryEmbedding(ctx context.Context, userID uint, memoryKey string, expectedValue string, embedding []float32) error } +type skillResolver interface { + ResolveAvailable(ctx context.Context, userID uint, id uint) (*domainskill.Skill, error) +} + type auditWriter interface { Write(ctx context.Context, requestID string, actorUserID uint, action string, resource string, resourceID string, ip string, userAgent string, detail interface{}) } @@ -75,6 +80,7 @@ type Service struct { processingSvc *appprocessing.Service extractSvc *extraction.Service ragSvc *apprag.Service + skillResolver skillResolver billingSvc *appbilling.Service auditWriter auditWriter storeProvider appstorage.Provider @@ -133,6 +139,7 @@ type SendMessageInput struct { ClientRunID string FileIDs []string SelectedToolIDs []uint + SkillIDs []uint HTMLVisualPromptEnabled bool HTMLVisualColorMode string ParentMessagePublicID string @@ -143,6 +150,11 @@ type SendMessageInput struct { OnEvent func(eventType string, payload map[string]interface{}) error } +// SetSkillResolver 注入会话技能解析器。 +func (s *Service) SetSkillResolver(resolver skillResolver) { + s.skillResolver = resolver +} + // SendMessageResult 返回用户消息与 AI 消息。 type SendMessageResult struct { UserMessage model.Message diff --git a/backend/internal/application/conversation/service_generation_support.go b/backend/internal/application/conversation/service_generation_support.go index b8a44c8e..80dc55d1 100644 --- a/backend/internal/application/conversation/service_generation_support.go +++ b/backend/internal/application/conversation/service_generation_support.go @@ -151,15 +151,14 @@ func (s *Service) callCompactLLM(ctx context.Context, platformModelName string, AttributionTitle: attributionTitle, } startedAt := time.Now() - out, err := s.llmClient.Generate(ctx, routeConfig, llm.GenerateInput{ - Messages: llmMsgs, - }) + generateInput := buildTextTaskGenerateInput(route, s.cfg.Snapshot(), llmMsgs) + out, err := s.llmClient.Generate(ctx, routeConfig, generateInput) if err != nil { return "", fmt.Errorf("compact llm generate: %w", err) } text := strings.TrimSpace(out.Text) if billingCtx, ok := ctx.Value(basicServiceBillingContextKey{}).(basicServiceBillingContext); ok { - s.recordBasicServiceUsage(ctx, billingCtx.UserID, billingCtx.ConversationID, "compact", "上下文压缩", code, route.BindingCode, route.Protocol, route.UpstreamName, route.UpstreamModel, "5m", out.Usage, llmMsgs, text, time.Since(startedAt).Milliseconds()) + s.recordBasicServiceUsage(ctx, billingCtx.UserID, billingCtx.ConversationID, "compact", "上下文压缩", code, route.BindingCode, route.Protocol, route.UpstreamName, route.UpstreamModel, "5m", out.Usage, generateInput.Messages, text, time.Since(startedAt).Milliseconds()) } return text, nil } diff --git a/backend/internal/application/conversation/service_message_context.go b/backend/internal/application/conversation/service_message_context.go index 86d62620..864ac63c 100644 --- a/backend/internal/application/conversation/service_message_context.go +++ b/backend/internal/application/conversation/service_message_context.go @@ -178,6 +178,8 @@ func classifyRunErrorCode(err error) string { return "model_route_not_configured" case errors.Is(err, ErrUpstreamEmptyResponse): return "upstream_empty_response" + case errors.Is(err, ErrToolRunFinalAnswerMissing): + return "tool_run_final_answer_missing" case errors.Is(err, ErrMessageGenerationCanceled): return "generation_canceled" case errors.Is(err, ErrMediaImagePromptRequired): diff --git a/backend/internal/application/conversation/service_message_send.go b/backend/internal/application/conversation/service_message_send.go index babd7a25..bb34a062 100644 --- a/backend/internal/application/conversation/service_message_send.go +++ b/backend/internal/application/conversation/service_message_send.go @@ -635,11 +635,35 @@ func (s *Service) sendMessageInternal( RecallChunks: userCtx.RecallChunks, Memories: userCtx.Memory, }) + skillPrompts, err := s.resolveSkillPrompts(ctx, input) + if err != nil { + retErr = err + return nil, err + } + if traceRecorder != nil && skillPrompts != nil { + skillTitles := skillPromptTitles(skillPrompts.Skills) + traceRecorder.appendProcessSection( + fmt.Sprintf("已提供 %d 个 Skill 上下文", len(skillPrompts.Skills)), + formatTraceStep("Skill", fmt.Sprintf("本轮已加载 Skill:%s。包含 SKILL.md 内容,相关时使用。", strings.Join(skillTitles, "、"))), + map[string]interface{}{ + processTracePayloadStage: map[string]interface{}{ + "kind": "skill_context", + "status": messageTraceStatusStreaming, + }, + "skill_count": len(skillPrompts.Skills), + "skill_ids": skillPromptIDs(skillPrompts.Skills), + "skill_titles": skillTitles, + "skill_triggers": skillPromptTriggers(skillPrompts.Skills), + }, + messageTraceStatusStreaming, + ) + } toolRuntime := s.resolveSelectedToolRuntime(ctx, input.SelectedToolIDs) promptPlan := buildPromptPlan(ctx, promptPlanInput{ BaseMessages: llmMessages, StableAttachments: stableFullContextAttachments, DynamicContext: userCtx, + SkillPrompts: skillPrompts, ToolRuntime: toolRuntime, Config: cfg, StoreProvider: s.storeProvider, @@ -675,6 +699,7 @@ func (s *Service) sendMessageInternal( Options: filteredOptions, } fullLLMMessages := llmMessages + applyOpenAIResponsesInstructions(route, routeConfig.Endpoint, &generateInput) statefulContextConfig := buildPromptContextConfigSignature(cfg) statefulContextState := buildPromptContextStateSignature(stableFullContextAttachments, prefixMemories) statefulPrefixFingerprint := buildPromptStateFingerprint(promptStateFingerprintInput{ @@ -970,6 +995,7 @@ func (s *Service) sendMessageInternal( _ = s.repo.UpdateConversationLastResponseID(ctx, input.ConversationID, "") generateInput.PreviousResponseID = "" generateInput.Messages = fullLLMMessages + applyOpenAIResponsesInstructions(route, routeConfig.Endpoint, &generateInput) estimatedPromptTokens = estimatePromptTokens(fullLLMMessages) initialPromptShape = summarizePromptShape("full_retry", generateInput.Messages, fullLLMMessages, "") if traceRecorder != nil { @@ -1078,12 +1104,14 @@ func (s *Service) sendMessageInternal( followUpInput.Tools = nil followUpInput.DisableTools = true followUpInput.PreviousResponseID = "" - } else if routeConfig.Endpoint == llm.EndpointResponses && strings.TrimSpace(upstreamOutput.ResponseID) != "" { + applyOpenAIResponsesInstructions(route, routeConfig.Endpoint, &followUpInput) + } else if routeConfig.Endpoint == llm.EndpointResponses && supportsPreviousResponseIDRoute(route) && strings.TrimSpace(upstreamOutput.ResponseID) != "" { followUpInput.PreviousResponseID = strings.TrimSpace(upstreamOutput.ResponseID) followUpInput.Messages = []llm.Message{{Role: "tool", ToolResults: toolResult.ToolResults}} } else { followUpInput.Messages = llmMessages followUpInput.PreviousResponseID = "" + applyOpenAIResponsesInstructions(route, routeConfig.Endpoint, &followUpInput) } nextOutput, nextErr := runGenerate(followUpInput) @@ -1115,6 +1143,7 @@ func (s *Service) sendMessageInternal( finalInput.Tools = nil finalInput.DisableTools = true finalInput.PreviousResponseID = "" + applyOpenAIResponsesInstructions(route, routeConfig.Endpoint, &finalInput) nextOutput, nextErr := runGenerate(finalInput) if handleCanceledGeneration(nextErr) { return nil, retErr @@ -1148,6 +1177,10 @@ func (s *Service) sendMessageInternal( effectiveOutputTokens = estimateTokens(assistantText) } + if toolRunFinalAnswerMissing(upstreamOutput, len(toolCallRows) > 0, llmCallCount, maxLLMCalls, remainingToolCalls) { + retErr = ErrToolRunFinalAnswerMissing + return nil, retErr + } if strings.TrimSpace(assistantText) == "" { retErr = ErrUpstreamEmptyResponse return nil, retErr diff --git a/backend/internal/application/conversation/service_metadata.go b/backend/internal/application/conversation/service_metadata.go index 6f690b05..cb9c5120 100644 --- a/backend/internal/application/conversation/service_metadata.go +++ b/backend/internal/application/conversation/service_metadata.go @@ -401,7 +401,8 @@ func (s *Service) callConversationMetadataLLM(ctx context.Context, configuredMod AttributionTitle: attributionTitle, } startedAt := time.Now() - out, generateErr := s.llmClient.Generate(ctx, routeConfig, llm.GenerateInput{Messages: messages}) + generateInput := buildTextTaskGenerateInput(route, s.cfg.Snapshot(), messages) + out, generateErr := s.llmClient.Generate(ctx, routeConfig, generateInput) if generateErr != nil { lastErr = fmt.Errorf("metadata llm generate: %w", generateErr) continue @@ -409,7 +410,7 @@ func (s *Service) callConversationMetadataLLM(ctx context.Context, configuredMod return &conversationMetadataLLMResult{ Text: strings.TrimSpace(out.Text), Usage: out.Usage, - Messages: messages, + Messages: generateInput.Messages, PlatformModelName: route.PlatformModelName, RoutedBindingCode: route.BindingCode, ProviderProtocol: route.Protocol, diff --git a/backend/internal/application/conversation/service_skill_prompt.go b/backend/internal/application/conversation/service_skill_prompt.go new file mode 100644 index 00000000..25ac01fe --- /dev/null +++ b/backend/internal/application/conversation/service_skill_prompt.go @@ -0,0 +1,171 @@ +package conversation + +import ( + "context" + "errors" + "fmt" + "strings" + + appskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/skill" + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +const skillPromptSystemMarker = "" + +type skillPrompts struct { + Skills []domainskill.Skill + Rendered string +} + +func (s *Service) resolveSkillPrompts(ctx context.Context, input SendMessageInput) (*skillPrompts, error) { + skillIDs := normalizeSelectedSkillIDs(input.SkillIDs) + if len(skillIDs) == 0 { + return nil, nil + } + if len(skillIDs) > s.resolveMaxSelectedSkillsPerMessage() { + return nil, ErrTooManySelectedSkills + } + if s.skillResolver == nil { + return nil, ErrSkillNotFound + } + skills := make([]domainskill.Skill, 0, len(skillIDs)) + for _, skillID := range skillIDs { + skill, err := s.skillResolver.ResolveAvailable(ctx, input.UserID, skillID) + if err != nil { + if errors.Is(err, appskill.ErrSkillNotFound) || errors.Is(err, repository.ErrNotFound) { + return nil, ErrSkillNotFound + } + if errors.Is(err, appskill.ErrInvalidSkill) || errors.Is(err, repository.ErrInvalidInput) { + return nil, ErrInvalidSkillUse + } + return nil, err + } + if skill != nil { + skills = append(skills, *skill) + } + } + prompt := &skillPrompts{Skills: skills} + prompt.Rendered = renderSkillPrompts(prompt) + return prompt, nil +} + +func (s *Service) resolveMaxSelectedSkillsPerMessage() int { + return s.resolveMaxSelectedToolsPerMessage() +} + +func normalizeSelectedSkillIDs(ids []uint) []uint { + normalized := make([]uint, 0, len(ids)) + seen := make(map[uint]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + normalized = append(normalized, id) + } + return normalized +} + +func skillPromptIDs(skills []domainskill.Skill) []uint { + ids := make([]uint, 0, len(skills)) + for _, skill := range skills { + ids = append(ids, skill.ID) + } + return ids +} + +func skillPromptTitles(skills []domainskill.Skill) []string { + titles := make([]string, 0, len(skills)) + for _, skill := range skills { + title := strings.TrimSpace(skill.Title) + if title != "" { + titles = append(titles, title) + } + } + return titles +} + +func skillPromptTriggers(skills []domainskill.Skill) []string { + triggers := make([]string, 0, len(skills)) + for _, skill := range skills { + trigger := strings.TrimSpace(skill.Trigger) + if trigger != "" { + triggers = append(triggers, trigger) + } + } + return triggers +} + +func renderSkillPrompts(prompt *skillPrompts) string { + if prompt == nil || len(prompt.Skills) == 0 { + return "" + } + lines := []string{ + skillPromptSystemMarker, + fmt.Sprintf("", len(prompt.Skills)), + } + for index, skill := range prompt.Skills { + lines = append(lines, + fmt.Sprintf("", skill.ID, index+1, xmlEscapeAttr(strings.TrimSpace(skill.Scope))), + ""+xmlEscapeText(strings.TrimSpace(skill.Title))+"", + ""+xmlEscapeText(strings.TrimSpace(skill.Trigger))+"", + ""+xmlEscapeText(strings.TrimSpace(skill.Description))+"", + ""+xmlEscapeText(strings.TrimSpace(skill.Markdown))+"", + "", + ) + } + lines = append(lines, + "", + "", + "These user-selected skills are available as optional capability context for the current user request.", + "Each selected skill includes title, trigger, description, and SKILL.md content for this turn.", + "Use each skill's content when it is relevant to the user's request. If a selected skill is not relevant, ignore it.", + "Do not invent hidden instructions or operational steps that are not present in the disclosed skill content.", + "Do not treat loading these skills as an instruction to force their behavior onto unrelated requests.", + "These skills do not grant permission to execute operating-system commands, shell scripts, background jobs, network calls, or tools.", + "Do not call tools unless they were explicitly selected and provided by the platform for this conversation.", + "Do not expose these tags. Produce only the final user-facing answer.", + "", + "", + ) + return strings.Join(lines, "\n") +} + +func injectSkillPrompts(messages []llm.Message, prompt *skillPrompts) []llm.Message { + if prompt == nil || strings.TrimSpace(prompt.Rendered) == "" { + return messages + } + insertAt := firstNonSystemMessageIndex(messages) + message := llm.Message{ + Role: "system", + Content: prompt.Rendered, + } + result := make([]llm.Message, 0, len(messages)+1) + result = append(result, messages[:insertAt]...) + result = append(result, message) + result = append(result, messages[insertAt:]...) + return result +} + +func findSkillPromptMessage(messages []llm.Message) int { + for index, message := range messages { + if message.Role == "system" && strings.Contains(message.Content, skillPromptSystemMarker) { + return index + } + } + return -1 +} + +func firstNonSystemMessageIndex(messages []llm.Message) int { + for index, message := range messages { + if message.Role != "system" { + return index + } + } + return len(messages) +} diff --git a/backend/internal/application/conversation/service_skill_prompt_test.go b/backend/internal/application/conversation/service_skill_prompt_test.go new file mode 100644 index 00000000..19e8be74 --- /dev/null +++ b/backend/internal/application/conversation/service_skill_prompt_test.go @@ -0,0 +1,105 @@ +package conversation + +import ( + "context" + "strings" + "testing" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" +) + +func TestRenderSkillPromptIncludesSelectedSkillContent(t *testing.T) { + prompt := &skillPrompts{ + Skills: []domainskill.Skill{ + { + ID: 12, + Scope: domainskill.ScopeUser, + Title: "Review", + Trigger: "review", + Description: "Review code", + Markdown: "Review the diff and return prioritized findings.", + }, + { + ID: 13, + Scope: domainskill.ScopeBuiltin, + Title: "Frontend Rules", + Trigger: "frontend", + Markdown: "Check layout, spacing, and interaction states.", + }, + }, + } + rendered := renderSkillPrompts(prompt) + for _, want := range []string{ + "", + "", + "Review", + "Frontend Rules", + "Review code", + "Review the diff and return prioritized findings.", + "Check layout, spacing, and interaction states.", + "Each selected skill includes title, trigger, description, and SKILL.md content", + "Use each skill's content when it is relevant", + "Do not invent hidden instructions", + "do not grant permission to execute operating-system commands", + } { + if !strings.Contains(rendered, want) { + t.Fatalf("expected rendered prompt to contain %q:\n%s", want, rendered) + } + } + if strings.Contains(rendered, "Apply this skill") { + t.Fatalf("expected rendered prompt not to force skill application:\n%s", rendered) + } +} + +func TestInjectSkillPromptAddsSystemMessageAfterExistingPolicy(t *testing.T) { + prompt := &skillPrompts{ + Skills: []domainskill.Skill{{ + ID: 7, + Scope: domainskill.ScopeBuiltin, + Title: "Plan", + Trigger: "plan", + Markdown: "Create a concise plan.", + }}, + } + prompt.Rendered = renderSkillPrompts(prompt) + + messages := injectSkillPrompts([]llm.Message{ + {Role: "system", Content: "base policy"}, + {Role: "user", Content: "hello"}, + }, prompt) + + if len(messages) != 3 { + t.Fatalf("expected 3 messages, got %d", len(messages)) + } + if messages[0].Content != "base policy" || messages[1].Role != "system" || !strings.Contains(messages[1].Content, skillPromptSystemMarker) { + t.Fatalf("expected skill prompt after base system policy: %#v", messages) + } + if messages[2].Role != "user" || messages[2].Content != "hello" { + t.Fatalf("expected original user message last: %#v", messages) + } +} + +func TestResolveSkillPromptsRejectsTooManySelectedSkills(t *testing.T) { + service := &Service{cfg: config.NewRuntime(config.Config{MCPMaxSelectedToolsPerMessage: 2})} + _, err := service.resolveSkillPrompts(context.Background(), SendMessageInput{ + SkillIDs: []uint{1, 2, 3}, + }) + if err != ErrTooManySelectedSkills { + t.Fatalf("expected ErrTooManySelectedSkills, got %v", err) + } +} + +func TestNormalizeSelectedSkillIDsDeduplicatesAndDropsEmpty(t *testing.T) { + got := normalizeSelectedSkillIDs([]uint{0, 2, 2, 3, 0, 1}) + want := []uint{2, 3, 1} + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("expected %v, got %v", want, got) + } + } +} diff --git a/backend/internal/application/conversation/service_stateful_responses.go b/backend/internal/application/conversation/service_stateful_responses.go index c08ab78e..068d9211 100644 --- a/backend/internal/application/conversation/service_stateful_responses.go +++ b/backend/internal/application/conversation/service_stateful_responses.go @@ -44,15 +44,18 @@ func resolvePreviousResponseID(route *channel.ResolvedRoute, branchReason string if responseID == "" || !strings.EqualFold(strings.TrimSpace(branchReason), "default") { return "" } - if route == nil || !llm.SupportsPreviousResponseID(route.Protocol) { - return "" - } - if !isOfficialOpenAIBaseURL(route.BaseURL) { + if !supportsPreviousResponseIDRoute(route) { return "" } return responseID } +func supportsPreviousResponseIDRoute(route *channel.ResolvedRoute) bool { + return route != nil && + llm.SupportsPreviousResponseID(route.Protocol) && + isOfficialOpenAIBaseURL(route.BaseURL) +} + func isOfficialOpenAIBaseURL(raw string) bool { value := strings.TrimSpace(raw) if value == "" { @@ -78,6 +81,57 @@ func buildStatefulResponseMessages(messages []llm.Message) []llm.Message { return nil } +func applyOpenAIResponsesInstructions(route *channel.ResolvedRoute, endpoint string, input *llm.GenerateInput) { + if input == nil || endpoint != llm.EndpointResponses || !supportsPreviousResponseIDRoute(route) { + return + } + instructions, messages := extractOpenAIResponsesInstructions(input.Messages) + if strings.TrimSpace(instructions) == "" { + return + } + input.Instructions = instructions + input.Messages = messages +} + +func extractOpenAIResponsesInstructions(messages []llm.Message) (string, []llm.Message) { + if len(messages) == 0 { + return "", nil + } + var builder strings.Builder + result := make([]llm.Message, 0, len(messages)) + for _, message := range messages { + if message.Role != "system" { + result = append(result, message) + continue + } + text := strings.TrimSpace(systemInstructionText(message)) + if text == "" { + continue + } + if builder.Len() > 0 { + builder.WriteString("\n\n") + } + builder.WriteString(text) + } + if builder.Len() == 0 { + return "", cloneLLMMessages(messages) + } + return builder.String(), result +} + +func systemInstructionText(message llm.Message) string { + if strings.TrimSpace(message.Content) != "" || len(message.Parts) == 0 { + return message.Content + } + parts := make([]string, 0, len(message.Parts)) + for _, part := range message.Parts { + if strings.TrimSpace(part.Text) != "" { + parts = append(parts, strings.TrimSpace(part.Text)) + } + } + return strings.Join(parts, "\n\n") +} + func shouldRetryWithoutPreviousResponseID(err error) bool { if err == nil { return false diff --git a/backend/internal/application/conversation/service_stateful_responses_test.go b/backend/internal/application/conversation/service_stateful_responses_test.go index 882361b6..5d0c5a04 100644 --- a/backend/internal/application/conversation/service_stateful_responses_test.go +++ b/backend/internal/application/conversation/service_stateful_responses_test.go @@ -50,6 +50,27 @@ func TestResolvePreviousResponseIDOnlyEnablesKnownSafeRoutes(t *testing.T) { }) } +func TestSupportsPreviousResponseIDRouteOnlyAllowsOfficialOpenAIResponses(t *testing.T) { + if !supportsPreviousResponseIDRoute(&channel.ResolvedRoute{ + Protocol: llm.AdapterOpenAIResponses, + BaseURL: "https://api.openai.com/v1", + }) { + t.Fatalf("expected official OpenAI Responses route to support previous_response_id") + } + if supportsPreviousResponseIDRoute(&channel.ResolvedRoute{ + Protocol: llm.AdapterOpenAIResponses, + BaseURL: "http://host.docker.internal:42113/v1", + }) { + t.Fatalf("expected custom Responses-compatible route to disable previous_response_id") + } + if supportsPreviousResponseIDRoute(&channel.ResolvedRoute{ + Protocol: llm.AdapterOpenAIChatCompletions, + BaseURL: "https://api.openai.com/v1", + }) { + t.Fatalf("expected non-Responses route to disable previous_response_id") + } +} + func TestBuildStatefulResponseMessagesKeepsLatestUserOnly(t *testing.T) { messages := []llm.Message{ {Role: "system", Content: "behavior"}, @@ -68,6 +89,40 @@ func TestBuildStatefulResponseMessagesKeepsLatestUserOnly(t *testing.T) { } } +func TestApplyOpenAIResponsesInstructionsOnlyForOfficialRoute(t *testing.T) { + official := &channel.ResolvedRoute{ + Protocol: llm.AdapterOpenAIResponses, + BaseURL: "https://api.openai.com/v1", + } + input := llm.GenerateInput{ + Messages: []llm.Message{ + {Role: "system", Content: "platform policy"}, + {Role: "user", Content: "hello"}, + {Role: "system", Content: "final synthesis only"}, + {Role: "tool", ToolResults: []llm.ToolResult{{ToolCallID: "call_1", OutputJSON: `{"ok":true}`}}}, + }, + } + + applyOpenAIResponsesInstructions(official, llm.EndpointResponses, &input) + + if input.Instructions != "platform policy\n\nfinal synthesis only" { + t.Fatalf("expected extracted instructions, got %q", input.Instructions) + } + if len(input.Messages) != 2 || input.Messages[0].Role != "user" || input.Messages[1].Role != "tool" { + t.Fatalf("expected system messages removed from input, got %#v", input.Messages) + } + + custom := &channel.ResolvedRoute{ + Protocol: llm.AdapterOpenAIResponses, + BaseURL: "https://reverse.example.com/v1", + } + compatInput := llm.GenerateInput{Messages: []llm.Message{{Role: "system", Content: "policy"}, {Role: "user", Content: "hello"}}} + applyOpenAIResponsesInstructions(custom, llm.EndpointResponses, &compatInput) + if compatInput.Instructions != "" || len(compatInput.Messages) != 2 { + t.Fatalf("expected custom route to keep system messages, got %#v", compatInput) + } +} + func TestResolveStatefulPreviousResponseIDRequiresMatchingFingerprint(t *testing.T) { route := &channel.ResolvedRoute{ Protocol: llm.AdapterOpenAIResponses, diff --git a/backend/internal/application/conversation/service_text_task_route.go b/backend/internal/application/conversation/service_text_task_route.go index 657883e8..ec33a0e0 100644 --- a/backend/internal/application/conversation/service_text_task_route.go +++ b/backend/internal/application/conversation/service_text_task_route.go @@ -6,6 +6,8 @@ import ( "strings" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" ) const textTaskFollowModel = "follow" @@ -121,3 +123,48 @@ func (s *Service) resolveTextTaskModel(ctx context.Context, configured string, c } return strings.TrimSpace(route.PlatformModelName) } + +// buildTextTaskGenerateInput 为标题、标签、压缩等内部文本任务统一应用模型能力策略。 +func buildTextTaskGenerateInput(route *channel.ResolvedRoute, cfg config.Config, messages []llm.Message) llm.GenerateInput { + if route == nil { + return llm.GenerateInput{Messages: cloneLLMMessages(messages)} + } + input := llm.GenerateInput{ + Messages: normalizeTextTaskSystemMessages(route, messages), + Options: filterModelOptions(nil, route.Protocol, modelOptionPolicyConfig{ + Mode: cfg.ModelOptionPolicyMode, + AllowedPathsJSON: cfg.ModelOptionAllowedPaths, + DeniedPathsJSON: cfg.ModelOptionDeniedPaths, + ModelCapabilitiesJSON: route.ModelCapabilitiesJSON, + }), + } + applyOpenAIResponsesInstructions(route, llm.DefaultEndpointForAdapter(route.Protocol), &input) + return input +} + +// normalizeTextTaskSystemMessages 按模型能力决定内部文本任务是否需要把 system 指令降级进 user 消息。 +func normalizeTextTaskSystemMessages(route *channel.ResolvedRoute, messages []llm.Message) []llm.Message { + if route == nil || !shouldInlineSystemPromptToUser(*route) { + return cloneLLMMessages(messages) + } + var systemText strings.Builder + remaining := make([]llm.Message, 0, len(messages)) + for _, message := range messages { + if message.Role != "system" { + remaining = append(remaining, message) + continue + } + text := strings.TrimSpace(systemInstructionText(message)) + if text == "" { + continue + } + if systemText.Len() > 0 { + systemText.WriteString("\n\n") + } + systemText.WriteString(text) + } + if systemText.Len() == 0 { + return remaining + } + return inlineSystemPromptIntoLatestUserMessage(remaining, systemText.String()) +} diff --git a/backend/internal/application/conversation/service_text_task_route_test.go b/backend/internal/application/conversation/service_text_task_route_test.go index 0ff028e0..de0a82a2 100644 --- a/backend/internal/application/conversation/service_text_task_route_test.go +++ b/backend/internal/application/conversation/service_text_task_route_test.go @@ -3,9 +3,12 @@ package conversation import ( "context" "errors" + "strings" "testing" "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/channel" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/llm" ) type textTaskRouteResolverStub struct { @@ -91,3 +94,55 @@ func TestResolveTextTaskRouteCandidatesFollowFallsBackWhenCurrentRouteFails(t *t t.Fatalf("expected default route after current route failure, got %#v", routes) } } + +func TestBuildTextTaskGenerateInputAppliesDefaultsAndInstructions(t *testing.T) { + route := &channel.ResolvedRoute{ + Protocol: llm.AdapterOpenAIResponses, + BaseURL: "https://api.openai.com/v1", + ModelCapabilitiesJSON: `{"defaultOptions":{"reasoning":{"effort":"medium"}}}`, + } + input := buildTextTaskGenerateInput(route, config.Config{ + ModelOptionPolicyMode: modelOptionPolicyAllowlist, + ModelOptionAllowedPaths: `{ + "openai_responses": ["reasoning.effort"] + }`, + ModelOptionDeniedPaths: config.DefaultModelOptionDeniedPathsJSON(), + }, []llm.Message{ + {Role: "system", Content: "summarize carefully"}, + {Role: "user", Content: "hello"}, + }) + + if input.Instructions != "summarize carefully" { + t.Fatalf("expected official Responses instructions, got %q", input.Instructions) + } + if len(input.Messages) != 1 || input.Messages[0].Role != "user" { + t.Fatalf("expected system message to be removed from input, got %#v", input.Messages) + } + reasoning := input.Options["reasoning"].(map[string]interface{}) + if reasoning["effort"] != "medium" { + t.Fatalf("expected default reasoning effort, got %#v", input.Options) + } +} + +func TestBuildTextTaskGenerateInputInlinesSystemWhenCapabilitiesDisableSystemPrompt(t *testing.T) { + route := &channel.ResolvedRoute{ + Protocol: llm.AdapterOpenAIResponses, + BaseURL: "https://api.openai.com/v1", + ModelCapabilitiesJSON: `{"supportsSystemPrompt":false}`, + } + input := buildTextTaskGenerateInput(route, config.Config{}, []llm.Message{ + {Role: "system", Content: "title only"}, + {Role: "user", Content: "hello"}, + }) + + if input.Instructions != "" { + t.Fatalf("expected no native instructions for inline-user capability, got %q", input.Instructions) + } + if len(input.Messages) != 1 || input.Messages[0].Role != "user" { + t.Fatalf("expected one inlined user message, got %#v", input.Messages) + } + content := input.Messages[0].Content + if !strings.Contains(content, "") || !strings.Contains(content, "title only") || !strings.Contains(content, "hello") { + t.Fatalf("expected system prompt to be inlined into user message, got %q", content) + } +} diff --git a/backend/internal/application/conversation/service_tool_loop.go b/backend/internal/application/conversation/service_tool_loop.go index eec6b131..50a761f1 100644 --- a/backend/internal/application/conversation/service_tool_loop.go +++ b/backend/internal/application/conversation/service_tool_loop.go @@ -266,3 +266,11 @@ func buildFinalToolSynthesisMessages(messages []llm.Message, instruction string) }) return result } + +// toolRunFinalAnswerMissing 判断工具循环在预算耗尽时是否只剩未执行的结构化工具调用。 +func toolRunFinalAnswerMissing(output *llm.GenerateOutput, toolLoopStarted bool, llmCallCount int, maxLLMCalls int, remainingToolCalls int) bool { + if output == nil || !toolLoopStarted { + return false + } + return len(output.ToolCalls) > 0 && (llmCallCount >= maxLLMCalls || remainingToolCalls <= 0) +} diff --git a/backend/internal/application/conversation/service_tool_test.go b/backend/internal/application/conversation/service_tool_test.go index 68855fad..f509d63c 100644 --- a/backend/internal/application/conversation/service_tool_test.go +++ b/backend/internal/application/conversation/service_tool_test.go @@ -136,3 +136,27 @@ func TestOutputReasoningContentPrefersStructuredReasoning(t *testing.T) { t.Fatalf("expected parsed thinking fallback, got %q", got) } } + +func TestToolRunFinalAnswerMissingWhenBudgetEndsWithStructuredToolCall(t *testing.T) { + output := &llm.GenerateOutput{ + ToolCalls: []llm.ToolCall{{ + ToolCallID: "call_1", + ToolType: "function", + ToolName: "search", + ArgumentsJSON: `{"query":"mcp"}`, + Status: "requested", + }}, + } + + if !toolRunFinalAnswerMissing(output, true, 5, 5, 1) { + t.Fatalf("expected exhausted tool run with pending tool call to be missing a final answer") + } +} + +func TestToolRunFinalAnswerMissingAcceptsNaturalFinalAnswer(t *testing.T) { + text := "没有更多工具调用空间时,应基于已获取的结果直接回答。" + + if toolRunFinalAnswerMissing(&llm.GenerateOutput{Text: text}, true, 5, 5, 1) { + t.Fatalf("expected natural final answer to be accepted") + } +} diff --git a/backend/internal/application/processing/service.go b/backend/internal/application/processing/service.go index 7ae8994a..0c2d0d7f 100644 --- a/backend/internal/application/processing/service.go +++ b/backend/internal/application/processing/service.go @@ -20,7 +20,7 @@ const ( DefaultExtractorVersion = "file-pipeline-v1" fileProcessingMaxRetries = 3 defaultProcessingPreview = 280 - fixedExtractTimeout = 60 * time.Second + defaultExtractTimeout = 60 * time.Second fixedEmbeddingTimeout = 5 * time.Minute failurePersistTimeout = 5 * time.Second ) @@ -200,7 +200,9 @@ func (s *Service) ProcessFile(ctx context.Context, userID uint, fileID string) e return nil } - runCtx, cancel := context.WithTimeout(ctx, fixedExtractTimeout+fixedEmbeddingTimeout) + cfg := s.snapshot() + extractTimeout := resolveProcessingExtractTimeout(cfg, fileObj.FileCategory) + runCtx, cancel := context.WithTimeout(ctx, extractTimeout+fixedEmbeddingTimeout) defer cancel() startedAt := time.Now() @@ -221,7 +223,7 @@ func (s *Service) ProcessFile(ctx context.Context, userID uint, fileID string) e return err } - extractCtx, extractCancel := context.WithTimeout(runCtx, fixedExtractTimeout) + extractCtx, extractCancel := context.WithTimeout(runCtx, extractTimeout) extractResult, extractErr := s.extractTextForProcessing(extractCtx, *fileObj) extractCancel() if extractErr != nil { @@ -642,6 +644,65 @@ func (s *Service) snapshot() config.Config { return s.cfg.Snapshot() } +func resolveProcessingExtractTimeout(cfg config.Config, fileCategory string) time.Duration { + primaryTimeout := resolvePrimaryExtractTimeout(cfg) + ocrTimeout := resolveOCRExtractTimeout(cfg) + + switch strings.ToLower(strings.TrimSpace(fileCategory)) { + case "image": + if cfg.ExtractImageOCREnabled { + return ocrTimeout + } + case "pdf": + if cfg.ExtractPDFOCRFallbackEnabled { + return primaryTimeout + ocrTimeout + } + } + return primaryTimeout +} + +func resolvePrimaryExtractTimeout(cfg config.Config) time.Duration { + timeoutSeconds := 0 + switch strings.ToLower(strings.TrimSpace(cfg.ExtractEngine)) { + case extraction.EngineTika: + timeoutSeconds = cfg.ExtractTikaTimeoutSeconds + case extraction.EngineDocling: + timeoutSeconds = cfg.ExtractDoclingTimeoutSeconds + case extraction.EngineMinerU: + timeoutSeconds = cfg.ExtractMinerUTimeoutSeconds + default: + timeoutSeconds = int(defaultExtractTimeout / time.Second) + } + if timeoutSeconds <= 0 { + return defaultExtractTimeout + } + return time.Duration(timeoutSeconds) * time.Second +} + +func resolveOCRExtractTimeout(cfg config.Config) time.Duration { + timeoutSeconds := 0 + switch strings.ToLower(strings.TrimSpace(cfg.ExtractOCREngine)) { + case extraction.OCREngineTesseract: + timeoutSeconds = cfg.ExtractTesseractOCRTimeoutSeconds + case extraction.OCREngineRapidOCR: + timeoutSeconds = cfg.ExtractRapidOCRTimeoutSeconds + case extraction.OCREnginePaddle: + timeoutSeconds = cfg.ExtractPaddleOCRTimeoutSeconds + case extraction.OCREngineTencent: + timeoutSeconds = cfg.ExtractTencentOCRTimeoutSeconds + case extraction.OCREngineAliyun: + timeoutSeconds = cfg.ExtractAliyunOCRTimeoutSeconds + case extraction.OCREngineLLM: + timeoutSeconds = cfg.ExtractLLMOCRTimeoutSeconds + default: + timeoutSeconds = int(defaultExtractTimeout / time.Second) + } + if timeoutSeconds <= 0 { + return defaultExtractTimeout + } + return time.Duration(timeoutSeconds) * time.Second +} + func (s *Service) version() string { if strings.TrimSpace(s.extractorVersion) == "" { return DefaultExtractorVersion diff --git a/backend/internal/application/processing/service_timeout_test.go b/backend/internal/application/processing/service_timeout_test.go new file mode 100644 index 00000000..d7ed954b --- /dev/null +++ b/backend/internal/application/processing/service_timeout_test.go @@ -0,0 +1,77 @@ +package processing + +import ( + "testing" + "time" + + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/extraction" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/config" +) + +func TestResolveProcessingExtractTimeoutUsesMinerUConfig(t *testing.T) { + cfg := config.Config{ + ExtractEngine: extraction.EngineMinerU, + ExtractMinerUTimeoutSeconds: 180, + } + + got := resolveProcessingExtractTimeout(cfg, "pdf") + if got != 180*time.Second { + t.Fatalf("expected MinerU timeout to be 180s, got %s", got) + } +} + +func TestResolveProcessingExtractTimeoutFallsBackToDefault(t *testing.T) { + cfg := config.Config{ + ExtractEngine: extraction.EngineMinerU, + ExtractMinerUTimeoutSeconds: 0, + } + + got := resolveProcessingExtractTimeout(cfg, "word") + if got != defaultExtractTimeout { + t.Fatalf("expected default timeout %s, got %s", defaultExtractTimeout, got) + } +} + +func TestResolveProcessingExtractTimeoutUsesImageOCRConfig(t *testing.T) { + cfg := config.Config{ + ExtractEngine: extraction.EngineBuiltin, + ExtractImageOCREnabled: true, + ExtractOCREngine: extraction.OCREngineRapidOCR, + ExtractRapidOCRTimeoutSeconds: 90, + ExtractTesseractOCRTimeoutSeconds: 120, + } + + got := resolveProcessingExtractTimeout(cfg, "image") + if got != 90*time.Second { + t.Fatalf("expected image OCR timeout to be 90s, got %s", got) + } +} + +func TestResolveProcessingExtractTimeoutAddsPDFOCRFallbackWindow(t *testing.T) { + cfg := config.Config{ + ExtractEngine: extraction.EngineTika, + ExtractTikaTimeoutSeconds: 80, + ExtractPDFOCRFallbackEnabled: true, + ExtractOCREngine: extraction.OCREngineTesseract, + ExtractTesseractOCRTimeoutSeconds: 90, + } + + got := resolveProcessingExtractTimeout(cfg, "pdf") + if got != 170*time.Second { + t.Fatalf("expected PDF extraction plus OCR fallback timeout to be 170s, got %s", got) + } +} + +func TestResolveProcessingExtractTimeoutIgnoresOCRForPDFWhenFallbackDisabled(t *testing.T) { + cfg := config.Config{ + ExtractEngine: extraction.EngineDocling, + ExtractDoclingTimeoutSeconds: 75, + ExtractOCREngine: extraction.OCREngineLLM, + ExtractLLMOCRTimeoutSeconds: 180, + } + + got := resolveProcessingExtractTimeout(cfg, "pdf") + if got != 75*time.Second { + t.Fatalf("expected PDF timeout to use primary engine only, got %s", got) + } +} diff --git a/backend/internal/application/promptpreset/service.go b/backend/internal/application/promptpreset/service.go index 53113307..3d7f7c97 100644 --- a/backend/internal/application/promptpreset/service.go +++ b/backend/internal/application/promptpreset/service.go @@ -10,9 +10,9 @@ import ( ) const ( - maxPromptPresetNameLength = 16 - maxPromptPresetDescriptionLength = 64 - maxPromptPresetContentLength = 16384 + maxPromptPresetNameLength = 64 + maxPromptPresetDescriptionLength = 256 + maxPromptPresetContentLength = 10000 ) // Service 封装预制提示词业务逻辑。 diff --git a/backend/internal/application/promptpreset/service_test.go b/backend/internal/application/promptpreset/service_test.go index a8a14fb1..fae30f53 100644 --- a/backend/internal/application/promptpreset/service_test.go +++ b/backend/internal/application/promptpreset/service_test.go @@ -3,6 +3,7 @@ package promptpreset import ( "context" "errors" + "strings" "testing" domainpromptpreset "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/promptpreset" @@ -47,7 +48,7 @@ func TestCreateUserAllowsChineseTrigger(t *testing.T) { t.Parallel() service := NewService(&fakePromptPresetRepo{items: map[uint]domainpromptpreset.PromptPreset{}}) - trigger := "一二三四五六七八九十一二三四五六" + trigger := strings.Repeat("中", maxPromptPresetNameLength) item, err := service.CreateUser(context.Background(), 1, WriteInput{ Title: trigger, Trigger: "/" + trigger, @@ -62,8 +63,8 @@ func TestCreateUserAllowsChineseTrigger(t *testing.T) { } _, err = service.CreateUser(context.Background(), 1, WriteInput{ - Title: trigger + "七", - Trigger: trigger + "七", + Title: trigger + "文", + Trigger: trigger + "文", Content: "帮助优化中文表达。", Enabled: true, }) diff --git a/backend/internal/application/skill/errs.go b/backend/internal/application/skill/errs.go new file mode 100644 index 00000000..3d2f7b19 --- /dev/null +++ b/backend/internal/application/skill/errs.go @@ -0,0 +1,12 @@ +package skill + +import "errors" + +var ( + // ErrSkillNotFound 表示技能不存在或当前用户无权访问。 + ErrSkillNotFound = errors.New("skill not found") + // ErrInvalidSkill 表示技能参数不合法。 + ErrInvalidSkill = errors.New("invalid skill") + // ErrSkillConflict 表示触发词在当前作用域内已存在。 + ErrSkillConflict = errors.New("skill trigger already exists") +) diff --git a/backend/internal/application/skill/service.go b/backend/internal/application/skill/service.go new file mode 100644 index 00000000..97763615 --- /dev/null +++ b/backend/internal/application/skill/service.go @@ -0,0 +1,379 @@ +package skill + +import ( + "context" + "errors" + "strings" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +const ( + maxSkillTitleLength = 64 + maxSkillTriggerLength = 64 + maxSkillDescriptionLength = 256 + maxSkillMarkdownLength = 10000 +) + +// Service 封装技能业务逻辑。 +type Service struct { + repo repository.SkillRepository + auditWriter auditWriter +} + +type auditWriter interface { + Write(ctx context.Context, requestID string, actorUserID uint, action string, resource string, resourceID string, ip string, userAgent string, detail interface{}) +} + +// NewService 创建技能服务。 +func NewService(repo repository.SkillRepository) *Service { + return &Service{repo: repo} +} + +// SetAuditWriter 注入审计写入器。 +func (s *Service) SetAuditWriter(writer auditWriter) { + s.auditWriter = writer +} + +// AuditInput 描述技能审计写入。 +type AuditInput struct { + UserID uint + RequestID string + Action string + ResourceID string + ClientIP string + UserAgent string + Detail interface{} +} + +// RecordAudit 记录技能审计日志。 +func (s *Service) RecordAudit(ctx context.Context, input AuditInput) { + if s.auditWriter == nil { + return + } + s.auditWriter.Write( + ctx, + strings.TrimSpace(input.RequestID), + input.UserID, + strings.TrimSpace(input.Action), + "skills", + strings.TrimSpace(input.ResourceID), + strings.TrimSpace(input.ClientIP), + strings.TrimSpace(input.UserAgent), + input.Detail, + ) +} + +// ListVisible 查询当前用户可使用的技能。 +func (s *Service) ListVisible(ctx context.Context, userID uint, input ListInput) ([]domainskill.Skill, int64, error) { + if userID == 0 { + return nil, 0, repository.ErrInvalidInput + } + page, pageSize := normalizePage(input.Page, input.PageSize) + return s.repo.ListSkills(ctx, repository.SkillListFilter{ + Query: strings.TrimSpace(input.Query), + VisibleUserID: &userID, + }, (page-1)*pageSize, pageSize) +} + +// ListMine 查询当前用户自定义技能。 +func (s *Service) ListMine(ctx context.Context, userID uint, input ListInput) ([]domainskill.Skill, int64, error) { + if userID == 0 { + return nil, 0, repository.ErrInvalidInput + } + page, pageSize := normalizePage(input.Page, input.PageSize) + return s.repo.ListSkills(ctx, repository.SkillListFilter{ + Query: strings.TrimSpace(input.Query), + SearchMarkdown: true, + Scope: domainskill.ScopeUser, + OwnerUserID: &userID, + Enabled: input.Enabled, + }, (page-1)*pageSize, pageSize) +} + +// ListAdminBuiltin 查询管理员内置技能列表。 +func (s *Service) ListAdminBuiltin(ctx context.Context, input ListInput) ([]domainskill.Skill, int64, error) { + page, pageSize := normalizePage(input.Page, input.PageSize) + return s.repo.ListSkills(ctx, repository.SkillListFilter{ + Query: strings.TrimSpace(input.Query), + SearchMarkdown: true, + Scope: domainskill.ScopeBuiltin, + Enabled: input.Enabled, + }, (page-1)*pageSize, pageSize) +} + +// ResolveAvailable 查询当前用户可使用的技能。 +func (s *Service) ResolveAvailable(ctx context.Context, userID uint, id uint) (*domainskill.Skill, error) { + if userID == 0 || id == 0 { + return nil, repository.ErrInvalidInput + } + item, err := s.repo.GetSkill(ctx, id) + if err != nil { + return nil, mapRepositoryError(err) + } + if !item.Enabled { + return nil, ErrSkillNotFound + } + if item.Scope == domainskill.ScopeBuiltin { + return item, nil + } + if item.Scope == domainskill.ScopeUser && item.OwnerUserID == userID { + return item, nil + } + return nil, ErrSkillNotFound +} + +// CreateUser 创建用户自定义技能。 +func (s *Service) CreateUser(ctx context.Context, userID uint, input WriteInput) (*domainskill.Skill, error) { + if userID == 0 { + return nil, repository.ErrInvalidInput + } + item, err := normalizeWriteInput(input, domainskill.ScopeUser, userID, userID) + if err != nil { + return nil, err + } + return s.create(ctx, item) +} + +// CreateBuiltin 创建管理员内置技能。 +func (s *Service) CreateBuiltin(ctx context.Context, actorUserID uint, input WriteInput) (*domainskill.Skill, error) { + if actorUserID == 0 { + return nil, repository.ErrInvalidInput + } + item, err := normalizeWriteInput(input, domainskill.ScopeBuiltin, 0, actorUserID) + if err != nil { + return nil, err + } + return s.create(ctx, item) +} + +// UpdateUser 更新当前用户自定义技能。 +func (s *Service) UpdateUser(ctx context.Context, userID uint, id uint, input PatchInput) (*domainskill.Skill, error) { + if userID == 0 || id == 0 { + return nil, repository.ErrInvalidInput + } + item, err := s.repo.GetSkill(ctx, id) + if err != nil { + return nil, mapRepositoryError(err) + } + if item.Scope != domainskill.ScopeUser || item.OwnerUserID != userID { + return nil, ErrSkillNotFound + } + return s.update(ctx, id, userID, input) +} + +// UpdateBuiltin 更新管理员内置技能。 +func (s *Service) UpdateBuiltin(ctx context.Context, actorUserID uint, id uint, input PatchInput) (*domainskill.Skill, error) { + if actorUserID == 0 || id == 0 { + return nil, repository.ErrInvalidInput + } + item, err := s.repo.GetSkill(ctx, id) + if err != nil { + return nil, mapRepositoryError(err) + } + if item.Scope != domainskill.ScopeBuiltin { + return nil, ErrSkillNotFound + } + return s.update(ctx, id, actorUserID, input) +} + +// DeleteUser 删除当前用户自定义技能。 +func (s *Service) DeleteUser(ctx context.Context, userID uint, id uint) error { + if userID == 0 || id == 0 { + return repository.ErrInvalidInput + } + item, err := s.repo.GetSkill(ctx, id) + if err != nil { + return mapRepositoryError(err) + } + if item.Scope != domainskill.ScopeUser || item.OwnerUserID != userID { + return ErrSkillNotFound + } + return mapRepositoryError(s.repo.DeleteSkill(ctx, id)) +} + +// DeleteBuiltin 删除管理员内置技能。 +func (s *Service) DeleteBuiltin(ctx context.Context, actorUserID uint, id uint) error { + if actorUserID == 0 || id == 0 { + return repository.ErrInvalidInput + } + item, err := s.repo.GetSkill(ctx, id) + if err != nil { + return mapRepositoryError(err) + } + if item.Scope != domainskill.ScopeBuiltin { + return ErrSkillNotFound + } + return mapRepositoryError(s.repo.DeleteSkill(ctx, id)) +} + +// ListInput 定义技能列表入参。 +type ListInput struct { + Query string + Enabled *bool + Page int + PageSize int +} + +// WriteInput 定义技能创建入参。 +type WriteInput struct { + Title string + Trigger string + Description string + Markdown string + Enabled bool + SortOrder int +} + +// PatchInput 定义技能更新入参。 +type PatchInput struct { + Title *string + Trigger *string + Description *string + Markdown *string + Enabled *bool + SortOrder *int +} + +func (s *Service) create(ctx context.Context, item *domainskill.Skill) (*domainskill.Skill, error) { + result, err := s.repo.CreateSkill(ctx, item) + if err != nil { + return nil, mapRepositoryError(err) + } + return result, nil +} + +func (s *Service) update(ctx context.Context, id uint, actorUserID uint, input PatchInput) (*domainskill.Skill, error) { + patch, err := normalizePatchInput(input, actorUserID) + if err != nil { + return nil, err + } + item, err := s.repo.PatchSkill(ctx, id, patch) + if err != nil { + return nil, mapRepositoryError(err) + } + return item, nil +} + +func normalizeWriteInput(input WriteInput, scope string, ownerUserID uint, actorUserID uint) (*domainskill.Skill, error) { + title, trigger, description, markdown, err := normalizeFields(input) + if err != nil { + return nil, err + } + if scope != domainskill.ScopeBuiltin && scope != domainskill.ScopeUser { + return nil, ErrInvalidSkill + } + return &domainskill.Skill{ + Scope: scope, + OwnerUserID: ownerUserID, + Title: title, + Trigger: trigger, + Description: description, + Markdown: markdown, + Enabled: input.Enabled, + SortOrder: input.SortOrder, + CreatedByUserID: actorUserID, + UpdatedByUserID: actorUserID, + }, nil +} + +func normalizePatchInput(input PatchInput, actorUserID uint) (repository.SkillPatch, error) { + patch := repository.SkillPatch{ + UpdatedByUserIDSet: true, + UpdatedByUserID: actorUserID, + } + if input.Title != nil { + title := strings.TrimSpace(*input.Title) + if title == "" || runeCount(title) > maxSkillTitleLength { + return repository.SkillPatch{}, ErrInvalidSkill + } + patch.Title = &title + } + if input.Trigger != nil { + trigger := normalizeTrigger(*input.Trigger) + if trigger == "" || runeCount(trigger) > maxSkillTriggerLength { + return repository.SkillPatch{}, ErrInvalidSkill + } + patch.Trigger = &trigger + } + if input.Description != nil { + description := strings.TrimSpace(*input.Description) + if runeCount(description) > maxSkillDescriptionLength { + return repository.SkillPatch{}, ErrInvalidSkill + } + patch.Description = &description + } + if input.Markdown != nil { + markdown := strings.TrimSpace(*input.Markdown) + if markdown == "" || runeCount(markdown) > maxSkillMarkdownLength { + return repository.SkillPatch{}, ErrInvalidSkill + } + patch.Markdown = &markdown + } + if input.Enabled != nil { + patch.Enabled = input.Enabled + } + if input.SortOrder != nil { + patch.SortOrder = input.SortOrder + } + return patch, nil +} + +func normalizeFields(input WriteInput) (string, string, string, string, error) { + title := strings.TrimSpace(input.Title) + trigger := normalizeTrigger(input.Trigger) + description := strings.TrimSpace(input.Description) + markdown := strings.TrimSpace(input.Markdown) + if title == "" || runeCount(title) > maxSkillTitleLength { + return "", "", "", "", ErrInvalidSkill + } + if trigger == "" || runeCount(trigger) > maxSkillTriggerLength { + return "", "", "", "", ErrInvalidSkill + } + if runeCount(description) > maxSkillDescriptionLength { + return "", "", "", "", ErrInvalidSkill + } + if markdown == "" || runeCount(markdown) > maxSkillMarkdownLength { + return "", "", "", "", ErrInvalidSkill + } + return title, trigger, description, markdown, nil +} + +func normalizeTrigger(value string) string { + return strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(value), "/")) +} + +func runeCount(value string) int { + return len([]rune(value)) +} + +func normalizePage(page int, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + const maxPageSize = 100 + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return page, pageSize +} + +func mapRepositoryError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, repository.ErrNotFound) { + return ErrSkillNotFound + } + if errors.Is(err, repository.ErrDuplicate) { + return ErrSkillConflict + } + if errors.Is(err, repository.ErrInvalidInput) { + return ErrInvalidSkill + } + return err +} diff --git a/backend/internal/application/skill/service_test.go b/backend/internal/application/skill/service_test.go new file mode 100644 index 00000000..acb906b2 --- /dev/null +++ b/backend/internal/application/skill/service_test.go @@ -0,0 +1,96 @@ +package skill + +import ( + "context" + "errors" + "testing" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" +) + +type fakeSkillRepo struct { + items map[uint]domainskill.Skill + next uint +} + +func (r *fakeSkillRepo) ListSkills(context.Context, repository.SkillListFilter, int, int) ([]domainskill.Skill, int64, error) { + return nil, 0, nil +} + +func (r *fakeSkillRepo) GetSkill(_ context.Context, id uint) (*domainskill.Skill, error) { + item, ok := r.items[id] + if !ok { + return nil, repository.ErrNotFound + } + return &item, nil +} + +func (r *fakeSkillRepo) CreateSkill(_ context.Context, item *domainskill.Skill) (*domainskill.Skill, error) { + if r.next == 0 { + r.next = 1 + } + item.ID = r.next + r.next++ + r.items[item.ID] = *item + result := r.items[item.ID] + return &result, nil +} + +func (r *fakeSkillRepo) PatchSkill(context.Context, uint, repository.SkillPatch) (*domainskill.Skill, error) { + return nil, nil +} + +func (r *fakeSkillRepo) DeleteSkill(context.Context, uint) error { + return nil +} + +func TestCreateUserRequiresMarkdown(t *testing.T) { + service := NewService(&fakeSkillRepo{items: map[uint]domainskill.Skill{}}) + _, err := service.CreateUser(context.Background(), 7, WriteInput{ + Title: "Review", + Trigger: "review", + Enabled: true, + }) + if !errors.Is(err, ErrInvalidSkill) { + t.Fatalf("expected ErrInvalidSkill, got %v", err) + } +} + +func TestCreateUserStoresMarkdown(t *testing.T) { + service := NewService(&fakeSkillRepo{items: map[uint]domainskill.Skill{}}) + item, err := service.CreateUser(context.Background(), 7, WriteInput{ + Title: "Review", + Trigger: "/review", + Description: "Review code", + Markdown: "Review the submitted code and return prioritized findings.", + Enabled: true, + }) + if err != nil { + t.Fatalf("expected skill to be created, got %v", err) + } + if item.Trigger != "review" { + t.Fatalf("expected normalized trigger, got %q", item.Trigger) + } + if item.Markdown != "Review the submitted code and return prioritized findings." { + t.Fatalf("unexpected markdown: %q", item.Markdown) + } +} + +func TestResolveAvailableEnforcesVisibility(t *testing.T) { + service := NewService(&fakeSkillRepo{items: map[uint]domainskill.Skill{ + 1: {ID: 1, Scope: domainskill.ScopeUser, OwnerUserID: 8, Enabled: true, Title: "Private"}, + 2: {ID: 2, Scope: domainskill.ScopeBuiltin, Enabled: true, Title: "Builtin"}, + }}) + + if _, err := service.ResolveAvailable(context.Background(), 7, 1); !errors.Is(err, ErrSkillNotFound) { + t.Fatalf("expected ErrSkillNotFound, got %v", err) + } + item, err := service.ResolveAvailable(context.Background(), 7, 2) + if err != nil { + t.Fatalf("expected builtin visible, got %v", err) + } + if item.ID != 2 { + t.Fatalf("expected id 2, got %d", item.ID) + } +} diff --git a/backend/internal/application/user/service.go b/backend/internal/application/user/service.go index 050018b0..f3f35979 100644 --- a/backend/internal/application/user/service.go +++ b/backend/internal/application/user/service.go @@ -140,6 +140,14 @@ func (s *Service) ListUsers(ctx context.Context, page int, pageSize int) ([]doma return s.repo.ListUsers(ctx, offset, limit) } +// ListLatestSessionActivityByUserIDs 批量查询用户最近会话活跃时间。 +func (s *Service) ListLatestSessionActivityByUserIDs(ctx context.Context, userIDs []uint) (map[uint]time.Time, error) { + if len(userIDs) == 0 { + return map[uint]time.Time{}, nil + } + return s.repo.ListLatestSessionActivityByUserIDs(ctx, userIDs) +} + func normalizePage(page int, pageSize int) (int, int) { if page <= 0 { page = 1 diff --git a/backend/internal/application/userview/view.go b/backend/internal/application/userview/view.go index fc0e6f60..6c8979e8 100644 --- a/backend/internal/application/userview/view.go +++ b/backend/internal/application/userview/view.go @@ -56,6 +56,7 @@ type UserView struct { TwoFactorRequired bool TwoFactorRecoveryCount int LastLoginAt *time.Time + LastActiveAt *time.Time CreatedAt time.Time UpdatedAt time.Time SubscriptionTier string @@ -91,6 +92,7 @@ func FromUser(item domainuser.User, subscription *SubscriptionState) UserView { PhoneVerifiedAt: item.PhoneVerifiedAt, UsernameChangedAt: item.UsernameChangedAt, LastLoginAt: item.LastLoginAt, + LastActiveAt: item.LastLoginAt, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, SubscriptionTier: "free", @@ -122,6 +124,14 @@ func FromUser(item domainuser.User, subscription *SubscriptionState) UserView { return view } +// WithLastActiveAt 设置用户视图中的最近活跃时间。 +func WithLastActiveAt(view UserView, value *time.Time) UserView { + if value != nil { + view.LastActiveAt = value + } + return view +} + // WithBillingAccount 设置用户视图中的按量余额信息。 func WithBillingAccount(view UserView, account *BillingAccountState) UserView { if account == nil { diff --git a/backend/internal/domain/skill/doc.go b/backend/internal/domain/skill/doc.go new file mode 100644 index 00000000..74c459da --- /dev/null +++ b/backend/internal/domain/skill/doc.go @@ -0,0 +1,2 @@ +// Package skill 定义按需加载的技能提示词领域对象与常量。 +package skill diff --git a/backend/internal/domain/skill/types.go b/backend/internal/domain/skill/types.go new file mode 100644 index 00000000..221a9981 --- /dev/null +++ b/backend/internal/domain/skill/types.go @@ -0,0 +1,27 @@ +package skill + +import "time" + +const ( + // ScopeBuiltin 表示管理员维护的全局内置技能。 + ScopeBuiltin = "builtin" + // ScopeUser 表示用户维护的个人自定义技能。 + ScopeUser = "user" +) + +// Skill 表示可在会话中按需加载的 SKILL.md 能力包。 +type Skill struct { + ID uint + Scope string + OwnerUserID uint + Title string + Trigger string + Description string + Markdown string + Enabled bool + SortOrder int + CreatedByUserID uint + UpdatedByUserID uint + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/backend/internal/infra/cache/redis/conversation_cache.go b/backend/internal/infra/cache/redis/conversation_cache.go index dec18fa7..a5125faf 100644 --- a/backend/internal/infra/cache/redis/conversation_cache.go +++ b/backend/internal/infra/cache/redis/conversation_cache.go @@ -291,7 +291,7 @@ func (c *conversationCache) GetGenerationStreamOwner(ctx context.Context, runID } return 0, false, err } - value, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 64) + value, err := strconv.ParseUint(strings.TrimSpace(raw), 10, strconv.IntSize) if err != nil || value == 0 { return 0, false, nil } diff --git a/backend/internal/infra/config/config.go b/backend/internal/infra/config/config.go index 21914574..b6c5ce2d 100644 --- a/backend/internal/infra/config/config.go +++ b/backend/internal/infra/config/config.go @@ -482,7 +482,7 @@ func Load() Config { DataEncryptionKey: envOr("DATA_ENCRYPTION_KEY", yc.Security.DataEncryptionKey, defaultDataEncryptionKey), SSRFProtectionEnabled: envOrBoolPtr("SSRF_PROTECTION_ENABLED", yc.Security.SSRFProtectionEnabled, false), DatabaseDriver: normalizeDatabaseDriver(envOr("DATABASE_DRIVER", yc.Database.Driver, "postgres")), - PostgresDSN: envOr("POSTGRES_DSN", yc.Database.Postgres.DSN, "host=127.0.0.1 user=deeix_chat password=deeix_chat_dev_2026 dbname=deeix_chat port=5432 sslmode=disable TimeZone=Asia/Shanghai"), + PostgresDSN: normalizePostgresDSN(envOr("POSTGRES_DSN", yc.Database.Postgres.DSN, "host=127.0.0.1 user=deeix_chat password=deeix_chat_dev_2026 dbname=deeix_chat port=5432 sslmode=disable TimeZone=Asia/Shanghai")), PostgresMaxOpenConns: envOrInt("POSTGRES_MAX_OPEN_CONNS", yc.Database.Postgres.MaxOpenConns, 30), PostgresMaxIdleConns: envOrInt("POSTGRES_MAX_IDLE_CONNS", yc.Database.Postgres.MaxIdleConns, 10), PostgresConnMaxLifetimeMin: envOrInt("POSTGRES_CONN_MAX_LIFETIME_MINUTES", yc.Database.Postgres.ConnMaxLifetimeMin, 60), @@ -841,6 +841,61 @@ func normalizeDatabaseDriver(value string) string { } } +func normalizePostgresDSN(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return trimmed + } + if strings.Contains(trimmed, "://") { + parsed, err := url.Parse(trimmed) + if err != nil || parsed == nil || parsed.RawQuery == "" { + return trimmed + } + parts := strings.Split(parsed.RawQuery, "&") + changed := false + for index, part := range parts { + key, rawValue, ok := strings.Cut(part, "=") + if !ok { + continue + } + decodedKey, keyErr := url.QueryUnescape(key) + if keyErr != nil || !strings.EqualFold(decodedKey, "timezone") || !strings.Contains(rawValue, "%") { + continue + } + decodedValue, valueErr := url.QueryUnescape(rawValue) + if valueErr != nil || strings.TrimSpace(decodedValue) == "" || decodedValue == rawValue { + continue + } + parts[index] = key + "=" + decodedValue + changed = true + } + if !changed { + return trimmed + } + parsed.RawQuery = strings.Join(parts, "&") + return parsed.String() + } + + parts := strings.Fields(trimmed) + changed := false + for index, part := range parts { + key, rawValue, ok := strings.Cut(part, "=") + if !ok || !strings.EqualFold(key, "timezone") || !strings.Contains(rawValue, "%") { + continue + } + decodedValue, err := url.QueryUnescape(rawValue) + if err != nil || strings.TrimSpace(decodedValue) == "" || decodedValue == rawValue { + continue + } + parts[index] = key + "=" + decodedValue + changed = true + } + if !changed { + return trimmed + } + return strings.Join(parts, " ") +} + func normalizeCacheDriver(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "", "redis": diff --git a/backend/internal/infra/config/config_test.go b/backend/internal/infra/config/config_test.go index 36b8b2d8..881507e7 100644 --- a/backend/internal/infra/config/config_test.go +++ b/backend/internal/infra/config/config_test.go @@ -58,6 +58,54 @@ func TestLoadNormalizesAPPEnvAliases(t *testing.T) { } } +func TestLoadNormalizesLegacyPostgresDSNTimeZone(t *testing.T) { + cleanupConfigEnv(t) + chdir(t, t.TempDir()) + t.Setenv("POSTGRES_DSN", "postgres://deeix_chat:secret%2Fvalue@postgres:5432/deeix_chat?sslmode=disable&TimeZone=Asia%2FShanghai") + + cfg := Load() + if cfg.PostgresDSN != "postgres://deeix_chat:secret%2Fvalue@postgres:5432/deeix_chat?sslmode=disable&TimeZone=Asia/Shanghai" { + t.Fatalf("expected legacy timezone to be normalized without decoding credentials, got %q", cfg.PostgresDSN) + } +} + +func TestNormalizePostgresDSNTimeZone(t *testing.T) { + tests := []struct { + name string + dsn string + want string + }{ + { + name: "url", + dsn: "postgres://user:pass%2Fword@postgres:5432/db?sslmode=disable&TimeZone=Asia%2FShanghai", + want: "postgres://user:pass%2Fword@postgres:5432/db?sslmode=disable&TimeZone=Asia/Shanghai", + }, + { + name: "key value", + dsn: "host=postgres user=deeix password=secret%2Fvalue dbname=deeix sslmode=disable TimeZone=Asia%2FShanghai", + want: "host=postgres user=deeix password=secret%2Fvalue dbname=deeix sslmode=disable TimeZone=Asia/Shanghai", + }, + { + name: "already normalized", + dsn: "host=postgres user=deeix dbname=deeix sslmode=disable TimeZone=Asia/Shanghai", + want: "host=postgres user=deeix dbname=deeix sslmode=disable TimeZone=Asia/Shanghai", + }, + { + name: "other percent encoded fields unchanged", + dsn: "postgres://user:pass@postgres:5432/db?application_name=DEEIX%2FChat&sslmode=disable", + want: "postgres://user:pass@postgres:5432/db?application_name=DEEIX%2FChat&sslmode=disable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizePostgresDSN(tt.dsn); got != tt.want { + t.Fatalf("normalizePostgresDSN() = %q, want %q", got, tt.want) + } + }) + } +} + func TestLoadReadsRepositoryRootConfigFromBackendWorkingDirectory(t *testing.T) { cleanupConfigEnv(t) @@ -171,6 +219,7 @@ func cleanupConfigEnv(t *testing.T) { "STORAGE_ROOT_DIR", "GEOIP_DATABASE_PATH", "TURNSTILE_SITEVERIFY_URL", + "POSTGRES_DSN", } for _, key := range keys { key := key diff --git a/backend/internal/infra/extract/mineru/client.go b/backend/internal/infra/extract/mineru/client.go index f4807055..f893b5b3 100644 --- a/backend/internal/infra/extract/mineru/client.go +++ b/backend/internal/infra/extract/mineru/client.go @@ -369,10 +369,10 @@ func (c *Client) createBatch(ctx context.Context, req Request) (string, string, if err := json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&parsed); err != nil { return "", "", fmt.Errorf("mineru_invalid_response") } - if strings.TrimSpace(parsed.Data.BatchID) == "" || len(parsed.Data.FileURLs) == 0 || strings.TrimSpace(parsed.Data.FileURLs[0].UploadURL) == "" { + if strings.TrimSpace(parsed.Data.BatchID) == "" || len(parsed.Data.FileURLs) == 0 || strings.TrimSpace(parsed.Data.FileURLs[0]) == "" { return "", "", fmt.Errorf("mineru_invalid_response") } - return strings.TrimSpace(parsed.Data.BatchID), strings.TrimSpace(parsed.Data.FileURLs[0].UploadURL), nil + return strings.TrimSpace(parsed.Data.BatchID), strings.TrimSpace(parsed.Data.FileURLs[0]), nil } func (c *Client) uploadFile(ctx context.Context, uploadURL string, absolutePath string) error { @@ -446,20 +446,27 @@ func (c *Client) pollBatch(ctx context.Context, batchID string) (string, error) } state := strings.ToLower(strings.TrimSpace(parsed.Data.State)) + var item batchResultItem + if len(parsed.Data.ExtractResult) > 0 { + item = parsed.Data.ExtractResult[0] + if itemState := strings.ToLower(strings.TrimSpace(item.State)); itemState != "" { + state = itemState + } + } switch state { case "done", "success", "completed": if len(parsed.Data.ExtractResult) == 0 { return "", fmt.Errorf("mineru_invalid_response") } - zipURL := strings.TrimSpace(parsed.Data.ExtractResult[0].FullZipURL) + zipURL := strings.TrimSpace(item.FullZipURL) if zipURL == "" { return "", fmt.Errorf("mineru_invalid_response") } return zipURL, nil case "failed", "error": detail := strings.TrimSpace(parsed.Data.ErrMsg) - if detail == "" && len(parsed.Data.ExtractResult) > 0 { - detail = strings.TrimSpace(parsed.Data.ExtractResult[0].ErrMsg) + if detail == "" { + detail = strings.TrimSpace(item.ErrMsg) } if detail == "" { return "", fmt.Errorf("mineru_failed") @@ -663,10 +670,8 @@ func awaitMultipartWriteError(errCh <-chan error) error { type batchCreateResponse struct { Data struct { - BatchID string `json:"batch_id"` - FileURLs []struct { - UploadURL string `json:"upload_url"` - } `json:"file_urls"` + BatchID string `json:"batch_id"` + FileURLs []string `json:"file_urls"` } `json:"data"` } @@ -689,11 +694,14 @@ func (r selfHostedResponse) firstMarkdown() string { type batchResultResponse struct { Data struct { - State string `json:"state"` - ErrMsg string `json:"err_msg"` - ExtractResult []struct { - ErrMsg string `json:"err_msg"` - FullZipURL string `json:"full_zip_url"` - } `json:"extract_result"` + State string `json:"state"` + ErrMsg string `json:"err_msg"` + ExtractResult []batchResultItem `json:"extract_result"` } `json:"data"` } + +type batchResultItem struct { + State string `json:"state"` + ErrMsg string `json:"err_msg"` + FullZipURL string `json:"full_zip_url"` +} diff --git a/backend/internal/infra/extract/mineru/client_test.go b/backend/internal/infra/extract/mineru/client_test.go new file mode 100644 index 00000000..4960b9cc --- /dev/null +++ b/backend/internal/infra/extract/mineru/client_test.go @@ -0,0 +1,109 @@ +package mineru + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +func TestBatchCreateResponseParsesFileURLsAsStrings(t *testing.T) { + raw := []byte(`{ + "code": 0, + "msg": "ok", + "data": { + "batch_id": "batch-1", + "file_urls": ["https://example.com/upload"] + } + }`) + + var parsed batchCreateResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("unmarshal batch create response: %v", err) + } + if parsed.Data.BatchID != "batch-1" { + t.Fatalf("unexpected batch id %q", parsed.Data.BatchID) + } + if len(parsed.Data.FileURLs) != 1 || parsed.Data.FileURLs[0] != "https://example.com/upload" { + t.Fatalf("unexpected file urls %#v", parsed.Data.FileURLs) + } +} + +func TestBatchResultResponseParsesExtractResultStateAndZipURL(t *testing.T) { + raw := []byte(`{ + "code": 0, + "msg": "ok", + "data": { + "state": "running", + "extract_result": [ + { + "state": "done", + "full_zip_url": "https://example.com/result.zip" + } + ] + } + }`) + + var parsed batchResultResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("unmarshal batch result response: %v", err) + } + if len(parsed.Data.ExtractResult) != 1 { + t.Fatalf("unexpected extract result %#v", parsed.Data.ExtractResult) + } + if parsed.Data.ExtractResult[0].State != "done" { + t.Fatalf("unexpected item state %q", parsed.Data.ExtractResult[0].State) + } + if parsed.Data.ExtractResult[0].FullZipURL != "https://example.com/result.zip" { + t.Fatalf("unexpected full zip url %q", parsed.Data.ExtractResult[0].FullZipURL) + } +} + +func TestPollBatchUsesExtractResultState(t *testing.T) { + var calls int + client := &Client{ + baseURL: "https://mineru.example/api/v4", + httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + calls++ + if r.URL.Path != "/api/v4/extract-results/batch/batch-1" { + t.Fatalf("unexpected path %q", r.URL.Path) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "code": 0, + "msg": "ok", + "data": { + "state": "running", + "extract_result": [ + { + "state": "done", + "full_zip_url": "https://example.com/result.zip" + } + ] + } + }`)), + }, nil + })}, + } + + zipURL, err := client.pollBatch(context.Background(), "batch-1") + if err != nil { + t.Fatalf("poll batch: %v", err) + } + if zipURL != "https://example.com/result.zip" { + t.Fatalf("unexpected zip url %q", zipURL) + } + if calls != 1 { + t.Fatalf("expected one poll call, got %d", calls) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} diff --git a/backend/internal/infra/llm/client.go b/backend/internal/infra/llm/client.go index 85f98f73..98d52c17 100644 --- a/backend/internal/infra/llm/client.go +++ b/backend/internal/infra/llm/client.go @@ -126,13 +126,16 @@ type GenerateInput struct { RequestID string ConversationID uint Messages []Message - Tools []ToolDefinition + // Instructions 承载可映射到上游原生指令字段的系统/开发者指令。 + // 不支持原生指令字段的 adapter 应继续通过 messages 承载系统提示。 + Instructions string + Tools []ToolDefinition // DisableTools 表示本轮调用必须只生成文本,adapter 不再声明 MCP 或厂商原生工具。 DisableTools bool // Options 承载本次调用的自由 JSON 参数。系统字段(model/messages/input/stream) // 由 adapter 固定构造;Options 只表达采样、推理、工具、缓存和厂商原生扩展。 Options map[string]interface{} - // PreviousResponseID 供 OpenAI/xAI Responses API 实现有状态会话。 + // PreviousResponseID 供 OpenAI Responses API 实现有状态会话。 // 非空时:仅在 input 中发送本轮新消息,服务端从存储状态续接历史。 // 空串时:退回全量发送模式,适用于所有 adapter。 PreviousResponseID string @@ -633,6 +636,8 @@ type GenerateOutput struct { GeneratedImages []GeneratedImage RawJSON string Debug *UpstreamDebugSnapshot `json:"-"` + + chatTextBuffer string } // GeneratedImage 表示图片生成/编辑接口返回的一张图片。 diff --git a/backend/internal/infra/llm/dsml_tool_calls.go b/backend/internal/infra/llm/dsml_tool_calls.go new file mode 100644 index 00000000..de847891 --- /dev/null +++ b/backend/internal/infra/llm/dsml_tool_calls.go @@ -0,0 +1,146 @@ +package llm + +import ( + "encoding/json" + "errors" + "html" + "regexp" + "strconv" + "strings" +) + +var errDeepSeekDSMLToolCallsIncomplete = errors.New("deepseek dsml tool calls ended before a complete tool call envelope") + +var ( + dsmlTokenRE = `(?:|DSML||||DSML|||\|\|DSML\|\||\|DSML\|)` + dsmlToolCallsBlockRE = regexp.MustCompile(`(?is)<\s*` + dsmlTokenRE + `\s*tool_calls\s*>(.*?)`) + dsmlInvokeRE = regexp.MustCompile(`(?is)<\s*` + dsmlTokenRE + `\s*invoke\b([^>]*)>(.*?)`) + dsmlParameterRE = regexp.MustCompile(`(?is)<\s*` + dsmlTokenRE + `\s*parameter\b([^>]*)>(.*?)`) + dsmlAttributeRE = regexp.MustCompile(`(?is)([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*("([^"]*)"|'([^']*)')`) +) + +// applyTextEncodedToolCalls 将 DeepSeek V4 文本编码的工具调用转换为内部结构化 ToolCall。 +// 这段兼容只在路由层显式判定为 DeepSeek Chat Completions 时调用,避免影响其他 OpenAI-compatible 模型。 +func applyTextEncodedToolCalls(output *GenerateOutput) { + if output == nil { + return + } + cleanText, toolCalls, ok := parseDSMLToolCalls(output.Text) + if !ok { + return + } + output.Text = cleanText + output.ToolCalls = append(output.ToolCalls, toolCalls...) +} + +// deepSeekTextEncodedToolCallsEnabled 判断当前路由是否需要启用 DeepSeek DSML 文本工具调用解析。 +func deepSeekTextEncodedToolCallsEnabled(route RouteConfig) bool { + if NormalizeAdapter(route.Protocol) != AdapterOpenAIChatCompletions { + return false + } + model := strings.ToLower(strings.TrimSpace(route.UpstreamModel)) + baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL)) + return strings.Contains(model, "deepseek") || strings.Contains(baseURL, "deepseek") +} + +// parseDSMLToolCalls 解析 DeepSeek V4 以 DSML 文本片段返回的工具调用。 +// 解析成功时只移除完整 tool_calls 块;无法形成合法工具调用时保留原文,交由调用方按普通文本或格式错误处理。 +func parseDSMLToolCalls(text string) (string, []ToolCall, bool) { + matches := dsmlToolCallsBlockRE.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return text, nil, false + } + + var clean strings.Builder + toolCalls := make([]ToolCall, 0) + last := 0 + for _, match := range matches { + blockStart, blockEnd := match[0], match[1] + contentStart, contentEnd := match[2], match[3] + blockToolCalls := parseDSMLInvokeToolCalls(text[contentStart:contentEnd], len(toolCalls)) + if len(blockToolCalls) == 0 { + continue + } + clean.WriteString(text[last:blockStart]) + last = blockEnd + toolCalls = append(toolCalls, blockToolCalls...) + } + if len(toolCalls) == 0 { + return text, nil, false + } + clean.WriteString(text[last:]) + return strings.TrimSpace(clean.String()), toolCalls, true +} + +// parseDSMLInvokeToolCalls 将 DSML invoke 节点映射为本地函数工具调用。 +func parseDSMLInvokeToolCalls(content string, offset int) []ToolCall { + matches := dsmlInvokeRE.FindAllStringSubmatch(content, -1) + if len(matches) == 0 { + return nil + } + result := make([]ToolCall, 0, len(matches)) + for _, match := range matches { + attrs := parseDSMLAttributes(match[1]) + toolName := strings.TrimSpace(attrs["name"]) + if toolName == "" { + continue + } + args := parseDSMLParameters(match[2]) + if args == nil { + continue + } + argsJSON, err := json.Marshal(args) + if err != nil { + argsJSON = []byte("{}") + } + result = append(result, ToolCall{ + ToolCallID: "dsml_call_" + strconv.Itoa(offset+len(result)+1), + ToolType: "function", + ToolName: toolName, + ArgumentsJSON: string(argsJSON), + Status: "requested", + }) + } + return result +} + +// parseDSMLParameters 按 DeepSeek DSML 的 string 标记还原参数类型。 +// 参数名重复时放弃本次 invoke,避免同名覆盖造成工具入参歧义。 +func parseDSMLParameters(content string) map[string]interface{} { + params := map[string]interface{}{} + for _, match := range dsmlParameterRE.FindAllStringSubmatch(content, -1) { + attrs := parseDSMLAttributes(match[1]) + name := strings.TrimSpace(attrs["name"]) + if name == "" { + continue + } + if _, exists := params[name]; exists { + return nil + } + value := strings.TrimSpace(html.UnescapeString(match[2])) + if strings.EqualFold(strings.TrimSpace(attrs["string"]), "true") { + params[name] = value + continue + } + var decoded interface{} + if err := json.Unmarshal([]byte(value), &decoded); err == nil { + params[name] = decoded + continue + } + params[name] = value + } + return params +} + +// parseDSMLAttributes 解析 DSML 标签属性,属性名统一小写以匹配上游格式波动。 +func parseDSMLAttributes(raw string) map[string]string { + attrs := map[string]string{} + for _, match := range dsmlAttributeRE.FindAllStringSubmatch(raw, -1) { + value := match[3] + if value == "" { + value = match[4] + } + attrs[strings.ToLower(strings.TrimSpace(match[1]))] = html.UnescapeString(value) + } + return attrs +} diff --git a/backend/internal/infra/llm/openai.go b/backend/internal/infra/llm/openai.go index 1e3acb76..6d303b4f 100644 --- a/backend/internal/infra/llm/openai.go +++ b/backend/internal/infra/llm/openai.go @@ -68,7 +68,7 @@ func (c *Client) generateOpenAICompatible(ctx context.Context, route RouteConfig } debug := upstreamDebugSnapshot(req, payload, resp, body) - output, err := parseOpenAIGenerateOutput(endpoint, route.Protocol, body) + output, err := parseOpenAIGenerateOutput(endpoint, route.Protocol, body, deepSeekTextEncodedToolCallsEnabled(route)) if err != nil { return nil, attachUpstreamDebug(err, debug) } @@ -151,7 +151,7 @@ func (c *Client) generateStreamOpenAICompatible( idleTimeout := resolveStreamIdleTimeout(route.StreamIdleTimeoutMS) idleReader := newIdleTimeoutReader(resp.Body, idleTimeout) streamBody := newUpstreamBodyRecorder(idleReader) - if err = consumeOpenAIGenerateStream(endpoint, route.Protocol, streamBody, result, onEvent); err != nil { + if err = consumeOpenAIGenerateStream(endpoint, route.Protocol, streamBody, result, onEvent, deepSeekTextEncodedToolCallsEnabled(route)); err != nil { return nil, attachUpstreamDebug(err, upstreamDebugSnapshot(req, payload, resp, streamErrorBody(streamBody, err))) } return result, nil @@ -384,6 +384,7 @@ func consumeOpenAIGenerateStream( reader io.Reader, result *GenerateOutput, onEvent func(GenerateStreamEvent) error, + allowTextEncodedToolCalls bool, ) error { scanner := bufio.NewScanner(reader) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) @@ -403,6 +404,11 @@ func consumeOpenAIGenerateStream( return nil } if strings.TrimSpace(payloadText) == "[DONE]" { + if normalizeEndpoint(endpoint) == EndpointChatCompletions && allowTextEncodedToolCalls { + if err := flushChatVisibleBuffer(result, onEvent, true); err != nil { + return err + } + } return errStreamDone } @@ -416,7 +422,7 @@ func consumeOpenAIGenerateStream( switch normalizeEndpoint(endpoint) { case EndpointChatCompletions: - return applyChatStreamEvent(adapter, parsed, result, onEvent) + return applyChatStreamEvent(adapter, parsed, result, onEvent, allowTextEncodedToolCalls) default: return applyResponsesStreamEvent(adapter, currentEvent, parsed, payloadText, result, onEvent) } @@ -450,25 +456,33 @@ func consumeOpenAIGenerateStream( if err := dispatch(); err != nil && !errors.Is(err, errStreamDone) { return err } + if normalizeEndpoint(endpoint) == EndpointChatCompletions && allowTextEncodedToolCalls { + if err := flushChatVisibleBuffer(result, onEvent, true); err != nil { + return err + } + } return nil } -func parseOpenAIGenerateOutput(endpoint string, adapter string, body []byte) (*GenerateOutput, error) { +func parseOpenAIGenerateOutput(endpoint string, adapter string, body []byte, allowTextEncodedToolCalls bool) (*GenerateOutput, error) { parsed := make(map[string]interface{}) if err := json.Unmarshal(body, &parsed); err != nil { return nil, err } - result := buildGenerateOutputFromParsedForAdapter(endpoint, adapter, parsed) + result := buildGenerateOutputFromParsedForAdapter(endpoint, adapter, parsed, allowTextEncodedToolCalls) + if normalizeEndpoint(endpoint) == EndpointChatCompletions && allowTextEncodedToolCalls && maybeDSMLToolCallsPrefix(result.Text) { + return nil, errDeepSeekDSMLToolCallsIncomplete + } result.RawJSON = string(body) return result, nil } func buildGenerateOutputFromParsed(endpoint string, parsed map[string]interface{}) *GenerateOutput { - return buildGenerateOutputFromParsedForAdapter(endpoint, AdapterOpenAIResponses, parsed) + return buildGenerateOutputFromParsedForAdapter(endpoint, AdapterOpenAIResponses, parsed, false) } -func buildGenerateOutputFromParsedForAdapter(endpoint string, adapter string, parsed map[string]interface{}) *GenerateOutput { +func buildGenerateOutputFromParsedForAdapter(endpoint string, adapter string, parsed map[string]interface{}, allowTextEncodedToolCalls bool) *GenerateOutput { result := &GenerateOutput{ ResponseID: strings.TrimSpace(getString(parsed["id"])), Text: "", @@ -480,7 +494,7 @@ func buildGenerateOutputFromParsedForAdapter(endpoint string, adapter string, pa switch normalizeEndpoint(endpoint) { case EndpointChatCompletions: - parseChatCompletionsOutput(adapter, parsed, result) + parseChatCompletionsOutput(adapter, parsed, result, allowTextEncodedToolCalls) default: parseResponsesOutput(adapter, parsed, result) } diff --git a/backend/internal/infra/llm/openai_chat_completions.go b/backend/internal/infra/llm/openai_chat_completions.go index fad5c416..32844b9f 100644 --- a/backend/internal/infra/llm/openai_chat_completions.go +++ b/backend/internal/infra/llm/openai_chat_completions.go @@ -266,6 +266,7 @@ func applyChatStreamEvent( parsed map[string]interface{}, result *GenerateOutput, onEvent func(GenerateStreamEvent) error, + allowTextEncodedToolCalls bool, ) error { if responseID := strings.TrimSpace(getString(parsed["id"])); responseID != "" { result.ResponseID = responseID @@ -273,14 +274,12 @@ func applyChatStreamEvent( delta := extractChatStreamDelta(parsed) if delta != "" { - result.Text += delta - if onEvent != nil { - if err := onEvent(GenerateStreamEvent{ - Delta: delta, - ResponseID: result.ResponseID, - }); err != nil { + if allowTextEncodedToolCalls { + if err := bufferChatVisibleDelta(result, delta, onEvent); err != nil { return err } + } else if err := emitChatVisibleDelta(result, delta, onEvent); err != nil { + return err } } if reasoning := extractChatStreamReasoningDelta(parsed); reasoning != nil && reasoning.Text != "" { @@ -359,7 +358,7 @@ func mergeChatStreamToolCalls(parsed map[string]interface{}, result *GenerateOut } } -func parseChatCompletionsOutput(adapter string, parsed map[string]interface{}, result *GenerateOutput) { +func parseChatCompletionsOutput(adapter string, parsed map[string]interface{}, result *GenerateOutput, allowTextEncodedToolCalls bool) { choice := firstMapItem(asSlice(parsed["choices"])) message := asMap(choice["message"]) result.Text = extractChatVisibleContentText(message["content"]) @@ -371,6 +370,9 @@ func parseChatCompletionsOutput(adapter string, parsed map[string]interface{}, r if len(toolCalls) > 0 { result.ToolCalls = append(result.ToolCalls, toolCalls...) } + if allowTextEncodedToolCalls { + applyTextEncodedToolCalls(result) + } } func parseChatReasoningOutput(message map[string]interface{}) *ReasoningOutput { @@ -464,6 +466,73 @@ func extractChatVisibleContentText(raw interface{}) string { } } +func bufferChatVisibleDelta(result *GenerateOutput, delta string, onEvent func(GenerateStreamEvent) error) error { + if result == nil || delta == "" { + return nil + } + result.chatTextBuffer += delta + return flushChatVisibleBuffer(result, onEvent, false) +} + +// flushChatVisibleBuffer 在 DeepSeek DSML 模式下延迟释放可见文本,确保完整工具调用不会作为普通文本输出。 +func flushChatVisibleBuffer(result *GenerateOutput, onEvent func(GenerateStreamEvent) error, final bool) error { + if result == nil || result.chatTextBuffer == "" { + return nil + } + if cleanText, toolCalls, ok := parseDSMLToolCalls(result.chatTextBuffer); ok { + result.chatTextBuffer = "" + result.ToolCalls = append(result.ToolCalls, toolCalls...) + if cleanText == "" { + return nil + } + return emitChatVisibleDelta(result, cleanText, onEvent) + } + if !final && maybeDSMLToolCallsPrefix(result.chatTextBuffer) { + return nil + } + if final && maybeDSMLToolCallsPrefix(result.chatTextBuffer) { + return errDeepSeekDSMLToolCallsIncomplete + } + text := result.chatTextBuffer + result.chatTextBuffer = "" + return emitChatVisibleDelta(result, text, onEvent) +} + +// emitChatVisibleDelta 统一写入可见文本并发送流式增量事件。 +func emitChatVisibleDelta(result *GenerateOutput, delta string, onEvent func(GenerateStreamEvent) error) error { + if delta == "" { + return nil + } + result.Text += delta + if onEvent == nil { + return nil + } + return onEvent(GenerateStreamEvent{ + Delta: delta, + ResponseID: result.ResponseID, + }) +} + +// maybeDSMLToolCallsPrefix 只识别 DeepSeek DSML tool_calls 的起始片段,用于流式等待更多 chunk。 +func maybeDSMLToolCallsPrefix(text string) bool { + value := strings.ToLower(strings.TrimLeft(strings.TrimSpace(text), "\ufeff")) + if value == "" { + return false + } + targets := []string{ + "<|dsml|tool_calls", + "<||dsml||tool_calls", + "<||dsml||tool_calls", + "<|dsml|tool_calls", + } + for _, target := range targets { + if strings.HasPrefix(target, value) || strings.HasPrefix(value, target) { + return true + } + } + return false +} + func extractChatReasoningContentText(raw interface{}) string { switch value := raw.(type) { case []interface{}: diff --git a/backend/internal/infra/llm/openai_responses.go b/backend/internal/infra/llm/openai_responses.go index 9e57d1d6..9ebf8d37 100644 --- a/backend/internal/infra/llm/openai_responses.go +++ b/backend/internal/infra/llm/openai_responses.go @@ -47,6 +47,9 @@ func buildResponsesRequestBody( "input": items, "stream": stream, } + if instructions := strings.TrimSpace(input.Instructions); adapter == AdapterOpenAIResponses && instructions != "" { + payload["instructions"] = instructions + } if maxTokens := modelParamInt(input.Options, "max_output_tokens"); maxTokens > 0 { payload["max_output_tokens"] = maxTokens } @@ -71,7 +74,7 @@ func buildResponsesRequestBody( if streamOptions := responsesStreamOptions(providerStreamOptions); stream && len(streamOptions) > 0 { payload["stream_options"] = streamOptions } - applyProviderOptions(payload, input.Options, responsesProtectedProviderOptionKeys(adapter)...) + applyProviderOptions(payload, input.Options, responsesProtectedProviderOptionKeys(adapter, strings.TrimSpace(input.Instructions) != "")...) if supportsResponsesIncludeDefaults(adapter) { defaultIncludes := responsesDefaultIncludeValues(adapter, stream, nativeTools) appendResponseInclude(payload, responseIncludeValues(input.Options, defaultIncludes...)...) @@ -81,7 +84,7 @@ func buildResponsesRequestBody( return payload } -func responsesProtectedProviderOptionKeys(adapter string) []string { +func responsesProtectedProviderOptionKeys(adapter string, hasManagedInstructions bool) []string { keys := []string{ "contents", "include", @@ -100,6 +103,8 @@ func responsesProtectedProviderOptionKeys(adapter string) []string { } if adapter != AdapterOpenAIResponses { keys = append(keys, "instructions", "metadata", "prompt") + } else if hasManagedInstructions { + keys = append(keys, "instructions") } return keys } @@ -376,7 +381,7 @@ func applyResponsesStreamEvent( mergeReasoningDeltaOutput(&result.Reasoning, reasoning) } case "response.completed": - output := buildGenerateOutputFromParsedForAdapter(EndpointResponses, adapter, asMap(parsed["response"])) + output := buildGenerateOutputFromParsedForAdapter(EndpointResponses, adapter, asMap(parsed["response"]), false) if result.Reasoning == nil && output.Reasoning != nil && onEvent != nil { if text := firstNonEmptyString(output.Reasoning.Text, output.Reasoning.Summary); text != "" { if err := onEvent(GenerateStreamEvent{ diff --git a/backend/internal/infra/llm/openrouter_responses.go b/backend/internal/infra/llm/openrouter_responses.go index d2fd02f6..a81c90ea 100644 --- a/backend/internal/infra/llm/openrouter_responses.go +++ b/backend/internal/infra/llm/openrouter_responses.go @@ -66,7 +66,7 @@ func buildOpenRouterResponsesRequestBody( if streamOptions := responsesStreamOptions(providerStreamOptions); stream && len(streamOptions) > 0 { payload["stream_options"] = streamOptions } - applyProviderOptions(payload, input.Options, responsesProtectedProviderOptionKeys(AdapterOpenRouterResponses)...) + applyProviderOptions(payload, input.Options, responsesProtectedProviderOptionKeys(AdapterOpenRouterResponses, false)...) if include := responseIncludeValues(input.Options); len(include) > 0 { appendResponseInclude(payload, include...) } diff --git a/backend/internal/infra/llm/request_params_test.go b/backend/internal/infra/llm/request_params_test.go index 6d6588c3..61ca6036 100644 --- a/backend/internal/infra/llm/request_params_test.go +++ b/backend/internal/infra/llm/request_params_test.go @@ -495,6 +495,30 @@ func TestBuildOpenAIResponsesNestedProviderOptionsMergeAndOfficialFields(t *test } } +func TestBuildOpenAIResponsesUsesManagedInstructions(t *testing.T) { + payload := mustBuildRequestBody(t, AdapterOpenAIResponses, "gpt-5", EndpointResponses, GenerateInput{ + Messages: []Message{{Role: "user", Content: "hello"}}, + Instructions: "managed developer instructions", + Options: map[string]interface{}{ + "instructions": "provider override", + "metadata": map[string]interface{}{"trace": "ok"}, + "prompt": map[string]interface{}{"id": "pmpt_123"}, + }, + }, true) + + if payload["instructions"] != "managed developer instructions" { + t.Fatalf("expected managed instructions, got %#v", payload["instructions"]) + } + metadata, ok := payload["metadata"].(map[string]interface{}) + if !ok || metadata["trace"] != "ok" { + t.Fatalf("expected metadata to remain available, got %#v", payload["metadata"]) + } + prompt, ok := payload["prompt"].(map[string]interface{}) + if !ok || prompt["id"] != "pmpt_123" { + t.Fatalf("expected prompt to remain available, got %#v", payload["prompt"]) + } +} + func TestBuildXAIResponsesOmitsUnsupportedSystemMetadata(t *testing.T) { payload := mustBuildRequestBody(t, AdapterXAIResponses, "grok-4.3", EndpointResponses, GenerateInput{ RequestID: "req-123", diff --git a/backend/internal/infra/llm/request_tools_test.go b/backend/internal/infra/llm/request_tools_test.go index bef17852..2df98207 100644 --- a/backend/internal/infra/llm/request_tools_test.go +++ b/backend/internal/infra/llm/request_tools_test.go @@ -90,7 +90,7 @@ func TestParseChatCompletionsOutputSeparatesReasoningContentParts(t *testing.T) }, }, }, - }, result) + }, result, false) if result.Text != "visible answer" { t.Fatalf("expected only visible content, got %q", result.Text) @@ -124,7 +124,7 @@ func TestApplyChatStreamEventSeparatesReasoningContentParts(t *testing.T) { reasoning += event.Reasoning.Text } return nil - }) + }, false) if err != nil { t.Fatalf("apply chat stream event: %v", err) } @@ -572,7 +572,7 @@ func TestChatStreamToolCallArgumentsAreConcatenatedWithoutDefaultPrefix(t *testi } for _, chunk := range chunks { - if err := applyChatStreamEvent(AdapterOpenAIChatCompletions, chunk, result, nil); err != nil { + if err := applyChatStreamEvent(AdapterOpenAIChatCompletions, chunk, result, nil, false); err != nil { t.Fatalf("apply stream event: %v", err) } } @@ -629,7 +629,7 @@ func TestChatStreamCustomToolCallInputIsConcatenated(t *testing.T) { } for _, chunk := range chunks { - if err := applyChatStreamEvent(AdapterOpenAIChatCompletions, chunk, result, nil); err != nil { + if err := applyChatStreamEvent(AdapterOpenAIChatCompletions, chunk, result, nil, false); err != nil { t.Fatalf("apply stream event: %v", err) } } @@ -662,11 +662,187 @@ func TestParseChatCompletionsCustomToolCall(t *testing.T) { } } +func TestParseChatCompletionsDSMLToolCalls(t *testing.T) { + payload := mustDecodeObject(t, `{ + "id": "chatcmpl_1", + "choices": [{ + "message": { + "role": "assistant", + "content": "<|DSML|tool_calls>\n<|DSML|invoke name=\"searchGitHub\">\n<|DSML|parameter name=\"query\" string=\"true\">默认启用MCP DEEIX\n\n" + } + }] + }`) + + result := buildGenerateOutputFromParsedForAdapter(EndpointChatCompletions, AdapterOpenAIChatCompletions, payload, true) + if result.Text != "" { + t.Fatalf("expected DSML envelope to be removed from visible text, got %q", result.Text) + } + if len(result.ToolCalls) != 1 { + t.Fatalf("expected one parsed DSML tool call, got %#v", result.ToolCalls) + } + call := result.ToolCalls[0] + if call.ToolCallID != "dsml_call_1" || call.ToolType != "function" || call.ToolName != "searchGitHub" || call.Status != "requested" { + t.Fatalf("unexpected DSML tool call: %#v", call) + } + if call.ArgumentsJSON != `{"query":"默认启用MCP DEEIX"}` { + t.Fatalf("unexpected DSML arguments: %q", call.ArgumentsJSON) + } +} + +func TestParseChatCompletionsDSMLToolCallsDecodesJSONParameters(t *testing.T) { + payload := mustDecodeObject(t, `{ + "id": "chatcmpl_1", + "choices": [{ + "message": { + "role": "assistant", + "content": "<|DSML|tool_calls>\n<|DSML|invoke name=\"searchGitHub\">\n<|DSML|parameter name=\"query\" string=\"true\">DEEIX\n<|DSML|parameter name=\"limit\" string=\"false\">3\n<|DSML|parameter name=\"filters\" string=\"false\">{\"language\":\"Go\"}\n\n" + } + }] + }`) + + result := buildGenerateOutputFromParsedForAdapter(EndpointChatCompletions, AdapterOpenAIChatCompletions, payload, true) + if len(result.ToolCalls) != 1 { + t.Fatalf("expected one parsed DSML tool call, got %#v", result.ToolCalls) + } + if result.ToolCalls[0].ArgumentsJSON != `{"filters":{"language":"Go"},"limit":3,"query":"DEEIX"}` { + t.Fatalf("unexpected DSML arguments: %q", result.ToolCalls[0].ArgumentsJSON) + } +} + +func TestParseChatCompletionsDSMLToolCallsDisabledByDefault(t *testing.T) { + payload := mustDecodeObject(t, `{ + "id": "chatcmpl_1", + "choices": [{ + "message": { + "role": "assistant", + "content": "<|DSML|tool_calls><|DSML|invoke name=\"searchGitHub\"><|DSML|parameter name=\"query\" string=\"true\">DEEIX" + } + }] + }`) + + result := buildGenerateOutputFromParsed(EndpointChatCompletions, payload) + if !strings.Contains(result.Text, "DSML") { + t.Fatalf("expected default chat completions path to keep DSML as text, got %q", result.Text) + } + if len(result.ToolCalls) != 0 { + t.Fatalf("expected default chat completions path not to parse DSML tool calls, got %#v", result.ToolCalls) + } +} + +func TestTextEncodedToolCallsOnlyEnabledForDeepSeekChatCompletions(t *testing.T) { + if !deepSeekTextEncodedToolCallsEnabled(RouteConfig{ + Protocol: AdapterOpenAIChatCompletions, + UpstreamModel: "deepseek-v4-flash", + }) { + t.Fatalf("expected DeepSeek chat completions route to enable text-encoded tool calls") + } + if deepSeekTextEncodedToolCallsEnabled(RouteConfig{ + Protocol: AdapterOpenAIChatCompletions, + UpstreamModel: "gpt-5.4", + }) { + t.Fatalf("expected non-DeepSeek chat completions route to keep text-encoded tool calls disabled") + } + if deepSeekTextEncodedToolCallsEnabled(RouteConfig{ + Protocol: AdapterOpenAIResponses, + UpstreamModel: "deepseek-v4-flash", + }) { + t.Fatalf("expected non-chat-completions route to keep text-encoded tool calls disabled") + } +} + +func TestConsumeChatStreamDSMLToolCallsAreNotEmittedAsText(t *testing.T) { + rawStream := strings.Join([]string{ + `data: {"id":"chatcmpl_1","choices":[{"delta":{"content":"<|DSML|tool_calls>\n<|DSML|invoke name=\"searchGitHub\">\n"}}]}`, + `data: {"id":"chatcmpl_1","choices":[{"delta":{"content":"<|DSML|parameter name=\"query\" string=\"true\">DEEIX MCP\n\n"}}]}`, + `data: [DONE]`, + ``, + }, "\n\n") + result := &GenerateOutput{ToolCalls: make([]ToolCall, 0)} + var deltas []string + + err := consumeOpenAIGenerateStream(EndpointChatCompletions, AdapterOpenAIChatCompletions, strings.NewReader(rawStream), result, func(event GenerateStreamEvent) error { + if event.Delta != "" { + deltas = append(deltas, event.Delta) + } + return nil + }, true) + if err != nil { + t.Fatalf("consume stream: %v", err) + } + if len(deltas) != 0 || result.Text != "" { + t.Fatalf("expected DSML stream to stay out of visible text, deltas=%#v text=%q", deltas, result.Text) + } + if len(result.ToolCalls) != 1 || result.ToolCalls[0].ToolName != "searchGitHub" || result.ToolCalls[0].ArgumentsJSON != `{"query":"DEEIX MCP"}` { + t.Fatalf("unexpected DSML stream tool calls: %#v", result.ToolCalls) + } +} + +func TestConsumeChatStreamIncompleteDSMLToolCallsReturnsError(t *testing.T) { + rawStream := strings.Join([]string{ + `data: {"id":"chatcmpl_1","choices":[{"delta":{"content":"<|DSML|tool_calls>\n<|DSML|invoke name=\"searchGitHub\">\n"}}]}`, + `data: [DONE]`, + ``, + }, "\n\n") + result := &GenerateOutput{ToolCalls: make([]ToolCall, 0)} + + err := consumeOpenAIGenerateStream(EndpointChatCompletions, AdapterOpenAIChatCompletions, strings.NewReader(rawStream), result, nil, true) + if !errors.Is(err, errDeepSeekDSMLToolCallsIncomplete) { + t.Fatalf("expected incomplete DSML error, got %v", err) + } + if result.Text != "" || len(result.ToolCalls) != 0 { + t.Fatalf("expected incomplete DSML to stay out of output, text=%q toolCalls=%#v", result.Text, result.ToolCalls) + } +} + +func TestParseOpenAIGenerateOutputIncompleteDSMLToolCallsReturnsError(t *testing.T) { + body := []byte(`{ + "id": "chatcmpl_1", + "choices": [{ + "message": { + "role": "assistant", + "content": "<|DSML|tool_calls>\n<|DSML|invoke name=\"searchGitHub\">" + } + }] + }`) + + _, err := parseOpenAIGenerateOutput(EndpointChatCompletions, AdapterOpenAIChatCompletions, body, true) + if !errors.Is(err, errDeepSeekDSMLToolCallsIncomplete) { + t.Fatalf("expected incomplete DSML error, got %v", err) + } +} + +func TestConsumeChatStreamAngleBracketTextStillEmits(t *testing.T) { + rawStream := strings.Join([]string{ + `data: {"id":"chatcmpl_1","choices":[{"delta":{"content":"<"}}]}`, + `data: {"id":"chatcmpl_1","choices":[{"delta":{"content":"not-dsml> ok"}}]}`, + `data: [DONE]`, + ``, + }, "\n\n") + result := &GenerateOutput{ToolCalls: make([]ToolCall, 0)} + var deltas []string + + err := consumeOpenAIGenerateStream(EndpointChatCompletions, AdapterOpenAIChatCompletions, strings.NewReader(rawStream), result, func(event GenerateStreamEvent) error { + if event.Delta != "" { + deltas = append(deltas, event.Delta) + } + return nil + }, true) + if err != nil { + t.Fatalf("consume stream: %v", err) + } + if got := strings.Join(deltas, ""); got != " ok" || result.Text != got { + t.Fatalf("expected ordinary angle bracket text to stream, deltas=%#v text=%q", deltas, result.Text) + } + if len(result.ToolCalls) != 0 { + t.Fatalf("expected no tool calls, got %#v", result.ToolCalls) + } +} + func TestConsumeChatStreamErrorPayloadReturnsUpstreamError(t *testing.T) { result := &GenerateOutput{ToolCalls: make([]ToolCall, 0)} stream := bytes.NewBufferString("data: {\"error\":{\"message\":\"Param Incorrect\",\"code\":400}}\n\n") - err := consumeOpenAIGenerateStream(EndpointChatCompletions, AdapterOpenAIChatCompletions, stream, result, nil) + err := consumeOpenAIGenerateStream(EndpointChatCompletions, AdapterOpenAIChatCompletions, stream, result, nil, false) var upstreamErr *UpstreamError if !errors.As(err, &upstreamErr) { t.Fatalf("expected upstream error, got %T %v", err, err) @@ -754,7 +930,7 @@ func TestStreamDebugSnapshotPreservesRawSSEBody(t *testing.T) { rawStream := "event: response.error\ndata: {\"type\":\"response.error\",\"error\":{\"message\":\"Argument not supported: metadata\"}}\n\n" recorder := newUpstreamBodyRecorder(bytes.NewBufferString(rawStream)) - err := consumeOpenAIGenerateStream(EndpointResponses, AdapterXAIResponses, recorder, result, nil) + err := consumeOpenAIGenerateStream(EndpointResponses, AdapterXAIResponses, recorder, result, nil, false) req, reqErr := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", strings.NewReader(`{"model":"grok-4.3"}`)) if reqErr != nil { t.Fatal(reqErr) @@ -787,7 +963,7 @@ func TestResponsesStreamReasoningSummaryDeltaIsEmittedAndStored(t *testing.T) { reasoningText += event.Reasoning.Text } return nil - }) + }, false) if err != nil { t.Fatalf("consume stream: %v", err) } @@ -813,7 +989,7 @@ func TestResponsesCompletedReasoningSummaryIsEmittedWhenNoDeltaArrived(t *testin reasoningText += event.Reasoning.Text } return nil - }) + }, false) if err != nil { t.Fatalf("consume stream: %v", err) } @@ -842,7 +1018,7 @@ func TestResponsesStreamDoneEventsAreMergedWithoutDuplicateText(t *testing.T) { ``, }, "\n") - if err := consumeOpenAIGenerateStream(EndpointResponses, AdapterOpenAIResponses, strings.NewReader(rawStream), result, nil); err != nil { + if err := consumeOpenAIGenerateStream(EndpointResponses, AdapterOpenAIResponses, strings.NewReader(rawStream), result, nil, false); err != nil { t.Fatalf("consume stream: %v", err) } if result.Text != "Hello" { @@ -1136,7 +1312,7 @@ func TestResponsesOutputItemDoneCapturesServerSideToolCall(t *testing.T) { ``, }, "\n") - err := consumeOpenAIGenerateStream(EndpointResponses, AdapterOpenAIResponses, strings.NewReader(rawStream), result, nil) + err := consumeOpenAIGenerateStream(EndpointResponses, AdapterOpenAIResponses, strings.NewReader(rawStream), result, nil, false) if err != nil { t.Fatalf("consume stream: %v", err) } @@ -1168,7 +1344,7 @@ func TestResponsesStreamEmitsServerSideToolStatusEvents(t *testing.T) { statuses = append(statuses, event.ServerToolCall.Status) } return nil - }) + }, false) if err != nil { t.Fatalf("consume stream: %v", err) } @@ -1191,7 +1367,7 @@ func TestResponsesServerToolFinalItemReplacesStreamingPlaceholder(t *testing.T) ``, }, "\n") - if err := consumeOpenAIGenerateStream(EndpointResponses, AdapterOpenAIResponses, strings.NewReader(rawStream), result, nil); err != nil { + if err := consumeOpenAIGenerateStream(EndpointResponses, AdapterOpenAIResponses, strings.NewReader(rawStream), result, nil, false); err != nil { t.Fatalf("consume stream: %v", err) } if len(result.ServerToolCalls) != 1 { @@ -1218,7 +1394,7 @@ func TestResponsesStreamStatusEventCapturesNestedServerToolItem(t *testing.T) { streamed = &value } return nil - }) + }, false) if err != nil { t.Fatalf("consume stream: %v", err) } @@ -1253,7 +1429,7 @@ func TestResponsesStreamCapturesXSearchCustomToolInput(t *testing.T) { events = append(events, *event.ServerToolCall) } return nil - }) + }, false) if err != nil { t.Fatalf("consume stream: %v", err) } diff --git a/backend/internal/infra/observability/tracing/tracing.go b/backend/internal/infra/observability/tracing/tracing.go index a2e6332e..51049268 100644 --- a/backend/internal/infra/observability/tracing/tracing.go +++ b/backend/internal/infra/observability/tracing/tracing.go @@ -15,7 +15,7 @@ import ( "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" - semconv "go.opentelemetry.io/otel/semconv/v1.39.0" + semconv "go.opentelemetry.io/otel/semconv/v1.41.0" "go.opentelemetry.io/otel/trace" ) diff --git a/backend/internal/infra/persistence/models/prompt_preset.go b/backend/internal/infra/persistence/models/prompt_preset.go index e8a7dea3..53f48f42 100644 --- a/backend/internal/infra/persistence/models/prompt_preset.go +++ b/backend/internal/infra/persistence/models/prompt_preset.go @@ -5,9 +5,9 @@ type PromptPreset struct { ControlPlaneModel Scope string `gorm:"size:32;not null;default:'user';index:idx_prompt_presets_scope;uniqueIndex:idx_prompt_presets_scope_owner_trigger;comment:作用域(builtin/user)"` OwnerUserID uint `gorm:"not null;default:0;index:idx_prompt_presets_owner;uniqueIndex:idx_prompt_presets_scope_owner_trigger;comment:所属用户ID,内置提示词为0"` - Title string `gorm:"size:16;not null;default:'';comment:提示词标题"` - Trigger string `gorm:"size:16;not null;default:'';index:idx_prompt_presets_trigger;uniqueIndex:idx_prompt_presets_scope_owner_trigger;comment:slash触发词,不含斜杠"` - Description string `gorm:"size:64;not null;default:'';comment:提示词说明"` + Title string `gorm:"size:64;not null;default:'';comment:提示词标题"` + Trigger string `gorm:"size:64;not null;default:'';index:idx_prompt_presets_trigger;uniqueIndex:idx_prompt_presets_scope_owner_trigger;comment:slash触发词,不含斜杠"` + Description string `gorm:"size:256;not null;default:'';comment:提示词说明"` Content string `gorm:"type:text;not null;default:'';comment:提示词内容"` Enabled bool `gorm:"not null;default:true;index:idx_prompt_presets_enabled;comment:是否启用"` SortOrder int `gorm:"not null;default:0;index:idx_prompt_presets_sort_order;comment:排序值"` diff --git a/backend/internal/infra/persistence/models/skill.go b/backend/internal/infra/persistence/models/skill.go new file mode 100644 index 00000000..289511f4 --- /dev/null +++ b/backend/internal/infra/persistence/models/skill.go @@ -0,0 +1,21 @@ +package model + +// Skill 记录平台内置和用户自定义的 SKILL.md 能力包。 +type Skill struct { + ControlPlaneModel + Scope string `gorm:"size:32;not null;default:'user';index:idx_skills_scope;uniqueIndex:idx_skills_scope_owner_trigger;comment:作用域(builtin/user)"` + OwnerUserID uint `gorm:"not null;default:0;index:idx_skills_owner;uniqueIndex:idx_skills_scope_owner_trigger;comment:所属用户ID,内置技能为0"` + Title string `gorm:"size:64;not null;default:'';comment:技能标题"` + Trigger string `gorm:"size:64;not null;default:'';index:idx_skills_trigger;uniqueIndex:idx_skills_scope_owner_trigger;comment:slash触发词,不含斜杠"` + Description string `gorm:"size:256;not null;default:'';comment:技能说明"` + Markdown string `gorm:"type:text;not null;default:'';comment:SKILL.md内容"` + Enabled bool `gorm:"not null;default:true;index:idx_skills_enabled;comment:是否启用"` + SortOrder int `gorm:"not null;default:0;index:idx_skills_sort_order;comment:排序值"` + CreatedByUserID uint `gorm:"not null;default:0;index:idx_skills_created_by;comment:创建人ID"` + UpdatedByUserID uint `gorm:"not null;default:0;index:idx_skills_updated_by;comment:最后更新人ID"` +} + +// TableName 指定表名。 +func (Skill) TableName() string { + return "skills" +} diff --git a/backend/internal/infra/persistence/postgres/postgres.go b/backend/internal/infra/persistence/postgres/postgres.go index 252c51bb..c1a6d8ef 100644 --- a/backend/internal/infra/persistence/postgres/postgres.go +++ b/backend/internal/infra/persistence/postgres/postgres.go @@ -144,6 +144,7 @@ func migrate(db *gorm.DB, cfg config.Config) error { "system_announcements": "站点公告表", "announcement_user_states": "用户公告展示状态表", "prompt_presets": "内置与用户自定义预制提示词表", + "skills": "内置与用户自定义技能提示词表", "system_settings": "系统动态配置表", "user_settings": "用户个人偏好配置表", "file_chunks": "RAG文件分片表", diff --git a/backend/internal/infra/persistence/postgres/skill/repository.go b/backend/internal/infra/persistence/postgres/skill/repository.go new file mode 100644 index 00000000..21cc6381 --- /dev/null +++ b/backend/internal/infra/persistence/postgres/skill/repository.go @@ -0,0 +1,259 @@ +package skill + +import ( + "context" + "strings" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/dberror" + model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// Repo 封装技能数据访问。 +type Repo struct { + db *gorm.DB +} + +// NewRepo 创建技能仓储。 +func NewRepo(db *gorm.DB) *Repo { + return &Repo{db: db} +} + +// ListSkills 分页查询技能。 +func (r *Repo) ListSkills(ctx context.Context, filter repository.SkillListFilter, offset int, limit int) ([]domainskill.Skill, int64, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + items := make([]model.Skill, 0, limit) + var total int64 + query := r.db.WithContext(ctx).Model(&model.Skill{}) + query = applySkillFilter(query, filter) + + if err := query.Count(&total).Error; err != nil { + return nil, 0, translateError(err) + } + if err := query. + Order(skillOrderClause(filter)). + Offset(offset). + Limit(limit). + Find(&items).Error; err != nil { + return nil, 0, translateError(err) + } + + results := make([]domainskill.Skill, 0, len(items)) + for _, item := range items { + results = append(results, toDomain(item)) + } + return results, total, nil +} + +// GetSkill 按主键查询技能。 +func (r *Repo) GetSkill(ctx context.Context, id uint) (*domainskill.Skill, error) { + if id == 0 { + return nil, repository.ErrInvalidInput + } + var record model.Skill + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&record).Error; err != nil { + return nil, translateError(err) + } + result := toDomain(record) + return &result, nil +} + +// CreateSkill 创建技能。 +func (r *Repo) CreateSkill(ctx context.Context, item *domainskill.Skill) (*domainskill.Skill, error) { + if item == nil { + return nil, repository.ErrInvalidInput + } + record := model.Skill{ + Scope: strings.TrimSpace(item.Scope), + OwnerUserID: item.OwnerUserID, + Title: strings.TrimSpace(item.Title), + Trigger: strings.TrimSpace(item.Trigger), + Description: strings.TrimSpace(item.Description), + Markdown: strings.TrimSpace(item.Markdown), + Enabled: item.Enabled, + SortOrder: item.SortOrder, + CreatedByUserID: item.CreatedByUserID, + UpdatedByUserID: item.UpdatedByUserID, + } + var result domainskill.Skill + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if record.SortOrder <= 0 { + var maxSortOrder int + if err := tx.Model(&model.Skill{}). + Where("scope = ? AND owner_user_id = ?", record.Scope, record.OwnerUserID). + Select("COALESCE(MAX(sort_order), 0)"). + Scan(&maxSortOrder).Error; err != nil { + return translateError(err) + } + record.SortOrder = maxSortOrder + 1 + } + if err := tx.Create(&record).Error; err != nil { + return translateError(err) + } + result = toDomain(record) + return nil + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// PatchSkill 更新技能字段。 +func (r *Repo) PatchSkill(ctx context.Context, id uint, patch repository.SkillPatch) (*domainskill.Skill, error) { + if id == 0 { + return nil, repository.ErrInvalidInput + } + var result domainskill.Skill + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var record model.Skill + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", id). + First(&record).Error; err != nil { + return translateError(err) + } + + updates := map[string]interface{}{} + if patch.Title != nil { + updates["title"] = strings.TrimSpace(*patch.Title) + } + if patch.Trigger != nil { + updates["trigger"] = strings.TrimSpace(*patch.Trigger) + } + if patch.Description != nil { + updates["description"] = strings.TrimSpace(*patch.Description) + } + if patch.Markdown != nil { + updates["markdown"] = strings.TrimSpace(*patch.Markdown) + } + if patch.Enabled != nil { + updates["enabled"] = *patch.Enabled + } + if patch.SortOrder != nil { + updates["sort_order"] = *patch.SortOrder + } + if patch.UpdatedByUserIDSet { + updates["updated_by_user_id"] = patch.UpdatedByUserID + } + if len(updates) > 0 { + if err := tx.Model(&record).Updates(updates).Error; err != nil { + return translateError(err) + } + } + if err := tx.Where("id = ?", id).First(&record).Error; err != nil { + return translateError(err) + } + result = toDomain(record) + return nil + }) + if err != nil { + return nil, err + } + return &result, nil +} + +// DeleteSkill 删除技能。 +func (r *Repo) DeleteSkill(ctx context.Context, id uint) error { + if id == 0 { + return repository.ErrInvalidInput + } + result := r.db.WithContext(ctx).Delete(&model.Skill{}, id) + if result.Error != nil { + return translateError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +func applySkillFilter(query *gorm.DB, filter repository.SkillListFilter) *gorm.DB { + if filter.VisibleUserID != nil { + userID := *filter.VisibleUserID + query = query.Where( + "(scope = ? AND enabled = ?) OR (scope = ? AND owner_user_id = ? AND enabled = ?)", + domainskill.ScopeBuiltin, + true, + domainskill.ScopeUser, + userID, + true, + ) + } else { + if scope := strings.TrimSpace(filter.Scope); scope != "" { + query = query.Where("scope = ?", scope) + } + if filter.OwnerUserID != nil { + query = query.Where("owner_user_id = ?", *filter.OwnerUserID) + } + if filter.Enabled != nil { + query = query.Where("enabled = ?", *filter.Enabled) + } + } + if keyword := strings.TrimSpace(filter.Query); keyword != "" { + like := "%" + strings.ToLower(keyword) + "%" + if filter.SearchMarkdown { + query = query.Where( + "LOWER(title) LIKE ? OR LOWER(trigger) LIKE ? OR LOWER(description) LIKE ? OR LOWER(markdown) LIKE ?", + like, + like, + like, + like, + ) + } else { + query = query.Where( + "LOWER(title) LIKE ? OR LOWER(trigger) LIKE ? OR LOWER(description) LIKE ?", + like, + like, + like, + ) + } + } + return query +} + +func skillOrderClause(filter repository.SkillListFilter) string { + if filter.VisibleUserID != nil { + return "CASE WHEN scope = 'user' THEN 0 ELSE 1 END ASC, sort_order ASC, updated_at DESC, id DESC" + } + return "CASE WHEN enabled THEN 0 ELSE 1 END ASC, sort_order ASC, updated_at DESC, id DESC" +} + +func toDomain(item model.Skill) domainskill.Skill { + return domainskill.Skill{ + ID: item.ID, + Scope: item.Scope, + OwnerUserID: item.OwnerUserID, + Title: item.Title, + Trigger: item.Trigger, + Description: item.Description, + Markdown: item.Markdown, + Enabled: item.Enabled, + SortOrder: item.SortOrder, + CreatedByUserID: item.CreatedByUserID, + UpdatedByUserID: item.UpdatedByUserID, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func translateError(err error) error { + if err == nil { + return nil + } + if dberror.IsRecordNotFound(err) { + return repository.ErrNotFound + } + if dberror.IsUniqueConstraint(err) { + return repository.ErrDuplicate + } + return err +} diff --git a/backend/internal/infra/persistence/postgres/user/repository.go b/backend/internal/infra/persistence/postgres/user/repository.go index a7fb031d..1851aec3 100644 --- a/backend/internal/infra/persistence/postgres/user/repository.go +++ b/backend/internal/infra/persistence/postgres/user/repository.go @@ -786,6 +786,36 @@ func (r *Repo) UpdateLastLogin(ctx context.Context, userID uint) error { Error) } +// ListLatestSessionActivityByUserIDs 批量查询用户最近会话活跃时间。 +func (r *Repo) ListLatestSessionActivityByUserIDs(ctx context.Context, userIDs []uint) (map[uint]time.Time, error) { + results := make(map[uint]time.Time, len(userIDs)) + if len(userIDs) == 0 { + return results, nil + } + + sessions := make([]model.UserSession, 0, len(userIDs)) + latestSessionQuery := r.db.WithContext(ctx). + Model(&model.UserSession{}). + Select("identity_sessions.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY COALESCE(last_seen_at, issued_at) DESC, id DESC) AS row_number"). + Where("user_id IN ?", userIDs) + + if err := r.db.WithContext(ctx). + Table("(?) AS latest_sessions", latestSessionQuery). + Where("row_number = ?", 1). + Find(&sessions).Error; err != nil { + return nil, translateError(err) + } + + for _, session := range sessions { + if session.LastSeenAt != nil { + results[session.UserID] = *session.LastSeenAt + continue + } + results[session.UserID] = session.IssuedAt + } + return results, nil +} + // DeleteAccountHard 删除用户主记录及主要用户域数据。 func (r *Repo) DeleteAccountHard(ctx context.Context, userID uint) error { return translateError(r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { diff --git a/backend/internal/infra/persistence/schema/schema.go b/backend/internal/infra/persistence/schema/schema.go index 7b455220..f626f570 100644 --- a/backend/internal/infra/persistence/schema/schema.go +++ b/backend/internal/infra/persistence/schema/schema.go @@ -50,6 +50,7 @@ func Models() []interface{} { &model.Announcement{}, &model.AnnouncementUserState{}, &model.PromptPreset{}, + &model.Skill{}, &model.SystemSetting{}, &model.UserSetting{}, &model.FileChunk{}, @@ -84,14 +85,21 @@ func backfillUsageLedgerBillingAt(db *gorm.DB) error { // CleanupRemovedColumns drops columns that were removed from the Gorm models. func CleanupRemovedColumns(db *gorm.DB) error { - if !db.Migrator().HasTable(&model.PromptPreset{}) { + if err := dropColumns(db, &model.PromptPreset{}, []string{"use_count", "last_used_at", "category", "tags_json"}); err != nil { + return err + } + return dropColumns(db, &model.Skill{}, []string{"content", "sections_json"}) +} + +func dropColumns(db *gorm.DB, table interface{}, columns []string) error { + if !db.Migrator().HasTable(table) { return nil } - for _, column := range []string{"use_count", "last_used_at", "category", "tags_json"} { - if !db.Migrator().HasColumn(&model.PromptPreset{}, column) { + for _, column := range columns { + if !db.Migrator().HasColumn(table, column) { continue } - if err := db.Migrator().DropColumn(&model.PromptPreset{}, column); err != nil { + if err := db.Migrator().DropColumn(table, column); err != nil { return err } } diff --git a/backend/internal/repository/skill.go b/backend/internal/repository/skill.go new file mode 100644 index 00000000..ed8a4d3c --- /dev/null +++ b/backend/internal/repository/skill.go @@ -0,0 +1,38 @@ +package repository + +import ( + "context" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" +) + +// SkillRepository 定义技能持久化能力。 +type SkillRepository interface { + ListSkills(ctx context.Context, filter SkillListFilter, offset int, limit int) ([]domainskill.Skill, int64, error) + GetSkill(ctx context.Context, id uint) (*domainskill.Skill, error) + CreateSkill(ctx context.Context, item *domainskill.Skill) (*domainskill.Skill, error) + PatchSkill(ctx context.Context, id uint, patch SkillPatch) (*domainskill.Skill, error) + DeleteSkill(ctx context.Context, id uint) error +} + +// SkillListFilter 描述技能列表筛选条件。 +type SkillListFilter struct { + Query string + SearchMarkdown bool + Scope string + OwnerUserID *uint + Enabled *bool + VisibleUserID *uint +} + +// SkillPatch 描述可更新的技能字段。 +type SkillPatch struct { + Title *string + Trigger *string + Description *string + Markdown *string + Enabled *bool + SortOrder *int + UpdatedByUserIDSet bool + UpdatedByUserID uint +} diff --git a/backend/internal/repository/user.go b/backend/internal/repository/user.go index a2264f7a..560c5acf 100644 --- a/backend/internal/repository/user.go +++ b/backend/internal/repository/user.go @@ -232,6 +232,7 @@ type UserRepository interface { ResetPasswordByAdmin(ctx context.Context, userID uint, passwordHash string, mustResetPassword bool) error MarkBootstrapSuperAdminPasswordResetRequired(ctx context.Context, username string) error UpdateLastLogin(ctx context.Context, userID uint) error + ListLatestSessionActivityByUserIDs(ctx context.Context, userIDs []uint) (map[uint]time.Time, error) DeleteAccountHard(ctx context.Context, userID uint) error ListDistinctFileStoragePathsByUserID(ctx context.Context, userID uint) ([]string, error) RecordAuthEvent( diff --git a/backend/internal/shared/response/error_code.go b/backend/internal/shared/response/error_code.go index e70c7718..57c52e06 100644 --- a/backend/internal/shared/response/error_code.go +++ b/backend/internal/shared/response/error_code.go @@ -154,6 +154,7 @@ var exactErrorSpecs = map[string]errorSpec{ "message generation canceled": {Code: "conversation_run.canceled", Message: "message generation canceled"}, "too many files in one message": {Code: "message.too_many_files", Message: "too many files in one message"}, "too many selected tools": {Code: "message.too_many_selected_tools", Message: "too many selected tools"}, + "too many selected skills": {Code: "message.too_many_selected_skills", Message: "too many selected skills"}, "generation stream not found": {Code: "conversation_run.stream_not_found", Message: "generation stream not found"}, "image prompt is required": {Code: "media.image_prompt_required", Message: "image prompt is required"}, "image generation does not accept input images": {Code: "media.image_generation_rejects_inputs", Message: "image generation does not accept input images"}, diff --git a/backend/internal/transport/http/admin/dto.go b/backend/internal/transport/http/admin/dto.go index 9d5b9c17..ae629f9d 100644 --- a/backend/internal/transport/http/admin/dto.go +++ b/backend/internal/transport/http/admin/dto.go @@ -87,6 +87,7 @@ type UserResponse struct { TwoFactorRequired bool `json:"twoFactorRequired"` TwoFactorRecoveryCount int `json:"twoFactorRecoveryCount"` LastLoginAt *time.Time `json:"lastLoginAt"` + LastActiveAt *time.Time `json:"lastActiveAt"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` SubscriptionTier string `json:"subscriptionTier"` @@ -426,6 +427,7 @@ func toUserResponse(v userview.UserView) UserResponse { TwoFactorRequired: v.TwoFactorRequired, TwoFactorRecoveryCount: v.TwoFactorRecoveryCount, LastLoginAt: v.LastLoginAt, + LastActiveAt: v.LastActiveAt, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, SubscriptionTier: v.SubscriptionTier, diff --git a/backend/internal/transport/http/admin/handler.go b/backend/internal/transport/http/admin/handler.go index e0710be0..7dc90a42 100644 --- a/backend/internal/transport/http/admin/handler.go +++ b/backend/internal/transport/http/admin/handler.go @@ -213,7 +213,7 @@ func (h *Handler) PatchUser(c *gin.Context) { actorUserID := middleware.MustUserID(c) rawID := c.Param("id") - parsedID, err := strconv.ParseUint(rawID, 10, 64) + parsedID, err := strconv.ParseUint(rawID, 10, strconv.IntSize) if err != nil || parsedID == 0 { response.Error(c, http.StatusBadRequest, "invalid user id") return @@ -584,7 +584,7 @@ func parseOptionalUintQuery(c *gin.Context, key string) (uint, bool) { if raw == "" { return 0, true } - parsed, err := strconv.ParseUint(raw, 10, 64) + parsed, err := strconv.ParseUint(raw, 10, strconv.IntSize) if err != nil || parsed == 0 { response.Error(c, http.StatusBadRequest, "invalid "+key) return 0, false @@ -622,7 +622,7 @@ func (h *Handler) RevokeUserSessions(c *gin.Context) { actorUserID := middleware.MustUserID(c) rawID := c.Param("id") - parsedID, err := strconv.ParseUint(rawID, 10, 64) + parsedID, err := strconv.ParseUint(rawID, 10, strconv.IntSize) if err != nil || parsedID == 0 { response.Error(c, http.StatusBadRequest, "invalid user id") return @@ -670,7 +670,7 @@ func (h *Handler) UpdateUserStatus(c *gin.Context) { actorUserID := middleware.MustUserID(c) rawID := c.Param("id") - parsedID, err := strconv.ParseUint(rawID, 10, 64) + parsedID, err := strconv.ParseUint(rawID, 10, strconv.IntSize) if err != nil || parsedID == 0 { response.Error(c, http.StatusBadRequest, "invalid user id") return @@ -741,7 +741,7 @@ func (h *Handler) ResetUserPassword(c *gin.Context) { actorUserID := middleware.MustUserID(c) rawID := c.Param("id") - parsedID, err := strconv.ParseUint(rawID, 10, 64) + parsedID, err := strconv.ParseUint(rawID, 10, strconv.IntSize) if err != nil || parsedID == 0 { response.Error(c, http.StatusBadRequest, "invalid user id") return @@ -794,7 +794,7 @@ func (h *Handler) ResetUserPassword(c *gin.Context) { func (h *Handler) ResetUserTwoFactor(c *gin.Context) { actorUserID := middleware.MustUserID(c) rawID := c.Param("id") - parsedID, err := strconv.ParseUint(rawID, 10, 64) + parsedID, err := strconv.ParseUint(rawID, 10, strconv.IntSize) if err != nil || parsedID == 0 { response.Error(c, http.StatusBadRequest, "invalid user id") return @@ -843,7 +843,7 @@ func (h *Handler) DeleteUser(c *gin.Context) { actorUserID := middleware.MustUserID(c) rawID := c.Param("id") - parsedID, err := strconv.ParseUint(rawID, 10, 64) + parsedID, err := strconv.ParseUint(rawID, 10, strconv.IntSize) if err != nil || parsedID == 0 { response.Error(c, http.StatusBadRequest, "invalid user id") return @@ -897,7 +897,7 @@ func (h *Handler) DeleteUser(c *gin.Context) { func (h *Handler) ListUserAuthEvents(c *gin.Context) { var userID uint if raw := c.Query("user_id"); raw != "" { - parsedID, err := strconv.ParseUint(raw, 10, 64) + parsedID, err := strconv.ParseUint(raw, 10, strconv.IntSize) if err != nil || parsedID == 0 { response.Error(c, http.StatusBadRequest, "invalid user_id") return diff --git a/backend/internal/transport/http/admin/handler_security_test.go b/backend/internal/transport/http/admin/handler_security_test.go index 8fa4e3ce..47bfb3ed 100644 --- a/backend/internal/transport/http/admin/handler_security_test.go +++ b/backend/internal/transport/http/admin/handler_security_test.go @@ -55,6 +55,10 @@ func (s *handlerUserServiceFake) ListUsers(context.Context, int, int) ([]domainu return nil, 0, nil } +func (s *handlerUserServiceFake) ListLatestSessionActivityByUserIDs(context.Context, []uint) (map[uint]time.Time, error) { + return map[uint]time.Time{}, nil +} + func (s *handlerUserServiceFake) CountSuperAdmins(context.Context) (int64, error) { var count int64 for _, item := range s.users { diff --git a/backend/internal/transport/http/announcement/handler.go b/backend/internal/transport/http/announcement/handler.go index 0c5e6e6a..a51b0571 100644 --- a/backend/internal/transport/http/announcement/handler.go +++ b/backend/internal/transport/http/announcement/handler.go @@ -56,7 +56,7 @@ func (h *Handler) ListAnnouncements(c *gin.Context) { // @Failure 500 {object} ErrorDoc // @Router /announcements/{id}/dismiss-today [post] func (h *Handler) DismissAnnouncementToday(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + id, err := strconv.ParseUint(c.Param("id"), 10, strconv.IntSize) if err != nil || id == 0 { response.Error(c, http.StatusBadRequest, "invalid announcement id") return @@ -91,7 +91,7 @@ func (h *Handler) DismissAnnouncementToday(c *gin.Context) { // @Failure 500 {object} ErrorDoc // @Router /announcements/{id}/close [post] func (h *Handler) CloseAnnouncement(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + id, err := strconv.ParseUint(c.Param("id"), 10, strconv.IntSize) if err != nil || id == 0 { response.Error(c, http.StatusBadRequest, "invalid announcement id") return @@ -188,7 +188,7 @@ func (h *Handler) CreateAnnouncement(c *gin.Context) { // @Failure 500 {object} ErrorDoc // @Router /admin/announcements/{id} [patch] func (h *Handler) PatchAnnouncement(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + id, err := strconv.ParseUint(c.Param("id"), 10, strconv.IntSize) if err != nil || id == 0 { response.Error(c, http.StatusBadRequest, "invalid announcement id") return @@ -231,7 +231,7 @@ func (h *Handler) PatchAnnouncement(c *gin.Context) { // @Failure 500 {object} ErrorDoc // @Router /admin/announcements/{id} [delete] func (h *Handler) DeleteAnnouncement(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + id, err := strconv.ParseUint(c.Param("id"), 10, strconv.IntSize) if err != nil || id == 0 { response.Error(c, http.StatusBadRequest, "invalid announcement id") return diff --git a/backend/internal/transport/http/auth/dto.go b/backend/internal/transport/http/auth/dto.go index e90d76f5..30e63326 100644 --- a/backend/internal/transport/http/auth/dto.go +++ b/backend/internal/transport/http/auth/dto.go @@ -321,6 +321,7 @@ type UserResponse struct { TwoFactorRequired bool `json:"twoFactorRequired"` TwoFactorRecoveryCount int `json:"twoFactorRecoveryCount"` LastLoginAt *time.Time `json:"lastLoginAt"` + LastActiveAt *time.Time `json:"lastActiveAt"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` SubscriptionTier string `json:"subscriptionTier"` @@ -521,6 +522,7 @@ func toUserResponse(v userview.UserView) UserResponse { TwoFactorRequired: v.TwoFactorRequired, TwoFactorRecoveryCount: v.TwoFactorRecoveryCount, LastLoginAt: v.LastLoginAt, + LastActiveAt: v.LastActiveAt, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, SubscriptionTier: v.SubscriptionTier, diff --git a/backend/internal/transport/http/auth/handler.go b/backend/internal/transport/http/auth/handler.go index 5938881e..2742fffc 100644 --- a/backend/internal/transport/http/auth/handler.go +++ b/backend/internal/transport/http/auth/handler.go @@ -489,7 +489,7 @@ func (h *Handler) DeleteCurrentUserIdentity(c *gin.Context) { return } rawID := c.Param("identity_id") - parsedID, err := strconv.ParseUint(rawID, 10, 64) + parsedID, err := strconv.ParseUint(rawID, 10, strconv.IntSize) if err != nil || parsedID == 0 { response.Error(c, http.StatusBadRequest, "invalid identity id") return diff --git a/backend/internal/transport/http/billing/handler.go b/backend/internal/transport/http/billing/handler.go index ca24c2e3..8e707a57 100644 --- a/backend/internal/transport/http/billing/handler.go +++ b/backend/internal/transport/http/billing/handler.go @@ -253,7 +253,7 @@ func (h *Handler) GetBillingAccount(c *gin.Context) { // @Failure 500 {object} ErrorDoc // @Router /admin/billing/accounts/{user_id}/balance [patch] func (h *Handler) UpdateBillingAccountBalance(c *gin.Context) { - targetUserID, err := strconv.ParseUint(c.Param("user_id"), 10, 64) + targetUserID, err := strconv.ParseUint(c.Param("user_id"), 10, strconv.IntSize) if err != nil || targetUserID == 0 { response.Error(c, http.StatusBadRequest, "invalid user id") return @@ -414,7 +414,7 @@ func writeRedemptionCodeError(c *gin.Context, err error) { // @Failure 500 {object} ErrorDoc // @Router /admin/billing/redemption-codes/{id}/code [get] func (h *Handler) RevealRedemptionCode(c *gin.Context) { - codeID, err := strconv.ParseUint(c.Param("id"), 10, 64) + codeID, err := strconv.ParseUint(c.Param("id"), 10, strconv.IntSize) if err != nil || codeID == 0 { response.Error(c, http.StatusBadRequest, "invalid redemption code id") return @@ -455,7 +455,7 @@ func (h *Handler) RevealRedemptionCode(c *gin.Context) { // @Failure 500 {object} ErrorDoc // @Router /admin/billing/redemption-codes/{id} [patch] func (h *Handler) PatchRedemptionCode(c *gin.Context) { - codeID, err := strconv.ParseUint(c.Param("id"), 10, 64) + codeID, err := strconv.ParseUint(c.Param("id"), 10, strconv.IntSize) if err != nil || codeID == 0 { response.Error(c, http.StatusBadRequest, "invalid redemption code id") return @@ -508,7 +508,7 @@ func (h *Handler) PatchRedemptionCode(c *gin.Context) { // @Failure 500 {object} ErrorDoc // @Router /admin/billing/redemption-codes/{id} [delete] func (h *Handler) DeleteRedemptionCode(c *gin.Context) { - codeID, err := strconv.ParseUint(c.Param("id"), 10, 64) + codeID, err := strconv.ParseUint(c.Param("id"), 10, strconv.IntSize) if err != nil || codeID == 0 { response.Error(c, http.StatusBadRequest, "invalid redemption code id") return @@ -648,7 +648,7 @@ func (h *Handler) GetBillingOverview(c *gin.Context) { // @Failure 500 {object} ErrorDoc // @Router /admin/billing/plans/{id} [patch] func (h *Handler) UpdatePlan(c *gin.Context) { - planID, err := strconv.ParseUint(c.Param("id"), 10, 64) + planID, err := strconv.ParseUint(c.Param("id"), 10, strconv.IntSize) if err != nil || planID == 0 { response.Error(c, http.StatusBadRequest, "invalid plan id") return diff --git a/backend/internal/transport/http/channel/handler.go b/backend/internal/transport/http/channel/handler.go index f17b612e..4bed67fc 100644 --- a/backend/internal/transport/http/channel/handler.go +++ b/backend/internal/transport/http/channel/handler.go @@ -1469,7 +1469,7 @@ func pageParams(c *gin.Context) (int, int) { } func uintParam(c *gin.Context, key string) (uint, error) { - value, err := strconv.ParseUint(c.Param(key), 10, 64) + value, err := strconv.ParseUint(c.Param(key), 10, strconv.IntSize) if err != nil { return 0, err } diff --git a/backend/internal/transport/http/conversation/dto_request.go b/backend/internal/transport/http/conversation/dto_request.go index 9e625aac..2fcc77d7 100644 --- a/backend/internal/transport/http/conversation/dto_request.go +++ b/backend/internal/transport/http/conversation/dto_request.go @@ -87,6 +87,7 @@ type SendMessageRequest struct { ClientRunID string `json:"clientRunID" binding:"omitempty,max=64"` FileIDs []string `json:"fileIDs" binding:"max=20"` SelectedToolIDs []uint `json:"selectedToolIDs" binding:"max=128"` + SkillIDs []uint `json:"skillIDs" binding:"max=128"` HTMLVisualPromptEnabled bool `json:"htmlVisualPrompt"` HTMLVisualColorMode string `json:"htmlVisualColorMode" binding:"omitempty,oneof=light dark"` ParentMessagePublicID string `json:"parentMessagePublicID" binding:"omitempty,max=32"` diff --git a/backend/internal/transport/http/conversation/handler.go b/backend/internal/transport/http/conversation/handler.go index fff8609e..baa52c5a 100644 --- a/backend/internal/transport/http/conversation/handler.go +++ b/backend/internal/transport/http/conversation/handler.go @@ -129,6 +129,15 @@ func mapStreamError(err error) streamError { case errors.Is(err, appconversation.ErrTooManySelectedTools): status = http.StatusBadRequest message = "too many selected tools" + case errors.Is(err, appconversation.ErrTooManySelectedSkills): + status = http.StatusBadRequest + message = "too many selected skills" + case errors.Is(err, appconversation.ErrSkillNotFound): + status = http.StatusNotFound + message = "skill not found" + case errors.Is(err, appconversation.ErrInvalidSkillUse): + status = http.StatusBadRequest + message = "invalid skill use" case errors.Is(err, appconversation.ErrFileProcessingNotReady): status = http.StatusBadRequest message = "file processing not ready" diff --git a/backend/internal/transport/http/conversation/handler_context_artifact.go b/backend/internal/transport/http/conversation/handler_context_artifact.go index a2b616b4..22c3546d 100644 --- a/backend/internal/transport/http/conversation/handler_context_artifact.go +++ b/backend/internal/transport/http/conversation/handler_context_artifact.go @@ -31,7 +31,7 @@ func (h *Handler) GetContextArtifact(c *gin.Context) { response.Error(c, http.StatusBadRequest, "invalid context artifact id") return } - parsedID, err := strconv.ParseUint(rawID, 10, 64) + parsedID, err := strconv.ParseUint(rawID, 10, strconv.IntSize) if err != nil || parsedID == 0 { response.Error(c, http.StatusBadRequest, "invalid context artifact id") return diff --git a/backend/internal/transport/http/conversation/handler_message_query.go b/backend/internal/transport/http/conversation/handler_message_query.go index 3617cbc7..967c0fb5 100644 --- a/backend/internal/transport/http/conversation/handler_message_query.go +++ b/backend/internal/transport/http/conversation/handler_message_query.go @@ -165,7 +165,7 @@ func (h *Handler) ListMessages(c *gin.Context) { page, pageSize := messagePageParams(c) var beforeID uint if rawBeforeID := strings.TrimSpace(c.Query("before_id")); rawBeforeID != "" { - parsed, parseErr := strconv.ParseUint(rawBeforeID, 10, 64) + parsed, parseErr := strconv.ParseUint(rawBeforeID, 10, strconv.IntSize) if parseErr != nil || parsed == 0 { response.Error(c, http.StatusBadRequest, "invalid before message id") return diff --git a/backend/internal/transport/http/conversation/handler_message_send.go b/backend/internal/transport/http/conversation/handler_message_send.go index 40c01e10..4790c924 100644 --- a/backend/internal/transport/http/conversation/handler_message_send.go +++ b/backend/internal/transport/http/conversation/handler_message_send.go @@ -93,6 +93,7 @@ func (h *Handler) parseSendMessageInput(c *gin.Context) (appconversation.SendMes ClientRunID: req.ClientRunID, FileIDs: req.FileIDs, SelectedToolIDs: req.SelectedToolIDs, + SkillIDs: req.SkillIDs, HTMLVisualPromptEnabled: req.HTMLVisualPromptEnabled, HTMLVisualColorMode: req.HTMLVisualColorMode, ParentMessagePublicID: req.ParentMessagePublicID, @@ -286,6 +287,12 @@ func handleSendMessageError(c *gin.Context, err error) { response.Error(c, http.StatusBadRequest, "too many files in one message") case errors.Is(err, appconversation.ErrTooManySelectedTools): response.Error(c, http.StatusBadRequest, "too many selected tools") + case errors.Is(err, appconversation.ErrTooManySelectedSkills): + response.Error(c, http.StatusBadRequest, "too many selected skills") + case errors.Is(err, appconversation.ErrSkillNotFound): + response.Error(c, http.StatusNotFound, "skill not found") + case errors.Is(err, appconversation.ErrInvalidSkillUse): + response.Error(c, http.StatusBadRequest, "invalid skill use") case errors.Is(err, appconversation.ErrInvalidMessageBranch): response.Error(c, http.StatusBadRequest, "invalid message branch") case errors.Is(err, appconversation.ErrFileProcessingNotReady): diff --git a/backend/internal/transport/http/mcp/handler.go b/backend/internal/transport/http/mcp/handler.go index 6ae66d05..822bda42 100644 --- a/backend/internal/transport/http/mcp/handler.go +++ b/backend/internal/transport/http/mcp/handler.go @@ -186,7 +186,7 @@ func (h *Handler) UpdateServerToolsStatus(c *gin.Context) { func parseIDParam(c *gin.Context, key string, resource string) (uint, bool) { raw := c.Param(key) - parsed, err := strconv.ParseUint(raw, 10, 64) + parsed, err := strconv.ParseUint(raw, 10, strconv.IntSize) if err != nil || parsed == 0 { response.Error(c, http.StatusBadRequest, "invalid "+resource+" id") return 0, false diff --git a/backend/internal/transport/http/promptpreset/dto.go b/backend/internal/transport/http/promptpreset/dto.go index 41c539d8..2c2b4f42 100644 --- a/backend/internal/transport/http/promptpreset/dto.go +++ b/backend/internal/transport/http/promptpreset/dto.go @@ -34,20 +34,20 @@ type PromptPresetDeleteDataResponse struct { // WritePromptPresetRequest 表示创建预制提示词请求。 type WritePromptPresetRequest struct { - Title string `json:"title" binding:"required,max=16"` - Trigger string `json:"trigger" binding:"required,max=16"` - Description string `json:"description" binding:"max=64"` - Content string `json:"content" binding:"required,max=16384"` + Title string `json:"title" binding:"required,max=64"` + Trigger string `json:"trigger" binding:"required,max=64"` + Description string `json:"description" binding:"max=256"` + Content string `json:"content" binding:"required,max=10000"` Enabled bool `json:"enabled"` SortOrder int `json:"sortOrder"` } // PatchPromptPresetRequest 表示更新预制提示词请求。 type PatchPromptPresetRequest struct { - Title *string `json:"title" binding:"omitempty,max=16"` - Trigger *string `json:"trigger" binding:"omitempty,max=16"` - Description *string `json:"description" binding:"omitempty,max=64"` - Content *string `json:"content" binding:"omitempty,max=16384"` + Title *string `json:"title" binding:"omitempty,max=64"` + Trigger *string `json:"trigger" binding:"omitempty,max=64"` + Description *string `json:"description" binding:"omitempty,max=256"` + Content *string `json:"content" binding:"omitempty,max=10000"` Enabled *bool `json:"enabled"` SortOrder *int `json:"sortOrder"` } diff --git a/backend/internal/transport/http/promptpreset/handler.go b/backend/internal/transport/http/promptpreset/handler.go index 83183c30..e70a5844 100644 --- a/backend/internal/transport/http/promptpreset/handler.go +++ b/backend/internal/transport/http/promptpreset/handler.go @@ -296,7 +296,7 @@ func patchInputFromRequest(req PatchPromptPresetRequest) apppromptpreset.PatchIn } func idParam(c *gin.Context) (uint, bool) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) + id, err := strconv.ParseUint(c.Param("id"), 10, strconv.IntSize) if err != nil || id == 0 { response.Error(c, http.StatusBadRequest, "invalid prompt preset id") return 0, false diff --git a/backend/internal/transport/http/server.go b/backend/internal/transport/http/server.go index 41b6694d..aa057e99 100644 --- a/backend/internal/transport/http/server.go +++ b/backend/internal/transport/http/server.go @@ -25,6 +25,7 @@ import ( "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/middleware" promptpresethttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/promptpreset" settingshttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/settings" + skillhttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/skill" userhttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/user" usersettingshttp "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/usersettings" "github.com/gin-gonic/gin" @@ -59,6 +60,7 @@ type Modules struct { Admin *adminhttp.Module Announcement *announcementhttp.Module PromptPreset *promptpresethttp.Module + Skill *skillhttp.Module Settings *settingshttp.Module User *userhttp.Module UserSettings *usersettingshttp.Module @@ -158,6 +160,9 @@ func NewEngine(cfg *config.Runtime, log *zap.Logger, modules Modules, hc HealthC if modules.PromptPreset != nil { modules.PromptPreset.RegisterRoutes(authRequired) } + if modules.Skill != nil { + modules.Skill.RegisterRoutes(authRequired) + } if modules.UserSettings != nil { modules.UserSettings.RegisterRoutes(authRequired) } @@ -167,7 +172,7 @@ func NewEngine(cfg *config.Runtime, log *zap.Logger, modules Modules, hc HealthC if modules.User != nil { modules.User.RegisterRoutes(authRequired) } - if modules.Admin != nil || modules.Auth != nil || modules.Billing != nil || modules.Channel != nil || modules.MCP != nil || modules.Settings != nil || modules.Announcement != nil || modules.PromptPreset != nil { + if modules.Admin != nil || modules.Auth != nil || modules.Billing != nil || modules.Channel != nil || modules.MCP != nil || modules.Settings != nil || modules.Announcement != nil || modules.PromptPreset != nil || modules.Skill != nil { adminGroup := authRequired.Group("/admin") adminGroup.Use(middleware.AdminOnly()) if modules.Auth != nil { @@ -194,6 +199,9 @@ func NewEngine(cfg *config.Runtime, log *zap.Logger, modules Modules, hc HealthC if modules.PromptPreset != nil { modules.PromptPreset.RegisterAdminRoutes(adminGroup) } + if modules.Skill != nil { + modules.Skill.RegisterAdminRoutes(adminGroup) + } } if modules.StartupLog != nil { diff --git a/backend/internal/transport/http/skill/dto.go b/backend/internal/transport/http/skill/dto.go new file mode 100644 index 00000000..b2257bc0 --- /dev/null +++ b/backend/internal/transport/http/skill/dto.go @@ -0,0 +1,148 @@ +package skill + +import ( + "time" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" +) + +// SkillResponse 表示技能响应。 +type SkillResponse struct { + ID uint `json:"id"` + Scope string `json:"scope"` + Title string `json:"title"` + Trigger string `json:"trigger"` + Description string `json:"description"` + Markdown string `json:"markdown"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` + CreatedByUserID uint `json:"createdByUserID"` + UpdatedByUserID uint `json:"updatedByUserID"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// SkillSummaryResponse 表示技能发现列表响应,不包含 SKILL.md 内容。 +type SkillSummaryResponse struct { + ID uint `json:"id"` + Scope string `json:"scope"` + Title string `json:"title"` + Trigger string `json:"trigger"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// SkillDataResponse 包裹单条技能响应。 +type SkillDataResponse struct { + Skill SkillResponse `json:"skill"` +} + +// SkillDeleteDataResponse 表示删除响应。 +type SkillDeleteDataResponse struct { + Deleted bool `json:"deleted"` +} + +// WriteSkillRequest 表示创建技能请求。 +type WriteSkillRequest struct { + Title string `json:"title" binding:"required,max=64"` + Trigger string `json:"trigger" binding:"required,max=64"` + Description string `json:"description" binding:"max=256"` + Markdown string `json:"markdown" binding:"required,max=10000"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` +} + +// PatchSkillRequest 表示更新技能请求。 +type PatchSkillRequest struct { + Title *string `json:"title" binding:"omitempty,max=64"` + Trigger *string `json:"trigger" binding:"omitempty,max=64"` + Description *string `json:"description" binding:"omitempty,max=256"` + Markdown *string `json:"markdown" binding:"omitempty,max=10000"` + Enabled *bool `json:"enabled"` + SortOrder *int `json:"sortOrder"` +} + +// SkillSummaryPageResponseDoc 用于 Swagger 展示技能发现分页响应。 +type SkillSummaryPageResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data struct { + Total int64 `json:"total"` + Results []SkillSummaryResponse `json:"results"` + } `json:"data"` +} + +// SkillPageResponseDoc 用于 Swagger 展示完整分页响应。 +type SkillPageResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data struct { + Total int64 `json:"total"` + Results []SkillResponse `json:"results"` + } `json:"data"` +} + +// SkillResponseDoc 用于 Swagger 展示单条响应。 +type SkillResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data SkillDataResponse `json:"data"` +} + +// SkillDeleteResponseDoc 用于 Swagger 展示删除响应。 +type SkillDeleteResponseDoc struct { + ErrorMsg string `json:"errorMsg"` + Data SkillDeleteDataResponse `json:"data"` +} + +// ErrorDoc 表示错误响应。 +type ErrorDoc struct { + ErrorMsg string `json:"errorMsg"` +} + +func toSkillSummaryResponses(items []domainskill.Skill) []SkillSummaryResponse { + results := make([]SkillSummaryResponse, 0, len(items)) + for _, item := range items { + results = append(results, toSkillSummaryResponse(item)) + } + return results +} + +func toSkillSummaryResponse(item domainskill.Skill) SkillSummaryResponse { + return SkillSummaryResponse{ + ID: item.ID, + Scope: item.Scope, + Title: item.Title, + Trigger: item.Trigger, + Description: item.Description, + Enabled: item.Enabled, + SortOrder: item.SortOrder, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func toSkillResponses(items []domainskill.Skill) []SkillResponse { + results := make([]SkillResponse, 0, len(items)) + for _, item := range items { + results = append(results, toSkillResponse(item)) + } + return results +} + +func toSkillResponse(item domainskill.Skill) SkillResponse { + return SkillResponse{ + ID: item.ID, + Scope: item.Scope, + Title: item.Title, + Trigger: item.Trigger, + Description: item.Description, + Markdown: item.Markdown, + Enabled: item.Enabled, + SortOrder: item.SortOrder, + CreatedByUserID: item.CreatedByUserID, + UpdatedByUserID: item.UpdatedByUserID, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} diff --git a/backend/internal/transport/http/skill/dto_test.go b/backend/internal/transport/http/skill/dto_test.go new file mode 100644 index 00000000..918b2f6c --- /dev/null +++ b/backend/internal/transport/http/skill/dto_test.go @@ -0,0 +1,46 @@ +package skill + +import ( + "encoding/json" + "strings" + "testing" + + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" +) + +func TestSkillSummaryResponseDoesNotExposeMarkdown(t *testing.T) { + payload, err := json.Marshal(toSkillSummaryResponse(domainskill.Skill{ + ID: 1, + Scope: domainskill.ScopeBuiltin, + Title: "Review", + Trigger: "review", + Description: "Review code", + Markdown: "private SKILL.md", + Enabled: true, + })) + if err != nil { + t.Fatalf("marshal summary response: %v", err) + } + text := string(payload) + if strings.Contains(text, "markdown") || strings.Contains(text, "private SKILL.md") { + t.Fatalf("summary response exposed markdown: %s", text) + } +} + +func TestSkillResponseExposesMarkdownForDetail(t *testing.T) { + payload, err := json.Marshal(toSkillResponse(domainskill.Skill{ + ID: 1, + Scope: domainskill.ScopeBuiltin, + Title: "Review", + Trigger: "review", + Markdown: "private SKILL.md", + Enabled: true, + })) + if err != nil { + t.Fatalf("marshal detail response: %v", err) + } + text := string(payload) + if !strings.Contains(text, `"markdown":"private SKILL.md"`) { + t.Fatalf("detail response did not expose markdown: %s", text) + } +} diff --git a/backend/internal/transport/http/skill/handler.go b/backend/internal/transport/http/skill/handler.go new file mode 100644 index 00000000..34172413 --- /dev/null +++ b/backend/internal/transport/http/skill/handler.go @@ -0,0 +1,390 @@ +package skill + +import ( + "errors" + "net/http" + "strconv" + + appskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/shared/response" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/middleware" + "github.com/gin-gonic/gin" +) + +// Handler 封装技能 HTTP 处理。 +type Handler struct { + service *appskill.Service +} + +// NewHandler 创建技能处理器。 +func NewHandler(service *appskill.Service) *Handler { + return &Handler{service: service} +} + +// ListVisibleSkills godoc +// @Summary 查询当前用户可用技能 +// @Description 返回管理员内置和当前用户自定义的已启用技能摘要,用于会话按需选择 Skill 上下文 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param q query string false "搜索关键词" +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} SkillSummaryPageResponseDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills [get] +func (h *Handler) ListVisibleSkills(c *gin.Context) { + page, pageSize := pageParams(c) + items, total, err := h.service.ListVisible(c.Request.Context(), middleware.MustUserID(c), appskill.ListInput{ + Query: c.Query("q"), + Page: page, + PageSize: pageSize, + }) + if err != nil { + writeSkillError(c, err) + return + } + response.SuccessPage(c, total, toSkillSummaryResponses(items)) +} + +// GetVisibleSkill godoc +// @Summary 查询当前用户可用技能详情 +// @Description 按需返回单个可用 Skill 的完整 SKILL.md 内容,用于用户查看详情 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "技能ID" +// @Success 200 {object} SkillResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills/{id} [get] +func (h *Handler) GetVisibleSkill(c *gin.Context) { + id, ok := idParam(c) + if !ok { + return + } + item, err := h.service.ResolveAvailable(c.Request.Context(), middleware.MustUserID(c), id) + if err != nil { + writeSkillError(c, err) + return + } + response.Success(c, SkillDataResponse{Skill: toSkillResponse(*item)}) +} + +// ListMySkills godoc +// @Summary 查询我的自定义技能 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param q query string false "搜索关键词" +// @Param enabled query bool false "是否启用" +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} SkillPageResponseDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills/mine [get] +func (h *Handler) ListMySkills(c *gin.Context) { + page, pageSize := pageParams(c) + items, total, err := h.service.ListMine(c.Request.Context(), middleware.MustUserID(c), appskill.ListInput{ + Query: c.Query("q"), + Enabled: boolQuery(c, "enabled"), + Page: page, + PageSize: pageSize, + }) + if err != nil { + writeSkillError(c, err) + return + } + response.SuccessPage(c, total, toSkillResponses(items)) +} + +// CreateMySkill godoc +// @Summary 创建我的自定义技能 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body WriteSkillRequest true "技能配置" +// @Success 200 {object} SkillResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills/mine [post] +func (h *Handler) CreateMySkill(c *gin.Context) { + var req WriteSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.CreateUser(c.Request.Context(), middleware.MustUserID(c), writeInputFromRequest(req)) + if err != nil { + writeSkillError(c, err) + return + } + response.Success(c, SkillDataResponse{Skill: toSkillResponse(*item)}) +} + +// PatchMySkill godoc +// @Summary 更新我的自定义技能 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "技能ID" +// @Param body body PatchSkillRequest true "更新字段" +// @Success 200 {object} SkillResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills/mine/{id} [patch] +func (h *Handler) PatchMySkill(c *gin.Context) { + id, ok := idParam(c) + if !ok { + return + } + var req PatchSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.UpdateUser(c.Request.Context(), middleware.MustUserID(c), id, patchInputFromRequest(req)) + if err != nil { + writeSkillError(c, err) + return + } + response.Success(c, SkillDataResponse{Skill: toSkillResponse(*item)}) +} + +// DeleteMySkill godoc +// @Summary 删除我的自定义技能 +// @Tags skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "技能ID" +// @Success 200 {object} SkillDeleteResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /skills/mine/{id} [delete] +func (h *Handler) DeleteMySkill(c *gin.Context) { + id, ok := idParam(c) + if !ok { + return + } + if err := h.service.DeleteUser(c.Request.Context(), middleware.MustUserID(c), id); err != nil { + writeSkillError(c, err) + return + } + response.Success(c, SkillDeleteDataResponse{Deleted: true}) +} + +// ListAdminSkills godoc +// @Summary 管理员查询内置技能 +// @Tags admin-skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param q query string false "搜索关键词" +// @Param enabled query bool false "是否启用" +// @Param page query int false "页码" +// @Param page_size query int false "每页数量" +// @Success 200 {object} SkillPageResponseDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/skills [get] +func (h *Handler) ListAdminSkills(c *gin.Context) { + page, pageSize := pageParams(c) + items, total, err := h.service.ListAdminBuiltin(c.Request.Context(), appskill.ListInput{ + Query: c.Query("q"), + Enabled: boolQuery(c, "enabled"), + Page: page, + PageSize: pageSize, + }) + if err != nil { + writeSkillError(c, err) + return + } + response.SuccessPage(c, total, toSkillResponses(items)) +} + +// CreateAdminSkill godoc +// @Summary 管理员创建内置技能 +// @Tags admin-skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body WriteSkillRequest true "技能配置" +// @Success 200 {object} SkillResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/skills [post] +func (h *Handler) CreateAdminSkill(c *gin.Context) { + var req WriteSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + userID := middleware.MustUserID(c) + item, err := h.service.CreateBuiltin(c.Request.Context(), userID, writeInputFromRequest(req)) + if err != nil { + writeSkillError(c, err) + return + } + h.service.RecordAudit(c.Request.Context(), auditInput(c, "skill.create_builtin", item.ID, map[string]interface{}{"trigger": item.Trigger})) + response.Success(c, SkillDataResponse{Skill: toSkillResponse(*item)}) +} + +// PatchAdminSkill godoc +// @Summary 管理员更新内置技能 +// @Tags admin-skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "技能ID" +// @Param body body PatchSkillRequest true "更新字段" +// @Success 200 {object} SkillResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 409 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/skills/{id} [patch] +func (h *Handler) PatchAdminSkill(c *gin.Context) { + id, ok := idParam(c) + if !ok { + return + } + var req PatchSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.InvalidRequestBody(c, err) + return + } + item, err := h.service.UpdateBuiltin(c.Request.Context(), middleware.MustUserID(c), id, patchInputFromRequest(req)) + if err != nil { + writeSkillError(c, err) + return + } + h.service.RecordAudit(c.Request.Context(), auditInput(c, "skill.update_builtin", item.ID, map[string]interface{}{"trigger": item.Trigger})) + response.Success(c, SkillDataResponse{Skill: toSkillResponse(*item)}) +} + +// DeleteAdminSkill godoc +// @Summary 管理员删除内置技能 +// @Tags admin-skills +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "技能ID" +// @Success 200 {object} SkillDeleteResponseDoc +// @Failure 400 {object} ErrorDoc +// @Failure 404 {object} ErrorDoc +// @Failure 500 {object} ErrorDoc +// @Router /admin/skills/{id} [delete] +func (h *Handler) DeleteAdminSkill(c *gin.Context) { + id, ok := idParam(c) + if !ok { + return + } + if err := h.service.DeleteBuiltin(c.Request.Context(), middleware.MustUserID(c), id); err != nil { + writeSkillError(c, err) + return + } + h.service.RecordAudit(c.Request.Context(), auditInput(c, "skill.delete_builtin", id, nil)) + response.Success(c, SkillDeleteDataResponse{Deleted: true}) +} + +func writeInputFromRequest(req WriteSkillRequest) appskill.WriteInput { + return appskill.WriteInput{ + Title: req.Title, + Trigger: req.Trigger, + Description: req.Description, + Markdown: req.Markdown, + Enabled: req.Enabled, + SortOrder: req.SortOrder, + } +} + +func patchInputFromRequest(req PatchSkillRequest) appskill.PatchInput { + return appskill.PatchInput{ + Title: req.Title, + Trigger: req.Trigger, + Description: req.Description, + Markdown: req.Markdown, + Enabled: req.Enabled, + SortOrder: req.SortOrder, + } +} + +func idParam(c *gin.Context) (uint, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, strconv.IntSize) + if err != nil || id == 0 { + response.Error(c, http.StatusBadRequest, "invalid skill id") + return 0, false + } + return uint(id), true +} + +func boolQuery(c *gin.Context, key string) *bool { + raw := c.Query(key) + if raw == "" { + return nil + } + parsed, err := strconv.ParseBool(raw) + if err != nil { + return nil + } + return &parsed +} + +func pageParams(c *gin.Context) (int, int) { + page := 1 + pageSize := 20 + const maxPageSize = 100 + if raw := c.Query("page"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { + page = parsed + } + } + if raw := c.Query("page_size"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { + pageSize = parsed + } + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return page, pageSize +} + +func auditInput(c *gin.Context, action string, resourceID uint, detail interface{}) appskill.AuditInput { + return appskill.AuditInput{ + UserID: middleware.MustUserID(c), + RequestID: middleware.MustRequestID(c), + Action: action, + ResourceID: strconv.FormatUint(uint64(resourceID), 10), + ClientIP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Detail: detail, + } +} + +func writeSkillError(c *gin.Context, err error) { + if errors.Is(err, appskill.ErrSkillNotFound) { + response.Error(c, http.StatusNotFound, "skill not found") + return + } + if errors.Is(err, appskill.ErrSkillConflict) { + response.Error(c, http.StatusConflict, "skill trigger already exists") + return + } + if errors.Is(err, appskill.ErrInvalidSkill) { + response.ErrorFrom(c, http.StatusBadRequest, err) + return + } + response.Error(c, http.StatusInternalServerError, "skill operation failed") +} diff --git a/backend/internal/transport/http/skill/module.go b/backend/internal/transport/http/skill/module.go new file mode 100644 index 00000000..fc9391cc --- /dev/null +++ b/backend/internal/transport/http/skill/module.go @@ -0,0 +1,11 @@ +package skill + +// Module 聚合技能 HTTP 处理器。 +type Module struct { + Handler *Handler +} + +// NewModule 创建技能 HTTP 模块。 +func NewModule(handler *Handler) *Module { + return &Module{Handler: handler} +} diff --git a/backend/internal/transport/http/skill/router.go b/backend/internal/transport/http/skill/router.go new file mode 100644 index 00000000..28bddba9 --- /dev/null +++ b/backend/internal/transport/http/skill/router.go @@ -0,0 +1,21 @@ +package skill + +import "github.com/gin-gonic/gin" + +// RegisterRoutes 注册技能用户侧路由。 +func (m *Module) RegisterRoutes(authRequired *gin.RouterGroup) { + authRequired.GET("/skills", m.Handler.ListVisibleSkills) + authRequired.GET("/skills/mine", m.Handler.ListMySkills) + authRequired.POST("/skills/mine", m.Handler.CreateMySkill) + authRequired.PATCH("/skills/mine/:id", m.Handler.PatchMySkill) + authRequired.DELETE("/skills/mine/:id", m.Handler.DeleteMySkill) + authRequired.GET("/skills/:id", m.Handler.GetVisibleSkill) +} + +// RegisterAdminRoutes 注册技能管理路由。 +func (m *Module) RegisterAdminRoutes(adminGroup *gin.RouterGroup) { + adminGroup.GET("/skills", m.Handler.ListAdminSkills) + adminGroup.POST("/skills", m.Handler.CreateAdminSkill) + adminGroup.PATCH("/skills/:id", m.Handler.PatchAdminSkill) + adminGroup.DELETE("/skills/:id", m.Handler.DeleteAdminSkill) +} diff --git a/backend/internal/transport/http/skill/router_test.go b/backend/internal/transport/http/skill/router_test.go new file mode 100644 index 00000000..9b230b72 --- /dev/null +++ b/backend/internal/transport/http/skill/router_test.go @@ -0,0 +1,72 @@ +package skill + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + appskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/application/skill" + domainskill "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/skill" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository" + "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/transport/http/middleware" + "github.com/gin-gonic/gin" +) + +type routeSkillRepo struct { + items []domainskill.Skill +} + +func (r routeSkillRepo) ListSkills(context.Context, repository.SkillListFilter, int, int) ([]domainskill.Skill, int64, error) { + return r.items, int64(len(r.items)), nil +} + +func (r routeSkillRepo) GetSkill(context.Context, uint) (*domainskill.Skill, error) { + return nil, repository.ErrNotFound +} + +func (r routeSkillRepo) CreateSkill(context.Context, *domainskill.Skill) (*domainskill.Skill, error) { + return nil, repository.ErrInvalidInput +} + +func (r routeSkillRepo) PatchSkill(context.Context, uint, repository.SkillPatch) (*domainskill.Skill, error) { + return nil, repository.ErrInvalidInput +} + +func (r routeSkillRepo) DeleteSkill(context.Context, uint) error { + return repository.ErrInvalidInput +} + +func TestSkillMineRouteIsNotCapturedByVisibleDetailRoute(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(middleware.ContextKeyUserID, uint(7)) + c.Next() + }) + + module := NewModule(NewHandler(appskill.NewService(routeSkillRepo{ + items: []domainskill.Skill{{ + ID: 1, + Scope: domainskill.ScopeUser, + OwnerUserID: 7, + Title: "Review", + Trigger: "review", + Markdown: "private SKILL.md", + Enabled: true, + }}, + }))) + module.RegisterRoutes(router.Group("")) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/skills/mine", nil) + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected /skills/mine to return 200, got %d: %s", recorder.Code, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "private SKILL.md") { + t.Fatalf("expected mine route to return full skill payload, got %s", recorder.Body.String()) + } +} diff --git a/config.example.yaml b/config.example.yaml index 504f20f3..0f6ed50f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -7,7 +7,7 @@ server: # Optional. Change these values only when local ports or public URLs differ. http_port: "8080" cors_allow_origin: "http://127.0.0.1:8080,http://localhost:8080,http://127.0.0.1:3000,http://localhost:3000" - trusted_proxies: "127.0.0.1/32,::1/128" + trusted_proxies: "127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7" public_api_base_url: "http://127.0.0.1:8080" public_web_base_url: "http://localhost:3000" frontend_dist_dir: "./frontend/out" @@ -31,7 +31,7 @@ database: driver: postgres postgres: # Required when database.driver is postgres. - dsn: "postgres://deeix_chat:deeix_chat_dev_2026@127.0.0.1:5432/deeix_chat?sslmode=disable&TimeZone=Asia%2FShanghai" + dsn: "postgres://deeix_chat:deeix_chat_dev_2026@127.0.0.1:5432/deeix_chat?sslmode=disable&TimeZone=Asia/Shanghai" max_open_conns: 30 max_idle_conns: 10 conn_max_lifetime_minutes: 60 diff --git a/config.full.example.yaml b/config.full.example.yaml index 3d35481e..fab2f7c7 100644 --- a/config.full.example.yaml +++ b/config.full.example.yaml @@ -12,7 +12,7 @@ server: # Optional. If http_port changes, also update the compose port mapping. http_port: "8080" cors_allow_origin: "http://localhost:8080,http://127.0.0.1:8080" - trusted_proxies: "127.0.0.1/32,::1/128" + trusted_proxies: "127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7" public_api_base_url: "http://localhost:8080" public_web_base_url: "http://localhost:8080" frontend_dist_dir: "/app/frontend/out" @@ -37,7 +37,7 @@ database: postgres: # Required when database.driver is postgres. # Optional. docker-compose.full.yml overrides this value with the bundled PostgreSQL service. - dsn: "postgres://deeix_chat:deeix_chat_2026@postgres:5432/deeix_chat?sslmode=disable&TimeZone=Asia%2FShanghai" + dsn: "postgres://deeix_chat:deeix_chat_2026@postgres:5432/deeix_chat?sslmode=disable&TimeZone=Asia/Shanghai" max_open_conns: 30 max_idle_conns: 10 conn_max_lifetime_minutes: 60 diff --git a/config.sqlite.example.yaml b/config.sqlite.example.yaml index 041b18c4..c3102b08 100644 --- a/config.sqlite.example.yaml +++ b/config.sqlite.example.yaml @@ -7,7 +7,7 @@ server: # Optional. Defaults match docker-compose.sqlite.yml. Change only when exposed ports or public URLs differ. http_port: "8080" cors_allow_origin: "http://localhost:8080,http://127.0.0.1:8080" - trusted_proxies: "127.0.0.1/32,::1/128" + trusted_proxies: "127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7" public_api_base_url: "http://localhost:8080" public_web_base_url: "http://localhost:8080" frontend_dist_dir: "/app/frontend/out" diff --git a/docker-compose.full.yml b/docker-compose.full.yml index 937992d5..9f5b1397 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -12,7 +12,7 @@ services: environment: # Full compose connects to bundled services on the compose network. # These environment variables override database and Redis values in config.yaml. - POSTGRES_DSN: "postgres://deeix_chat:deeix_chat_2026@postgres:5432/deeix_chat?sslmode=disable&TimeZone=Asia%2FShanghai" + POSTGRES_DSN: "postgres://deeix_chat:deeix_chat_2026@postgres:5432/deeix_chat?sslmode=disable&TimeZone=Asia/Shanghai" REDIS_ADDR: "redis:6379" REDIS_USERNAME: "" REDIS_PASSWORD: "deeix_chat_2026" diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 43857fa8..a26a6002 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1105,7 +1105,7 @@ } :root[data-chat-font="mono"] { - --font-chat: var(--font-jetbrains-mono); + --font-chat: var(--font-jetbrains-mono, "JetBrains Mono", var(--font-mono)); } :root[data-chat-font-weight="medium"] { @@ -1139,6 +1139,13 @@ } @layer utilities { + @media (hover: none), (pointer: coarse), (any-hover: none), (any-pointer: coarse) { + .chat-message-meta[data-hover-actions="true"] { + opacity: 1; + pointer-events: auto; + } + } + .text-\[8px\] { font-size: calc(8px * var(--ui-font-scale)); } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 78e1cd96..3e703868 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -73,9 +73,13 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/frontend/components/ui/sidebar.tsx b/frontend/components/ui/sidebar.tsx index 3a96e5d6..8c552454 100644 --- a/frontend/components/ui/sidebar.tsx +++ b/frontend/components/ui/sidebar.tsx @@ -28,11 +28,13 @@ import { const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_STORAGE_KEY = "deeix.sidebar.open" const SIDEBAR_WIDTH = "17.96875rem" const SIDEBAR_WIDTH_MOBILE = "17.96875rem" const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b" -const SIDEBAR_AUTO_COLLAPSE_BREAKPOINT = 1280 +const SIDEBAR_AUTO_COLLAPSE_AT = 1180 +const SIDEBAR_AUTO_RESTORE_AT = 1360 type SidebarContextProps = { state: "expanded" | "collapsed" @@ -55,23 +57,63 @@ function useSidebar() { return context } -function isCompactSidebarViewport() { - return typeof window !== "undefined" && window.innerWidth < SIDEBAR_AUTO_COLLAPSE_BREAKPOINT +function shouldAutoCollapseSidebar() { + return typeof window !== "undefined" && window.innerWidth < SIDEBAR_AUTO_COLLAPSE_AT } -function useCompactSidebarViewport() { - const [isCompact, setIsCompact] = React.useState(isCompactSidebarViewport) +function shouldAutoRestoreSidebar() { + return typeof window !== "undefined" && window.innerWidth >= SIDEBAR_AUTO_RESTORE_AT +} + +function readSidebarInitialOpen(defaultOpen: boolean) { + if (!defaultOpen) { + return false + } + if (typeof window === "undefined") { + return defaultOpen + } + + try { + const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY) + if (stored === "true") { + return true + } + if (stored === "false") { + return false + } + } catch { + // Keep the default state when localStorage is unavailable. + } + + return defaultOpen +} + +function useSidebarAutoViewport() { + const [autoViewport, setAutoViewport] = React.useState(() => ({ + shouldCollapse: shouldAutoCollapseSidebar(), + shouldRestore: shouldAutoRestoreSidebar(), + })) React.useEffect(() => { - const mediaQuery = window.matchMedia(`(max-width: ${SIDEBAR_AUTO_COLLAPSE_BREAKPOINT - 1}px)`) - const sync = () => setIsCompact(mediaQuery.matches) + const collapseQuery = window.matchMedia(`(max-width: ${SIDEBAR_AUTO_COLLAPSE_AT - 1}px)`) + const restoreQuery = window.matchMedia(`(min-width: ${SIDEBAR_AUTO_RESTORE_AT}px)`) + const sync = () => { + setAutoViewport({ + shouldCollapse: collapseQuery.matches, + shouldRestore: restoreQuery.matches, + }) + } sync() - mediaQuery.addEventListener("change", sync) - return () => mediaQuery.removeEventListener("change", sync) + collapseQuery.addEventListener("change", sync) + restoreQuery.addEventListener("change", sync) + return () => { + collapseQuery.removeEventListener("change", sync) + restoreQuery.removeEventListener("change", sync) + } }, []) - return isCompact + return autoViewport } function SidebarProvider({ @@ -88,14 +130,15 @@ function SidebarProvider({ onOpenChange?: (open: boolean) => void }) { const isMobile = useIsMobile() - const isCompactViewport = useCompactSidebarViewport() + const { shouldCollapse, shouldRestore } = useSidebarAutoViewport() const [openMobile, setOpenMobile] = React.useState(false) - const wasCompactViewportRef = React.useRef(isCompactSidebarViewport()) - const autoCollapsedRef = React.useRef(defaultOpen && isCompactSidebarViewport()) + const autoCollapsedRef = React.useRef(readSidebarInitialOpen(defaultOpen) && shouldAutoCollapseSidebar()) + const wasAutoCollapseViewportRef = React.useRef(shouldAutoCollapseSidebar()) + const wasAutoRestoreViewportRef = React.useRef(shouldAutoRestoreSidebar()) // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(() => defaultOpen && !isCompactSidebarViewport()) + const [_open, _setOpen] = React.useState(() => readSidebarInitialOpen(defaultOpen) && !shouldAutoCollapseSidebar()) const open = openProp ?? _open const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { @@ -109,16 +152,22 @@ function SidebarProvider({ // This sets the cookie to keep the sidebar state. document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + try { + window.localStorage.setItem(SIDEBAR_STORAGE_KEY, openState ? "true" : "false") + } catch { + // Ignore storage failures; the current in-memory state still controls the UI. + } }, [setOpenProp, open] ) React.useEffect(() => { - const leftCompactViewport = !isCompactViewport && wasCompactViewportRef.current - const enteredCompactViewport = isCompactViewport && !wasCompactViewportRef.current - wasCompactViewportRef.current = isCompactViewport + const enteredAutoCollapseViewport = shouldCollapse && !wasAutoCollapseViewportRef.current + const enteredAutoRestoreViewport = shouldRestore && !wasAutoRestoreViewportRef.current + wasAutoCollapseViewportRef.current = shouldCollapse + wasAutoRestoreViewportRef.current = shouldRestore - if (enteredCompactViewport) { + if (enteredAutoCollapseViewport) { autoCollapsedRef.current = open if (!open) { return @@ -133,7 +182,7 @@ function SidebarProvider({ return } - if (!leftCompactViewport || !autoCollapsedRef.current || !defaultOpen) { + if (!enteredAutoRestoreViewport || !autoCollapsedRef.current) { return } @@ -144,7 +193,7 @@ function SidebarProvider({ } _setOpen(true) - }, [defaultOpen, isCompactViewport, open, setOpenProp]) + }, [open, setOpenProp, shouldCollapse, shouldRestore]) // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { diff --git a/frontend/features/admin/components/sections/accounts/accounts-user-editor.tsx b/frontend/features/admin/components/sections/accounts/accounts-user-editor.tsx index eb82dcbf..1a7481c1 100644 --- a/frontend/features/admin/components/sections/accounts/accounts-user-editor.tsx +++ b/frontend/features/admin/components/sections/accounts/accounts-user-editor.tsx @@ -653,6 +653,7 @@ export function EditUserSheet({ label={t("editor.twoFactor")} value={editDialogTarget?.twoFactorEnabled ? t("editor.twoFactorEnabled", { count: editDialogTarget.twoFactorRecoveryCount }) : t("editor.twoFactorDisabled")} /> + diff --git a/frontend/features/admin/components/sections/accounts/accounts-users.tsx b/frontend/features/admin/components/sections/accounts/accounts-users.tsx index cd6a9f3a..5e9baaec 100644 --- a/frontend/features/admin/components/sections/accounts/accounts-users.tsx +++ b/frontend/features/admin/components/sections/accounts/accounts-users.tsx @@ -377,7 +377,7 @@ const UserTableRow = React.memo(function UserTableRow({ {resolveValue(item.timezone)} - {formatDateTime(item.lastLoginAt, locale)} + {formatDateTime(item.lastActiveAt || item.lastLoginAt, locale)}
- + @@ -2102,7 +2102,7 @@ export function AdminBillingPage() { - +

{t("toolPricing.nativeToolCount", { count: nativeToolPricing.length })}

@@ -2167,7 +2167,7 @@ export function AdminBillingPage() {

{t("toolPricing.defaultPriceDescription")}

{t("toolPricing.note")}

-
+ ({ + emptyLabel, + getSummary, + icon: Icon, items, loading, - onEdit, onDelete, + onEdit, onEnabledChange, }: { - items: PromptPresetDTO[]; + emptyLabel: string; + getSummary: (item: T) => string; + icon: React.ComponentType<{ className?: string; strokeWidth?: number }>; + items: T[]; loading: boolean; - onEdit: (item: PromptPresetDTO) => void; - onDelete: (item: PromptPresetDTO) => void; - onEnabledChange: (item: PromptPresetDTO, checked: boolean) => void; + onDelete: (item: T) => void; + onEdit: (item: T) => void; + onEnabledChange: (item: T, checked: boolean) => void; }) { const t = useTranslations("adminPrompts"); const locale = useLocale(); @@ -92,19 +113,19 @@ function PromptPresetsTable({ {initialLoading ? : null} {items.length === 0 && !loading ? ( - {t("empty")} + {emptyLabel} ) : ( <> {virtualRows.rows.map(({ item }) => { const displayName = item.trigger || item.title; - const summary = item.description || item.content; + const summary = getSummary(item); return ( onEdit(item)}>
- + {displayName}
@@ -156,58 +177,93 @@ function PromptPresetsTable({ export function ConversationPromptPresetsSection() { const t = useTranslations("adminPrompts"); + const [activeType, setActiveType] = React.useState("skills"); const prompts = useAdminPromptPresets(); + const skills = useAdminSkills(); + const activeLoading = activeType === "skills" ? skills.loading : prompts.loading; + const activeQuery = activeType === "skills" ? skills.query : prompts.query; + const activePage = activeType === "skills" ? skills.page : prompts.page; + const activePageCount = activeType === "skills" ? skills.pageCount : prompts.pageCount; + const activePageSize = activeType === "skills" ? skills.pageSize : prompts.pageSize; + const activeTotal = activeType === "skills" ? skills.total : prompts.total; + const activeSearchPlaceholder = activeType === "skills" ? t("skillsSearchPlaceholder") : t("searchPlaceholder"); + const activeCreateLabel = activeType === "skills" ? t("createSkill") : t("create"); return ( <>
+
+ setActiveType(value as PromptLibraryType)}> + + {t("types.skills")} + {t("types.prompts")} + + +
+ void prompts.load()} + query={activeQuery} + queryPlaceholder={activeSearchPlaceholder} + onQueryChange={activeType === "skills" ? skills.setQuery : prompts.setQuery} + loading={activeLoading} + onRefresh={() => void (activeType === "skills" ? skills.load() : prompts.load())} > - void prompts.toggleEnabled(target, checked)} - /> + {activeType === "skills" ? ( + + emptyLabel={t("skillsEmpty")} + getSummary={(item: SkillDTO) => item.description || item.markdown} + icon={Box} + items={skills.items} + loading={skills.loading} + onEdit={skills.openEdit} + onDelete={skills.setDeleteTarget} + onEnabledChange={(target, checked) => void skills.toggleEnabled(target, checked)} + /> + ) : ( + + emptyLabel={t("empty")} + getSummary={(item: PromptPresetDTO) => item.description || item.content} + icon={FileBox} + items={prompts.items} + loading={prompts.loading} + onEdit={prompts.openEdit} + onDelete={prompts.setDeleteTarget} + onEnabledChange={(target, checked) => void prompts.toggleEnabled(target, checked)} + /> + )}
!prompts.saving && prompts.setDialogOpen(open)}> - - + + {prompts.form.id ? t("editTitle") : t("createTitle")} {t("dialogDescription")} -
+

{t("fields.name")}

@@ -232,7 +288,7 @@ export function ConversationPromptPresetsSection() {

{t("fields.content")}