Merge pull request #117 from CJackHwang/dev

Merge pull request #115 from CJackHwang/codex/fix-version-detection-for-ds2api

Expose version endpoint, add version package, and inject build version into artifacts/Docker images
This commit is contained in:
CJACK.
2026-03-21 00:51:36 +08:00
committed by GitHub
20 changed files with 595 additions and 144 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1 +1 @@
0.1.0
2.3.6

View File

@@ -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)
})
}

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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;
}

View File

@@ -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;
}

185
internal/version/version.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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',

View File

@@ -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)', () => {

View File

@@ -78,6 +78,10 @@
"source": "/admin/export",
"destination": "/api/index"
},
{
"source": "/admin/version",
"destination": "/api/index"
},
{
"source": "/admin",
"destination": "/admin/index.html"

View File

@@ -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
<div className="text-lg font-bold text-foreground">{config.keys?.length || 0}</div>
</div>
</div>
<div className="bg-background rounded-lg p-3 border border-border shadow-sm">
<div className="text-[9px] text-muted-foreground font-bold uppercase tracking-wider mb-1 opacity-70">{t('sidebar.version')}</div>
<div className="text-xs font-semibold text-foreground">{versionInfo?.current_tag || '-'}</div>
{versionInfo?.has_update && (
<a
className="inline-flex mt-1 text-[10px] text-amber-500 hover:text-amber-400"
href={versionInfo?.release_url || 'https://github.com/CJackHwang/ds2api/releases/latest'}
target="_blank"
rel="noreferrer"
>
{t('sidebar.updateAvailable', { latest: versionInfo.latest_tag || '' })}
</a>
)}
</div>
<button
onClick={onLogout}
className="w-full h-10 flex items-center justify-center gap-2 rounded-lg border border-border text-xs font-medium text-muted-foreground hover:bg-destructive/10 hover:text-destructive hover:border-destructive/20 transition-all"

View File

@@ -32,7 +32,9 @@
"statusOnline": "Online",
"accounts": "Accounts",
"keys": "Keys",
"signOut": "Sign out"
"signOut": "Sign out",
"version": "Version",
"updateAvailable": "Update available: {latest}"
},
"auth": {
"expired": "Authentication expired. Please sign in again.",

View File

@@ -32,7 +32,9 @@
"statusOnline": "在线",
"accounts": "账号",
"keys": "密钥",
"signOut": "退出登录"
"signOut": "退出登录",
"version": "版本",
"updateAvailable": "发现新版本 {latest}"
},
"auth": {
"expired": "认证已过期,请重新登录",