Migrate AI runtime to SDK and hardcode Zhipu coding endpoint
This commit is contained in:
21
.env.example
21
.env.example
@@ -12,21 +12,12 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-secret
|
||||
BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz
|
||||
|
||||
# OpenClaw / ZeroClaw (OpenAI-compatible)
|
||||
# Leave empty to use internal `openclaw` Compose service (`http://openclaw:4000`).
|
||||
# Set this only when targeting an external OpenClaw endpoint.
|
||||
OPENCLAW_BASE_URL=
|
||||
OPENCLAW_API_KEY=
|
||||
OPENCLAW_MODEL=zeroclaw
|
||||
OPENCLAW_AUTH_MODE=none
|
||||
OPENCLAW_PORT=4000
|
||||
|
||||
# OpenClaw container source for Docker Compose
|
||||
OPENCLAW_IMAGE=coolify-zeroclaw:local
|
||||
# If this repo is private, include credentials in the URL:
|
||||
# OPENCLAW_BUILD_CONTEXT=https://<username>:<token>@gitea-hs848cs8kgs840o8c8s8cwkk.b11studio.xyz/Francy51/coolify_ZeroClaw.git
|
||||
OPENCLAW_BUILD_CONTEXT=https://gitea-hs848cs8kgs840o8c8s8cwkk.b11studio.xyz/Francy51/coolify_ZeroClaw.git
|
||||
OPENCLAW_DOCKERFILE=Dockerfile
|
||||
# AI SDK (Vercel) + Zhipu provider
|
||||
# Legacy OPENCLAW_* variables are removed and no longer read by the app.
|
||||
# Coding endpoint is hardcoded in runtime: https://api.z.ai/api/coding/paas/v4
|
||||
ZHIPU_API_KEY=
|
||||
ZHIPU_MODEL=glm-4.7-flashx
|
||||
AI_TEMPERATURE=0.2
|
||||
|
||||
# SEC API etiquette
|
||||
SEC_USER_AGENT=Fiscal Clone <support@fiscal.local>
|
||||
|
||||
57
README.md
57
README.md
@@ -1,6 +1,6 @@
|
||||
# Fiscal Clone 3.0
|
||||
|
||||
Turbopack-first rebuild of a fiscal.ai-style terminal with OpenClaw integration.
|
||||
Turbopack-first rebuild of a fiscal.ai-style terminal with Vercel AI SDK integration.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -14,7 +14,7 @@ Turbopack-first rebuild of a fiscal.ai-style terminal with OpenClaw integration.
|
||||
- Eden Treaty for type-safe frontend API calls
|
||||
- Workflow DevKit Local World for background task execution
|
||||
- SQLite-backed domain storage (watchlist, holdings, filings, tasks, insights)
|
||||
- OpenClaw/ZeroClaw analysis via OpenAI-compatible chat endpoint
|
||||
- Vercel AI SDK (`ai`) + Zhipu community provider (`zhipu-ai-provider`) for analysis tasks (hardcoded to `https://api.z.ai/api/coding/paas/v4`)
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -44,10 +44,8 @@ cp .env.example .env
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
For local Docker, host port mapping comes from `docker-compose.override.yml` (default `http://localhost:3000` via `APP_PORT`, and `http://localhost:4000` for OpenClaw via `OPENCLAW_PORT`).
|
||||
OpenClaw is included as a Compose service (`openclaw`) and is built by default from `OPENCLAW_BUILD_CONTEXT` (set to `Francy51/coolify_ZeroClaw` in `.env.example`).
|
||||
If that Gitea repo is private, set `OPENCLAW_BUILD_CONTEXT` with embedded credentials (`https://<username>:<token>@.../coolify_ZeroClaw.git`) or point `OPENCLAW_IMAGE` to a prebuilt image you can pull.
|
||||
The app container defaults to `OPENCLAW_BASE_URL=http://openclaw:4000` unless you explicitly set a different `OPENCLAW_BASE_URL`.
|
||||
For local Docker, host port mapping comes from `docker-compose.override.yml` (default `http://localhost:3000` via `APP_PORT`).
|
||||
The app calls Zhipu directly via AI SDK and always targets the Coding API endpoint (`https://api.z.ai/api/coding/paas/v4`), so no extra AI gateway container is required.
|
||||
On container startup, the app applies Drizzle migrations automatically before launching Next.js.
|
||||
The app stores SQLite data in Docker volume `fiscal_sqlite_data` (mounted to `/app/data`) and workflow local data in `fiscal_workflow_data` (mounted to `/app/.workflow-data`).
|
||||
|
||||
@@ -88,19 +86,10 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-secret
|
||||
BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz
|
||||
|
||||
OPENCLAW_BASE_URL=
|
||||
OPENCLAW_API_KEY=
|
||||
OPENCLAW_MODEL=zeroclaw
|
||||
OPENCLAW_AUTH_MODE=none
|
||||
OPENCLAW_PORT=4000
|
||||
OPENCLAW_IMAGE=coolify-zeroclaw:local
|
||||
OPENCLAW_BUILD_CONTEXT=https://gitea-hs848cs8kgs840o8c8s8cwkk.b11studio.xyz/Francy51/coolify_ZeroClaw.git
|
||||
OPENCLAW_DOCKERFILE=Dockerfile
|
||||
# for OPENCLAW_AUTH_MODE=basic
|
||||
# OPENCLAW_BASIC_AUTH_USERNAME=your_nginx_user
|
||||
# OPENCLAW_BASIC_AUTH_PASSWORD=your_nginx_password
|
||||
# optional: forward API key in a custom header when using basic/none auth
|
||||
# OPENCLAW_API_KEY_HEADER=X-OpenClaw-API-Key
|
||||
ZHIPU_API_KEY=
|
||||
ZHIPU_MODEL=glm-4.7-flashx
|
||||
# optional generation tuning
|
||||
AI_TEMPERATURE=0.2
|
||||
SEC_USER_AGENT=Fiscal Clone <support@fiscal.local>
|
||||
|
||||
WORKFLOW_TARGET_WORLD=local
|
||||
@@ -108,19 +97,27 @@ WORKFLOW_LOCAL_DATA_DIR=.workflow-data
|
||||
WORKFLOW_LOCAL_QUEUE_CONCURRENCY=100
|
||||
```
|
||||
|
||||
If OpenClaw is unset or invalidly configured, the app uses local fallback analysis so task workflows still run.
|
||||
If `ZHIPU_API_KEY` is unset, the app uses local fallback analysis so task workflows still run.
|
||||
`ZHIPU_BASE_URL` is deprecated and ignored; runtime always uses `https://api.z.ai/api/coding/paas/v4`.
|
||||
|
||||
For OpenClaw behind Nginx Basic Auth, use:
|
||||
## Migration from OPENCLAW_* to ZHIPU_*
|
||||
|
||||
```env
|
||||
OPENCLAW_BASE_URL=https://your-nginx-host
|
||||
OPENCLAW_AUTH_MODE=basic
|
||||
OPENCLAW_BASIC_AUTH_USERNAME=your_nginx_user
|
||||
OPENCLAW_BASIC_AUTH_PASSWORD=your_nginx_password
|
||||
# optional if upstream still needs an API key in a non-Authorization header
|
||||
OPENCLAW_API_KEY=your_key
|
||||
OPENCLAW_API_KEY_HEADER=X-OpenClaw-API-Key
|
||||
```
|
||||
This repository now uses direct provider calls through AI SDK. The legacy gateway container path is removed.
|
||||
The AI runtime endpoint is fixed to `https://api.z.ai/api/coding/paas/v4`.
|
||||
|
||||
| Legacy variable | Replacement |
|
||||
| --- | --- |
|
||||
| `OPENCLAW_API_KEY` | `ZHIPU_API_KEY` |
|
||||
| `OPENCLAW_MODEL` | `ZHIPU_MODEL` |
|
||||
| `OPENCLAW_BASE_URL` | Removed (runtime endpoint is hardcoded to `https://api.z.ai/api/coding/paas/v4`) |
|
||||
| `OPENCLAW_AUTH_MODE` | Removed (not needed with direct provider calls) |
|
||||
| `OPENCLAW_BASIC_AUTH_USERNAME` | Removed |
|
||||
| `OPENCLAW_BASIC_AUTH_PASSWORD` | Removed |
|
||||
| `OPENCLAW_API_KEY_HEADER` | Removed |
|
||||
| `OPENCLAW_PORT` | Removed (no gateway service) |
|
||||
| `OPENCLAW_IMAGE` | Removed |
|
||||
| `OPENCLAW_BUILD_CONTEXT` | Removed |
|
||||
| `OPENCLAW_DOCKERFILE` | Removed |
|
||||
|
||||
## API surface
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Fiscal Clone',
|
||||
description: 'Futuristic fiscal intelligence terminal with durable tasks and OpenClaw integration.'
|
||||
description: 'Futuristic fiscal intelligence terminal with durable tasks and AI SDK integration.'
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function CommandCenterPage() {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="AI Brief" subtitle="Latest portfolio insight from OpenClaw/ZeroClaw" className="xl:col-span-2">
|
||||
<Panel title="AI Brief" subtitle="Latest portfolio insight from AI SDK (Zhipu)" className="xl:col-span-2">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading intelligence output...</p>
|
||||
) : state.latestInsight ? (
|
||||
|
||||
26
bun.lock
26
bun.lock
@@ -8,6 +8,7 @@
|
||||
"@elysiajs/eden": "^1.4.8",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"ai": "^6.0.104",
|
||||
"better-auth": "^1.4.19",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -19,6 +20,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.7.0",
|
||||
"workflow": "^4.1.0-beta.60",
|
||||
"zhipu-ai-provider": "^0.2.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.3.0",
|
||||
@@ -34,6 +36,12 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.58", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2e1hBCKsd+7m0hELwrakR1QDfZfFhz9PF2d4qb8TxQueEyApo7ydlEWRpXeKC+KdA2FRV21dMb1G6FxdeNDa2w=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||
@@ -348,6 +356,8 @@
|
||||
|
||||
"@oclif/plugin-help": ["@oclif/plugin-help@6.2.31", "", { "dependencies": { "@oclif/core": "^4" } }, "sha512-o4xR98DEFf+VqY+M9B3ZooTm2T/mlGvyBHwHcnsPJCEnvzHqEA9xUlCUK4jm7FBXHhkppziMgCC2snsueLoIpQ=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@react-router/node": ["@react-router/node@7.13.0", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.13.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-Mhr3fAou19oc/S93tKMIBHwCPfqLpWyWM/m0NWd3pJh/wZin8/9KhAdjwxhYbXw1TrTBZBLDENa35uZ+Y7oh3A=="],
|
||||
|
||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" } }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
|
||||
@@ -546,7 +556,7 @@
|
||||
|
||||
"@vercel/functions": ["@vercel/functions@3.4.3", "", { "dependencies": { "@vercel/oidc": "3.2.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-kA14KIUVgAY6VXbhZ5jjY+s0883cV3cZqIU3WhrSRxuJ9KvxatMjtmzl0K23HK59oOUjYl7HaE/eYMmhmqpZzw=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"@vercel/queue": ["@vercel/queue@0.0.0-alpha.38", "", { "dependencies": { "@vercel/oidc": "^3.0.5", "mixpart": "0.0.5-alpha.1" } }, "sha512-gSYpZrYy1LpzfXqDMUZX7xEIQxyhelvMDgthxijs495kHIxWC65S0C3vaAw5+3c1ujbPeJgLz8fn3SDTJspssw=="],
|
||||
|
||||
@@ -614,6 +624,8 @@
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"ai": ["ai@6.0.104", "", { "dependencies": { "@ai-sdk/gateway": "3.0.58", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-boYGxbtdsa1YX3uuN7BV0FvAL3sGq7p/RLAMonK94jyt5C7sKj6jfib3/wD12koqX53htLTI/l4tX0HqNFRMZQ=="],
|
||||
|
||||
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
|
||||
@@ -864,6 +876,8 @@
|
||||
|
||||
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
|
||||
|
||||
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
@@ -1030,6 +1044,8 @@
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
||||
@@ -1468,6 +1484,8 @@
|
||||
|
||||
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
|
||||
|
||||
"zhipu-ai-provider": ["zhipu-ai-provider@0.2.2", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.0" } }, "sha512-UjX1ho4DI9ICUv/mrpAnzmrRe5/LXrGkS5hF6h4WDY2aup5GketWWopFzWYCqsbArXAM5wbzzdH9QzZusgGiBg=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.3", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-tma6D8/xHZHJEUqmr6ksZjZ0onyIUqKDQLyp50ttZJmS0IwFYzxBgp5CxFvpYAnah52V3UtgrqGA6E83gtT7NQ=="],
|
||||
@@ -1680,6 +1698,8 @@
|
||||
|
||||
"@workflow/world-local/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||
|
||||
"@workflow/world-vercel/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
|
||||
|
||||
"@workflow/world-vercel/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
|
||||
|
||||
"@xhmikosr/archive-type/file-type": ["file-type@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="],
|
||||
@@ -1772,6 +1792,10 @@
|
||||
|
||||
"wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"zhipu-ai-provider/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
|
||||
|
||||
"zhipu-ai-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
@@ -18,7 +18,7 @@ export function AuthShell({ title, subtitle, children, footer }: AuthShellProps)
|
||||
<p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold text-[color:var(--terminal-bright)]">Autonomous Analyst Desk</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-muted)]">
|
||||
Secure entry into filings intelligence, portfolio diagnostics, and async AI workflows connected to OpenClaw/ZeroClaw.
|
||||
Secure entry into filings intelligence, portfolio diagnostics, and async AI workflows powered by AI SDK.
|
||||
</p>
|
||||
<Link
|
||||
href="https://www.sec.gov/"
|
||||
|
||||
@@ -94,7 +94,7 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)
|
||||
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">{displayName}</p>
|
||||
{role ? <p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Role: {role}</p> : null}
|
||||
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]">
|
||||
OpenClaw and market data are driven by environment configuration and live API tasks.
|
||||
AI and market data are driven by environment configuration and live API tasks.
|
||||
</p>
|
||||
<Button className="mt-3 w-full" variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
|
||||
<LogOut className="size-4" />
|
||||
|
||||
@@ -5,6 +5,3 @@ services:
|
||||
environment:
|
||||
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-http://localhost:3000}
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:3000}
|
||||
openclaw:
|
||||
ports:
|
||||
- '${OPENCLAW_PORT:-4000}:4000'
|
||||
|
||||
@@ -18,13 +18,9 @@ services:
|
||||
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-https://fiscal.b11studio.xyz}
|
||||
BETTER_AUTH_ADMIN_USER_IDS: ${BETTER_AUTH_ADMIN_USER_IDS:-}
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-https://fiscal.b11studio.xyz}
|
||||
OPENCLAW_BASE_URL: ${OPENCLAW_BASE_URL:-http://openclaw:4000}
|
||||
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
|
||||
OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw}
|
||||
OPENCLAW_AUTH_MODE: ${OPENCLAW_AUTH_MODE:-none}
|
||||
OPENCLAW_BASIC_AUTH_USERNAME: ${OPENCLAW_BASIC_AUTH_USERNAME:-}
|
||||
OPENCLAW_BASIC_AUTH_PASSWORD: ${OPENCLAW_BASIC_AUTH_PASSWORD:-}
|
||||
OPENCLAW_API_KEY_HEADER: ${OPENCLAW_API_KEY_HEADER:-}
|
||||
ZHIPU_API_KEY: ${ZHIPU_API_KEY:-}
|
||||
ZHIPU_MODEL: ${ZHIPU_MODEL:-glm-4.7-flashx}
|
||||
AI_TEMPERATURE: ${AI_TEMPERATURE:-0.2}
|
||||
SEC_USER_AGENT: ${SEC_USER_AGENT:-Fiscal Clone <support@fiscal.local>}
|
||||
WORKFLOW_TARGET_WORLD: local
|
||||
WORKFLOW_LOCAL_DATA_DIR: ${WORKFLOW_LOCAL_DATA_DIR:-/app/.workflow-data}
|
||||
@@ -32,9 +28,6 @@ services:
|
||||
volumes:
|
||||
- fiscal_sqlite_data:/app/data
|
||||
- fiscal_workflow_data:/app/.workflow-data
|
||||
depends_on:
|
||||
openclaw:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/health || exit 1"]
|
||||
interval: 30s
|
||||
@@ -43,15 +36,6 @@ services:
|
||||
expose:
|
||||
- "3000"
|
||||
|
||||
openclaw:
|
||||
image: ${OPENCLAW_IMAGE:-coolify-zeroclaw:local}
|
||||
build:
|
||||
context: ${OPENCLAW_BUILD_CONTEXT:-https://gitea-hs848cs8kgs840o8c8s8cwkk.b11studio.xyz/Francy51/coolify_ZeroClaw.git}
|
||||
dockerfile: ${OPENCLAW_DOCKERFILE:-Dockerfile}
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "4000"
|
||||
|
||||
volumes:
|
||||
fiscal_sqlite_data:
|
||||
fiscal_workflow_data:
|
||||
|
||||
192
lib/server/ai.test.ts
Normal file
192
lib/server/ai.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
import {
|
||||
__resetAiWarningsForTests,
|
||||
getAiConfig,
|
||||
runAiAnalysis
|
||||
} from './ai';
|
||||
|
||||
type EnvSource = Record<string, string | undefined>;
|
||||
const CODING_API_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
|
||||
|
||||
describe('ai config and runtime', () => {
|
||||
beforeEach(() => {
|
||||
__resetAiWarningsForTests();
|
||||
});
|
||||
|
||||
it('uses coding endpoint defaults when optional env values are missing', () => {
|
||||
const config = getAiConfig({
|
||||
env: {
|
||||
ZHIPU_API_KEY: 'key'
|
||||
},
|
||||
warn: () => {}
|
||||
});
|
||||
|
||||
expect(config.apiKey).toBe('key');
|
||||
expect(config.baseUrl).toBe(CODING_API_BASE_URL);
|
||||
expect(config.model).toBe('glm-4.7-flashx');
|
||||
expect(config.temperature).toBe(0.2);
|
||||
});
|
||||
|
||||
it('ignores ZHIPU_BASE_URL and keeps the hardcoded coding endpoint', () => {
|
||||
const config = getAiConfig({
|
||||
env: {
|
||||
ZHIPU_API_KEY: 'key',
|
||||
ZHIPU_BASE_URL: 'https://api.z.ai/api/paas/v4'
|
||||
},
|
||||
warn: () => {}
|
||||
});
|
||||
|
||||
expect(config.baseUrl).toBe(CODING_API_BASE_URL);
|
||||
});
|
||||
|
||||
it('clamps temperature into [0, 2]', () => {
|
||||
const negative = getAiConfig({
|
||||
env: {
|
||||
ZHIPU_API_KEY: 'key',
|
||||
AI_TEMPERATURE: '-2'
|
||||
},
|
||||
warn: () => {}
|
||||
});
|
||||
expect(negative.temperature).toBe(0);
|
||||
|
||||
const high = getAiConfig({
|
||||
env: {
|
||||
ZHIPU_API_KEY: 'key',
|
||||
AI_TEMPERATURE: '9'
|
||||
},
|
||||
warn: () => {}
|
||||
});
|
||||
expect(high.temperature).toBe(2);
|
||||
|
||||
const invalid = getAiConfig({
|
||||
env: {
|
||||
ZHIPU_API_KEY: 'key',
|
||||
AI_TEMPERATURE: 'not-a-number'
|
||||
},
|
||||
warn: () => {}
|
||||
});
|
||||
expect(invalid.temperature).toBe(0.2);
|
||||
});
|
||||
|
||||
it('returns fallback output when ZHIPU_API_KEY is missing', async () => {
|
||||
const generate = mock(async () => ({ text: 'should-not-be-used' }));
|
||||
|
||||
const result = await runAiAnalysis(
|
||||
'Prompt line one\nPrompt line two',
|
||||
'System prompt',
|
||||
{
|
||||
env: {},
|
||||
warn: () => {},
|
||||
generate
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.provider).toBe('local-fallback');
|
||||
expect(result.model).toBe('glm-4.7-flashx');
|
||||
expect(result.text).toContain('AI SDK fallback mode is active');
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns once when deprecated OPENCLAW_* env vars are present', () => {
|
||||
const warn = mock((_message: string) => {});
|
||||
|
||||
const env: EnvSource = {
|
||||
OPENCLAW_API_KEY: 'legacy-key',
|
||||
OPENCLAW_BASE_URL: 'http://legacy.local',
|
||||
ZHIPU_API_KEY: 'new-key'
|
||||
};
|
||||
|
||||
getAiConfig({ env, warn });
|
||||
getAiConfig({ env, warn });
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('warns once when ZHIPU_BASE_URL is set because coding endpoint is hardcoded', () => {
|
||||
const warn = mock((_message: string) => {});
|
||||
|
||||
const env: EnvSource = {
|
||||
ZHIPU_API_KEY: 'new-key',
|
||||
ZHIPU_BASE_URL: 'https://api.z.ai/api/paas/v4'
|
||||
};
|
||||
|
||||
getAiConfig({ env, warn });
|
||||
getAiConfig({ env, warn });
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not consume OPENCLAW_* values for live generation', async () => {
|
||||
const generate = mock(async () => ({ text: 'should-not-be-used' }));
|
||||
const warn = mock((_message: string) => {});
|
||||
|
||||
const result = await runAiAnalysis('Legacy-only env prompt', undefined, {
|
||||
env: {
|
||||
OPENCLAW_API_KEY: 'legacy-key',
|
||||
OPENCLAW_MODEL: 'legacy-model'
|
||||
},
|
||||
warn,
|
||||
generate
|
||||
});
|
||||
|
||||
expect(result.provider).toBe('local-fallback');
|
||||
expect(result.model).toBe('glm-4.7-flashx');
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses configured ZHIPU values and injected generator when API key exists', async () => {
|
||||
const createModel = mock((config: {
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
temperature: number;
|
||||
}) => {
|
||||
expect(config.apiKey).toBe('new-key');
|
||||
expect(config.baseUrl).toBe(CODING_API_BASE_URL);
|
||||
expect(config.model).toBe('glm-4-plus');
|
||||
expect(config.temperature).toBe(0.4);
|
||||
return { modelId: config.model };
|
||||
});
|
||||
const generate = mock(async (input: {
|
||||
model: unknown;
|
||||
system?: string;
|
||||
prompt: string;
|
||||
temperature: number;
|
||||
}) => {
|
||||
expect(input.system).toBe('Use concise style');
|
||||
expect(input.prompt).toBe('Analyze this filing');
|
||||
expect(input.temperature).toBe(0.4);
|
||||
return { text: ' Generated insight ' };
|
||||
});
|
||||
|
||||
const result = await runAiAnalysis('Analyze this filing', 'Use concise style', {
|
||||
env: {
|
||||
ZHIPU_API_KEY: 'new-key',
|
||||
ZHIPU_MODEL: 'glm-4-plus',
|
||||
ZHIPU_BASE_URL: 'https://api.z.ai/api/paas/v4',
|
||||
AI_TEMPERATURE: '0.4'
|
||||
},
|
||||
warn: () => {},
|
||||
createModel,
|
||||
generate
|
||||
});
|
||||
|
||||
expect(createModel).toHaveBeenCalledTimes(1);
|
||||
expect(generate).toHaveBeenCalledTimes(1);
|
||||
expect(result.provider).toBe('zhipu');
|
||||
expect(result.model).toBe('glm-4-plus');
|
||||
expect(result.text).toBe('Generated insight');
|
||||
});
|
||||
|
||||
it('throws when AI generation returns an empty response', async () => {
|
||||
await expect(
|
||||
runAiAnalysis('Analyze this filing', undefined, {
|
||||
env: { ZHIPU_API_KEY: 'new-key' },
|
||||
warn: () => {},
|
||||
createModel: () => ({}),
|
||||
generate: async () => ({ text: ' ' })
|
||||
})
|
||||
).rejects.toThrow('AI SDK returned an empty response');
|
||||
});
|
||||
});
|
||||
190
lib/server/ai.ts
Normal file
190
lib/server/ai.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { generateText } from 'ai';
|
||||
import { createZhipu } from 'zhipu-ai-provider';
|
||||
|
||||
type AiConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
};
|
||||
|
||||
type EnvSource = Record<string, string | undefined>;
|
||||
|
||||
type GetAiConfigOptions = {
|
||||
env?: EnvSource;
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
|
||||
type AiGenerateInput = {
|
||||
model: unknown;
|
||||
system?: string;
|
||||
prompt: string;
|
||||
temperature: number;
|
||||
};
|
||||
|
||||
type AiGenerateOutput = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type RunAiAnalysisOptions = GetAiConfigOptions & {
|
||||
createModel?: (config: AiConfig) => unknown;
|
||||
generate?: (input: AiGenerateInput) => Promise<AiGenerateOutput>;
|
||||
};
|
||||
|
||||
const DEPRECATED_LEGACY_GATEWAY_ENV_KEYS = [
|
||||
'OPENCLAW_BASE_URL',
|
||||
'OPENCLAW_API_KEY',
|
||||
'OPENCLAW_MODEL',
|
||||
'OPENCLAW_AUTH_MODE',
|
||||
'OPENCLAW_BASIC_AUTH_USERNAME',
|
||||
'OPENCLAW_BASIC_AUTH_PASSWORD',
|
||||
'OPENCLAW_API_KEY_HEADER',
|
||||
'OPENCLAW_PORT',
|
||||
'OPENCLAW_IMAGE',
|
||||
'OPENCLAW_BUILD_CONTEXT',
|
||||
'OPENCLAW_DOCKERFILE'
|
||||
] as const;
|
||||
|
||||
const CODING_API_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
|
||||
|
||||
let warnedDeprecatedGatewayEnv = false;
|
||||
let warnedIgnoredZhipuBaseUrl = false;
|
||||
|
||||
function envValue(name: string, env: EnvSource = process.env) {
|
||||
const value = env[name];
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function parseTemperature(value: string | undefined) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 0.2;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(parsed, 0), 2);
|
||||
}
|
||||
|
||||
function warnDeprecatedGatewayEnv(env: EnvSource, warn: (message: string) => void) {
|
||||
if (warnedDeprecatedGatewayEnv) {
|
||||
return;
|
||||
}
|
||||
|
||||
const presentKeys = DEPRECATED_LEGACY_GATEWAY_ENV_KEYS.filter((key) => Boolean(envValue(key, env)));
|
||||
if (presentKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
warnedDeprecatedGatewayEnv = true;
|
||||
warn(
|
||||
`[AI SDK] Deprecated OPENCLAW_* variables are ignored after migration: ${presentKeys.join(', ')}. Use ZHIPU_API_KEY, ZHIPU_MODEL, and AI_TEMPERATURE.`
|
||||
);
|
||||
}
|
||||
|
||||
function warnIgnoredZhipuBaseUrl(env: EnvSource, warn: (message: string) => void) {
|
||||
if (warnedIgnoredZhipuBaseUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredBaseUrl = envValue('ZHIPU_BASE_URL', env);
|
||||
if (!configuredBaseUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
warnedIgnoredZhipuBaseUrl = true;
|
||||
warn(
|
||||
`[AI SDK] ZHIPU_BASE_URL is ignored. The Coding API endpoint is hardcoded to ${CODING_API_BASE_URL}.`
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackResponse(prompt: string) {
|
||||
const clipped = prompt.split('\n').slice(0, 6).join(' ').slice(0, 260);
|
||||
|
||||
return [
|
||||
'AI SDK fallback mode is active (Zhipu configuration is missing).',
|
||||
'Thesis: Portfolio remains analyzable with local heuristics until live model access is configured.',
|
||||
'Risk scan: Concentration and filing sentiment should be monitored after each sync cycle.',
|
||||
`Context digest: ${clipped}`
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
function defaultCreateModel(config: AiConfig) {
|
||||
const zhipu = createZhipu({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl
|
||||
});
|
||||
|
||||
return zhipu(config.model);
|
||||
}
|
||||
|
||||
async function defaultGenerate(input: AiGenerateInput): Promise<AiGenerateOutput> {
|
||||
const result = await generateText({
|
||||
model: input.model as never,
|
||||
system: input.system,
|
||||
prompt: input.prompt,
|
||||
temperature: input.temperature
|
||||
});
|
||||
|
||||
return { text: result.text };
|
||||
}
|
||||
|
||||
export function getAiConfig(options?: GetAiConfigOptions) {
|
||||
const env = options?.env ?? process.env;
|
||||
warnDeprecatedGatewayEnv(env, options?.warn ?? console.warn);
|
||||
warnIgnoredZhipuBaseUrl(env, options?.warn ?? console.warn);
|
||||
|
||||
return {
|
||||
apiKey: envValue('ZHIPU_API_KEY', env),
|
||||
baseUrl: CODING_API_BASE_URL,
|
||||
model: envValue('ZHIPU_MODEL', env) ?? 'glm-4.7-flashx',
|
||||
temperature: parseTemperature(envValue('AI_TEMPERATURE', env))
|
||||
} satisfies AiConfig;
|
||||
}
|
||||
|
||||
export function isAiConfigured(options?: GetAiConfigOptions) {
|
||||
const config = getAiConfig(options);
|
||||
return Boolean(config.apiKey);
|
||||
}
|
||||
|
||||
export async function runAiAnalysis(prompt: string, systemPrompt?: string, options?: RunAiAnalysisOptions) {
|
||||
const config = getAiConfig(options);
|
||||
|
||||
if (!config.apiKey) {
|
||||
return {
|
||||
provider: 'local-fallback',
|
||||
model: config.model,
|
||||
text: fallbackResponse(prompt)
|
||||
};
|
||||
}
|
||||
|
||||
const createModel = options?.createModel ?? defaultCreateModel;
|
||||
const generate = options?.generate ?? defaultGenerate;
|
||||
const model = createModel(config);
|
||||
|
||||
const result = await generate({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt,
|
||||
temperature: config.temperature
|
||||
});
|
||||
|
||||
const text = result.text.trim();
|
||||
if (!text) {
|
||||
throw new Error('AI SDK returned an empty response');
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'zhipu',
|
||||
model: config.model,
|
||||
text
|
||||
};
|
||||
}
|
||||
|
||||
export function __resetAiWarningsForTests() {
|
||||
warnedDeprecatedGatewayEnv = false;
|
||||
warnedIgnoredZhipuBaseUrl = false;
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
type ChatCompletionResponse = {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
type OpenClawAuthMode = 'bearer' | 'basic' | 'none';
|
||||
|
||||
type OpenClawConfig = {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
authMode: OpenClawAuthMode;
|
||||
basicAuthUsername?: string;
|
||||
basicAuthPassword?: string;
|
||||
apiKeyHeader?: string;
|
||||
};
|
||||
|
||||
function envValue(name: string) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_MODEL = 'zeroclaw';
|
||||
const DEFAULT_AUTH_MODE: OpenClawAuthMode = 'bearer';
|
||||
|
||||
function parseAuthMode(value: string | undefined): OpenClawAuthMode {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === 'basic' || normalized === 'none') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return DEFAULT_AUTH_MODE;
|
||||
}
|
||||
|
||||
function hasSupportedProtocol(url: string) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCompletionsUrl(baseUrl: string) {
|
||||
if (!hasSupportedProtocol(baseUrl)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const withSlash = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
return new URL('v1/chat/completions', withSlash).toString();
|
||||
}
|
||||
|
||||
function hasRequiredAuth(config: OpenClawConfig) {
|
||||
if (config.authMode === 'none') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (config.authMode === 'basic') {
|
||||
return Boolean(config.basicAuthUsername && config.basicAuthPassword);
|
||||
}
|
||||
|
||||
return Boolean(config.apiKey);
|
||||
}
|
||||
|
||||
function buildAuthHeaders(config: OpenClawConfig) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (config.authMode === 'basic' && config.basicAuthUsername && config.basicAuthPassword) {
|
||||
const credentials = Buffer
|
||||
.from(`${config.basicAuthUsername}:${config.basicAuthPassword}`, 'utf8')
|
||||
.toString('base64');
|
||||
headers.Authorization = `Basic ${credentials}`;
|
||||
} else if (config.authMode === 'bearer' && config.apiKey) {
|
||||
headers.Authorization = `Bearer ${config.apiKey}`;
|
||||
}
|
||||
|
||||
if (config.apiKey && config.apiKeyHeader) {
|
||||
headers[config.apiKeyHeader] = config.apiKey;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function fallbackResponse(prompt: string) {
|
||||
const clipped = prompt.split('\n').slice(0, 6).join(' ').slice(0, 260);
|
||||
|
||||
return [
|
||||
'OpenClaw fallback mode is active (configuration is missing or invalid).',
|
||||
'Thesis: Portfolio remains analyzable with local heuristics until live model access is configured.',
|
||||
'Risk scan: Concentration and filing sentiment should be monitored after each sync cycle.',
|
||||
`Context digest: ${clipped}`
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
export function getOpenClawConfig() {
|
||||
return {
|
||||
baseUrl: envValue('OPENCLAW_BASE_URL'),
|
||||
apiKey: envValue('OPENCLAW_API_KEY'),
|
||||
model: envValue('OPENCLAW_MODEL') ?? DEFAULT_MODEL,
|
||||
authMode: parseAuthMode(envValue('OPENCLAW_AUTH_MODE')),
|
||||
basicAuthUsername: envValue('OPENCLAW_BASIC_AUTH_USERNAME'),
|
||||
basicAuthPassword: envValue('OPENCLAW_BASIC_AUTH_PASSWORD'),
|
||||
apiKeyHeader: envValue('OPENCLAW_API_KEY_HEADER')
|
||||
} satisfies OpenClawConfig;
|
||||
}
|
||||
|
||||
export function isOpenClawConfigured() {
|
||||
const config = getOpenClawConfig();
|
||||
return Boolean(config.baseUrl && hasSupportedProtocol(config.baseUrl) && hasRequiredAuth(config));
|
||||
}
|
||||
|
||||
export async function runOpenClawAnalysis(prompt: string, systemPrompt?: string) {
|
||||
const config = getOpenClawConfig();
|
||||
const endpoint = config.baseUrl ? buildCompletionsUrl(config.baseUrl) : undefined;
|
||||
|
||||
if (!endpoint || !hasRequiredAuth(config)) {
|
||||
return {
|
||||
provider: 'local-fallback',
|
||||
model: config.model,
|
||||
text: fallbackResponse(prompt)
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: buildAuthHeaders(config),
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
temperature: 0.2,
|
||||
messages: [
|
||||
systemPrompt
|
||||
? { role: 'system', content: systemPrompt }
|
||||
: null,
|
||||
{ role: 'user', content: prompt }
|
||||
].filter(Boolean)
|
||||
}),
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`OpenClaw request failed (${response.status}): ${body.slice(0, 220)}`);
|
||||
}
|
||||
|
||||
const payload = await response.json() as ChatCompletionResponse;
|
||||
const text = payload.choices?.[0]?.message?.content?.trim();
|
||||
|
||||
if (!text) {
|
||||
throw new Error('OpenClaw returned an empty response');
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'openclaw',
|
||||
model: config.model,
|
||||
text
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Filing, Holding, Task } from '@/lib/types';
|
||||
import { runOpenClawAnalysis } from '@/lib/server/openclaw';
|
||||
import { runAiAnalysis } from '@/lib/server/ai';
|
||||
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
||||
import { getQuote } from '@/lib/server/prices';
|
||||
import {
|
||||
@@ -143,7 +143,7 @@ async function processAnalyzeFiling(task: Task) {
|
||||
'Return concise sections: Thesis, Red Flags, Follow-up Questions, Portfolio Impact.'
|
||||
].join('\n');
|
||||
|
||||
const analysis = await runOpenClawAnalysis(prompt, 'Use concise institutional analyst language.');
|
||||
const analysis = await runAiAnalysis(prompt, 'Use concise institutional analyst language.');
|
||||
|
||||
await saveFilingAnalysis(accessionNumber, {
|
||||
provider: analysis.provider,
|
||||
@@ -186,7 +186,7 @@ async function processPortfolioInsights(task: Task) {
|
||||
'Respond with: 1) health score (0-100), 2) top 3 risks, 3) top 3 opportunities, 4) next actions in 7 days.'
|
||||
].join('\n');
|
||||
|
||||
const analysis = await runOpenClawAnalysis(prompt, 'Act as a risk-aware buy-side analyst.');
|
||||
const analysis = await runAiAnalysis(prompt, 'Act as a risk-aware buy-side analyst.');
|
||||
|
||||
await createPortfolioInsight({
|
||||
userId,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@elysiajs/eden": "^1.4.8",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"ai": "^6.0.104",
|
||||
"better-auth": "^1.4.19",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -25,7 +26,8 @@
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.7.0",
|
||||
"workflow": "^4.1.0-beta.60"
|
||||
"workflow": "^4.1.0-beta.60",
|
||||
"zhipu-ai-provider": "^0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.3.0",
|
||||
|
||||
Reference in New Issue
Block a user