diff --git a/.github/workflows/glm-review.yml b/.github/workflows/glm-review.yml new file mode 100644 index 00000000..f7ad52a9 --- /dev/null +++ b/.github/workflows/glm-review.yml @@ -0,0 +1,191 @@ +name: GLM PR Review + +# PR 打开 / reopen / 每次 push 新 commit(synchronize)时自动 review diff。 +# 防邮件洪水靠三道闸而非「少触发」:① concurrency + cancel-in-progress 让同一 PR +# 新 push 立刻取消上一轮;② review step 只维护**一条 sticky 总结评论** +# (gh pr comment --edit-last 原地覆盖,GitHub 对编辑评论不发邮件,仅首条发一封); +# ③ 不逐行贴 inline 评论。需对历史 commit 重审可去 Actions 页跑 workflow_dispatch。 +# 非阻塞设计:即使 GLM 出错(API 超时、token 过期等), +# 本 workflow 不会阻止 PR 合并。review 评论是锦上添花,不是门禁。 +# Auth: 智谱 GLM API key 存在 GitHub Actions Secret ZHIPUAI_API_KEY + +on: + pull_request: + types: [opened, reopened, synchronize] + workflow_dispatch: + inputs: + pr_number: + description: 手动指定 PR 编号(留空 = 用 workflow_dispatch 的当前分支) + required: false + +# 同一 PR 新 push 立刻取消上一次 review,避免重复评论 +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + review: + name: GLM-5.2 · auto-review PR + runs-on: ubuntu-latest + continue-on-error: true + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch PR diff + id: diff + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: | + gh pr diff "$PR_NUMBER" --color never > /tmp/pr_diff.txt + echo "diff_lines=$(wc -l < /tmp/pr_diff.txt)" >> $GITHUB_OUTPUT + # diff 太长截断(GLM 上下文窗口虽大,但 token 和成本也要考虑) + if [ "$(wc -c < /tmp/pr_diff.txt)" -gt 200000 ]; then + head -c 200000 /tmp/pr_diff.txt > /tmp/pr_diff_truncated.txt + echo -e "\n\n[注意:diff 过大已截断至 200KB,完整 diff 请到 PR Files 页查看]" >> /tmp/pr_diff_truncated.txt + mv /tmp/pr_diff_truncated.txt /tmp/pr_diff.txt + fi + + - name: Fetch PR metadata + id: meta + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: | + echo "title<> $GITHUB_OUTPUT + gh pr view "$PR_NUMBER" --json title,body,headRefName,baseRefName --jq '"\(.title) | \(.headRefName) → \(.baseRefName)"' >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Call Zhipu GLM-5.2 for review + id: review + env: + ZHIPUAI_API_KEY: ${{ secrets.ZHIPUAI_API_KEY }} + PR_TITLE: ${{ steps.meta.outputs.title }} + run: | + DIFF_CONTENT=$(cat /tmp/pr_diff.txt | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))') + + PROMPT=$(cat << 'END_PROMPT' +你是一个资深代码审查者。请审查以下 PR diff。 + +## 审查要求 +1. 先一句话总结这个 PR 的目的 +2. 逐维度评估(命中才提,不硬凑): + - **正确性**:边界条件、空值、off-by-one、错误假设、异常路径没处理 + - **设计/架构**:职责放错层、越过模块边界、重复造轮子 + - **契约/兼容**:改了公共接口/schema/API 是否破坏现有调用方 + - **错误处理**:失败是被静默吞掉还是显式处理 + - **资源/性能**:无界增长、N+1、循环无上限 + - **安全**:注入、越权、密钥泄漏 +3. **severity 阈值**:只提 ≥ medium 的问题;nit/风格跳过 +4. **误报闸**:必须能说出具体失败场景,说不出就不提 +5. **不重复 lint**:ruff/tsc/mypy 已能抓的不要再提 +6. **格式要求**:用 JSON 输出,每条带 severity( critical|major|medium ) + file + line + summary + failure_scenario + +如果没有任何 medium 以上问题,只回复一个 JSON: {"summary": "LGTM,没发现问题", "findings": []} + +其他情况回复 JSON: +{ + "summary": "一句话总结", + "findings": [ + {"severity": "major", "file": "path/to/file.py", "line": 42, "summary": "描述", "failure_scenario": "什么输入/时序下出问题"}, + ... + ] +} +END_PROMPT + + RESPONSE=$(curl -s -w "\n%{http_code}" --request POST \ + --url "https://yuanyuaicloud.cn/v1/chat/completions" \ + --header "Authorization: Bearer $ZHIPUAI_API_KEY" \ + --header "Content-Type: application/json" \ + --data "$(python3 -c " +import json, os +d = { + 'model': 'glm-5.2', + 'messages': [ + {'role': 'system', 'content': '''$(echo "$PROMPT" | python3 -c 'import sys; print(sys.stdin.read().replace(chr(39), chr(39)+chr(39)+chr(39)))')'''}, + {'role': 'user', 'content': '## PR 标题\n$PR_TITLE\n\n## Diff\n$DIFF_CONTENT'} + ], + 'temperature': 0.1, + 'max_tokens': 4096 +} +print(json.dumps(d)) +")") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" != "200" ]; then + echo "API error: $HTTP_CODE $BODY" + echo "result='GLM API 调用失败(HTTP $HTTP_CODE),请检查 ZHIPUAI_API_KEY 和网络连接。'" > /tmp/review_body.txt + exit 0 + fi + + # 提取 content + CONTENT=$(echo "$BODY" | python3 -c 'import sys,json; print(json.loads(sys.stdin.read())["choices"][0]["message"]["content"])') + echo "$CONTENT" > /tmp/review_result.json + + # 生成评论正文 + python3 -c " +import json, sys +try: + with open('/tmp/review_result.json') as f: + data = json.load(f) +except json.JSONDecodeError: + # 不是 JSON 也接受(模型可能直接输出自然语言) + with open('/tmp/review_result.json') as f: + text = f.read() + with open('/tmp/review_body.txt', 'w') as out: + out.write(text) + sys.exit(0) + +lines = [] +lines.append('## 🤖 GLM-5.2 PR Review') +lines.append('') +lines.append(data.get('summary', '')) +lines.append('') + +findings = data.get('findings', []) +if not findings: + lines.append('✅ 未发现 medium 以上问题。') +else: + # 按 severity 排序 + severity_order = {'critical': 0, 'major': 1, 'medium': 2} + findings.sort(key=lambda x: severity_order.get(x.get('severity','medium'), 99)) + + for f in findings: + sev = f.get('severity', 'medium') + label = {'critical': '🔴', 'major': '🟠', 'medium': '🟡'}.get(sev, '🟡') + file_line = f.get('file', '?') + if f.get('line'): + file_line += f':{f[\"line\"]}' + lines.append(f'{label} **[{sev.upper()}]** {file_line}') + lines.append(f' - {f.get(\"summary\", \"\")}') + if f.get('failure_scenario'): + lines.append(f' - *失败场景:{f[\"failure_scenario\"]}*') + lines.append('') + +with open('/tmp/review_body.txt', 'w') as out: + out.write('\n'.join(lines)) +" + + - name: Post/Update sticky comment + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + run: | + REVIEW_BODY=$(cat /tmp/review_body.txt) + # 加标记行方便辨识 + MARKER="" + SHA=$(git rev-parse --short HEAD) + FULL_BODY="${MARKER}\nReview base: ${SHA}\n\n${REVIEW_BODY}" + gh pr comment "$PR_NUMBER" --edit-last --create-if-none --body "$(echo -e "$FULL_BODY")" + + - name: Log review status + if: always() + run: | + echo "GLM review completed (non-blocking)" \ No newline at end of file diff --git a/.github/workflows/glm.yml b/.github/workflows/glm.yml new file mode 100644 index 00000000..fcdf7f69 --- /dev/null +++ b/.github/workflows/glm.yml @@ -0,0 +1,142 @@ +name: GLM @mention 互动 + +# 在 PR/issue/review comment 里 @glm 触发 GLM-5.2 对话。 +# 本 workflow 不阻塞 CI/CD,失败不影响 PR 合并。 +# Auth: 智谱 GLM API key 存在 GitHub Actions Secret ZHIPUAI_API_KEY + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + issues: + types: [opened, assigned] + +jobs: + glm: + name: GLM-5.2 · @glm 触发 + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@glm')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@glm')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@glm')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@glm') || contains(github.event.issue.title, '@glm'))) + runs-on: ubuntu-latest + continue-on-error: true + permissions: + contents: read + pull-requests: write + issues: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Collect context + id: context + env: + GH_TOKEN: ${{ github.token }} + run: | + case "${{ github.event_name }}" in + issue_comment) + ISSUE_NUM="${{ github.event.issue.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## Issue #$ISSUE_NUM" >> $GITHUB_OUTPUT + gh issue view "$ISSUE_NUM" --json title,body --jq '"### \(.title)\n\n\(.body // "无正文")"' >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### @glm 评论" >> $GITHUB_OUTPUT + echo "${{ github.event.comment.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$ISSUE_NUM" >> $GITHUB_OUTPUT + echo "type=issue" >> $GITHUB_OUTPUT + ;; + pull_request_review_comment) + PR_NUM="${{ github.event.pull_request.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## PR #$PR_NUM Review Comment" >> $GITHUB_OUTPUT + echo "File: ${{ github.event.comment.path }}" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### @glm 评论" >> $GITHUB_OUTPUT + echo "${{ github.event.comment.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$PR_NUM" >> $GITHUB_OUTPUT + echo "type=pr" >> $GITHUB_OUTPUT + ;; + pull_request_review) + PR_NUM="${{ github.event.pull_request.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## PR #$PR_NUM Review" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### @glm 评论" >> $GITHUB_OUTPUT + echo "${{ github.event.review.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$PR_NUM" >> $GITHUB_OUTPUT + echo "type=pr" >> $GITHUB_OUTPUT + ;; + issues) + ISSUE_NUM="${{ github.event.issue.number }}" + echo "context<> $GITHUB_OUTPUT + echo "## Issue #$ISSUE_NUM" >> $GITHUB_OUTPUT + echo "### 标题" >> $GITHUB_OUTPUT + echo "${{ github.event.issue.title }}" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "### 正文" >> $GITHUB_OUTPUT + echo "${{ github.event.issue.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "target=$ISSUE_NUM" >> $GITHUB_OUTPUT + echo "type=issue" >> $GITHUB_OUTPUT + ;; + esac + + - name: Call Zhipu GLM-5.2 + id: call + env: + ZHIPUAI_API_KEY: ${{ secrets.ZHIPUAI_API_KEY }} + CONTEXT: ${{ steps.context.outputs.context }} + TARGET: ${{ steps.context.outputs.target }} + TYPE: ${{ steps.context.outputs.type }} + run: | + # 用 python3 构造请求体,避免 bash JSON 转义坑 + RESPONSE=$(curl -s -w "\n%{http_code}" --request POST \ + --url "https://yuanyuaicloud.cn/v1/chat/completions" \ + --header "Authorization: Bearer $ZHIPUAI_API_KEY" \ + --header "Content-Type: application/json" \ + --data "$(python3 -c " +import json, os +ctx = os.environ.get('CONTEXT', '') +d = { + 'model': 'glm-5.2', + 'messages': [ + {'role': 'system', 'content': '你是 Inalpha 仓库的 AI 助手。用户通过 @glm 触发你的回复。请用中文回答。'}, + {'role': 'user', 'content': ctx} + ], + 'temperature': 0.7, + 'max_tokens': 2048 +} +print(json.dumps(d)) +")") + + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" != "200" ]; then + echo "API error: $HTTP_CODE $BODY" + exit 0 + fi + + CONTENT=$(echo "$BODY" | python3 -c 'import sys,json; print(json.loads(sys.stdin.read())["choices"][0]["message"]["content"])') + echo "$CONTENT" > /tmp/glm_reply.txt + + - name: Post reply + env: + GH_TOKEN: ${{ github.token }} + TARGET: ${{ steps.context.outputs.target }} + TYPE: ${{ steps.context.outputs.type }} + run: | + REPLY=$(cat /tmp/glm_reply.txt) + if [ "$TYPE" = "issue" ]; then + gh issue comment "$TARGET" --body "🤖 **GLM-5.2**:\n\n$REPLY" + else + gh pr comment "$TARGET" --body "🤖 **GLM-5.2**:\n\n$REPLY" + fi \ No newline at end of file diff --git a/apps/dashboard/messages/en.json b/apps/dashboard/messages/en.json index af2b9c0c..23556c90 100644 --- a/apps/dashboard/messages/en.json +++ b/apps/dashboard/messages/en.json @@ -65,10 +65,14 @@ "mark": "Mark", "liqPrice": "Liq.", "unrealized": "Unrealized", - "realized": "Realized PnL" + "realized": "Realized PnL", + "margin": "Margin" }, "leverageTitle": "{leverage}× leverage · margin {margin}", - "liqStale": "Liquidation price (mark crossing it triggers liquidation)" + "liqStale": "Liquidation price (mark crossing it triggers liquidation)", + "marginTitle": "Initial margin used by this position", + "modeSpot": "SPOT", + "modePerp": "PERP" }, "orders": { "title": "Recent Orders", @@ -100,7 +104,9 @@ "running": "Running", "stopped": "Stopped", "errored": "Errored" - } + }, + "modeSpot": "SPOT", + "modePerp": "PERP {leverage}×" }, "strategyPanel": { "title": "Strategy Pool", @@ -149,7 +155,10 @@ "errors": "{count} error(s)", "viewDecisions": "View decisions", "neverRan": "no bars processed yet", - "history": "Past runs ({count})" + "history": "Past runs ({count})", + "modeSpot": "SPOT", + "modePerp": "PERP {leverage}×", + "allocation": "Allocation" }, "detail": { "back": "Live Runners", @@ -178,7 +187,10 @@ "fee": "Fee", "outcome": "Outcome", "reason": "Reason" - } + }, + "modeSpot": "SPOT", + "modePerp": "PERP {leverage}×", + "allocation": "allocation {amount}" }, "factors": { "title": "Effective Factors (Instrument)", diff --git a/apps/dashboard/messages/zh.json b/apps/dashboard/messages/zh.json index 81c2db03..85d6d2f8 100644 --- a/apps/dashboard/messages/zh.json +++ b/apps/dashboard/messages/zh.json @@ -65,10 +65,14 @@ "mark": "最新价", "liqPrice": "强平价", "unrealized": "浮动盈亏", - "realized": "已实现盈亏" + "realized": "已实现盈亏", + "margin": "保证金" }, "leverageTitle": "{leverage}× 杠杆 · 占用保证金 {margin}", - "liqStale": "强平价(mark 穿越即强平)" + "liqStale": "强平价(mark 穿越即强平)", + "marginTitle": "该仓占用的初始保证金", + "modeSpot": "现货", + "modePerp": "合约" }, "orders": { "title": "最近订单", @@ -100,7 +104,9 @@ "running": "运行中", "stopped": "已停止", "errored": "错误" - } + }, + "modeSpot": "现货", + "modePerp": "合约 {leverage}×" }, "strategyPanel": { "title": "策略池", @@ -149,7 +155,10 @@ "errors": "{count} 条错误", "viewDecisions": "查看决策", "neverRan": "尚未处理任何 bar", - "history": "历史运行 {count} 次" + "history": "历史运行 {count} 次", + "modeSpot": "现货", + "modePerp": "合约 {leverage}×", + "allocation": "资金额度" }, "detail": { "back": "Live Runner", @@ -178,7 +187,10 @@ "fee": "手续费", "outcome": "结果", "reason": "原因" - } + }, + "modeSpot": "现货", + "modePerp": "合约 {leverage}×", + "allocation": "资金额度 {amount}" }, "factors": { "title": "标的有效因子", diff --git a/apps/dashboard/src/app/api/auth/login/route.ts b/apps/dashboard/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..80fcc1af --- /dev/null +++ b/apps/dashboard/src/app/api/auth/login/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; + +import { BackendError, backendFetch } from "@/lib/backend"; +import { + SESSION_COOKIE, + SESSION_COOKIE_OPTS, + SESSION_TTL_SEC, + createSessionToken, +} from "@/lib/session"; + +/** + * 登录:校验凭据 → 落 session cookie。 + * + * dashboard 无 DB 凭据,把邮箱 / 密码反代到内网 paper `/auth/login` 校验;成功后用 + * `JWT_SECRET` 签 httpOnly session cookie。密码只透传一次,不落任何日志。 + */ +export async function POST(req: Request): Promise { + let email: unknown; + let password: unknown; + try { + ({ email, password } = await req.json()); + } catch { + return NextResponse.json({ error: "请求体格式错误" }, { status: 400 }); + } + if (typeof email !== "string" || typeof password !== "string" || !email || !password) { + return NextResponse.json({ error: "缺少邮箱或密码" }, { status: 400 }); + } + + try { + const user = await backendFetch<{ subject: string; email: string; roles: string[] }>( + "paper", + "/auth/login", + { auth: false, method: "POST", body: { email, password } }, + ); + const token = await createSessionToken({ + subject: user.subject, + email: user.email, + roles: user.roles ?? [], + }); + const res = NextResponse.json({ ok: true }); + res.cookies.set(SESSION_COOKIE, token, { + ...SESSION_COOKIE_OPTS, + maxAge: SESSION_TTL_SEC, + }); + return res; + } catch (err) { + if (err instanceof BackendError && err.status === 401) { + return NextResponse.json({ error: "邮箱或密码不正确" }, { status: 401 }); + } + // 透传 paper 的失败节流(429),否则会被误报成"登录服务不可用"(502), + // 用户看不到"尝试过于频繁"的真实原因。 + if (err instanceof BackendError && err.status === 429) { + return NextResponse.json( + { error: "尝试过于频繁,请稍后再试" }, + { status: 429 }, + ); + } + return NextResponse.json({ error: "登录服务暂不可用,请稍后重试" }, { status: 502 }); + } +} diff --git a/apps/dashboard/src/app/api/auth/logout/route.ts b/apps/dashboard/src/app/api/auth/logout/route.ts new file mode 100644 index 00000000..c14cdd8d --- /dev/null +++ b/apps/dashboard/src/app/api/auth/logout/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +import { SESSION_COOKIE, SESSION_COOKIE_OPTS } from "@/lib/session"; + +/** 登出:清 session cookie。前端随后跳 /login。 */ +export async function POST(): Promise { + const res = NextResponse.json({ ok: true }); + res.cookies.set(SESSION_COOKIE, "", { ...SESSION_COOKIE_OPTS, maxAge: 0 }); + return res; +} diff --git a/apps/dashboard/src/app/api/auth/session/route.ts b/apps/dashboard/src/app/api/auth/session/route.ts new file mode 100644 index 00000000..9b9ce461 --- /dev/null +++ b/apps/dashboard/src/app/api/auth/session/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +import { readSession } from "@/lib/session"; + +/** + * 当前登录用户(供侧栏显示 email + 登出按钮判存在)。未登录 / 未启用登录 → `{ user: null }`。 + * 不返回任何凭据。 + */ +export async function GET(): Promise { + const session = await readSession(); + return NextResponse.json({ + user: session ? { email: session.email, subject: session.subject } : null, + }); +} diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx new file mode 100644 index 00000000..6dc68c35 --- /dev/null +++ b/apps/dashboard/src/app/login/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { LoginForm } from "@/components/auth/LoginForm"; + +/** + * 登录页。刻意放在 `[locale]` 外壳之外 —— 不套控制台侧栏 / 对话栏 / 活动日志, + * 避免未登录时这些组件挂载后打 401。middleware 未登录时重定向到这里。 + */ +export const metadata = { + title: "Sign in · Inalpha", + robots: { index: false, follow: false }, +}; + +export default function LoginPage() { + return ( +
+ {/* useSearchParams 需要 Suspense 边界。 */} + + + +
+ ); +} diff --git a/apps/dashboard/src/components/auth/LoginForm.tsx b/apps/dashboard/src/components/auth/LoginForm.tsx new file mode 100644 index 00000000..80d36aac --- /dev/null +++ b/apps/dashboard/src/components/auth/LoginForm.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +/** + * 登录表单。登录页在 `[locale]` 外壳之外(不套控制台侧栏 / 对话栏 / intl provider), + * 故文案在此按 `navigator.language` 做最小中英切换,不依赖 next-intl。 + */ + +const STRINGS = { + en: { + title: "Operator Console", + subtitle: "Sign in to continue", + email: "Email", + password: "Password", + submit: "Sign in", + submitting: "Signing in…", + invalid: "Incorrect email or password", + rateLimited: "Too many attempts, try again later", + unavailable: "Login service unavailable, try again later", + }, + zh: { + title: "操作控制台", + subtitle: "登录以继续", + email: "邮箱", + password: "密码", + submit: "登录", + submitting: "登录中…", + invalid: "邮箱或密码不正确", + rateLimited: "尝试过于频繁,请稍后再试", + unavailable: "登录服务暂不可用,请稍后重试", + }, +}; + +function pickLang(): "en" | "zh" { + if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("zh")) { + return "zh"; + } + return "en"; +} + +export function LoginForm() { + const router = useRouter(); + const params = useSearchParams(); + const t = STRINGS[pickLang()]; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (res.ok) { + const from = params.get("from"); + // 只接受站内相对路径,防开放重定向。 + const dest = from && from.startsWith("/") && !from.startsWith("//") ? from : "/"; + router.replace(dest); + router.refresh(); + return; + } + setError( + res.status === 401 + ? t.invalid + : res.status === 429 + ? t.rateLimited + : t.unavailable, + ); + } catch { + setError(t.unavailable); + } finally { + setLoading(false); + } + } + + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Inalpha +
+
Inalpha
+
+ {t.title} +
+
+
+ +

{t.subtitle}

+ +
+ + +
+ + {error && ( +

+ {error} +

+ )} + + +
+ ); +} diff --git a/apps/dashboard/src/components/chat/ChatErrorBanner.tsx b/apps/dashboard/src/components/chat/ChatErrorBanner.tsx new file mode 100644 index 00000000..5b297cb8 --- /dev/null +++ b/apps/dashboard/src/components/chat/ChatErrorBanner.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { TriangleAlert, X } from "lucide-react"; + +/** + * Agent 错误横幅。 + * + * 上游 LLM 报错 / 流中断时在对话栏顶部显示红字提示, + * 用于区分"agent 在思考"和"真的出错了"。 + */ +export function ChatErrorBanner({ + error, + onDismiss, + dismissLabel, +}: { + error: string; + onDismiss: () => void; + dismissLabel: string; +}) { + return ( +
+ + {error} + +
+ ); +} diff --git a/apps/dashboard/src/components/chat/ChatHistoryPanel.tsx b/apps/dashboard/src/components/chat/ChatHistoryPanel.tsx new file mode 100644 index 00000000..2500ad04 --- /dev/null +++ b/apps/dashboard/src/components/chat/ChatHistoryPanel.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +import { cn } from "@/lib/cn"; + +/** 历史会话摘要。 */ +export interface ThreadSummary { + id: string; + title: string | null; + updatedAt: string; +} + +/** + * 历史会话下拉面板。 + * + * 纯展示组件,状态由父组件(ChatThread)管理。 + */ +export function ChatHistoryPanel({ + open, + threads, + historyError, + currentThreadId, + sourceDownLabel, + untitledLabel, + onSwitch, + onReload, +}: { + open: boolean; + threads: ThreadSummary[] | null; + historyError: boolean; + currentThreadId: string; + sourceDownLabel: string; + untitledLabel: string; + onSwitch: (threadId: string) => void; + onReload: (threadId: string) => void; +}) { + const t = useTranslations("chat"); + + if (!open) return null; + + return ( +
+ {threads === null ? ( +

+ {t("loadingHistory")} +

+ ) : threads.length === 0 ? ( +

+ {t("historyEmpty")} +

+ ) : ( + threads.map((th) => ( + + )) + )} + {historyError && ( +

+ {sourceDownLabel} +

+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/chat/ChatInput.tsx b/apps/dashboard/src/components/chat/ChatInput.tsx new file mode 100644 index 00000000..f5265c74 --- /dev/null +++ b/apps/dashboard/src/components/chat/ChatInput.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { MapPin, SendHorizontal, Square, X } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { type KeyboardEvent, useCallback, useEffect, useRef } from "react"; + +import { cn } from "@/lib/cn"; + +/** + * 对话输入区域:输入框 + 发送/停止按钮 + 页面上下文胶囊。 + * + * 所有状态由父组件(ChatThread)管理,通过 props 传入。 + * + * 聚焦:`open` 由父组件传入——对话栏由 `translate-x` 滑入而非条件卸载, + * 所以"打开时聚焦输入框"必须由 open prop 变化驱动(组件挂载 effect 补不上)。 + */ +export function ChatInput({ + draft, + isLoading, + open, + contextAttached, + contextKind, + contextId, + onDraftChange, + onSubmit, + onStop, + onContextDismiss, +}: { + draft: string; + isLoading: boolean; + open: boolean; + contextAttached: boolean; + contextKind: string; + contextId?: string; + onDraftChange: (v: string) => void; + onSubmit: () => void; + onStop: () => void; + onContextDismiss: () => void; +}) { + const t = useTranslations("chat"); + const textareaRef = useRef(null); + + // 打开对话栏时聚焦输入框(Phase 3 拆分后从 ChatThread 迁移进来)。 + useEffect(() => { + if (open) textareaRef.current?.focus(); + }, [open]); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onSubmit(); + } + }, + [onSubmit], + ); + + return ( +
+ {contextAttached && ( +
+ + + {t(`context.kind.${contextKind}`)} + + {contextId && ( + + {contextId.slice(0, 8)} + + )} + +
+ )} +
+