mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
feat: AI 助手推广位、显示细节优化与模拟盘设计方案
- AI 助手: RunningHub 推广位(标题行跑马灯、一周静默、无缝循环、悬停暂停), 抽屉头部与侧边栏模型名完整显示 - 测试: mining manager 终态事件轮询收敛, 消除状态先于事件落盘的读侧竞态 - 文档: 新增虚拟账户(模拟盘)功能设计方案 docs/paper-trading-plan.md
This commit is contained in:
@@ -38,6 +38,28 @@ def _wait_for_status(
|
|||||||
pytest.fail(f"run {run_id} did not reach {status}")
|
pytest.fail(f"run {run_id} did not reach {status}")
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_event_types(
|
||||||
|
manager: MiningJobManager,
|
||||||
|
run_id: str,
|
||||||
|
expected: list[str],
|
||||||
|
*,
|
||||||
|
timeout: float = 2.0,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""轮询到事件序列收敛后返回。
|
||||||
|
|
||||||
|
manager 各终态路径先 transition_status 再 append_event (两次独立写入),
|
||||||
|
状态可见时终态事件可能尚未落盘 —— 状态到达后立即断言事件序列会偶发读到
|
||||||
|
缺末尾事件的状态。终态后不会再有新事件, 轮询必然收敛。
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
events = manager.store.read_events(run_id)
|
||||||
|
if [event["type"] for event in events] == expected:
|
||||||
|
return events
|
||||||
|
time.sleep(0.005)
|
||||||
|
pytest.fail(f"run {run_id} events did not converge to {expected}")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def isolated_limiter(monkeypatch: pytest.MonkeyPatch) -> HeavyJobLimiter:
|
def isolated_limiter(monkeypatch: pytest.MonkeyPatch) -> HeavyJobLimiter:
|
||||||
limiter = HeavyJobLimiter(capacity=2, cancel_poll_interval=0.005)
|
limiter = HeavyJobLimiter(capacity=2, cancel_poll_interval=0.005)
|
||||||
@@ -122,12 +144,9 @@ def test_start_records_states_events_progress_and_worker_payload(
|
|||||||
assert terminal["started_at"] is not None
|
assert terminal["started_at"] is not None
|
||||||
assert terminal["finished_at"] is not None
|
assert terminal["finished_at"] is not None
|
||||||
assert manager.store.read_summary(run_id) == result
|
assert manager.store.read_summary(run_id) == result
|
||||||
assert [event["type"] for event in manager.store.read_events(run_id)] == [
|
_wait_for_event_types(
|
||||||
"queued",
|
manager, run_id, ["queued", "running", "progress", "succeeded"]
|
||||||
"running",
|
)
|
||||||
"progress",
|
|
||||||
"succeeded",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_accepts_valid_persistent_run_id(make_manager) -> None:
|
def test_start_accepts_valid_persistent_run_id(make_manager) -> None:
|
||||||
@@ -226,11 +245,9 @@ def test_cancel_while_waiting_for_capacity_never_calls_runner(
|
|||||||
assert cancelling["status"] == "cancelling"
|
assert cancelling["status"] == "cancelling"
|
||||||
_wait_for_status(manager, run_id, "cancelled")
|
_wait_for_status(manager, run_id, "cancelled")
|
||||||
assert not runner_called.is_set()
|
assert not runner_called.is_set()
|
||||||
assert [event["type"] for event in manager.store.read_events(run_id)] == [
|
_wait_for_event_types(
|
||||||
"queued",
|
manager, run_id, ["queued", "cancelling", "cancelled"]
|
||||||
"cancelling",
|
)
|
||||||
"cancelled",
|
|
||||||
]
|
|
||||||
finally:
|
finally:
|
||||||
isolated_limiter.release("mining")
|
isolated_limiter.release("mining")
|
||||||
|
|
||||||
@@ -251,8 +268,7 @@ def test_cancel_running_job_wins_over_worker_success(make_manager) -> None:
|
|||||||
cancelling = manager.cancel(run_id)
|
cancelling = manager.cancel(run_id)
|
||||||
assert cancelling["status"] == "cancelling"
|
assert cancelling["status"] == "cancelling"
|
||||||
_wait_for_status(manager, run_id, "cancelled")
|
_wait_for_status(manager, run_id, "cancelled")
|
||||||
event_types = [event["type"] for event in manager.store.read_events(run_id)]
|
_wait_for_event_types(manager, run_id, ["queued", "running", "cancelling", "cancelled"])
|
||||||
assert event_types == ["queued", "running", "cancelling", "cancelled"]
|
|
||||||
assert manager.store.read_summary(run_id) == {}
|
assert manager.store.read_summary(run_id) == {}
|
||||||
|
|
||||||
|
|
||||||
@@ -266,8 +282,7 @@ def test_runner_exception_marks_failed_and_appends_error_event(make_manager) ->
|
|||||||
|
|
||||||
failed = _wait_for_status(manager, run_id, "failed")
|
failed = _wait_for_status(manager, run_id, "failed")
|
||||||
assert failed["error"] == "mining exploded"
|
assert failed["error"] == "mining exploded"
|
||||||
events = manager.store.read_events(run_id)
|
events = _wait_for_event_types(manager, run_id, ["queued", "running", "error"])
|
||||||
assert [event["type"] for event in events] == ["queued", "running", "error"]
|
|
||||||
assert events[-1]["payload"] == {
|
assert events[-1]["payload"] == {
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"message": "mining exploded",
|
"message": "mining exploded",
|
||||||
@@ -301,7 +316,9 @@ def test_budget_exhausted_result_uses_distinct_success_status(make_manager) -> N
|
|||||||
|
|
||||||
_wait_for_status(manager, run_id, "succeeded_with_budget_exhausted")
|
_wait_for_status(manager, run_id, "succeeded_with_budget_exhausted")
|
||||||
assert manager.store.read_summary(run_id) == result
|
assert manager.store.read_summary(run_id) == result
|
||||||
assert manager.store.read_events(run_id)[-1]["type"] == ("succeeded_with_budget_exhausted")
|
_wait_for_event_types(
|
||||||
|
manager, run_id, ["queued", "running", "succeeded_with_budget_exhausted"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_recover_interrupted_delegates_to_store(make_manager) -> None:
|
def test_recover_interrupted_delegates_to_store(make_manager) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# 虚拟账户(模拟盘)设计方案
|
||||||
|
|
||||||
|
> 状态: 设计稿(未实现)。本文档描述目标形态与实现路径, 供评审与分期开发。
|
||||||
|
> 遵循 [CONTRIBUTING.md](../CONTRIBUTING.md) 的数据口径、插件化与测试矩阵要求。
|
||||||
|
|
||||||
|
## 1. 目标与非目标
|
||||||
|
|
||||||
|
**目标**
|
||||||
|
|
||||||
|
- 把「策略 → 信号 → 决策 → 收益」打通为实时闭环: 用户用虚拟资金跟单自己的策略/信号, 以真实行情价格模拟成交, 每天看到虚拟净值曲线与回合统计。
|
||||||
|
- 与回测互补: 回测重放历史, 模拟盘**实时向前**, 无未来函数, 验证的是「我现在敢不敢按这个信号操作」。
|
||||||
|
|
||||||
|
**非目标**
|
||||||
|
|
||||||
|
- 不接入券商、不做实盘下单、不提供任何资金通道。
|
||||||
|
- 不做高频/tick 级撮合模拟(分钟/快照级足够)。
|
||||||
|
- V1 不做多账户并行(数据结构预留, UI 只开放单账户)。
|
||||||
|
|
||||||
|
## 2. 核心概念与账务模型
|
||||||
|
|
||||||
|
| 概念 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| Account 账户 | 初始虚拟资金 + 费用参数 + 状态(active/frozen/closed) |
|
||||||
|
| Order 订单 | 买/卖意向; 来源 manual / auto(规则触发); 含订单类型与时效 |
|
||||||
|
| Fill 成交 | 订单的一次实际成交(价格、数量、费用); **append-only 台账** |
|
||||||
|
| Position 持仓 | 由成交台账推导物化(avg_cost、可卖数量), 可随时重建 |
|
||||||
|
| NAV 净值 | cash + Σ(qty × 市价); 收盘后用日线收盘价定版, 盘中用实时价估算 |
|
||||||
|
| 回合 Round-trip | FIFO 配对的开->平周期, 统计胜率/盈亏比/持有天数 |
|
||||||
|
|
||||||
|
**账务正确性原则**(镜像财务报表口径): 成交台账是唯一事实源, 持仓与净值全部可由台账重放推导; 任何修复动作以「冲正成交」记录, 不允许直接改持仓文件。
|
||||||
|
|
||||||
|
## 3. 数据模型与存储
|
||||||
|
|
||||||
|
沿用 `app/strategy/lots.py` 的域模块模式: 每实体一个 JSON + `atomic_write_text` 原子写, 目录挂 `data_dir` 下, 跨请求写互斥(镜像 lots API 的 `_write_lock` 与「全部校验通过才落盘」)。
|
||||||
|
|
||||||
|
```
|
||||||
|
data/paper/
|
||||||
|
account.json # 单账户 V1: id, initial_cash, fees, status, created_at
|
||||||
|
orders/order_{id}.json # 含 status: pending / filled / cancelled / expired
|
||||||
|
fills.jsonl # append-only 成交台账(每行一笔, 带序号与时间戳)
|
||||||
|
positions.json # 物化持仓(可由 fills.jsonl 重建, rebuild 接口兜底)
|
||||||
|
nav/daily.jsonl # 每交易日一条定版净值 {date, cash, mv, nav, benchmark_nav}
|
||||||
|
auto_rules/rule_{id}.json # 自动跟单规则
|
||||||
|
```
|
||||||
|
|
||||||
|
关键字段口径:
|
||||||
|
|
||||||
|
- **价格一律不复权 raw 价**(与真实交易一致); 持仓市值用 `raw_close`。
|
||||||
|
- **除权处理**: 日终结算时检测持仓 symbol 当日有除权因子(`adj_factor_etf`/`adj_factor`), 按因子调整数量与成本(送转 → 数量乘因子、成本除因子; 现金分红 → cash 入账), 并记一条 `corporate_action` 冲正成交, 保证净值曲线连续。
|
||||||
|
- **费用**: `commission_pct`(双边, 默认万 2.5, 最低 5 元)、`stamp_tax_pct`(卖出印花税, 默认千 1)、`slippage_bps`(默认 5)——与回测引擎 `app/backtest/engine.py` 的参数名和默认值完全一致, 用户心智一份口径。
|
||||||
|
|
||||||
|
## 4. 撮合规则(金融口径, 必须逐条测试)
|
||||||
|
|
||||||
|
| 规则 | 口径 |
|
||||||
|
|---|---|
|
||||||
|
| 即时市价单(盘中) | 以订单创建后的**下一次实时快照价**成交 + 滑点; 非交易时段创建则转为次日开盘单 |
|
||||||
|
| 次日开盘单 | 次一交易日 `raw_open` 成交 + 滑点; 当日无行情(停牌/新股)则顺延, 顺延满 N 日(默认 3)自动 expired 并留痕 |
|
||||||
|
| 当日收盘单 | 当日 `raw_close` 成交(盘后管道执行) |
|
||||||
|
| 买入数量 | 100 股整数倍(ETF 同); 资金不足按可用现金向下取整到百股, 不足一手则拒绝 |
|
||||||
|
| T+1 | 当日买入份额次日方可卖; `Position.available_qty` 在日终结算解锁 |
|
||||||
|
| 涨跌停 | V1 简化: 触及涨停价的买单 / 跌停价的卖单直接 `expired`(留痕); V2 可选「排队次日重试」 |
|
||||||
|
| 停牌 | 无法取价即不成交, 按顺延规则处理 |
|
||||||
|
| 撤单 | 仅 `pending` 可撤; 已成交不可逆(只能反向平仓) |
|
||||||
|
|
||||||
|
**时点约束**: 所有成交时间戳取北京墙钟(复用仓库既有北京时间口径); 交易日判断复用交易日探针, 非交易日不撮合不结算。
|
||||||
|
|
||||||
|
## 5. 运行时挂点(全部复用现有引擎)
|
||||||
|
|
||||||
|
```
|
||||||
|
盘中: quote_service._evaluate_monitors 的同一 tick
|
||||||
|
└─ paper_engine.evaluate(now)
|
||||||
|
├─ 处理 pending 即时单(用实时快照价)
|
||||||
|
└─ 自动规则监听信号事件 → 生成订单(带 cooldown 去重, 复用监控事件去重思路)
|
||||||
|
|
||||||
|
盘后: daily_pipeline 新增 stage: paper_settle
|
||||||
|
├─ 收盘价定版: 未成交的收盘单/顺延单撮合
|
||||||
|
├─ mark-to-market: 写 nav/daily 定版(含基准 000300.SH 同日收盘)
|
||||||
|
├─ 除权调整 + T+1 解锁
|
||||||
|
└─ 异常(缺行情/缺因子) warning 留痕, 不中断管道
|
||||||
|
```
|
||||||
|
|
||||||
|
- 自动跟单规则 V1 形态: `{signal_source: 策略id|信号id, side: buy, size_mode: fixed_amount|pct_equity, order_type: next_open, cooldown_days: 5, scope: 自选分组|全信号}`; 触发链路复用信号库/策略扫描的产出事件, 不新增采集。
|
||||||
|
- 语音/推送: 成交与规则触发事件走监控中心既有 SSE + 语音播报 + 飞书通道(新增事件类型, 前端无需新管道)。
|
||||||
|
|
||||||
|
## 6. API 设计(`/api/paper/*`, 薄胶水 + 域模块)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET / PUT | `/api/paper/account` | 账户详情/费用参数(初始化向导) |
|
||||||
|
| GET | `/api/paper/overview` | 现金 + 持仓(实时估值) + 今日订单/成交 + 盘中估算净值 |
|
||||||
|
| POST | `/api/paper/orders` | 手动下单 {symbol, side, qty \| amount, order_type} |
|
||||||
|
| GET | `/api/paper/orders` | 订单列表(可按 status 过滤) |
|
||||||
|
| DELETE | `/api/paper/orders/{id}` | 撤销 pending 订单 |
|
||||||
|
| GET | `/api/paper/trades` | 成交台账(分页) |
|
||||||
|
| GET | `/api/paper/nav` | 净值序列 + 基准(沪深300 归一化) |
|
||||||
|
| GET | `/api/paper/stats` | 回合统计: 胜率/盈亏比/平均持有天数/最大回撤/对比基准 |
|
||||||
|
| CRUD | `/api/paper/auto_rules` | 自动跟单规则 |
|
||||||
|
| POST | `/api/paper/rebuild` | 由 fills.jsonl 重建 positions(修复兜底, 管理入口) |
|
||||||
|
|
||||||
|
约定: symbol 经 `repo.resolve_asset_type` 区分 stock/etf(镜像 lots API); 资产类型影响涨跌停与最小单位判断。
|
||||||
|
|
||||||
|
## 7. 前端设计
|
||||||
|
|
||||||
|
新页面「模拟盘 Paper」(侧边栏一级菜单, 图标 💰/PiggyBank):
|
||||||
|
|
||||||
|
- **总览区**: 净值曲线 vs 沪深300(复用 ECharts 组件)、现金/市值/总资产/当日盈亏卡片、盘中估值角标「实时估算, 收盘定版」
|
||||||
|
- **持仓表**: 复用 Lots/自选的表格交互——现价(实时)、成本、浮盈、可卖数量、占比; 行内「卖出」按钮
|
||||||
|
- **下单面板**: symbol 搜索 + 数量/金额 + 订单类型; 入口同时嵌到**个股分析页与自选行内**的「虚拟买入」按钮
|
||||||
|
- **流水区**: 今日订单/成交、全部成交台账
|
||||||
|
- **自动跟单页签**: 规则列表 + 新建(选策略/信号 + 仓位方式 + cooldown)
|
||||||
|
- **统计页签**: 回合明细与汇总(口径与回测报告一致, 降低理解成本)
|
||||||
|
|
||||||
|
## 8. 统计口径(与回测对齐)
|
||||||
|
|
||||||
|
- 回合配对: FIFO; 胜率 = 盈利回合数 / 总回合; 盈亏比 = 平均盈利 / 平均亏损
|
||||||
|
- 最大回撤基于 nav/daily 定版序列
|
||||||
|
- 基准: `000300.SH` 同期归一化(数据已有); 盘中估算净值不参与统计, 只展示
|
||||||
|
|
||||||
|
## 9. 边界、并发与风控
|
||||||
|
|
||||||
|
- **写并发**: 域模块内全局写锁; 盘中 evaluate 与手动下单竞争同一把锁(镜像 mining_manager 的互斥模式)
|
||||||
|
- **单账户限制**: 持仓标的数上限(默认 50)防误操作; 单笔金额上限(默认不超过可用现金, 不提供杠杆)
|
||||||
|
- **冻结**: 账户 frozen 后拒绝新订单, 持仓只读; 保留数据可重建
|
||||||
|
- **数据缺失**: 缺行情/缺因子一律留痕 warning + 顺延, 绝不臆造成交价(金融数据错误原则)
|
||||||
|
- **回滚**: 停用功能 = 冻结账户 + 隐藏菜单; 数据目录独立(`data/paper/`), 删除即卸载
|
||||||
|
|
||||||
|
## 10. 测试矩阵(per CONTRIBUTING)
|
||||||
|
|
||||||
|
| 层 | 测试 |
|
||||||
|
|---|---|
|
||||||
|
| 撮合域模块 | T+1 解锁、涨跌停拒单、百股取整、最低佣金、印花税单向、滑点方向、停牌顺延与过期、除权数量/成本调整、资金不足拒绝 |
|
||||||
|
| 账务 | fills.jsonl 重放 == positions.json(性质测试)、净值 = cash + Σ市值、冲正成交后曲线连续 |
|
||||||
|
| 管道挂点 | 盘后结算幂等(重跑同日不重复成交)、非交易日不结算 |
|
||||||
|
| 自动规则 | 信号触发生成订单、cooldown 去重、scope 过滤 |
|
||||||
|
| API 契约 | 下单/撤单/overview 成功与错误响应 |
|
||||||
|
| 前端 | `tsc + vite build`; 关键交互本地 dev 目测 |
|
||||||
|
|
||||||
|
修复 bug 一律先写复现测试再修(先失败后通过)。
|
||||||
|
|
||||||
|
## 11. 分期与工作量
|
||||||
|
|
||||||
|
| 期 | 内容 | 预估 |
|
||||||
|
|---|---|---|
|
||||||
|
| **V1 核心闭环** | 账户+手动下单(即时/次日开盘/收盘)+撮合域模块+盘后结算+净值曲线+持仓/流水页 | 3-4 个 PR, 约 1.5-2 周 |
|
||||||
|
| **V2 自动化** | 自动跟单规则、涨跌停排队可选、多账户、统计页签完整化 | 约 1 周 |
|
||||||
|
| **V3 增强** | 成交推送/语音、导出 CSV、与回测报告同构对比页、AI 复盘注入模拟盘数据 | 约 1 周 |
|
||||||
|
|
||||||
|
V1 交付即有完整价值: 手动跟单信号 → 看实时虚拟净值。
|
||||||
|
|
||||||
|
## 12. 明确不做 / 后续再议
|
||||||
|
|
||||||
|
- 实盘通道、融资融券、期权期货
|
||||||
|
- tick 级盘口撮合
|
||||||
|
- 龙虎榜游资席位跟单(可作 V3 的规则信号源之一)
|
||||||
@@ -329,7 +329,7 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st
|
|||||||
<span className="pointer-events-none absolute inset-y-1.5 left-0 w-[2px] rounded-full bg-purple-400/50 transition-colors group-hover:bg-purple-400" />
|
<span className="pointer-events-none absolute inset-y-1.5 left-0 w-[2px] rounded-full bg-purple-400/50 transition-colors group-hover:bg-purple-400" />
|
||||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-muted group-hover:text-purple-400 transition-colors" />
|
<Sparkles className="h-3.5 w-3.5 shrink-0 text-muted group-hover:text-purple-400 transition-colors" />
|
||||||
{configured ? (
|
{configured ? (
|
||||||
<span className="truncate text-[11px] font-medium text-secondary group-hover:text-foreground transition-colors">
|
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-secondary group-hover:text-foreground transition-colors">
|
||||||
{model || '已接入模型'}
|
{model || '已接入模型'}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
Gift,
|
||||||
History,
|
History,
|
||||||
Plus,
|
Plus,
|
||||||
SendHorizontal,
|
SendHorizontal,
|
||||||
@@ -46,6 +47,10 @@ const WIDTH_STORAGE_KEY = 'assistant.width.v1'
|
|||||||
const DEFAULT_WIDTH = 720
|
const DEFAULT_WIDTH = 720
|
||||||
const MIN_WIDTH = 480
|
const MIN_WIDTH = 480
|
||||||
|
|
||||||
|
const RH_PROMO_URL = 'https://www.runninghub.ai/zh-cn/call-api/llm/models?source=github&inviteCode=edt5wh7c'
|
||||||
|
const RH_PROMO_DISMISS_KEY = 'assistant.rh_promo.dismissed_at.v1'
|
||||||
|
const RH_PROMO_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
function loadWidth(): number {
|
function loadWidth(): number {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(WIDTH_STORAGE_KEY)
|
const raw = localStorage.getItem(WIDTH_STORAGE_KEY)
|
||||||
@@ -169,6 +174,61 @@ function DrawerPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** RunningHub 推广条: 嵌在抽屉标题行中间, 单行跑马灯横向滚动(悬停暂停),
|
||||||
|
* 琥珀色高亮(深浅主题各取一档), 可点击跳转邀请链接。
|
||||||
|
* 点 X 后一周(7 天)内不再显示 —— 时间戳持久化到 localStorage; DrawerPanel 随抽屉
|
||||||
|
* 开合挂载/卸载, 每次点开对话框重新检查是否已到期, 到期后自动恢复。 */
|
||||||
|
function RhPromoBanner() {
|
||||||
|
const [dismissed, setDismissed] = useState(() => {
|
||||||
|
try {
|
||||||
|
const ts = Number(localStorage.getItem(RH_PROMO_DISMISS_KEY))
|
||||||
|
return Number.isFinite(ts) && Date.now() - ts < RH_PROMO_SNOOZE_MS
|
||||||
|
} catch { return false }
|
||||||
|
})
|
||||||
|
if (dismissed) return null
|
||||||
|
const snooze = () => {
|
||||||
|
setDismissed(true)
|
||||||
|
try { localStorage.setItem(RH_PROMO_DISMISS_KEY, String(Date.now())) } catch { /* 存储不可用时仅本次关闭 */ }
|
||||||
|
}
|
||||||
|
const copy = (key: string) => (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={RH_PROMO_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex shrink-0 items-center gap-1 pr-8 text-[10px] leading-relaxed text-secondary transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-amber-600 dark:text-amber-400">Claude、ChatGPT、Gemini</span>
|
||||||
|
<span>等国际模型直连稳定不掉线,</span>
|
||||||
|
<span className="font-medium text-amber-600 dark:text-amber-400">最高优惠 80%</span>
|
||||||
|
<span>,</span>
|
||||||
|
<span className="font-medium text-amber-600 dark:text-amber-400">通过此链接注册赠送 1000 RH 积分</span>
|
||||||
|
<span>🎁</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
// 窗口(overflow-hidden)裁剪滚动内容, 礼包图标与 X 按钮在窗口外, 永不被文字盖住;
|
||||||
|
// 轨道 w-max 按内容撑宽, 份数为偶数时 translateX(-50%) 恰好位移整份文案宽度,
|
||||||
|
// 循环回跳视觉无缝。4 份保证极宽抽屉下窗口内也始终有内容。
|
||||||
|
return (
|
||||||
|
<div className="group flex min-w-0 flex-1 items-center gap-1.5 rounded-btn border border-amber-400/30 bg-amber-400/15 py-1 pl-2 pr-1 dark:border-amber-400/25 dark:bg-amber-400/10">
|
||||||
|
<Gift className="h-3 w-3 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
<div className="flex w-max animate-[rh-marquee_26s_linear_infinite] whitespace-nowrap motion-reduce:animate-none group-hover:[animation-play-state:paused]">
|
||||||
|
{['a', 'b', 'c', 'd'].map(copy)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={snooze}
|
||||||
|
title="关闭推广 (一周内不再显示)"
|
||||||
|
aria-label="关闭推广 (一周内不再显示)"
|
||||||
|
className="shrink-0 rounded-btn p-0.5 text-muted transition-colors hover:bg-elevated hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DrawerHeader({
|
function DrawerHeader({
|
||||||
status,
|
status,
|
||||||
sessions,
|
sessions,
|
||||||
@@ -185,16 +245,17 @@ function DrawerHeader({
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
<div className="relative flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
|
||||||
<Sparkles className="h-4 w-4 shrink-0 text-accent" />
|
<Sparkles className="h-4 w-4 shrink-0 text-accent" />
|
||||||
<span className="text-sm font-semibold text-foreground">AI 助手</span>
|
<span className="shrink-0 text-sm font-semibold text-foreground">AI 助手</span>
|
||||||
{status?.model && (
|
{status?.model && (
|
||||||
<span
|
<span
|
||||||
className="max-w-36 truncate rounded-btn bg-elevated px-1.5 py-0.5 font-mono text-[10px] text-muted"
|
className="min-w-0 truncate rounded-btn bg-elevated px-1.5 py-0.5 font-mono text-[10px] text-muted"
|
||||||
title={`供应商: ${status.provider}`}
|
title={`模型: ${status.model}\n供应商: ${status.provider ?? '-'}`}
|
||||||
>
|
>
|
||||||
{status.model}
|
{status.model}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex items-center gap-0.5">
|
<RhPromoBanner />
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<IconButton title="历史会话" onClick={() => setMenuOpen(v => !v)} disabled={sending}>
|
<IconButton title="历史会话" onClick={() => setMenuOpen(v => !v)} disabled={sending}>
|
||||||
<History className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
|
|||||||
@@ -77,3 +77,10 @@ body {
|
|||||||
-moz-osx-font-smoothing: auto;
|
-moz-osx-font-smoothing: auto;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* RunningHub 推广横幅跑马灯: 双份相同内容 + translateX(-50%) 无缝循环,
|
||||||
|
* 悬停暂停由 Tailwind group-hover:[animation-play-state:paused] 控制 */
|
||||||
|
@keyframes rh-marquee {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ type AiPreset = { label: string; provider?: string; url: string; model: string;
|
|||||||
|
|
||||||
const PRESETS: AiPreset[] = [
|
const PRESETS: AiPreset[] = [
|
||||||
{ label: '自定义', url: '', model: '', website: '', websiteLabel: '', description: '不自动填充任何配置,完全手动填写 API 地址、模型和密钥。', custom: true },
|
{ label: '自定义', url: '', model: '', website: '', websiteLabel: '', description: '不自动填充任何配置,完全手动填写 API 地址、模型和密钥。', custom: true },
|
||||||
{ label: 'RunningHub', url: 'https://llm.runninghub.ai/v1', model: 'openai/gpt-6-astra-saver', website: 'https://www.runninghub.ai/zh-cn/call-api/llm/models?source=github&inviteCode=edt5wh7c', websiteLabel: 'www.runninghub.ai · 赞助', sponsor: true, description: <>本项目赞助商 · OpenAI 兼容中转,单一接口直连 400+ 主流大模型,<span className="font-medium text-amber-600 dark:text-amber-400">Claude、ChatGPT、Gemini</span> 等国际模型直连稳定不掉线,注册即享折扣优惠。通过下方链接注册即为项目提供赞助支持。</> },
|
{ label: 'RunningHub', url: 'https://llm.runninghub.ai/v1', model: 'openai/gpt-6-astra-saver', website: 'https://www.runninghub.ai/zh-cn/call-api/llm/models?source=github&inviteCode=edt5wh7c', websiteLabel: 'www.runninghub.ai · 赞助', sponsor: true, description: <>本项目赞助商 · OpenAI 兼容中转,单一接口直连 400+ 主流大模型,<span className="font-medium text-amber-600 dark:text-amber-400">Claude、ChatGPT、Gemini</span> 等国际模型直连稳定不掉线,<span className="font-medium text-amber-600 dark:text-amber-400">最高优惠 80%</span>,<span className="font-medium text-amber-600 dark:text-amber-400">邀请链接注册赠送 1000 RH 积分</span>。通过下方链接注册即为项目提供赞助支持。</> },
|
||||||
{ label: 'OpenAI', provider: OPENAI_PROVIDER, url: 'https://api.openai.com/v1', model: DEFAULT_OPENAI_MODEL, website: 'https://platform.openai.com/', websiteLabel: 'platform.openai.com', description: 'OpenAI 官方接口,可单独配置模型支持的推理强度。' },
|
{ label: 'OpenAI', provider: OPENAI_PROVIDER, url: 'https://api.openai.com/v1', model: DEFAULT_OPENAI_MODEL, website: 'https://platform.openai.com/', websiteLabel: 'platform.openai.com', description: 'OpenAI 官方接口,可单独配置模型支持的推理强度。' },
|
||||||
{ label: 'DeepSeek', url: 'https://api.deepseek.com', model: 'deepseek-v4-pro', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' },
|
{ label: 'DeepSeek', url: 'https://api.deepseek.com', model: 'deepseek-v4-pro', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' },
|
||||||
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-3.6plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' },
|
{ label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-3.6plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' },
|
||||||
|
|||||||
Reference in New Issue
Block a user