diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 3293fb8..d2ba851 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -51,6 +51,10 @@ jobs: run: | set -euo pipefail TAG="${RELEASE_TAG}" + BUILD_VERSION="${TAG}" + if [ -z "${BUILD_VERSION}" ] && [ -f VERSION ]; then + BUILD_VERSION="$(cat VERSION | tr -d '[:space:]')" + fi mkdir -p dist targets=( @@ -73,7 +77,7 @@ jobs: mkdir -p "${STAGE}/static" CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \ - go build -trimpath -ldflags="-s -w" -o "${STAGE}/${BIN}" ./cmd/ds2api + go build -trimpath -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION}" -o "${STAGE}/${BIN}" ./cmd/ds2api cp config.example.json .env.example sha3_wasm_bg.7b9ca65ddd.wasm LICENSE README.MD README.en.md "${STAGE}/" cp -R static/admin "${STAGE}/static/admin" diff --git a/.github/workflows/release-dockerhub.yml b/.github/workflows/release-dockerhub.yml index 536c3dc..815ab8d 100644 --- a/.github/workflows/release-dockerhub.yml +++ b/.github/workflows/release-dockerhub.yml @@ -123,5 +123,7 @@ jobs: labels: | org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} org.opencontainers.image.revision=${{ github.sha }} + build-args: | + BUILD_VERSION=${{ steps.next_version.outputs.new_tag }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da3264f..ebd0cef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,128 +1,130 @@ -name: Release to Aliyun CR - -on: - workflow_dispatch: - inputs: - version_type: - description: '版本类型' - required: true - default: 'patch' - type: choice - options: - - patch - - minor - - major - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Get current version - id: get_version - run: | - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") - TAG_VERSION=${LATEST_TAG#v} - - if [ -f VERSION ]; then - FILE_VERSION=$(cat VERSION | tr -d '[:space:]') - else - FILE_VERSION="0.0.0" - fi - - function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } - - if version_gt "$FILE_VERSION" "$TAG_VERSION"; then - VERSION="$FILE_VERSION" - else - VERSION="$TAG_VERSION" - fi - - echo "Current version: $VERSION" - echo "current_version=$VERSION" >> $GITHUB_OUTPUT - - - name: Calculate next version - id: next_version - env: - VERSION_TYPE: ${{ github.event.inputs.version_type }} - run: | - VERSION="${{ steps.get_version.outputs.current_version }}" - BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//') - - IFS='.' read -r -a version_parts <<< "$BASE_VERSION" - MAJOR="${version_parts[0]:-0}" - MINOR="${version_parts[1]:-0}" - PATCH="${version_parts[2]:-0}" - - case "$VERSION_TYPE" in - major) - NEW_VERSION="$((MAJOR + 1)).0.0" - ;; - minor) - NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" - ;; - *) - NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" - ;; - esac - - echo "New version: $NEW_VERSION" - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT - - - name: Update VERSION file - run: | - echo "${{ steps.next_version.outputs.new_version }}" > VERSION - - - name: Commit VERSION and create tag - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git add VERSION - if ! git diff --cached --quiet; then - git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]" - fi - - NEW_TAG="${{ steps.next_version.outputs.new_tag }}" - git tag -a "$NEW_TAG" -m "Release $NEW_TAG" - git push origin HEAD:main "$NEW_TAG" - - # Docker 构建并推送到阿里云 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Aliyun Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ secrets.ALIYUN_REGISTRY }} - username: ${{ secrets.ALIYUN_REGISTRY_USER }} - password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_tag }} - ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_version }} - ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:latest - labels: | - org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} - org.opencontainers.image.revision=${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max +name: Release to Aliyun CR + +on: + workflow_dispatch: + inputs: + version_type: + description: '版本类型' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get current version + id: get_version + run: | + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + TAG_VERSION=${LATEST_TAG#v} + + if [ -f VERSION ]; then + FILE_VERSION=$(cat VERSION | tr -d '[:space:]') + else + FILE_VERSION="0.0.0" + fi + + function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } + + if version_gt "$FILE_VERSION" "$TAG_VERSION"; then + VERSION="$FILE_VERSION" + else + VERSION="$TAG_VERSION" + fi + + echo "Current version: $VERSION" + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Calculate next version + id: next_version + env: + VERSION_TYPE: ${{ github.event.inputs.version_type }} + run: | + VERSION="${{ steps.get_version.outputs.current_version }}" + BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//') + + IFS='.' read -r -a version_parts <<< "$BASE_VERSION" + MAJOR="${version_parts[0]:-0}" + MINOR="${version_parts[1]:-0}" + PATCH="${version_parts[2]:-0}" + + case "$VERSION_TYPE" in + major) + NEW_VERSION="$((MAJOR + 1)).0.0" + ;; + minor) + NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" + ;; + *) + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" + ;; + esac + + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Update VERSION file + run: | + echo "${{ steps.next_version.outputs.new_version }}" > VERSION + + - name: Commit VERSION and create tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add VERSION + if ! git diff --cached --quiet; then + git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]" + fi + + NEW_TAG="${{ steps.next_version.outputs.new_tag }}" + git tag -a "$NEW_TAG" -m "Release $NEW_TAG" + git push origin HEAD:main "$NEW_TAG" + + # Docker 构建并推送到阿里云 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Aliyun Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.ALIYUN_REGISTRY }} + username: ${{ secrets.ALIYUN_REGISTRY_USER }} + password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_tag }} + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_version }} + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:latest + labels: | + org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} + org.opencontainers.image.revision=${{ github.sha }} + build-args: | + BUILD_VERSION=${{ steps.next_version.outputs.new_tag }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 6996f43..b40570c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,13 +10,16 @@ FROM golang:1.24 AS go-builder WORKDIR /app ARG TARGETOS ARG TARGETARCH +ARG BUILD_VERSION COPY go.mod go.sum* ./ RUN go mod download COPY . . RUN set -eux; \ GOOS="${TARGETOS:-$(go env GOOS)}"; \ GOARCH="${TARGETARCH:-$(go env GOARCH)}"; \ - CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -o /out/ds2api ./cmd/ds2api + BUILD_VERSION_RESOLVED="${BUILD_VERSION}"; \ + if [ -z "${BUILD_VERSION_RESOLVED}" ] && [ -f VERSION ]; then BUILD_VERSION_RESOLVED="$(cat VERSION | tr -d "[:space:]")"; fi; \ + CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION_RESOLVED}" -o /out/ds2api ./cmd/ds2api FROM busybox:1.36.1-musl AS busybox-tools diff --git a/VERSION b/VERSION index 7ecf123..e75da3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +2.3.6 diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 96111e2..fc20533 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -39,5 +39,6 @@ func RegisterRoutes(r chi.Router, h *Handler) { pr.Get("/export", h.exportConfig) pr.Get("/dev/captures", h.getDevCaptures) pr.Delete("/dev/captures", h.clearDevCaptures) + pr.Get("/version", h.getVersion) }) } diff --git a/internal/admin/handler_accounts_testing.go b/internal/admin/handler_accounts_testing.go index 93b0c8d..e528de0 100644 --- a/internal/admin/handler_accounts_testing.go +++ b/internal/admin/handler_accounts_testing.go @@ -89,7 +89,15 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any { start := time.Now() identifier := acc.Identifier() - result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model, "session_count": 0} + result := map[string]any{ + "account": identifier, + "success": false, + "response_time": 0, + "message": "", + "model": model, + "session_count": 0, + "config_writable": !h.Store.IsEnvBacked(), + } defer func() { status := "failed" if ok, _ := result["success"].(bool); ok { @@ -105,7 +113,10 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me return result } token = newToken - _ = h.Store.UpdateAccountToken(acc.Identifier(), token) + if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil { + result["message"] = "登录成功但写入配置失败: " + err.Error() + return result + } } authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token} sessionID, err := h.DS.CreateSession(ctx, authCtx, 1) @@ -117,7 +128,10 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me } token = newToken authCtx.DeepSeekToken = token - _ = h.Store.UpdateAccountToken(acc.Identifier(), token) + if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil { + result["message"] = "刷新 token 成功但写入配置失败: " + err.Error() + return result + } sessionID, err = h.DS.CreateSession(ctx, authCtx, 1) if err != nil { result["message"] = "创建会话失败: " + err.Error() diff --git a/internal/admin/handler_version.go b/internal/admin/handler_version.go new file mode 100644 index 0000000..1c38dfe --- /dev/null +++ b/internal/admin/handler_version.go @@ -0,0 +1,75 @@ +package admin + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "ds2api/internal/version" +) + +const latestReleaseAPI = "https://api.github.com/repos/CJackHwang/ds2api/releases/latest" + +type latestReleasePayload struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` +} + +func (h *Handler) getVersion(w http.ResponseWriter, _ *http.Request) { + current, source := version.Current() + resp := map[string]any{ + "success": true, + "current_version": current, + "current_tag": version.Tag(current), + "source": source, + "checked_at": time.Now().UTC().Format(time.RFC3339), + } + + req, err := http.NewRequest(http.MethodGet, latestReleaseAPI, nil) + if err != nil { + resp["check_error"] = err.Error() + writeJSON(w, http.StatusOK, resp) + return + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "ds2api-version-check") + + client := &http.Client{Timeout: 4 * time.Second} + r, err := client.Do(req) + if err != nil { + resp["check_error"] = err.Error() + writeJSON(w, http.StatusOK, resp) + return + } + defer r.Body.Close() + if r.StatusCode < 200 || r.StatusCode >= 300 { + resp["check_error"] = "github api status: " + r.Status + writeJSON(w, http.StatusOK, resp) + return + } + + var data latestReleasePayload + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + resp["check_error"] = err.Error() + writeJSON(w, http.StatusOK, resp) + return + } + + latest := strings.TrimSpace(data.TagName) + if latest == "" { + resp["check_error"] = "missing latest tag" + writeJSON(w, http.StatusOK, resp) + return + } + latestVersion := strings.TrimPrefix(latest, "v") + + resp["latest_tag"] = latest + resp["latest_version"] = latestVersion + resp["release_url"] = data.HTMLURL + resp["published_at"] = data.PublishedAt + resp["has_update"] = version.Compare(current, latestVersion) < 0 + + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/deepseek/client_auth.go b/internal/deepseek/client_auth.go index bedb75e..a510a27 100644 --- a/internal/deepseek/client_auth.go +++ b/internal/deepseek/client_auth.go @@ -73,7 +73,7 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte } config.Logger.Warn("[create_session] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID) if a.UseConfigToken { - if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed { + if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) { if c.Auth.RefreshToken(ctx, a) { refreshed = true continue @@ -118,7 +118,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in } config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID) if a.UseConfigToken { - if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed { + if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) { if c.Auth.RefreshToken(ctx, a) { refreshed = true continue @@ -160,6 +160,47 @@ func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string strings.Contains(msg, "invalid jwt") } +func shouldAttemptRefresh(status int, code int, bizCode int, msg string, bizMsg string) bool { + if isTokenInvalid(status, code, bizCode, msg, bizMsg) { + return true + } + // Some DeepSeek failures come back as HTTP 200/code=0 but with non-zero biz_code. + // Only attempt refresh when these biz failures still look auth-related. + return status == http.StatusOK && + code == 0 && + bizCode != 0 && + isAuthIndicativeBizFailure(msg, bizMsg) +} + +func isAuthIndicativeBizFailure(msg string, bizMsg string) bool { + combined := strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg)) + authKeywords := []string{ + "auth", + "authorization", + "credential", + "expired", + "invalid jwt", + "jwt", + "login", + "not login", + "session expired", + "token", + "unauthorized", + "登录", + "未登录", + "认证", + "凭证", + "会话过期", + "令牌", + } + for _, keyword := range authKeywords { + if strings.Contains(combined, keyword) { + return true + } + } + return false +} + func extractResponseStatus(resp map[string]any) (code int, bizCode int, msg string, bizMsg string) { code = intFrom(resp["code"]) msg, _ = resp["msg"].(string) diff --git a/internal/deepseek/client_auth_refresh_test.go b/internal/deepseek/client_auth_refresh_test.go new file mode 100644 index 0000000..2506a00 --- /dev/null +++ b/internal/deepseek/client_auth_refresh_test.go @@ -0,0 +1,27 @@ +package deepseek + +import "testing" + +func TestShouldAttemptRefreshOnTokenInvalidSignal(t *testing.T) { + if !shouldAttemptRefresh(401, 0, 0, "unauthorized", "") { + t.Fatal("expected refresh when response indicates invalid token") + } +} + +func TestShouldAttemptRefreshOnAuthIndicativeBizCodeFailure(t *testing.T) { + if !shouldAttemptRefresh(200, 0, 400123, "", "login expired, token invalid") { + t.Fatal("expected refresh on auth-indicative biz_code failure") + } +} + +func TestShouldAttemptRefreshFalseOnNonAuthBizCodeFailure(t *testing.T) { + if shouldAttemptRefresh(200, 0, 400123, "", "session create failed: quota reached") { + t.Fatal("did not expect refresh on non-auth biz_code failure") + } +} + +func TestShouldAttemptRefreshFalseOnGenericServerError(t *testing.T) { + if shouldAttemptRefresh(500, 500, 0, "internal error", "") { + t.Fatal("did not expect refresh on generic server error") + } +} diff --git a/internal/js/chat-stream/toolcall_policy.js b/internal/js/chat-stream/toolcall_policy.js index 4523b89..ff4611e 100644 --- a/internal/js/chat-stream/toolcall_policy.js +++ b/internal/js/chat-stream/toolcall_policy.js @@ -60,6 +60,9 @@ function formatIncrementalToolCallDeltas(deltas, idStore) { if (typeof d.arguments === 'string' && d.arguments !== '') { fn.arguments = d.arguments; } + if (Object.keys(fn).length === 0) { + continue; + } if (Object.keys(fn).length > 0) { item.function = fn; } diff --git a/internal/js/helpers/stream-tool-sieve/parse.js b/internal/js/helpers/stream-tool-sieve/parse.js index 22d11d1..eeb2e34 100644 --- a/internal/js/helpers/stream-tool-sieve/parse.js +++ b/internal/js/helpers/stream-tool-sieve/parse.js @@ -17,15 +17,18 @@ function extractToolNames(tools) { return []; } const out = []; + const seen = new Set(); for (const t of tools) { if (!t || typeof t !== 'object') { continue; } const fn = t.function && typeof t.function === 'object' ? t.function : t; const name = toStringSafe(fn.name); - // Keep parity with Go injectToolPrompt: object tools without name still - // enter tool mode via fallback name "unknown". - out.push(name || 'unknown'); + if (!name || seen.has(name)) { + continue; + } + seen.add(name); + out.push(name); } return out; } diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..03542b9 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,185 @@ +package version + +import ( + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" +) + +// BuildVersion can be injected at build time via -ldflags. +// In release builds it should come from Git tag (e.g. v2.3.5). +var BuildVersion = "" + +var ( + currentOnce sync.Once + currentVal string + sourceVal string +) + +func Current() (value string, source string) { + currentOnce.Do(func() { + if build := strings.TrimSpace(BuildVersion); build != "" { + currentVal = normalize(build) + sourceVal = "build-ldflags" + return + } + if fv := readVersionFile(); fv != "" { + currentVal = normalize(fv) + sourceVal = "file:VERSION" + return + } + + if vv := versionFromVercelEnv(); vv != "" { + currentVal = vv + sourceVal = "env:vercel" + return + } + currentVal = "dev" + sourceVal = "default" + }) + return currentVal, sourceVal +} + +func readVersionFile() string { + candidates := []string{"VERSION"} + if wd, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(wd, "VERSION")) + } + if _, file, _, ok := runtime.Caller(0); ok { + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "../..")) + candidates = append(candidates, filepath.Join(repoRoot, "VERSION")) + } + seen := map[string]struct{}{} + for _, c := range candidates { + c = filepath.Clean(strings.TrimSpace(c)) + if c == "" { + continue + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + b, err := os.ReadFile(c) + if err != nil { + continue + } + if v := strings.TrimSpace(string(b)); v != "" { + return v + } + } + return "" +} + +func normalize(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + return strings.TrimPrefix(v, "v") +} + +func Tag(v string) string { + v = normalize(v) + if v == "" || v == "dev" { + return v + } + if v[0] < '0' || v[0] > '9' { + return v + } + return "v" + v +} + +func versionFromVercelEnv() string { + if tag := normalize(strings.TrimSpace(os.Getenv("VERCEL_GIT_COMMIT_TAG"))); tag != "" { + return tag + } + ref := strings.TrimSpace(os.Getenv("VERCEL_GIT_COMMIT_REF")) + sha := strings.TrimSpace(os.Getenv("VERCEL_GIT_COMMIT_SHA")) + if len(sha) > 7 { + sha = sha[:7] + } + ref = sanitizeVersionLabel(ref) + sha = sanitizeVersionLabel(sha) + if ref == "" && sha == "" { + return "" + } + if ref != "" && sha != "" { + return "preview-" + ref + "." + sha + } + if ref != "" { + return "preview-" + ref + } + return "preview-" + sha +} + +func sanitizeVersionLabel(in string) string { + in = strings.TrimSpace(strings.ToLower(in)) + if in == "" { + return "" + } + var b strings.Builder + b.Grow(len(in)) + prevDash := false + for i := 0; i < len(in); i++ { + c := in[i] + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { + b.WriteByte(c) + prevDash = false + continue + } + if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + out := strings.Trim(b.String(), "-") + return out +} + +func Compare(a, b string) int { + pa := parse(normalize(a)) + pb := parse(normalize(b)) + for i := 0; i < 3; i++ { + if pa[i] < pb[i] { + return -1 + } + if pa[i] > pb[i] { + return 1 + } + } + return 0 +} + +func parse(v string) [3]int { + var out [3]int + parts := strings.SplitN(v, ".", 4) + for i := 0; i < 3 && i < len(parts); i++ { + n := readLeadingInt(parts[i]) + out[i] = n + } + return out +} + +func readLeadingInt(s string) int { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + i := 0 + for ; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + break + } + } + if i == 0 { + return 0 + } + n, err := strconv.Atoi(s[:i]) + if err != nil { + return 0 + } + return n +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..03f7e95 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,39 @@ +package version + +import "testing" + +func TestNormalizeAndTag(t *testing.T) { + if got := normalize("v2.3.5"); got != "2.3.5" { + t.Fatalf("normalize failed: %q", got) + } + if got := Tag("2.3.5"); got != "v2.3.5" { + t.Fatalf("tag failed: %q", got) + } +} + +func TestCompare(t *testing.T) { + if Compare("2.3.5", "2.3.5") != 0 { + t.Fatal("expected equal") + } + if Compare("2.3.5", "2.3.6") >= 0 { + t.Fatal("expected less") + } + if Compare("v2.10.0", "2.3.9") <= 0 { + t.Fatal("expected greater") + } +} + +func TestTagKeepsPreviewStyle(t *testing.T) { + if got := Tag("preview-dev.abcd123"); got != "preview-dev.abcd123" { + t.Fatalf("expected preview tag unchanged, got %q", got) + } +} + +func TestVersionFromVercelEnv(t *testing.T) { + t.Setenv("VERCEL_GIT_COMMIT_TAG", "") + t.Setenv("VERCEL_GIT_COMMIT_REF", "dev") + t.Setenv("VERCEL_GIT_COMMIT_SHA", "abcdef123456") + if got := versionFromVercelEnv(); got != "preview-dev.abcdef1" { + t.Fatalf("unexpected vercel preview version: %q", got) + } +} diff --git a/tests/node/chat-stream.test.js b/tests/node/chat-stream.test.js index 48be6ff..6f3317a 100644 --- a/tests/node/chat-stream.test.js +++ b/tests/node/chat-stream.test.js @@ -98,6 +98,12 @@ test('incremental and final tool formatting share stable id via idStore', () => assert.equal(incremental[0].id, finalCalls[0].id); }); +test('formatIncrementalToolCallDeltas drops empty deltas (Go parity)', () => { + const idStore = new Map(); + const formatted = formatIncrementalToolCallDeltas([{ index: 0 }], idStore); + assert.deepEqual(formatted, []); +}); + test('parseChunkForContent keeps split response/content fragments inside response array', () => { const chunk = { p: 'response', diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index d4b5481..498155f 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -31,13 +31,14 @@ function collectText(events) { .join(''); } -test('extractToolNames keeps tool mode enabled with unknown fallback', () => { +test('extractToolNames keeps only declared tool names (Go parity)', () => { const names = extractToolNames([ { function: { description: 'no name tool' } }, { function: { name: ' read_file ' } }, + { function: { name: 'read_file' } }, {}, ]); - assert.deepEqual(names, ['unknown', 'read_file', 'unknown']); + assert.deepEqual(names, ['read_file']); }); test('parseToolCalls keeps non-object argument strings as _raw (Go parity)', () => { diff --git a/vercel.json b/vercel.json index bad49e0..ee5aaff 100644 --- a/vercel.json +++ b/vercel.json @@ -78,6 +78,10 @@ "source": "/admin/export", "destination": "/api/index" }, + { + "source": "/admin/version", + "destination": "/api/index" + }, { "source": "/admin", "destination": "/admin/index.html" diff --git a/webui/src/layout/DashboardShell.jsx b/webui/src/layout/DashboardShell.jsx index 399833b..5e91f8a 100644 --- a/webui/src/layout/DashboardShell.jsx +++ b/webui/src/layout/DashboardShell.jsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { LayoutDashboard, Upload, @@ -47,6 +47,29 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s return res }, [onLogout, t, token]) + + const [versionInfo, setVersionInfo] = useState(null) + + useEffect(() => { + let disposed = false + async function loadVersion() { + try { + const res = await authFetch('/admin/version') + const data = await res.json() + if (!disposed) { + setVersionInfo(data) + } + } catch (_err) { + if (!disposed) { + setVersionInfo(null) + } + } + } + loadVersion() + return () => { + disposed = true + } + }, [authFetch]) const renderTab = () => { switch (activeTab) { case 'accounts': @@ -135,6 +158,20 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s