Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ NODE_ENV=development

# LLM 对话模型配置
# 可选:openai / openai-compatible / other
# 以下非敏感字段可由管理后台修改,并原子回写到本地 .env
# Provider 和模型名可由管理后台修改;API Base URL 只能由部署环境修改
LLM_PROVIDER=openai
LLM_API_BASE=
LLM_MODEL=gpt-4o-mini
# Secret: inject at runtime; the admin UI and SQLite never store this value.
LLM_API_KEY=
LLM_STREAM_MAX_BYTES=262144

# Embedding 模型配置(独立于 LLM)
# 可选:openai / openai-compatible / other
Expand All @@ -26,9 +27,9 @@ OPENAI_MODEL=gpt-4o-mini
OPENAI_EMBED_MODEL=text-embedding-3-small

# Authentication
JWT_SECRET=change-me-to-a-random-secret
JWT_SECRET=
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_PASSWORD=

# Database
DB_PATH=./data/customer-service.db
Expand All @@ -45,7 +46,8 @@ QDRANT_TIMEOUT_MS=5000
RETRIEVAL_TRACE_RETENTION_DAYS=30

# Optional local PaddleOCR/PP-StructureV3 worker. Existing FAQ/text document
# features keep working when this is empty.
# features keep working when the URL is empty. A matching token is mandatory
# whenever the worker URL is configured.
OCR_SERVICE_URL=
OCR_SERVICE_TOKEN=
OCR_ENGINE_VERSION=3.0.3
Expand All @@ -66,6 +68,8 @@ ALLOWED_ORIGINS=http://localhost:5173
RATE_LIMIT_CHAT=20
RATE_LIMIT_ADMIN=100
RATE_LIMIT_LOGIN=5
RATE_LIMIT_FAQ_SEARCH=60
FAQ_SEARCH_MAX_CONCURRENCY=4

# Conversation lifecycle and bounded synchronous export
SESSION_INACTIVITY_MINUTES=30
Expand Down
42 changes: 28 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

**Chinese version**: [README_CN.md](README_CN.md)

Development version: **v0.3.2 (pre-1.0)**. The latest published release is
Development version: **v0.3.3 (pre-1.0)**. The latest published release is
v0.3.2; APIs and persisted data remain subject to change before 1.0.

<p align="center">
Expand Down Expand Up @@ -219,6 +219,7 @@ Use Node.js 20.16+ or 22.3+.
```bash
npm install
cp .env.example .env
# Set unique JWT_SECRET and ADMIN_PASSWORD values in .env before continuing.
npm run db:init
npm run db:seed
EMBED_PROVIDER=other npm run dev
Expand All @@ -229,14 +230,10 @@ Open:
- Customer chat: http://localhost:5173/
- Admin console: http://localhost:5173/admin

Default local admin account:

```text
Username: admin
Password: admin123
```

Change `ADMIN_PASSWORD` before any production-like deployment. The server blocks the default password when `NODE_ENV=production`.
The local admin username defaults to `admin`; its password comes from
`ADMIN_PASSWORD`. Seeding synchronizes the single environment-managed account
when either value changes and removes stale privileged rows left by earlier
starts. Never reuse the example or another deployment's credentials.

---

Expand All @@ -246,6 +243,9 @@ Change `ADMIN_PASSWORD` before any production-like deployment. The server blocks
docker compose up --build
```

Compose requires non-empty `JWT_SECRET` and `ADMIN_PASSWORD` values in `.env`
before startup and binds the frontend/backend ports to `127.0.0.1` by default.

Docker exposes:

- Frontend: http://localhost:5173/
Expand All @@ -268,10 +268,14 @@ it cannot edit the provider, URL, or API key.
Start the optional CPU OCR worker with the Compose profile:

```bash
OCR_SERVICE_URL=http://ocr-worker:8001 docker compose --profile ocr up --build
OCR_SERVICE_URL=http://ocr-worker:8001 \
OCR_SERVICE_TOKEN='<generate-a-random-secret>' \
docker compose --profile ocr up --build
```

The first worker start downloads Paddle models. See
The first worker start downloads Paddle models. If native inference exceeds
its deadline, the worker exits after returning `504` and Compose restarts it
with clean process state. See
[ocr-worker/README.md](ocr-worker/README.md) for the local Python path, worker
contract, and Paddle installation references.

Expand All @@ -297,6 +301,7 @@ Copy `.env.example` to `.env`, then configure the values you need:
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Local admin account |
| `LLM_PROVIDER` / `EMBED_PROVIDER` | `openai`, `openai-compatible`, or `other` |
| `LLM_API_BASE` / `LLM_API_KEY` / `LLM_MODEL` | Chat model endpoint, environment-only credential, and model |
| `LLM_STREAM_MAX_BYTES` | Maximum buffered UTF-8 bytes for one streamed model answer; defaults to `262144` |
| `EMBED_API_BASE` / `EMBED_API_KEY` / `EMBED_MODEL` | OpenAI-compatible embedding model |
| `VECTOR_STORE_PROVIDER` | `memory` (default) or explicitly configured `qdrant`; changing it requires restart |
| `QDRANT_URL` / `QDRANT_API_KEY` | Qdrant REST endpoint and optional environment-only credential |
Expand All @@ -305,15 +310,24 @@ Copy `.env.example` to `.env`, then configure the values you need:
| `RETRIEVAL_TRACE_RETENTION_DAYS` | Trace retention in days; defaults to `30`, accepted range `1`–`90` |
| `DOCUMENT_UPLOAD_DIR` | Private document file directory; defaults to `./data/uploads` |
| `OCR_SERVICE_URL` | Optional PaddleOCR/PP-StructureV3 worker base URL; when empty, existing FAQ and text-document features still work |
| `OCR_SERVICE_TOKEN` | Optional bearer token sent only to the configured OCR worker |
| `OCR_SERVICE_TOKEN` | Required bearer token whenever `OCR_SERVICE_URL` is configured |
| `OCR_ENGINE_VERSION` / `OCR_TIMEOUT_MS` | Required worker version match and request timeout; defaults to `3.0.3` / `120000` ms |
| `OCR_BACKGROUND_ENABLED` / `OCR_POLL_INTERVAL_MS` | Durable SQLite queue polling; defaults to `true` / `1000` ms |
| `OCR_SHADOW_SERVICE_URL` / `OCR_SHADOW_SERVICE_TOKEN` / `OCR_SHADOW_ENGINE_VERSION` | Optional comparison-only DeepSeek-OCR-2-compatible worker; never replaces Paddle review content |
| `RATE_LIMIT_CHAT` / `RATE_LIMIT_ADMIN` / `RATE_LIMIT_LOGIN` | API rate limits |
| `RATE_LIMIT_CHAT` / `RATE_LIMIT_ADMIN` / `RATE_LIMIT_LOGIN` / `RATE_LIMIT_FAQ_SEARCH` | IPv6-aware API rate limits |
| `FAQ_SEARCH_MAX_CONCURRENCY` | Maximum in-flight public semantic FAQ searches; defaults to `4` |
| `SESSION_INACTIVITY_MINUTES` | Minutes without activity before an active conversation is closed; defaults to `30` |
| `CONVERSATION_EXPORT_MAX_MESSAGES` | Maximum complete message rows in one synchronous filtered CSV export; defaults to `5000` |

The environment is the source of truth for model configuration. The admin model page reads provider, endpoint, and model name from the environment and atomically writes non-secret edits back to the local `.env` file so they take effect immediately. Legacy `model_configs` rows in SQLite no longer override these values. The `openai` provider always uses `https://api.openai.com/v1`; custom API Base URLs are used only by `openai-compatible` and `other`. The admin API exposes only whether a key is configured and never accepts, returns, or rewrites key material; inject keys through environment variables or deployment secrets. In container or managed deployments where environment variables are externally injected or the filesystem is read-only, update the deployment secret/configuration and redeploy instead.
The environment is the source of truth for model configuration. The admin
model page may update provider and model name, but API Base URLs and credentials
are deployment-owned and read-only in the UI/API. Legacy `model_configs` rows
in SQLite no longer override these values. The `openai` provider always uses
`https://api.openai.com/v1`; custom API Base URLs are used only by
`openai-compatible` and `other`. A chat credential is reused for embeddings
only when both resolve to the same normalized endpoint. Inject keys through
environment variables or deployment secrets and redeploy managed/read-only
environments after changing them.

---

Expand Down
37 changes: 23 additions & 14 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

**English version**: [README.md](README.md)

开发版本:**v0.3.2(pre-1.0)**。最新公开发布版为 v0.3.2;在 1.0
开发版本:**v0.3.3(pre-1.0)**。最新公开发布版为 v0.3.2;在 1.0
之前,API 和持久化数据结构仍可能调整。

<p align="center">
Expand Down Expand Up @@ -201,6 +201,7 @@ JSON 与 SSE 写接口还支持可选的 `Idempotency-Key` 请求头:同一键
```bash
npm install
cp .env.example .env
# 继续前先在 .env 中设置唯一的 JWT_SECRET 和 ADMIN_PASSWORD。
npm run db:init
npm run db:seed
EMBED_PROVIDER=other npm run dev
Expand All @@ -211,14 +212,9 @@ EMBED_PROVIDER=other npm run dev
- 用户聊天页:http://localhost:5173/
- 管理后台:http://localhost:5173/admin

默认本地管理员账号:

```text
用户名:admin
密码:admin123
```

任何接近生产的部署前,都必须修改 `ADMIN_PASSWORD`。当 `NODE_ENV=production` 时,服务端会拦截默认管理员密码。
本地管理员用户名默认为 `admin`,密码来自 `ADMIN_PASSWORD`。当部署值发生
变化时,seed 会同步唯一的环境管理员账号,并清理旧启动遗留的可登录管理员行。
不要复用示例值或其他部署的凭据。

---

Expand All @@ -228,6 +224,9 @@ EMBED_PROVIDER=other npm run dev
docker compose up --build
```

Compose 启动前要求 `.env` 中存在非空 `JWT_SECRET` 和 `ADMIN_PASSWORD`,
前后端端口默认只绑定到 `127.0.0.1`。

Docker 默认暴露:

- 前端:http://localhost:5173/
Expand All @@ -249,10 +248,13 @@ alias,但不能修改 provider、URL 或 API Key。
通过 Compose profile 启动可选 CPU OCR Worker:

```bash
OCR_SERVICE_URL=http://ocr-worker:8001 docker compose --profile ocr up --build
OCR_SERVICE_URL=http://ocr-worker:8001 \
OCR_SERVICE_TOKEN='<生成一个随机密钥>' \
docker compose --profile ocr up --build
```

Worker 首次启动会下载 Paddle 模型。本地 Python 启动方式、Worker 契约和
Worker 首次启动会下载 Paddle 模型。原生推理超过截止时间时,Worker 返回
`504` 后退出,Compose 会用干净进程重启。本地 Python 启动方式、Worker 契约和
Paddle 安装资料见 [ocr-worker/README.md](ocr-worker/README.md)。

Compose 使用 `resolve-weave` 项目名,并将本地镜像构建为
Expand All @@ -276,6 +278,7 @@ RESOLVE_WEAVE_DATA_VOLUME=<原物理卷名称> docker compose up --build
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 本地管理员账号 |
| `LLM_PROVIDER` / `EMBED_PROVIDER` | `openai`、`openai-compatible` 或 `other` |
| `LLM_API_BASE` / `LLM_API_KEY` / `LLM_MODEL` | 对话模型地址、仅环境注入的凭据和模型名 |
| `LLM_STREAM_MAX_BYTES` | 单次流式模型回答可缓冲的 UTF-8 字节上限,默认 `262144` |
| `EMBED_API_BASE` / `EMBED_API_KEY` / `EMBED_MODEL` | OpenAI 兼容 embedding 模型 |
| `VECTOR_STORE_PROVIDER` | `memory`(默认)或显式配置的 `qdrant`;变更后需重启 |
| `QDRANT_URL` / `QDRANT_API_KEY` | Qdrant REST 地址和可选、仅环境注入的凭据 |
Expand All @@ -284,15 +287,21 @@ RESOLVE_WEAVE_DATA_VOLUME=<原物理卷名称> docker compose up --build
| `RETRIEVAL_TRACE_RETENTION_DAYS` | Trace 保留天数,默认 `30`,范围 `1`–`90` |
| `DOCUMENT_UPLOAD_DIR` | 私有文档文件目录,默认 `./data/uploads` |
| `OCR_SERVICE_URL` | 可选 PaddleOCR/PP-StructureV3 Worker 根地址;留空时原有 FAQ 和文本文档能力仍可运行 |
| `OCR_SERVICE_TOKEN` | 可选 Bearer Token,只发送给已配置的 OCR Worker |
| `OCR_SERVICE_TOKEN` | 配置 `OCR_SERVICE_URL` 时必需的 Bearer Token |
| `OCR_ENGINE_VERSION` / `OCR_TIMEOUT_MS` | Worker 版本匹配和请求超时,默认 `3.0.3` / `120000` 毫秒 |
| `OCR_BACKGROUND_ENABLED` / `OCR_POLL_INTERVAL_MS` | SQLite 持久化队列轮询,默认 `true` / `1000` 毫秒 |
| `OCR_SHADOW_SERVICE_URL` / `OCR_SHADOW_SERVICE_TOKEN` / `OCR_SHADOW_ENGINE_VERSION` | 可选、仅用于对照的 DeepSeek-OCR-2 兼容 Worker;不会替换 Paddle 复核内容 |
| `RATE_LIMIT_CHAT` / `RATE_LIMIT_ADMIN` / `RATE_LIMIT_LOGIN` | API 限流配置 |
| `RATE_LIMIT_CHAT` / `RATE_LIMIT_ADMIN` / `RATE_LIMIT_LOGIN` / `RATE_LIMIT_FAQ_SEARCH` | 支持 IPv6 子网归一的 API 限流配置 |
| `FAQ_SEARCH_MAX_CONCURRENCY` | 公共语义 FAQ 检索的最大并发数,默认 `4` |
| `SESSION_INACTIVITY_MINUTES` | 活跃会话无消息后自动关闭的分钟数,默认 `30` |
| `CONVERSATION_EXPORT_MAX_MESSAGES` | 一次同步筛选 CSV 可导出的完整消息行上限,默认 `5000` |

环境变量是模型配置的唯一生效来源。管理后台模型配置页从环境读取服务商、地址和模型名,并把非敏感修改原子回写到本地 `.env`,当前进程会立即生效;SQLite 中历史 `model_configs` 数据不再覆盖环境配置。`openai` 服务商始终使用 `https://api.openai.com/v1`;只有 `openai-compatible` 和 `other` 使用自定义 API Base URL。管理接口只返回密钥是否已配置,不接收、不返回、不回写 API Key 内容;密钥必须通过环境变量或部署 Secret 注入。容器或托管环境若使用外部注入变量或只读文件系统,应修改部署配置并重新部署,而不是依赖后台写文件。
环境变量是模型配置的唯一生效来源。管理后台只可修改服务商和模型名;API
Base URL 与凭据属于部署配置,在 UI/API 中只读。SQLite 中历史
`model_configs` 数据不再覆盖环境配置。`openai` 始终使用官方地址,只有
`openai-compatible` 和 `other` 使用自定义地址;仅当对话与 embedding 解析为
同一规范化端点时才允许复用对话密钥。密钥必须通过环境变量或部署 Secret
注入;托管或只读环境修改后应重新部署。

---

Expand Down
7 changes: 6 additions & 1 deletion client/src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
FaqIndexStatus,
ModelConfigResponseDTO,
ModelConfigDTO,
EditableModelConfigDTO,
KnowledgeReviewItem,
KnowledgeReviewStats,
KnowledgeReviewStatus,
Expand Down Expand Up @@ -70,6 +71,7 @@ export type {
FaqIndexStatus,
ModelConfigResponseDTO,
ModelConfigDTO,
EditableModelConfigDTO,
KnowledgeReviewItem,
KnowledgeReviewStats,
KnowledgeReviewStatus,
Expand Down Expand Up @@ -403,7 +405,10 @@ export async function getModelConfig(): Promise<ModelConfigResponseDTO> {
* Update non-secret model configuration. Only non-empty fields are persisted.
* Empty/omitted fields keep their current value.
*/
export async function updateModelConfig(updates: Partial<ModelConfigDTO>, resetKeys: string[] = []): Promise<void> {
export async function updateModelConfig(
updates: Partial<EditableModelConfigDTO>,
resetKeys: Array<keyof EditableModelConfigDTO> = [],
): Promise<void> {
return put<void>(
'/admin/config/model',
{ ...updates, resetKeys },
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/chat/ChatBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import { Tag } from 'tdesign-react';
import type { ChatMessage } from '../../hooks/useChat';
import { intentLabel } from '../../i18n';
import { useTranslation } from '../../hooks/usePreferences';
import { SatisfactionRating } from './SatisfactionRating';
import { SafeMarkdown } from '../common/SafeMarkdown';

interface ChatBubbleProps {
message: ChatMessage;
Expand Down Expand Up @@ -48,7 +48,7 @@ export function ChatBubble({ message, onSubmitRating }: ChatBubbleProps): React.
{message.content ? (
isUser
? message.content
: <ReactMarkdown className="app-chat-markdown" skipHtml>{message.content}</ReactMarkdown>
: <SafeMarkdown className="app-chat-markdown" content={message.content} />
) : (message.isStreaming ? (
<span style={{ opacity: 0.6 }}>{t('chat.thinking')}</span>
) : '')}
Expand Down
5 changes: 5 additions & 0 deletions client/src/components/common/SafeMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';

const SAFE_MARKDOWN_COMPONENTS = {
img: () => null,
};

interface SafeMarkdownProps {
content: string;
className?: string;
Expand All @@ -14,6 +18,7 @@ export function SafeMarkdown({
<ReactMarkdown
className={['app-markdown-content', className].filter(Boolean).join(' ')}
skipHtml
components={SAFE_MARKDOWN_COMPONENTS}
>
{content}
</ReactMarkdown>
Expand Down
8 changes: 6 additions & 2 deletions client/src/i18n/dictionary.json
Original file line number Diff line number Diff line change
Expand Up @@ -816,8 +816,8 @@
"en": "Embedding Model"
},
"config.providerHelp": {
"zh": "OpenAI 使用官方 API 地址;OpenAI Compatible 和其他服务商使用下方自定义地址。",
"en": "OpenAI uses the official API endpoint. OpenAI Compatible and Other providers use the custom endpoint below."
"zh": "OpenAI 使用官方 API 地址;其他服务商的端点由部署环境控制。",
"en": "OpenAI uses the official API endpoint. Other provider endpoints are controlled by the deployment environment."
},
"config.provider": {
"zh": "服务商",
Expand All @@ -827,6 +827,10 @@
"zh": "API Base URL",
"en": "API Base URL"
},
"config.apiBaseEnvironmentHelp": {
"zh": "由部署环境变量 {variable} 管理,管理员无法在此修改。",
"en": "Managed by the {variable} deployment environment variable and cannot be changed here."
},
"config.model": {
"zh": "模型",
"en": "Model"
Expand Down
6 changes: 6 additions & 0 deletions client/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,12 @@ select {
color: var(--app-text-secondary);
}

.app-config-environment-field {
display: grid;
gap: 8px;
color: var(--app-text-secondary);
}

.app-toolbar-row {
width: 100%;
display: flex;
Expand Down
Loading
Loading