mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-31 16:17:35 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ef8263eaf | ||
|
|
3c54a67416 | ||
|
|
98ca395edd | ||
|
|
c616822cd6 | ||
|
|
ba1796d7ce | ||
|
|
dad14c55c1 | ||
|
|
cc0233d7b4 | ||
|
|
7b6f56e476 | ||
|
|
2522fc6ae2 | ||
|
|
99d7380450 | ||
|
|
837e41459a | ||
|
|
5cbd828cad | ||
|
|
8cb317cbd6 | ||
|
|
94a2ba0406 | ||
|
|
4ee7204509 | ||
|
|
c326adbb66 | ||
|
|
7d1e9bdac4 | ||
|
|
151b445c74 | ||
|
|
af4b25b84e | ||
|
|
f0b1955059 | ||
|
|
f5d580bbf0 | ||
|
|
44d069da2b | ||
|
|
9297e6e6ee | ||
|
|
9bbc28c14a | ||
|
|
4943b9419e | ||
|
|
446ccd3edb | ||
|
|
a00643b9c0 | ||
|
|
5c13819f66 |
+91
-8
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"cyberstrike-ai/internal/app"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/logger"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"cyberstrike-ai/internal/termout"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -12,17 +14,21 @@ import (
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPath = flag.String("config", "config.yaml", "配置文件路径")
|
||||
var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致")
|
||||
var httpBootstrap = flag.Bool("http", false, "强制主站使用明文 HTTP:覆盖配置文件中的 tls_enabled/tls_auto_self_sign/tls_cert_path/tls_key_path")
|
||||
var configPath = flag.String("config", "config.yaml", "Path to the configuration file")
|
||||
var httpsBootstrap = flag.Bool("https", false, "Enable HTTPS for the main site; uses an in-memory self-signed certificate when no cert/key is configured")
|
||||
var httpBootstrap = flag.Bool("http", false, "Force plain HTTP for the main site, overriding TLS settings in the configuration file")
|
||||
var resetAdminPassword = flag.Bool("reset-admin-password", false, "Interactively reset the built-in admin password and exit")
|
||||
flag.Parse()
|
||||
|
||||
// 环境变量兼容(便于 systemd/docker 等不传参场景)
|
||||
if *httpsBootstrap && *httpBootstrap {
|
||||
fmt.Fprintln(os.Stderr, "--http 与 --https 不能同时使用")
|
||||
fmt.Fprintln(os.Stderr, "--http and --https cannot be used together")
|
||||
os.Exit(2)
|
||||
}
|
||||
if !*httpsBootstrap && !*httpBootstrap {
|
||||
@@ -38,24 +44,32 @@ func main() {
|
||||
cp = "config.yaml"
|
||||
}
|
||||
if strings.HasPrefix(cp, "-") {
|
||||
fmt.Fprintf(os.Stderr, "无效的 -config 路径 %q。\n若同时需要 HTTPS,请写成: ./cyberstrike-ai --https -config config.yaml(-config 后必须是 yaml 文件路径)。\n", cp)
|
||||
fmt.Fprintf(os.Stderr, "Invalid -config path %q.\nIf HTTPS is also needed, use: ./cyberstrike-ai --https -config config.yaml (-config must be followed by a yaml file path).\n", cp)
|
||||
os.Exit(2)
|
||||
}
|
||||
localConfig, err := config.EnsureLocalConfig(cp)
|
||||
if err != nil {
|
||||
fmt.Printf("加载配置失败: %v\n", err)
|
||||
fmt.Printf("Failed to load config: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Load(cp)
|
||||
if err != nil {
|
||||
fmt.Printf("加载配置失败: %v\n", err)
|
||||
fmt.Printf("Failed to load config: %v\n", err)
|
||||
return
|
||||
}
|
||||
if localConfig.Created {
|
||||
termout.PrintConfigCreated()
|
||||
}
|
||||
|
||||
if *resetAdminPassword {
|
||||
if err := runResetAdminPassword(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to reset admin password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if *httpBootstrap {
|
||||
config.ApplyPlainHTTPBootstrap(cfg)
|
||||
} else if *httpsBootstrap {
|
||||
@@ -79,7 +93,7 @@ func main() {
|
||||
|
||||
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
||||
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
|
||||
fmt.Printf("MCP 鉴权配置失败: %v\n", err)
|
||||
fmt.Printf("Failed to configure MCP authentication: %v\n", err)
|
||||
return
|
||||
}
|
||||
if cfg.MCP.Enabled {
|
||||
@@ -121,3 +135,72 @@ func main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runResetAdminPassword(cfg *config.Config) error {
|
||||
dbPath := strings.TrimSpace(cfg.Database.Path)
|
||||
if dbPath == "" {
|
||||
dbPath = "data/conversations.db"
|
||||
}
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("database does not exist: %s; start the service once to initialize it first", dbPath)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Reset built-in admin password")
|
||||
fmt.Println()
|
||||
|
||||
password, err := readHiddenPassword("New admin password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
password = strings.TrimSpace(password)
|
||||
if len(password) < 8 {
|
||||
return fmt.Errorf("new password must be at least 8 characters")
|
||||
}
|
||||
confirm, err := readHiddenPassword("Confirm new password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if password != strings.TrimSpace(confirm) {
|
||||
return fmt.Errorf("passwords do not match")
|
||||
}
|
||||
|
||||
hash, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
admin, err := db.GetRBACUserByUsername("admin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("built-in admin account was not found; start the service once to initialize it first: %w", err)
|
||||
}
|
||||
if !admin.IsBuiltin {
|
||||
return fmt.Errorf("admin account is not built in; refusing to reset it")
|
||||
}
|
||||
if err := db.UpdateRBACAdminPassword(hash); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Admin password has been reset.")
|
||||
fmt.Println("If the service is running, existing login sessions remain valid until the service restarts or the sessions expire.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func readHiddenPassword(prompt string) (string, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(password), nil
|
||||
}
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.7.8"
|
||||
version: "v1.7.10"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -68,7 +68,7 @@ ai:
|
||||
api_key: sk-xxxxxxx
|
||||
model: qwen3-max
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 16384
|
||||
max_completion_tokens: 32768
|
||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
||||
reasoning:
|
||||
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
||||
|
||||
@@ -67,6 +67,25 @@ Streaming endpoints are long-lived. Clients should:
|
||||
- disable proxy buffering;
|
||||
- pass `conversationId` when continuing a conversation.
|
||||
|
||||
## File Management Sources
|
||||
|
||||
The file management page and `GET /api/chat-uploads` group conversation-related files by source. Directory names still use project IDs or conversation IDs for stability, while the UI prefers project names or conversation titles and keeps the full ID available in tooltips or copied paths.
|
||||
|
||||
| Source | `source` | Typical directory | Meaning | Mutability |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Workspace files | `workspace` | `tmp/workspace/projects/<projectId>/...`, `tmp/workspace/conversations/<conversationId>/...` | The Agent workspace for downloaded files, analysis scripts, intermediate results, and generated CSV/XLSX/Markdown files. If an AI-generated file is missing from the UI, check this source first. | Read-only listing; supports copy path, download, and export. |
|
||||
| Conversation artifacts | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | Conversation-scoped deliverables or archived artifacts such as summaries, reports, or middleware-generated artifacts. | Read-only listing; supports copy path, download, and export. |
|
||||
| Tool outputs | `reduction` | `tmp/reduction/projects/<projectId>/...`, `tmp/reduction/conversations/<conversationId>/...` | Persisted full tool outputs, scan raw data, or outputs saved before truncation. Useful for reviewing long command or scan results. | Read-only listing; supports copy path, download, and export. |
|
||||
| Chat uploads | `upload` | `chat_uploads/<date>/<conversationId>/...` | Files manually uploaded in chat or from the file management page. Copy the server absolute path into chat when the AI should reference a file. | Supports upload, mkdir, text edit, rename, delete, copy path, download, and export. |
|
||||
|
||||
Related endpoints:
|
||||
|
||||
- `GET /api/chat-uploads`: list files filtered by source, project, conversation, or filename.
|
||||
- `GET /api/chat-uploads/path`: resolve a file-management relative path or internal virtual path to a server absolute path for copy actions.
|
||||
- `GET /api/chat-uploads/download`: download a file.
|
||||
- `GET /api/chat-uploads/export`: export the current filtered result as a ZIP.
|
||||
- `POST /api/chat-uploads`: upload into the chat uploads directory.
|
||||
|
||||
## Asset Management and Bulk Import
|
||||
|
||||
Asset endpoints:
|
||||
|
||||
@@ -46,13 +46,21 @@ Login fails:
|
||||
|
||||
If another administrator with `rbac:write` is available, reset the password under **Platform permissions → User management**.
|
||||
|
||||
If no administrator session is available, the built-in `admin` account can be recovered on the server. Stop CyberStrikeAI, back up the database, change to the project root, and run the command below. Enter and confirm the new password when prompted:
|
||||
If no administrator session is available, the built-in `admin` account can be recovered on the server. Change to the project root and run:
|
||||
|
||||
```bash
|
||||
./run.sh --reset-admin-password
|
||||
```
|
||||
|
||||
Enter and confirm the new password when prompted. The script hides input and stores a bcrypt hash. If the service is running, restart it afterward to invalidate existing login sessions.
|
||||
|
||||
If `run.sh` is not available, run the command below manually. Enter and confirm the new password when prompted:
|
||||
|
||||
```bash
|
||||
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
||||
```
|
||||
|
||||
Output `1` means that the row was updated. The command requires `sqlite3` and `htpasswd`. If `database.path` in `config.yaml` is not the default, replace `data/conversations.db`. Password input is hidden, is not written to shell history, and is stored as a bcrypt hash. Restart the service afterward to invalidate existing login sessions.
|
||||
Output `1` means that the row was updated. The command requires `sqlite3` and `htpasswd`. If `database.path` in `config.yaml` is not the default, replace `data/conversations.db`. Password input is hidden and is not written to shell history.
|
||||
|
||||
Model fails:
|
||||
|
||||
|
||||
@@ -74,6 +74,25 @@ Content-Type: application/json
|
||||
- `POST /api/conversations/:id/delete-turn`
|
||||
- `GET /api/messages/:id/process-details`
|
||||
|
||||
## 文件管理来源
|
||||
|
||||
文件管理页面和 `/api/chat-uploads` 列表接口会把对话相关文件按来源归类。底层目录仍使用项目 ID 或会话 ID 保持稳定,界面会优先显示项目名或对话标题,完整 ID 可在提示或路径中查看。
|
||||
|
||||
| 来源 | `source` | 典型目录 | 说明 | 可变更性 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 工作目录 | `workspace` | `tmp/workspace/projects/<projectId>/...`、`tmp/workspace/conversations/<conversationId>/...` | Agent 执行任务时保存下载文件、分析脚本、中间结果和生成的 CSV/XLSX/Markdown 等。用户反馈“AI 生成的文件找不到”时,通常先看这里。 | 只读展示;支持复制路径、下载、导出。 |
|
||||
| 会话产物 | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | 系统按会话归档的交付物或会话级产物,例如总结、报告、模型中间件生成的归档内容。 | 只读展示;支持复制路径、下载、导出。 |
|
||||
| 工具输出 | `reduction` | `tmp/reduction/projects/<projectId>/...`、`tmp/reduction/conversations/<conversationId>/...` | 超长工具输出、扫描原文或被截断前落盘的结果缓存。适合回看完整工具输出。 | 只读展示;支持复制路径、下载、导出。 |
|
||||
| 对话附件 | `upload` | `chat_uploads/<date>/<conversationId>/...` | 用户在对话或文件管理页手动上传的附件。需要让 AI 引用某文件时,可复制服务器绝对路径粘贴到对话中。 | 可上传、新建目录、编辑文本文件、重命名、删除、复制路径、下载、导出。 |
|
||||
|
||||
相关接口:
|
||||
|
||||
- `GET /api/chat-uploads`:按来源、项目、会话、文件名筛选文件。
|
||||
- `GET /api/chat-uploads/path`:把文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件或目录路径。
|
||||
- `GET /api/chat-uploads/download`:下载指定文件。
|
||||
- `GET /api/chat-uploads/export`:导出当前筛选结果为 ZIP。
|
||||
- `POST /api/chat-uploads`:上传到对话附件目录。
|
||||
|
||||
## 项目、漏洞、攻击链
|
||||
|
||||
项目:
|
||||
|
||||
@@ -32,13 +32,21 @@ https://127.0.0.1:8080/
|
||||
|
||||
如果仍有其他具备 `rbac:write` 权限的管理员账号,优先在 **平台权限 → 用户管理** 中重置密码。
|
||||
|
||||
如果没有可用的管理员会话,可在服务器上紧急重置内置 `admin` 账号。先停止 CyberStrikeAI 服务并备份数据库,然后在项目根目录执行以下命令,按提示输入并确认新密码:
|
||||
如果没有可用的管理员会话,可在服务器上紧急重置内置 `admin` 账号。在项目根目录执行:
|
||||
|
||||
```bash
|
||||
./run.sh --reset-admin-password
|
||||
```
|
||||
|
||||
按提示输入并确认新密码。脚本会隐藏输入并写入 bcrypt 哈希。如果服务正在运行,完成后重新启动服务,使原有登录会话失效。
|
||||
|
||||
如果无法使用 `run.sh`,也可以手动执行以下命令,按提示输入并确认新密码:
|
||||
|
||||
```bash
|
||||
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
||||
```
|
||||
|
||||
输出 `1` 表示修改成功。该命令需要 `sqlite3` 和 `htpasswd`;如果 `config.yaml` 中的 `database.path` 不是默认值,请替换 `data/conversations.db`。密码输入不会显示,也不会写入 Shell 历史,并以 bcrypt 哈希保存。完成后重新启动服务,使原有登录会话失效。
|
||||
输出 `1` 表示修改成功。该命令需要 `sqlite3` 和 `htpasswd`;如果 `config.yaml` 中的 `database.path` 不是默认值,请替换 `data/conversations.db`。密码输入不会显示,也不会写入 Shell 历史。
|
||||
|
||||
## 模型无响应
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.34.0
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/term v0.32.0
|
||||
golang.org/x/text v0.26.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
+14
-22
@@ -514,6 +514,14 @@ type ToolExecutionResult struct {
|
||||
IsError bool
|
||||
}
|
||||
|
||||
func buildToolFailureMessage(toolName, detail string, err error) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "工具调用失败\n\n")
|
||||
fmt.Fprintf(&b, "工具名称: %s\n", toolName)
|
||||
fmt.Fprintf(&b, "错误详情: %s", detail)
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// executeToolViaMCP 通过MCP执行工具
|
||||
// 即使工具执行失败,也返回结果而不是错误,让AI能够处理错误情况
|
||||
func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
|
||||
@@ -573,32 +581,16 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
||||
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
|
||||
if err != nil {
|
||||
detail := err.Error()
|
||||
timeoutMinutes := 10
|
||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||
timeoutMinutes = a.agentConfig.ToolTimeoutMinutes
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
detail = "工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。"
|
||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||
min := 10
|
||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||
min = a.agentConfig.ToolTimeoutMinutes
|
||||
}
|
||||
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", min)
|
||||
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", timeoutMinutes)
|
||||
}
|
||||
errorMsg := fmt.Sprintf(`工具调用失败
|
||||
|
||||
工具名称: %s
|
||||
错误类型: 系统错误
|
||||
错误详情: %s
|
||||
|
||||
可能的原因:
|
||||
- 工具 "%s" 不存在或未启用
|
||||
- 单次执行超时(agent.tool_timeout_minutes)
|
||||
- 系统配置问题
|
||||
- 网络或权限问题
|
||||
|
||||
建议:
|
||||
- 检查工具名称是否正确
|
||||
- 若需更长执行时间,可适当增大 agent.tool_timeout_minutes
|
||||
- 尝试使用其他替代工具
|
||||
- 如果这是必需的工具,请向用户说明情况`, toolName, detail, toolName)
|
||||
errorMsg := buildToolFailureMessage(toolName, detail, err)
|
||||
|
||||
return &ToolExecutionResult{
|
||||
Result: errorMsg,
|
||||
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -71,6 +72,80 @@ func TestAgent_NewAgent_CustomConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolFailureMessageAuthorizationDenied(t *testing.T) {
|
||||
msg := buildToolFailureMessage(
|
||||
"list_project_facts",
|
||||
"tool authorization denied: no access to project",
|
||||
errors.New("tool authorization denied: no access to project"),
|
||||
)
|
||||
for _, want := range []string{
|
||||
"工具名称: list_project_facts",
|
||||
"错误详情: tool authorization denied: no access to project",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
for _, notWant := range []string{
|
||||
"可能的原因",
|
||||
"建议",
|
||||
"错误类型",
|
||||
"工具 \"list_project_facts\" 不存在或未启用",
|
||||
"单次执行超时",
|
||||
} {
|
||||
if strings.Contains(msg, notWant) {
|
||||
t.Fatalf("message should not include generic hint %q:\n%s", notWant, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolFailureMessageCanceled(t *testing.T) {
|
||||
msg := buildToolFailureMessage(
|
||||
"long_running_tool",
|
||||
"工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。",
|
||||
context.Canceled,
|
||||
)
|
||||
|
||||
for _, want := range []string{
|
||||
"工具名称: long_running_tool",
|
||||
"错误详情: 工具调用已被手动终止",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolFailureMessageDeadlineExceeded(t *testing.T) {
|
||||
msg := buildToolFailureMessage(
|
||||
"nmap",
|
||||
"工具执行超过 15 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)",
|
||||
context.DeadlineExceeded,
|
||||
)
|
||||
|
||||
for _, want := range []string{
|
||||
"工具名称: nmap",
|
||||
"错误详情: 工具执行超过 15 分钟被自动终止",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolFailureMessageUnknownKeepsGenericFallback(t *testing.T) {
|
||||
msg := buildToolFailureMessage("custom_tool", "dial tcp: connection reset by peer", errors.New("dial tcp: connection reset by peer"))
|
||||
|
||||
for _, want := range []string{
|
||||
"工具名称: custom_tool",
|
||||
"错误详情: dial tcp: connection reset by peer",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCancelRunningMCPToolsForConversation(t *testing.T) {
|
||||
ag := setupTestAgent(t)
|
||||
ag.mcpServer.ConfigureToolWaitTimeoutSeconds(1)
|
||||
|
||||
@@ -1340,6 +1340,7 @@ func setupRoutes(
|
||||
protected.GET("/chat-uploads", chatUploadsHandler.List)
|
||||
protected.GET("/chat-uploads/export", chatUploadsHandler.Export)
|
||||
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
|
||||
protected.GET("/chat-uploads/path", chatUploadsHandler.ResolvePath)
|
||||
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
|
||||
protected.POST("/chat-uploads", chatUploadsHandler.Upload)
|
||||
protected.POST("/chat-uploads/mkdir", chatUploadsHandler.Mkdir)
|
||||
|
||||
@@ -75,6 +75,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
||||
"bind_host": map[string]interface{}{"type": "string", "description": "绑定地址,默认 127.0.0.1;外网监听常用 0.0.0.0"},
|
||||
"callback_host": map[string]interface{}{"type": "string", "description": "可选:植入端/Payload 回连主机名(公网 IP 或域名)。写入 config_json;生成 oneliner/beacon 时优先于 bind_host。update 时传入空字符串可清除"},
|
||||
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port)", webListenPort), "minimum": 1, "maximum": 65535},
|
||||
"project_id": map[string]interface{}{"type": "string", "description": "所属项目 ID。create 省略时默认使用当前对话绑定项目;未绑定项目的对话则创建未绑定监听器"},
|
||||
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
|
||||
"remark": map[string]interface{}{"type": "string", "description": "备注"},
|
||||
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false)"},
|
||||
@@ -116,6 +117,13 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
||||
cfg = &c2.ListenerConfig{}
|
||||
_ = json.Unmarshal(cfgBytes, cfg)
|
||||
}
|
||||
projectID := strings.TrimSpace(getString(params, "project_id"))
|
||||
if projectID == "" {
|
||||
projectID = mcpEffectiveProjectFilter(ctx, m.DB())
|
||||
if projectID == database.ProjectFilterUnbound {
|
||||
projectID = ""
|
||||
}
|
||||
}
|
||||
input := c2.CreateListenerInput{
|
||||
Name: getString(params, "name"),
|
||||
Type: getString(params, "type"),
|
||||
@@ -123,7 +131,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
||||
BindPort: int(getFloat64(params, "bind_port")),
|
||||
ProfileID: getString(params, "profile_id"),
|
||||
Remark: getString(params, "remark"),
|
||||
ProjectID: strings.TrimSpace(mcp.MCPProjectIDFromContext(ctx)),
|
||||
ProjectID: projectID,
|
||||
Config: cfg,
|
||||
CallbackHost: getString(params, "callback_host"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/authctx"
|
||||
"cyberstrike-ai/internal/c2"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestC2ListenerCreateInheritsConversationProject(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "c2-tools.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
user, err := db.CreateRBACUser("c2-agent", "C2 Agent", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := db.CreateProject(&database.Project{Name: "engagement"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "project", project.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := db.CreateConversation("project chat", database.ConversationCreateMeta{ProjectID: project.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{
|
||||
"c2:read": true, "c2:write": true,
|
||||
})
|
||||
ctx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), conversation.ID), principal)
|
||||
server := mcp.NewServer(zap.NewNop())
|
||||
server.SetToolAuthorizer(mcpToolAuthorizer(db))
|
||||
registerC2Tools(server, c2.NewManager(db, zap.NewNop(), t.TempDir()), zap.NewNop(), 8080)
|
||||
|
||||
result, _, err := server.CallTool(ctx, builtin.ToolC2Listener, map[string]interface{}{
|
||||
"action": "create",
|
||||
"name": "tcp-reverse-2222",
|
||||
"type": "tcp_reverse",
|
||||
"bind_host": "0.0.0.0",
|
||||
"bind_port": 2222,
|
||||
})
|
||||
if err != nil || result == nil || result.IsError {
|
||||
t.Fatalf("create listener result=%#v err=%v text=%q", result, err, toolResultText(result))
|
||||
}
|
||||
|
||||
listeners, err := db.ListC2Listeners()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 1 {
|
||||
t.Fatalf("listener count=%d, want 1", len(listeners))
|
||||
}
|
||||
if listeners[0].ProjectID != project.ID {
|
||||
t.Fatalf("listener project_id=%q, want %q", listeners[0].ProjectID, project.ID)
|
||||
}
|
||||
}
|
||||
@@ -244,7 +244,20 @@ func authorizeC2Action(ctx context.Context, principal authctx.Principal, db *dat
|
||||
return nil
|
||||
}
|
||||
if id == "" {
|
||||
if action == "create" || action == "list" {
|
||||
if action == "create" {
|
||||
projectID := mcpAuthorizationString(args, "project_id")
|
||||
if projectID == "" {
|
||||
projectID = mcpEffectiveProjectFilter(ctx, db)
|
||||
if projectID == database.ProjectFilterUnbound {
|
||||
projectID = ""
|
||||
}
|
||||
}
|
||||
if projectID != "" && (db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID)) {
|
||||
return fmt.Errorf("no access to project %s", projectID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if action == "list" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("missing resource identifier %s", argument)
|
||||
@@ -369,7 +382,13 @@ func authorizeProjectTool(ctx context.Context, principal authctx.Principal, db *
|
||||
return fmt.Errorf("no access to conversation %s", conversationID)
|
||||
}
|
||||
projectID, err := db.GetConversationProjectID(conversationID)
|
||||
if err != nil || strings.TrimSpace(projectID) == "" || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("no access to project: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return fmt.Errorf("当前对话未绑定项目,无法使用项目黑板工具,请先在对话中选择项目或创建带项目的对话")
|
||||
}
|
||||
if !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
|
||||
return fmt.Errorf("no access to project %s", projectID)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -835,6 +835,11 @@ func (db *DB) ConversationArtifactsBaseDir() string {
|
||||
return strings.TrimSpace(db.conversationArtifactsDir)
|
||||
}
|
||||
|
||||
// EinoWorkspaceBaseDir returns the configured agent workspace root.
|
||||
func (db *DB) EinoWorkspaceBaseDir() string {
|
||||
return db.einoWorkspaceBaseDir()
|
||||
}
|
||||
|
||||
func (db *DB) einoWorkspaceBaseDir() string {
|
||||
if db == nil {
|
||||
return ""
|
||||
@@ -1419,7 +1424,8 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
||||
seenExecIDs := make(map[string]bool)
|
||||
// A provider may reuse a fallback toolCallId across streaming rounds. Keep a
|
||||
// FIFO per ID instead of a single index so every persisted call gets at most
|
||||
// one result. Results without an ID fall back to the oldest unmatched call.
|
||||
// one result. Results without a stable ID are kept separate instead of being
|
||||
// guessed by order; showing no link is safer than linking to the wrong tool.
|
||||
toolIndexesByCallID := make(map[string][]int)
|
||||
lastMatchedToolIndexByCallID := make(map[string]int)
|
||||
matchedToolIndexes := make([]bool, 0)
|
||||
@@ -1494,7 +1500,7 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
if idx < 0 && toolCallID != "" {
|
||||
for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] {
|
||||
nextUnmatchedToolIdx++
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T) {
|
||||
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||
@@ -32,14 +32,24 @@ func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if len(summary.ToolExecutions) != 4 {
|
||||
t.Fatalf("tool executions = %d, want 4", len(summary.ToolExecutions))
|
||||
if len(summary.ToolExecutions) != 6 {
|
||||
t.Fatalf("tool executions = %d, want 6", len(summary.ToolExecutions))
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions {
|
||||
for i, execution := range summary.ToolExecutions[:2] {
|
||||
if execution.Status != "completed" {
|
||||
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
|
||||
}
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions[2:4] {
|
||||
if execution.Status != "result_missing" {
|
||||
t.Fatalf("unmatched call %d status = %q, want result_missing", i, execution.Status)
|
||||
}
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions[4:] {
|
||||
if execution.Status != "completed" || execution.ToolCallID != "" {
|
||||
t.Fatalf("idless result %d = %#v, want separate completed result without toolCallId", i, execution)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryPairsRepeatedToolCallIDsFIFO(t *testing.T) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var factKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*$`)
|
||||
var factKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
|
||||
|
||||
// ValidateFactKey 校验事实 key(项目内唯一标识)。
|
||||
func ValidateFactKey(key string) error {
|
||||
@@ -22,7 +22,7 @@ func ValidateFactKey(key string) error {
|
||||
return fmt.Errorf("fact_key 过长(最多 128 字符)")
|
||||
}
|
||||
if !factKeyPattern.MatchString(key) {
|
||||
return fmt.Errorf("fact_key 格式无效,仅允许小写字母、数字及 . _ / -,且须以小写字母或数字开头")
|
||||
return fmt.Errorf("fact_key 格式无效,仅允许字母、数字及 . _ / -,且须以字母或数字开头(支持驼峰命名)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -111,6 +111,19 @@ func (db *DB) GetProject(id string) (*Project, error) {
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetProjectName returns a project display name without loading the full record.
|
||||
func (db *DB) GetProjectName(id string) (string, error) {
|
||||
var name string
|
||||
err := db.QueryRow(`SELECT name FROM projects WHERE id = ?`, id).Scan(&name)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("项目不存在")
|
||||
}
|
||||
return "", fmt.Errorf("获取项目名称失败: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(name), nil
|
||||
}
|
||||
|
||||
func projectListSearchPattern(q string) string {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
|
||||
@@ -27,11 +27,14 @@ import (
|
||||
const (
|
||||
chatUploadsRootDirName = "chat_uploads"
|
||||
reductionRootDirName = "tmp/reduction"
|
||||
workspaceRootDirName = "tmp/workspace"
|
||||
artifactsRootDirName = "data/conversation_artifacts"
|
||||
reductionVirtualPrefix = "__reduction__/"
|
||||
workspaceVirtualPrefix = "__workspace__/"
|
||||
artifactVirtualPrefix = "__conversation_artifact__/"
|
||||
chatUploadSourceUpload = "upload"
|
||||
chatUploadSourceReduction = "reduction"
|
||||
chatUploadSourceWorkspace = "workspace"
|
||||
chatUploadSourceConversation = "conversation_artifact"
|
||||
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
|
||||
)
|
||||
@@ -104,6 +107,11 @@ func (h *ChatUploadsHandler) reductionPathAllowed(c *gin.Context, scope, id stri
|
||||
|
||||
func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool {
|
||||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
|
||||
rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
|
||||
if rel == "projects" || rel == "conversations" {
|
||||
_, ok := security.CurrentSession(c)
|
||||
return ok
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
@@ -111,6 +119,24 @@ func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativ
|
||||
return h.reductionPathAllowed(c, parts[0], parts[1])
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) workspacePathAllowed(c *gin.Context, scope, id string) bool {
|
||||
return h.reductionPathAllowed(c, scope, id)
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) workspaceVirtualPathAllowed(c *gin.Context, relativePath string) bool {
|
||||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
|
||||
rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
|
||||
if rel == "projects" || rel == "conversations" {
|
||||
_, ok := security.CurrentSession(c)
|
||||
return ok
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
return h.workspacePathAllowed(c, parts[0], parts[1])
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) conversationArtifactPathAllowed(c *gin.Context, conversationID string) bool {
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok || h.db == nil {
|
||||
@@ -163,6 +189,26 @@ func (h *ChatUploadsHandler) absReductionRoot() (string, error) {
|
||||
return filepath.Abs(filepath.Join(cwd, reductionRootDirName))
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) absWorkspaceRoot() (string, error) {
|
||||
if h.db != nil {
|
||||
if base := strings.TrimSpace(h.db.EinoWorkspaceBaseDir()); base != "" {
|
||||
if filepath.IsAbs(base) {
|
||||
return filepath.Abs(base)
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Abs(filepath.Join(cwd, base))
|
||||
}
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Abs(filepath.Join(cwd, workspaceRootDirName))
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) {
|
||||
if h.db != nil {
|
||||
if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" {
|
||||
@@ -211,15 +257,17 @@ func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs s
|
||||
|
||||
// ChatUploadFileItem 列表项
|
||||
type ChatUploadFileItem struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedUnix int64 `json:"modifiedUnix"`
|
||||
Date string `json:"date"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedUnix int64 `json:"modifiedUnix"`
|
||||
Date string `json:"date"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
ConversationTitle string `json:"conversationTitle,omitempty"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
ProjectName string `json:"projectName,omitempty"`
|
||||
// SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。
|
||||
SubPath string `json:"subPath"`
|
||||
}
|
||||
@@ -240,6 +288,38 @@ func (h *ChatUploadsHandler) conversationProjectID(conversationID string, cache
|
||||
return projectID
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) conversationTitle(conversationID string, cache map[string]string) string {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" || conversationID == "_manual" || conversationID == "_new" || h.db == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := cache[conversationID]; ok {
|
||||
return v
|
||||
}
|
||||
title, err := h.db.GetConversationTitle(conversationID)
|
||||
if err != nil {
|
||||
title = ""
|
||||
}
|
||||
cache[conversationID] = title
|
||||
return title
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) projectName(projectID string, cache map[string]string) string {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" || h.db == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := cache[projectID]; ok {
|
||||
return v
|
||||
}
|
||||
name, err := h.db.GetProjectName(projectID)
|
||||
if err != nil {
|
||||
name = ""
|
||||
}
|
||||
cache[projectID] = name
|
||||
return name
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) {
|
||||
root, err := h.absRoot()
|
||||
if err != nil {
|
||||
@@ -252,6 +332,8 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
|
||||
var files []ChatUploadFileItem
|
||||
var folders []string
|
||||
projectCache := make(map[string]string)
|
||||
projectNameCache := make(map[string]string)
|
||||
conversationTitleCache := make(map[string]string)
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
@@ -293,16 +375,18 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
|
||||
}
|
||||
absPath, _ := filepath.Abs(path)
|
||||
files = append(files, ChatUploadFileItem{
|
||||
RelativePath: relSlash,
|
||||
AbsolutePath: absPath,
|
||||
Name: d.Name(),
|
||||
Source: chatUploadSourceUpload,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: dateStr,
|
||||
ConversationID: convID,
|
||||
ProjectID: projectID,
|
||||
SubPath: subPath,
|
||||
RelativePath: relSlash,
|
||||
AbsolutePath: absPath,
|
||||
Name: d.Name(),
|
||||
Source: chatUploadSourceUpload,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: dateStr,
|
||||
ConversationID: convID,
|
||||
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||
ProjectID: projectID,
|
||||
ProjectName: h.projectName(projectID, projectNameCache),
|
||||
SubPath: subPath,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
@@ -354,6 +438,12 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
|
||||
} else if len(reductionFiles) > 0 {
|
||||
files = append(files, reductionFiles...)
|
||||
}
|
||||
workspaceFiles, err := h.collectWorkspaceFiles(c, conversationFilter, projectFilter)
|
||||
if err != nil {
|
||||
h.logger.Warn("列举 workspace 产物失败", zap.Error(err))
|
||||
} else if len(workspaceFiles) > 0 {
|
||||
files = append(files, workspaceFiles...)
|
||||
}
|
||||
artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter)
|
||||
if err != nil {
|
||||
h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err))
|
||||
@@ -375,6 +465,8 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
|
||||
return nil, nil
|
||||
}
|
||||
projectCache := make(map[string]string)
|
||||
projectNameCache := make(map[string]string)
|
||||
conversationTitleCache := make(map[string]string)
|
||||
files := make([]ChatUploadFileItem, 0)
|
||||
for _, scope := range []string{"conversations", "projects"} {
|
||||
scopeRoot := filepath.Join(root, scope)
|
||||
@@ -403,7 +495,10 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
|
||||
projectID = ownerID
|
||||
}
|
||||
if conversationFilter != "" && convID != conversationFilter {
|
||||
return nil
|
||||
if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
|
||||
return nil
|
||||
}
|
||||
convID = conversationFilter
|
||||
}
|
||||
if projectFilter != "" && projectID != projectFilter {
|
||||
return nil
|
||||
@@ -418,16 +513,90 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
|
||||
}
|
||||
abs, _ := filepath.Abs(path)
|
||||
files = append(files, ChatUploadFileItem{
|
||||
RelativePath: reductionVirtualPrefix + relSlash,
|
||||
AbsolutePath: abs,
|
||||
Name: name,
|
||||
Source: chatUploadSourceReduction,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: info.ModTime().Format("2006-01-02"),
|
||||
ConversationID: convID,
|
||||
ProjectID: projectID,
|
||||
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
|
||||
RelativePath: reductionVirtualPrefix + relSlash,
|
||||
AbsolutePath: abs,
|
||||
Name: name,
|
||||
Source: chatUploadSourceReduction,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: info.ModTime().Format("2006-01-02"),
|
||||
ConversationID: convID,
|
||||
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||
ProjectID: projectID,
|
||||
ProjectName: h.projectName(projectID, projectNameCache),
|
||||
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) collectWorkspaceFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
|
||||
root, err := h.absWorkspaceRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st, err := os.Stat(root); err != nil || !st.IsDir() {
|
||||
return nil, nil
|
||||
}
|
||||
projectCache := make(map[string]string)
|
||||
projectNameCache := make(map[string]string)
|
||||
conversationTitleCache := make(map[string]string)
|
||||
files := make([]ChatUploadFileItem, 0)
|
||||
for _, scope := range []string{"conversations", "projects"} {
|
||||
scopeRoot := filepath.Join(root, scope)
|
||||
_ = filepath.WalkDir(scopeRoot, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil || d == nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
parts := strings.Split(relSlash, "/")
|
||||
if len(parts) < 3 {
|
||||
return nil
|
||||
}
|
||||
ownerID := parts[1]
|
||||
if !h.workspacePathAllowed(c, scope, ownerID) {
|
||||
return nil
|
||||
}
|
||||
var convID, projectID string
|
||||
if scope == "conversations" {
|
||||
convID = ownerID
|
||||
projectID = h.conversationProjectID(convID, projectCache)
|
||||
} else {
|
||||
projectID = ownerID
|
||||
}
|
||||
if conversationFilter != "" && convID != conversationFilter {
|
||||
if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
|
||||
return nil
|
||||
}
|
||||
convID = conversationFilter
|
||||
}
|
||||
if projectFilter != "" && projectID != projectFilter {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
abs, _ := filepath.Abs(path)
|
||||
files = append(files, ChatUploadFileItem{
|
||||
RelativePath: workspaceVirtualPrefix + relSlash,
|
||||
AbsolutePath: abs,
|
||||
Name: d.Name(),
|
||||
Source: chatUploadSourceWorkspace,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: info.ModTime().Format("2006-01-02"),
|
||||
ConversationID: convID,
|
||||
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||
ProjectID: projectID,
|
||||
ProjectName: h.projectName(projectID, projectNameCache),
|
||||
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
@@ -444,6 +613,8 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co
|
||||
return nil, nil
|
||||
}
|
||||
projectCache := make(map[string]string)
|
||||
projectNameCache := make(map[string]string)
|
||||
conversationTitleCache := make(map[string]string)
|
||||
files := make([]ChatUploadFileItem, 0)
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil || d == nil || d.IsDir() {
|
||||
@@ -479,16 +650,18 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co
|
||||
}
|
||||
abs, _ := filepath.Abs(path)
|
||||
files = append(files, ChatUploadFileItem{
|
||||
RelativePath: artifactVirtualPrefix + relSlash,
|
||||
AbsolutePath: abs,
|
||||
Name: name,
|
||||
Source: chatUploadSourceConversation,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: info.ModTime().Format("2006-01-02"),
|
||||
ConversationID: convID,
|
||||
ProjectID: projectID,
|
||||
SubPath: strings.Join(parts[1:len(parts)-1], "/"),
|
||||
RelativePath: artifactVirtualPrefix + relSlash,
|
||||
AbsolutePath: abs,
|
||||
Name: name,
|
||||
Source: chatUploadSourceConversation,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: info.ModTime().Format("2006-01-02"),
|
||||
ConversationID: convID,
|
||||
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
|
||||
ProjectID: projectID,
|
||||
ProjectName: h.projectName(projectID, projectNameCache),
|
||||
SubPath: strings.Join(parts[1:len(parts)-1], "/"),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
@@ -516,6 +689,27 @@ func (h *ChatUploadsHandler) resolveReductionVirtualPath(relativePath string) (s
|
||||
return full, nil
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) resolveWorkspaceVirtualPath(relativePath string) (string, error) {
|
||||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
|
||||
rel = filepath.Clean(filepath.FromSlash(rel))
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
root, err := h.absWorkspaceRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
full, err := filepath.Abs(filepath.Join(root, rel))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rootAbs, _ := filepath.Abs(root)
|
||||
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path escapes workspace root")
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath string) (string, error) {
|
||||
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix)
|
||||
rel = filepath.Clean(filepath.FromSlash(rel))
|
||||
@@ -539,8 +733,10 @@ func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath
|
||||
|
||||
func chatUploadItemIsInternal(item ChatUploadFileItem) bool {
|
||||
return item.Source == chatUploadSourceReduction ||
|
||||
item.Source == chatUploadSourceWorkspace ||
|
||||
item.Source == chatUploadSourceConversation ||
|
||||
strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) ||
|
||||
strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) ||
|
||||
strings.HasPrefix(item.RelativePath, artifactVirtualPrefix)
|
||||
}
|
||||
|
||||
@@ -548,6 +744,8 @@ func (h *ChatUploadsHandler) resolveListedFilePath(item ChatUploadFileItem) (str
|
||||
switch {
|
||||
case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix):
|
||||
return h.resolveReductionVirtualPath(item.RelativePath)
|
||||
case item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix):
|
||||
return h.resolveWorkspaceVirtualPath(item.RelativePath)
|
||||
case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix):
|
||||
return h.resolveConversationArtifactVirtualPath(item.RelativePath)
|
||||
default:
|
||||
@@ -705,7 +903,7 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) {
|
||||
"fileCount": len(files),
|
||||
"files": files,
|
||||
"layout": "chat uploads are stored under conversations/<conversationId>/; internal outputs are stored under internal/<source>/",
|
||||
"sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, artifactsRootDirName},
|
||||
"sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, workspaceRootDirName, artifactsRootDirName},
|
||||
}
|
||||
manifestBytes, _ := json.MarshalIndent(manifest, "", " ")
|
||||
mw, err := zw.Create("manifest.json")
|
||||
@@ -732,6 +930,9 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) {
|
||||
if filepath.Ext(zipName) == "" {
|
||||
zipName += ".txt"
|
||||
}
|
||||
} else if item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) {
|
||||
rel := strings.TrimPrefix(item.RelativePath, workspaceVirtualPrefix)
|
||||
zipName = filepath.ToSlash(filepath.Join("internal", "workspace", rel))
|
||||
} else if item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) {
|
||||
rel := strings.TrimPrefix(item.RelativePath, artifactVirtualPrefix)
|
||||
zipName = filepath.ToSlash(filepath.Join("internal", "conversation_artifacts", rel))
|
||||
@@ -800,6 +1001,24 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) {
|
||||
c.FileAttachment(abs, name)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(p), workspaceVirtualPrefix) {
|
||||
if !h.workspaceVirtualPathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err := h.resolveWorkspaceVirtualPath(p)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil || st.IsDir() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.FileAttachment(abs, filepath.Base(abs))
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) {
|
||||
if !h.conversationArtifactVirtualPathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
@@ -839,6 +1058,93 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) {
|
||||
c.FileAttachment(abs, filepath.Base(abs))
|
||||
}
|
||||
|
||||
// ResolvePath GET /api/chat-uploads/path?path=...&kind=file|directory
|
||||
func (h *ChatUploadsHandler) ResolvePath(c *gin.Context) {
|
||||
p := strings.TrimSpace(c.Query("path"))
|
||||
kind := strings.TrimSpace(c.Query("kind"))
|
||||
if kind == "" {
|
||||
kind = "file"
|
||||
}
|
||||
var abs string
|
||||
var err error
|
||||
switch {
|
||||
case strings.HasPrefix(p, reductionVirtualPrefix):
|
||||
if strings.Trim(strings.TrimPrefix(p, reductionVirtualPrefix), "/") == "" {
|
||||
if _, ok := security.CurrentSession(c); !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.absReductionRoot()
|
||||
break
|
||||
}
|
||||
if !h.reductionVirtualPathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.resolveReductionVirtualPath(p)
|
||||
case strings.HasPrefix(p, workspaceVirtualPrefix):
|
||||
if strings.Trim(strings.TrimPrefix(p, workspaceVirtualPrefix), "/") == "" {
|
||||
if _, ok := security.CurrentSession(c); !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.absWorkspaceRoot()
|
||||
break
|
||||
}
|
||||
if !h.workspaceVirtualPathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.resolveWorkspaceVirtualPath(p)
|
||||
case strings.HasPrefix(p, artifactVirtualPrefix):
|
||||
if strings.Trim(strings.TrimPrefix(p, artifactVirtualPrefix), "/") == "" {
|
||||
if _, ok := security.CurrentSession(c); !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.absConversationArtifactsRoot()
|
||||
break
|
||||
}
|
||||
if !h.conversationArtifactVirtualPathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.resolveConversationArtifactVirtualPath(p)
|
||||
default:
|
||||
if p == "" || p == "." {
|
||||
if _, ok := security.CurrentSession(c); !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.absRoot()
|
||||
break
|
||||
}
|
||||
if !h.pathAllowed(c, p) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
abs, err = h.resolveUnderChatUploads(p)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "path not found"})
|
||||
return
|
||||
}
|
||||
if kind == "directory" && !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||
return
|
||||
}
|
||||
if kind != "directory" && st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a file"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"absolutePath": abs, "isDir": st.IsDir()})
|
||||
}
|
||||
|
||||
type chatUploadPathBody struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
+45
-12
@@ -5803,7 +5803,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}},
|
||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
|
||||
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "page", "in": "query", "required": false, "description": "页码,从1开始", "schema": map[string]interface{}{"type": "integer", "default": 1}},
|
||||
{"name": "pageSize", "in": "query", "required": false, "description": "每页数量,传 all 返回全部", "schema": map[string]interface{}{"oneOf": []map[string]interface{}{{"type": "integer"}, {"type": "string", "enum": []string{"all"}}}}},
|
||||
@@ -5821,16 +5821,18 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"relativePath": map[string]interface{}{"type": "string"},
|
||||
"absolutePath": map[string]interface{}{"type": "string"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
"size": map[string]interface{}{"type": "integer"},
|
||||
"modifiedUnix": map[string]interface{}{"type": "integer"},
|
||||
"date": map[string]interface{}{"type": "string"},
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"projectId": map[string]interface{}{"type": "string"},
|
||||
"subPath": map[string]interface{}{"type": "string"},
|
||||
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/conversation_artifact"},
|
||||
"relativePath": map[string]interface{}{"type": "string"},
|
||||
"absolutePath": map[string]interface{}{"type": "string"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
"size": map[string]interface{}{"type": "integer"},
|
||||
"modifiedUnix": map[string]interface{}{"type": "integer"},
|
||||
"date": map[string]interface{}{"type": "string"},
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"conversationTitle": map[string]interface{}{"type": "string"},
|
||||
"projectId": map[string]interface{}{"type": "string"},
|
||||
"projectName": map[string]interface{}{"type": "string"},
|
||||
"subPath": map[string]interface{}{"type": "string"},
|
||||
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/workspace/conversation_artifact"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -5923,7 +5925,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}},
|
||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
|
||||
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
@@ -5962,6 +5964,37 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat-uploads/path": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"对话附件"},
|
||||
"summary": "解析附件路径",
|
||||
"description": "将文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件/目录路径。",
|
||||
"operationId": "resolveChatUploadPath",
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "path", "in": "query", "required": true, "description": "相对路径或虚拟路径(如 __workspace__/projects/<id>/csv)", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "kind", "in": "query", "required": false, "description": "路径类型:file/directory,默认 file", "schema": map[string]interface{}{"type": "string", "enum": []string{"file", "directory"}}},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "解析成功",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"absolutePath": map[string]interface{}{"type": "string"},
|
||||
"isDir": map[string]interface{}{"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
"403": map[string]interface{}{"description": "无权访问"},
|
||||
"404": map[string]interface{}{"description": "路径不存在"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat-uploads/content": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"对话附件"},
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -146,6 +148,131 @@ func TestChatUploadPathAuthorizationFollowsConversationAccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatUploadsListIncludesAuthorizedProjectWorkspaceFiles(t *testing.T) {
|
||||
db, user := setupConversationRBACTest(t)
|
||||
fsBase := t.TempDir()
|
||||
workspaceBase := filepath.Join(fsBase, "workspace")
|
||||
reductionBase := filepath.Join(fsBase, "reduction")
|
||||
db.SetEinoConversationDirs("", "", reductionBase, workspaceBase)
|
||||
allowedProject, _ := db.CreateProject(&database.Project{Name: "allowed"})
|
||||
hiddenProject, _ := db.CreateProject(&database.Project{Name: "hidden"})
|
||||
conversation, _ := db.CreateConversation("project conversation", database.ConversationCreateMeta{ProjectID: allowedProject.ID})
|
||||
if err := db.AssignResourceToUser(user.ID, "project", allowedProject.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowedFile := filepath.Join(workspaceBase, "projects", allowedProject.ID, "csv", "assets.csv")
|
||||
hiddenFile := filepath.Join(workspaceBase, "projects", hiddenProject.ID, "csv", "secret.csv")
|
||||
for _, path := range []string{allowedFile, hiddenFile} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("name\nexample\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
h := NewChatUploadsHandler(zap.NewNop(), db)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads?source=workspace&pageSize=all&conversation="+conversation.ID, nil)
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
h.List(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Files []ChatUploadFileItem `json:"files"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Total != 1 || len(response.Files) != 1 {
|
||||
t.Fatalf("files = %#v, total = %d, want only authorized workspace file", response.Files, response.Total)
|
||||
}
|
||||
got := response.Files[0]
|
||||
if got.Source != chatUploadSourceWorkspace || got.Name != "assets.csv" || got.ProjectID != allowedProject.ID {
|
||||
t.Fatalf("workspace file = %#v", got)
|
||||
}
|
||||
if got.ProjectName != allowedProject.Name {
|
||||
t.Fatalf("projectName = %q, want %q", got.ProjectName, allowedProject.Name)
|
||||
}
|
||||
if got.ConversationID != conversation.ID {
|
||||
t.Fatalf("conversationId = %q, want %q", got.ConversationID, conversation.ID)
|
||||
}
|
||||
if got.ConversationTitle != conversation.Title {
|
||||
t.Fatalf("conversationTitle = %q, want %q", got.ConversationTitle, conversation.Title)
|
||||
}
|
||||
if got.AbsolutePath != allowedFile {
|
||||
t.Fatalf("absolutePath = %q, want %q", got.AbsolutePath, allowedFile)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(w)
|
||||
resolveURL := "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects%2F" + allowedProject.ID
|
||||
c.Request = httptest.NewRequest(http.MethodGet, resolveURL, nil)
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
h.ResolvePath(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("resolve status = %d, want 200: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resolved struct {
|
||||
AbsolutePath string `json:"absolutePath"`
|
||||
IsDir bool `json:"isDir"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDir := filepath.Join(workspaceBase, "projects", allowedProject.ID)
|
||||
if !resolved.IsDir || resolved.AbsolutePath != wantDir {
|
||||
t.Fatalf("resolved = %#v, want dir %q", resolved, wantDir)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects", nil)
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
h.ResolvePath(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("resolve projects container status = %d, want 200: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantContainer := filepath.Join(workspaceBase, "projects")
|
||||
if !resolved.IsDir || resolved.AbsolutePath != wantContainer {
|
||||
t.Fatalf("resolved container = %#v, want dir %q", resolved, wantContainer)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"__workspace__/", workspaceBase},
|
||||
{"__reduction__/", reductionBase},
|
||||
{"__conversation_artifact__/", db.ConversationArtifactsBaseDir()},
|
||||
} {
|
||||
if err := os.MkdirAll(tc.want, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path="+url.QueryEscape(tc.path), nil)
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
h.ResolvePath(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("resolve root %q status = %d, want 200: %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantAbs, _ := filepath.Abs(tc.want)
|
||||
if !resolved.IsDir || resolved.AbsolutePath != wantAbs {
|
||||
t.Fatalf("resolved root %q = %#v, want dir %q", tc.path, resolved, wantAbs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareMultiAgentSessionRejectsForeignConversation(t *testing.T) {
|
||||
db, user := setupConversationRBACTest(t)
|
||||
hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{})
|
||||
|
||||
@@ -674,48 +674,6 @@ func (m *ExternalMCPManager) updateToolCache(name string, tools []Tool) {
|
||||
|
||||
// CallTool 调用外部MCP工具(返回执行ID)
|
||||
func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
|
||||
_, authenticated := authctx.PrincipalFromContext(ctx)
|
||||
m.mu.RLock()
|
||||
authorizer := m.toolAuthorizer
|
||||
m.mu.RUnlock()
|
||||
if authorizer != nil {
|
||||
if err := authorizer(ctx, toolName, args); err != nil {
|
||||
return nil, "", fmt.Errorf("external tool authorization denied: %w", err)
|
||||
}
|
||||
} else if authenticated {
|
||||
return nil, "", fmt.Errorf("external tool authorization policy is not configured")
|
||||
}
|
||||
// 解析工具名称:name::toolName
|
||||
var mcpName, actualToolName string
|
||||
if idx := findSubstring(toolName, "::"); idx > 0 {
|
||||
mcpName = toolName[:idx]
|
||||
actualToolName = toolName[idx+2:]
|
||||
} else {
|
||||
return nil, "", fmt.Errorf("无效的工具名称格式: %s", toolName)
|
||||
}
|
||||
|
||||
client, exists := m.GetClient(mcpName)
|
||||
if !exists {
|
||||
return nil, "", fmt.Errorf("外部MCP客户端不存在: %s", mcpName)
|
||||
}
|
||||
if err := m.checkExternalMCPCircuit(mcpName); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// 检查连接状态,如果未连接或状态为error,不允许调用
|
||||
if !client.IsConnected() {
|
||||
status := client.GetStatus()
|
||||
if status == "error" {
|
||||
// 获取错误信息(如果有)
|
||||
errorMsg := m.GetError(mcpName)
|
||||
if errorMsg != "" {
|
||||
return nil, "", fmt.Errorf("外部MCP连接失败: %s (错误: %s)", mcpName, errorMsg)
|
||||
}
|
||||
return nil, "", fmt.Errorf("外部MCP连接失败: %s", mcpName)
|
||||
}
|
||||
return nil, "", fmt.Errorf("外部MCP客户端未连接: %s (状态: %s)", mcpName, status)
|
||||
}
|
||||
|
||||
if m.executionService == nil {
|
||||
m.executionService = NewExecutionService(m.storage, m.logger)
|
||||
m.executionService.ConfigureToolResultMaxBytes(m.toolResultMaxBytes)
|
||||
@@ -725,12 +683,57 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
||||
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
|
||||
ownerUserID = principal.UserID
|
||||
}
|
||||
var mcpName, actualToolName string
|
||||
var client ExternalMCPClient
|
||||
handle, err := m.executionService.Submit(ctx, ExecutionRequest{
|
||||
ToolName: toolName,
|
||||
Arguments: args,
|
||||
ConversationID: MCPConversationIDFromContext(ctx),
|
||||
OwnerUserID: ownerUserID,
|
||||
PreRun: func(runCtx context.Context, exec *ToolExecution) (func(), error) {
|
||||
_, authenticated := authctx.PrincipalFromContext(runCtx)
|
||||
m.mu.RLock()
|
||||
authorizer := m.toolAuthorizer
|
||||
m.mu.RUnlock()
|
||||
if authorizer != nil {
|
||||
if err := authorizer(runCtx, toolName, args); err != nil {
|
||||
return nil, fmt.Errorf("external tool authorization denied: %w", err)
|
||||
}
|
||||
} else if authenticated {
|
||||
return nil, fmt.Errorf("external tool authorization policy is not configured")
|
||||
}
|
||||
|
||||
// 解析工具名称:name::toolName
|
||||
if idx := findSubstring(toolName, "::"); idx > 0 {
|
||||
mcpName = toolName[:idx]
|
||||
actualToolName = toolName[idx+2:]
|
||||
} else {
|
||||
return nil, fmt.Errorf("无效的工具名称格式: %s", toolName)
|
||||
}
|
||||
|
||||
var exists bool
|
||||
client, exists = m.GetClient(mcpName)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("外部MCP客户端不存在: %s", mcpName)
|
||||
}
|
||||
if err := m.checkExternalMCPCircuit(mcpName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查连接状态,如果未连接或状态为error,不允许调用
|
||||
if !client.IsConnected() {
|
||||
status := client.GetStatus()
|
||||
if status == "error" {
|
||||
// 获取错误信息(如果有)
|
||||
errorMsg := m.GetError(mcpName)
|
||||
if errorMsg != "" {
|
||||
return nil, fmt.Errorf("外部MCP连接失败: %s (错误: %s)", mcpName, errorMsg)
|
||||
}
|
||||
return nil, fmt.Errorf("外部MCP连接失败: %s", mcpName)
|
||||
}
|
||||
return nil, fmt.Errorf("外部MCP客户端未连接: %s (状态: %s)", mcpName, status)
|
||||
}
|
||||
|
||||
release, acquireErr := m.acquireExternalMCPCallSlot(runCtx, mcpName)
|
||||
if acquireErr != nil {
|
||||
return nil, acquireErr
|
||||
@@ -746,7 +749,9 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
||||
},
|
||||
OnDone: func(exec *ToolExecution) {
|
||||
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
|
||||
m.recordExternalMCPResult(mcpName, failed)
|
||||
if mcpName != "" {
|
||||
m.recordExternalMCPResult(mcpName, failed)
|
||||
}
|
||||
m.updateStats(toolName, failed)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -20,10 +20,20 @@ func TestExternalManagerEnforcesConfiguredAuthorizer(t *testing.T) {
|
||||
return errors.New("denied by policy")
|
||||
})
|
||||
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"agent:execute": true}))
|
||||
_, _, err := manager.CallTool(ctx, "server::tool", map[string]interface{}{})
|
||||
_, executionID, err := manager.CallTool(ctx, "server::tool", map[string]interface{}{})
|
||||
if err == nil || !strings.Contains(err.Error(), "authorization denied") {
|
||||
t.Fatalf("external call bypassed authorizer: %v", err)
|
||||
}
|
||||
if executionID == "" {
|
||||
t.Fatal("denied external call should still return an execution id")
|
||||
}
|
||||
execution, ok := manager.GetExecution(executionID)
|
||||
if !ok || execution == nil {
|
||||
t.Fatalf("missing denied external execution %q", executionID)
|
||||
}
|
||||
if execution.Status != ToolExecutionStatusFailed || !strings.Contains(execution.Error, "denied by policy") {
|
||||
t.Fatalf("denied external execution = %#v, want failed with policy error", execution)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalMCPManager_AddOrUpdateConfig(t *testing.T) {
|
||||
|
||||
+15
-19
@@ -895,25 +895,6 @@ func (s *Server) GetAllTools() []Tool {
|
||||
|
||||
// CallTool 直接调用工具(用于内部调用)
|
||||
func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]interface{}) (*ToolResult, string, error) {
|
||||
_, authenticated := authctx.PrincipalFromContext(ctx)
|
||||
s.mu.RLock()
|
||||
authorizer := s.toolAuthorizer
|
||||
s.mu.RUnlock()
|
||||
if authorizer != nil {
|
||||
if err := authorizer(ctx, toolName, args); err != nil {
|
||||
return nil, "", fmt.Errorf("tool authorization denied: %w", err)
|
||||
}
|
||||
} else if authenticated {
|
||||
return nil, "", errors.New("tool authorization policy is not configured")
|
||||
}
|
||||
s.mu.RLock()
|
||||
handler, exists := s.tools[toolName]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, "", fmt.Errorf("工具 %s 未找到", toolName)
|
||||
}
|
||||
|
||||
if s.executionService == nil {
|
||||
s.executionService = NewExecutionService(s.storage, s.logger)
|
||||
s.executionService.ConfigureToolResultMaxBytes(s.toolResultMaxBytes)
|
||||
@@ -929,6 +910,21 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
|
||||
ConversationID: MCPConversationIDFromContext(ctx),
|
||||
OwnerUserID: ownerUserID,
|
||||
Run: func(runCtx context.Context) (*ToolResult, error) {
|
||||
_, authenticated := authctx.PrincipalFromContext(runCtx)
|
||||
s.mu.RLock()
|
||||
authorizer := s.toolAuthorizer
|
||||
handler, exists := s.tools[toolName]
|
||||
s.mu.RUnlock()
|
||||
if authorizer != nil {
|
||||
if err := authorizer(runCtx, toolName, args); err != nil {
|
||||
return nil, fmt.Errorf("tool authorization denied: %w", err)
|
||||
}
|
||||
} else if authenticated {
|
||||
return nil, errors.New("tool authorization policy is not configured")
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("工具 %s 未找到", toolName)
|
||||
}
|
||||
return handler(runCtx, args)
|
||||
},
|
||||
OnDone: func(exec *ToolExecution) {
|
||||
|
||||
@@ -23,9 +23,20 @@ func TestToolAuthorizerIsUniversalAndExecutionKeepsOwner(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if _, _, err := server.CallTool(context.Background(), "echo", nil); err == nil {
|
||||
_, deniedExecutionID, err := server.CallTool(context.Background(), "echo", nil)
|
||||
if err == nil {
|
||||
t.Fatal("tool call without principal was allowed")
|
||||
}
|
||||
if deniedExecutionID == "" {
|
||||
t.Fatal("denied tool call should still return an execution id")
|
||||
}
|
||||
deniedExecution, ok := server.GetExecution(deniedExecutionID)
|
||||
if !ok || deniedExecution == nil {
|
||||
t.Fatalf("missing denied execution %q", deniedExecutionID)
|
||||
}
|
||||
if deniedExecution.Status != ToolExecutionStatusFailed || !strings.Contains(deniedExecution.Error, "principal required") {
|
||||
t.Fatalf("denied execution = %#v, want failed with authorization error", deniedExecution)
|
||||
}
|
||||
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", "assigned", map[string]bool{"mcp:execute": true}))
|
||||
_, executionID, err := server.CallTool(ctx, "echo", nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -60,6 +60,7 @@ func isEinoTransientRunError(err error) bool {
|
||||
"bad gateway",
|
||||
"gateway timeout",
|
||||
"internal server error",
|
||||
"unexpected internal error",
|
||||
"connection reset",
|
||||
"connection refused",
|
||||
"connection closed",
|
||||
@@ -72,6 +73,7 @@ func isEinoTransientRunError(err error) bool {
|
||||
"dial tcp",
|
||||
"tls handshake timeout",
|
||||
"stream error",
|
||||
"failed to receive stream chunk",
|
||||
"goaway", // http2: server sent GOAWAY and closed the connection
|
||||
"unexpected eof",
|
||||
`": eof`, // net/http: Post "url": EOF (often wraps io.EOF)
|
||||
@@ -136,7 +138,8 @@ func einoTransientRunErrorUserDetail(err error) (kind, summary string) {
|
||||
case strings.Contains(lower, "overloaded") ||
|
||||
strings.Contains(lower, "capacity") ||
|
||||
strings.Contains(lower, "temporarily unavailable") ||
|
||||
strings.Contains(lower, "service unavailable"):
|
||||
strings.Contains(lower, "service unavailable") ||
|
||||
strings.Contains(lower, "unexpected internal error"):
|
||||
kind = "upstream_busy"
|
||||
case strings.Contains(lower, "connection reset") ||
|
||||
strings.Contains(lower, "connection refused") ||
|
||||
@@ -153,6 +156,7 @@ func einoTransientRunErrorUserDetail(err error) (kind, summary string) {
|
||||
strings.Contains(lower, "unexpected eof"):
|
||||
kind = "network"
|
||||
case strings.Contains(lower, "stream error") ||
|
||||
strings.Contains(lower, "failed to receive stream chunk") ||
|
||||
strings.Contains(lower, "unexpected end of json"):
|
||||
kind = "stream"
|
||||
default:
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestIsEinoTransientRunError(t *testing.T) {
|
||||
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true},
|
||||
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
|
||||
{"http2 goaway", errors.New("failed to receive stream chunk: error, http2: server sent GOAWAY and closed the connection; LastStreamID=791, ErrCode=NO_ERROR"), true},
|
||||
{"unexpected internal stream chunk", errors.New("failed to receive stream chunk: error, The service encountered an unexpected internal error. Request id: 0217851391106464f01ec66621d0980a42fd45436ed75957a6a0a"), true},
|
||||
{"unexpected eof", errors.New("unexpected EOF"), true},
|
||||
{"503", errors.New("upstream returned 503"), true},
|
||||
{"iteration limit", errors.New("max iteration reached"), false},
|
||||
@@ -74,6 +75,7 @@ func TestEinoTransientRunErrorUserDetail(t *testing.T) {
|
||||
{"upstream", errors.New("upstream returned 503"), "upstream_server"},
|
||||
{"network", errors.New("read tcp: connection reset by peer"), "network"},
|
||||
{"stream", errors.New("unexpected end of JSON"), "stream"},
|
||||
{"stream chunk", errors.New("failed to receive stream chunk: error, The service encountered an unexpected internal error. Request id: abc"), "upstream_busy"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
|
||||
@@ -96,7 +96,17 @@ func (m *modelOutputGuardMiddleware) AfterModelRewriteState(
|
||||
badIndex := -1
|
||||
argumentBytes := 0
|
||||
if strings.EqualFold(strings.TrimSpace(finishReason), "length") {
|
||||
reason = "output_limit"
|
||||
if len(last.ToolCalls) == 0 {
|
||||
reason = "output_limit"
|
||||
} else {
|
||||
for i, tc := range last.ToolCalls {
|
||||
r, n := validateGeneratedToolCall(tc, m.cfg)
|
||||
if r != "" {
|
||||
reason, badIndex, argumentBytes = "output_limit", i, n
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i, tc := range last.ToolCalls {
|
||||
r, n := validateGeneratedToolCall(tc, m.cfg)
|
||||
|
||||
@@ -50,6 +50,18 @@ func TestModelOutputGuardRejectsTruncatedToolCallBeforeExecution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelOutputGuardAllowsValidToolCallDespiteLengthFinish(t *testing.T) {
|
||||
original := `{"command":"echo ok"}`
|
||||
state, err := runModelOutputGuard(t, []adk.Message{schema.UserMessage("run"), guardedAssistant(original, "length")}, config.MultiAgentEinoMiddlewareConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := state.Messages[len(state.Messages)-1].ToolCalls[0].Function.Arguments
|
||||
if got != original {
|
||||
t.Fatalf("valid arguments should pass unchanged: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelOutputGuardRejectsInvalidJSONShapes(t *testing.T) {
|
||||
for _, arguments := range []string{"", `[]`, `{"command":`} {
|
||||
t.Run(arguments, func(t *testing.T) {
|
||||
|
||||
@@ -893,7 +893,7 @@ func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
transport = &reasoningToolChoiceCompatRoundTripper{base: transport}
|
||||
transport = &reasoningToolChoiceCompatRoundTripper{base: transport, cfg: cfg}
|
||||
if isClaudeProvider(cfg) {
|
||||
transport = &claudeRoundTripper{
|
||||
base: transport,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
@@ -52,6 +54,28 @@ func StripReasoningIfForcedToolChoice(rawBody []byte) ([]byte, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StripToolChoiceForThinkingMode removes tool_choice while preserving tools and
|
||||
// thinking fields. DeepSeek thinking mode can use tools, but rejects the
|
||||
// tool_choice parameter itself on some agent requests.
|
||||
func StripToolChoiceForThinkingMode(rawBody []byte) ([]byte, error) {
|
||||
var payload map[string]any
|
||||
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||||
return rawBody, nil
|
||||
}
|
||||
if !thinkingModeEnabledByPayload(payload) {
|
||||
return rawBody, nil
|
||||
}
|
||||
if _, ok := payload["tool_choice"]; !ok {
|
||||
return rawBody, nil
|
||||
}
|
||||
delete(payload, "tool_choice")
|
||||
out, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
return rawBody, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func stripReasoningFields(payload map[string]any) bool {
|
||||
changed := false
|
||||
for _, key := range reasoningPayloadKeys {
|
||||
@@ -77,3 +101,17 @@ func forcedToolChoiceIncompatibleWithThinking(payload map[string]any) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func thinkingModeEnabledByPayload(payload map[string]any) bool {
|
||||
thinking, ok := payload["thinking"]
|
||||
if !ok || thinking == nil {
|
||||
// DeepSeek enables thinking by default unless explicitly disabled.
|
||||
return true
|
||||
}
|
||||
if m, ok := thinking.(map[string]any); ok {
|
||||
if typ, ok := m["type"].(string); ok && strings.EqualFold(strings.TrimSpace(typ), "disabled") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
func TestStripReasoningFromChatCompletionBody(t *testing.T) {
|
||||
@@ -82,6 +84,58 @@ func TestStripReasoningIfForcedToolChoice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripToolChoiceForThinkingMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantToolChoice bool
|
||||
wantThinking bool
|
||||
}{
|
||||
{
|
||||
name: "enabled thinking removes tool_choice",
|
||||
in: `{"model":"deepseek-v4","messages":[],"thinking":{"type":"enabled"},"tool_choice":"required","tools":[{"type":"function","function":{"name":"scan"}}]}`,
|
||||
wantToolChoice: false,
|
||||
wantThinking: true,
|
||||
},
|
||||
{
|
||||
name: "default thinking removes tool_choice",
|
||||
in: `{"model":"deepseek-v4","messages":[],"tool_choice":"auto","tools":[]}`,
|
||||
wantToolChoice: false,
|
||||
wantThinking: false,
|
||||
},
|
||||
{
|
||||
name: "disabled thinking keeps tool_choice",
|
||||
in: `{"model":"deepseek-v4","messages":[],"thinking":{"type":"disabled"},"tool_choice":"required","tools":[]}`,
|
||||
wantToolChoice: true,
|
||||
wantThinking: true,
|
||||
},
|
||||
{
|
||||
name: "no tool_choice unchanged",
|
||||
in: `{"model":"deepseek-v4","messages":[],"thinking":{"type":"enabled"},"tools":[]}`,
|
||||
wantToolChoice: false,
|
||||
wantThinking: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := StripToolChoiceForThinkingMode([]byte(tc.in))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(out)
|
||||
if strings.Contains(s, "tool_choice") != tc.wantToolChoice {
|
||||
t.Fatalf("tool_choice presence mismatch, got %s", s)
|
||||
}
|
||||
if strings.Contains(s, "thinking") != tc.wantThinking {
|
||||
t.Fatalf("thinking presence mismatch, got %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "tools") {
|
||||
t.Fatalf("expected tools preserved, got %s", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningToolChoiceCompatRoundTripper(t *testing.T) {
|
||||
var gotBody string
|
||||
rt := &reasoningToolChoiceCompatRoundTripper{
|
||||
@@ -113,6 +167,44 @@ func TestReasoningToolChoiceCompatRoundTripper(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningToolChoiceCompatRoundTripperDeepSeek(t *testing.T) {
|
||||
var gotBody string
|
||||
rt := &reasoningToolChoiceCompatRoundTripper{
|
||||
cfg: &config.OpenAIConfig{
|
||||
BaseURL: "https://api.deepseek.com/v1",
|
||||
Model: "deepseek-v4",
|
||||
},
|
||||
base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
gotBody = string(b)
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, "https://api.deepseek.com/v1/chat/completions", strings.NewReader(
|
||||
`{"model":"deepseek-v4","thinking":{"type":"enabled"},"tool_choice":"required","tools":[],"messages":[]}`,
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = rt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(gotBody, "tool_choice") {
|
||||
t.Fatalf("expected DeepSeek tool_choice stripped in transit, got %s", gotBody)
|
||||
}
|
||||
if !strings.Contains(gotBody, "thinking") {
|
||||
t.Fatalf("expected thinking preserved for DeepSeek, got %s", gotBody)
|
||||
}
|
||||
if !strings.Contains(gotBody, "tools") {
|
||||
t.Fatalf("expected tools preserved for DeepSeek, got %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
// reasoningToolChoiceCompatRoundTripper strips thinking/reasoning fields from
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
// when thinking mode is enabled on the same request.
|
||||
type reasoningToolChoiceCompatRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
cfg *config.OpenAIConfig
|
||||
}
|
||||
|
||||
func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
@@ -32,7 +35,13 @@ func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*
|
||||
return nil, err
|
||||
}
|
||||
|
||||
patched, perr := StripReasoningIfForcedToolChoice(body)
|
||||
patched := body
|
||||
var perr error
|
||||
if isDeepSeekToolChoiceCompatProfile(rt.cfg) {
|
||||
patched, perr = StripToolChoiceForThinkingMode(body)
|
||||
} else {
|
||||
patched, perr = StripReasoningIfForcedToolChoice(body)
|
||||
}
|
||||
if perr != nil {
|
||||
patched = body
|
||||
}
|
||||
@@ -41,3 +50,19 @@ func (rt *reasoningToolChoiceCompatRoundTripper) RoundTrip(req *http.Request) (*
|
||||
req.Header.Set("Content-Length", strconv.Itoa(len(patched)))
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func isDeepSeekToolChoiceCompatProfile(cfg *config.OpenAIConfig) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
profile := strings.ToLower(strings.TrimSpace(cfg.Reasoning.ProfileEffective()))
|
||||
if profile == "deepseek" || profile == "deepseek_compat" {
|
||||
return true
|
||||
}
|
||||
if profile != "" && profile != "auto" {
|
||||
return false
|
||||
}
|
||||
baseURL := strings.ToLower(cfg.BaseURL)
|
||||
model := strings.ToLower(cfg.Model)
|
||||
return strings.Contains(baseURL, "deepseek") || strings.Contains(model, "deepseek")
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
bodyDepFactLine = regexp.MustCompile(`(?im)^[\s\-*]*依赖事实\s*[::]\s*([a-z0-9][a-z0-9._/-]*)`)
|
||||
bodyRelFactLine = regexp.MustCompile(`(?im)^[\s\-*]*相关\s*fact_key\s*[::]\s*([a-z0-9][a-z0-9._/-]*)`)
|
||||
bodyDepFactLine = regexp.MustCompile(`(?im)^[\s\-*]*依赖事实\s*[::]\s*([a-zA-Z0-9][a-zA-Z0-9._/-]*)`)
|
||||
bodyRelFactLine = regexp.MustCompile(`(?im)^[\s\-*]*相关\s*fact_key\s*[::]\s*([a-zA-Z0-9][a-zA-Z0-9._/-]*)`)
|
||||
bodyAssocSection = regexp.MustCompile(`(?im)^##\s*关联\s*$`)
|
||||
bodySyncLinksHead = "结构化关系边(自动同步)"
|
||||
)
|
||||
|
||||
@@ -61,25 +61,30 @@ show_progress() {
|
||||
printf "\r"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " CyberStrikeAI Deploy & Start Script"
|
||||
echo " (HTTPS with self-signed cert by default; plain HTTP: $0 --http)"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
print_banner() {
|
||||
local show_mirrors="${1:-1}"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " CyberStrikeAI Deploy & Start Script"
|
||||
echo " (HTTPS with self-signed cert by default; plain HTTP: $0 --http)"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Show temporary mirror/proxy info
|
||||
echo ""
|
||||
warning "Note: this script uses temporary mirrors to speed up downloads"
|
||||
echo ""
|
||||
info "Python pip temporary mirror:"
|
||||
echo " ${PIP_INDEX_URL}"
|
||||
info "Go temporary proxy:"
|
||||
echo " ${GOPROXY}"
|
||||
echo ""
|
||||
note "These settings apply only while this script runs and do not change system config"
|
||||
echo ""
|
||||
sleep 1
|
||||
if [ "$show_mirrors" -eq 1 ]; then
|
||||
# Show temporary mirror/proxy info
|
||||
echo ""
|
||||
warning "Note: this script uses temporary mirrors to speed up downloads"
|
||||
echo ""
|
||||
info "Python pip temporary mirror:"
|
||||
echo " ${PIP_INDEX_URL}"
|
||||
info "Go temporary proxy:"
|
||||
echo " ${GOPROXY}"
|
||||
echo ""
|
||||
note "These settings apply only while this script runs and do not change system config"
|
||||
echo ""
|
||||
sleep 1
|
||||
fi
|
||||
}
|
||||
|
||||
CONFIG_FILE="$ROOT_DIR/config.yaml"
|
||||
EXAMPLE_CONFIG_FILE="$ROOT_DIR/config.example.yaml"
|
||||
@@ -136,6 +141,28 @@ check_go() {
|
||||
success "Go check passed: $(go version)"
|
||||
}
|
||||
|
||||
check_go_quiet() {
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
error "Go not found"
|
||||
echo ""
|
||||
info "Install Go 1.21 or later first:"
|
||||
echo " macOS: brew install go"
|
||||
echo " Ubuntu: sudo apt-get install golang-go"
|
||||
echo " CentOS: sudo yum install golang"
|
||||
echo " Or visit: https://go.dev/dl/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
|
||||
GO_MAJOR=$(echo "$GO_VERSION" | cut -d. -f1)
|
||||
GO_MINOR=$(echo "$GO_VERSION" | cut -d. -f2)
|
||||
|
||||
if [ "$GO_MAJOR" -lt 1 ] || ([ "$GO_MAJOR" -eq 1 ] && [ "$GO_MINOR" -lt 21 ]); then
|
||||
error "Go version too old: $GO_VERSION (requires 1.21+)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Set up Python virtual environment
|
||||
setup_python_env() {
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
@@ -331,6 +358,34 @@ build_go_project() {
|
||||
fi
|
||||
}
|
||||
|
||||
build_go_project_quiet() {
|
||||
info "Building $BINARY_NAME..."
|
||||
|
||||
GO_DOWNLOAD_LOG=$(mktemp)
|
||||
if ! GOPROXY="$GOPROXY" go mod download >"$GO_DOWNLOAD_LOG" 2>&1; then
|
||||
error "Go dependency download failed"
|
||||
echo ""
|
||||
info "Download error details:"
|
||||
cat "$GO_DOWNLOAD_LOG" | sed 's/^/ /'
|
||||
echo ""
|
||||
rm -f "$GO_DOWNLOAD_LOG"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$GO_DOWNLOAD_LOG"
|
||||
|
||||
GO_BUILD_LOG=$(mktemp)
|
||||
if ! GOPROXY="$GOPROXY" go build -o "$BINARY_NAME" cmd/server/main.go >"$GO_BUILD_LOG" 2>&1; then
|
||||
error "Build failed"
|
||||
echo ""
|
||||
info "Build error details:"
|
||||
cat "$GO_BUILD_LOG" | sed 's/^/ /'
|
||||
echo ""
|
||||
rm -f "$GO_BUILD_LOG"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$GO_BUILD_LOG"
|
||||
}
|
||||
|
||||
# Check whether a rebuild is needed
|
||||
need_rebuild() {
|
||||
if [ ! -f "$BINARY_NAME" ]; then
|
||||
@@ -351,6 +406,7 @@ need_rebuild() {
|
||||
# Default: HTTPS (--https passed to binary); --http forces plain HTTP even if config.yaml enables TLS.
|
||||
main() {
|
||||
USE_HTTPS=1
|
||||
RESET_ADMIN_PASSWORD=0
|
||||
FORWARD_ARGS=()
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--http" ]; then
|
||||
@@ -361,9 +417,33 @@ main() {
|
||||
USE_HTTPS=1
|
||||
continue
|
||||
fi
|
||||
if [ "$arg" = "--reset-admin-password" ]; then
|
||||
RESET_ADMIN_PASSWORD=1
|
||||
continue
|
||||
fi
|
||||
FORWARD_ARGS+=("$arg")
|
||||
done
|
||||
|
||||
if [ "$RESET_ADMIN_PASSWORD" -eq 1 ]; then
|
||||
if [ ! -f "$CONFIG_FILE" ] && [ ! -f "$EXAMPLE_CONFIG_FILE" ]; then
|
||||
error "config.yaml not found, and config.example.yaml is missing"
|
||||
info "The server binary creates config.yaml from config.example.yaml on first start"
|
||||
exit 1
|
||||
fi
|
||||
check_go_quiet
|
||||
if need_rebuild; then
|
||||
build_go_project_quiet
|
||||
echo ""
|
||||
fi
|
||||
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
|
||||
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --reset-admin-password "${FORWARD_ARGS[@]}"
|
||||
else
|
||||
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --reset-admin-password
|
||||
fi
|
||||
fi
|
||||
|
||||
print_banner 1
|
||||
|
||||
# Environment checks
|
||||
info "Checking runtime environment..."
|
||||
check_python
|
||||
@@ -417,5 +497,5 @@ main() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Run main (supports args, e.g. ./run.sh --http)
|
||||
# Run main (supports args, e.g. ./run.sh --http, ./run.sh --reset-admin-password)
|
||||
main "$@"
|
||||
|
||||
+484
-88
@@ -4430,7 +4430,7 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
|
||||
.ai-channel-manager {
|
||||
border: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 88%, transparent);
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg, #fff);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -4446,8 +4446,8 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
}
|
||||
|
||||
.ai-channel-manager-header h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
margin: 0 0 6px;
|
||||
font-size: 17px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@@ -4467,55 +4467,75 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
color: var(--error-color, #e53e3e);
|
||||
}
|
||||
|
||||
.ai-channel-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-channel-save-btn {
|
||||
flex-shrink: 0;
|
||||
min-width: 92px;
|
||||
}
|
||||
|
||||
.ai-channel-manager-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
display: block;
|
||||
min-height: 0;
|
||||
align-items: stretch;
|
||||
background: var(--card-bg, #fff);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ai-channel-sidebar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 76%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary, #f8fafc) 86%, var(--card-bg, #fff));
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.ai-channel-sidebar-head {
|
||||
.ai-channel-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 0;
|
||||
color: var(--text-secondary, #4a5568);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
gap: 16px;
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 72%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-secondary, #f8fafc) 70%, var(--card-bg, #fff));
|
||||
}
|
||||
|
||||
.ai-channel-bulk-actions {
|
||||
.ai-channel-switcher-field {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(260px, 420px);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-channel-switcher-field label {
|
||||
color: var(--text-secondary, #4a5568);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-channel-switch-select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 0.45rem 2rem 0.45rem 0.7rem;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 88%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--input-bg, var(--card-bg, #fff)) 92%, transparent);
|
||||
color: var(--text-primary, #2d3748);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ai-channel-switch-select:focus {
|
||||
border-color: color-mix(in srgb, var(--accent-color, #3182ce) 70%, transparent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color, #3182ce) 12%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ai-channel-switcher-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-channel-bulk-btn {
|
||||
@@ -4523,8 +4543,8 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 26px;
|
||||
padding: 4px 8px;
|
||||
min-height: 28px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 68%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--card-bg, #fff) 38%, transparent);
|
||||
@@ -4555,8 +4575,8 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
}
|
||||
|
||||
.ai-channel-icon-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg, #fff);
|
||||
@@ -4577,19 +4597,7 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
}
|
||||
|
||||
.ai-channel-list {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 13px;
|
||||
bottom: 18px;
|
||||
left: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 2px 5px 2px 1px;
|
||||
scrollbar-gutter: stable;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-channel-list::-webkit-scrollbar {
|
||||
@@ -4616,10 +4624,10 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
padding: 12px;
|
||||
min-height: 86px;
|
||||
padding: 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 64%, transparent);
|
||||
border-radius: 8px;
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--card-bg, #fff) 70%, transparent);
|
||||
color: var(--text-color, #2d3748);
|
||||
text-align: left;
|
||||
@@ -4640,7 +4648,7 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
height: 16px;
|
||||
margin: 3px 0 0;
|
||||
border: 1px solid color-mix(in srgb, var(--text-muted, #718096) 52%, transparent);
|
||||
border-radius: 5px;
|
||||
border-radius: 4px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: color-mix(in srgb, var(--card-bg, #fff) 84%, transparent);
|
||||
@@ -4671,7 +4679,6 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
.ai-channel-list-item:hover {
|
||||
background: var(--card-bg, #fff);
|
||||
border-color: color-mix(in srgb, var(--accent-color, #3182ce) 24%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ai-channel-list-item.active {
|
||||
@@ -4732,6 +4739,10 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.ai-channel-status-label.complete {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.ai-channel-status-label.testing {
|
||||
color: #3b82f6;
|
||||
}
|
||||
@@ -4753,6 +4764,10 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.ai-channel-status-dot.complete {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.ai-channel-status-dot.draft {
|
||||
background: #f59e0b;
|
||||
}
|
||||
@@ -4779,17 +4794,17 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: visible;
|
||||
padding: 22px 28px 28px;
|
||||
padding: 20px 28px 28px;
|
||||
background: var(--card-bg, #fff);
|
||||
}
|
||||
|
||||
.ai-channel-editor-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 18px;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 72%, transparent);
|
||||
}
|
||||
|
||||
@@ -4801,18 +4816,60 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ai-channel-editor-head h5 {
|
||||
.ai-channel-editor-title {
|
||||
margin: 0;
|
||||
color: var(--text-color, #2d3748);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.ai-channel-editor-head p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-muted, #718096);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ai-channel-editor-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ai-channel-editor-chip {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 70%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--card-bg, #fff) 92%, var(--primary-color, #3182ce));
|
||||
color: var(--text-muted, #718096);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.ai-channel-editor-chip.default,
|
||||
.ai-channel-editor-chip.complete {
|
||||
border-color: rgba(49, 130, 206, 0.22);
|
||||
background: rgba(49, 130, 206, 0.1);
|
||||
color: var(--primary-color, #3182ce);
|
||||
}
|
||||
|
||||
.ai-channel-editor-chip.ready {
|
||||
border-color: rgba(16, 185, 129, 0.22);
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.ai-channel-editor-chip.testing {
|
||||
border-color: rgba(214, 158, 46, 0.24);
|
||||
background: rgba(214, 158, 46, 0.1);
|
||||
color: #b7791f;
|
||||
}
|
||||
|
||||
.ai-channel-editor-chip.failed,
|
||||
.ai-channel-editor-chip.draft {
|
||||
border-color: rgba(229, 62, 62, 0.2);
|
||||
background: rgba(229, 62, 62, 0.08);
|
||||
color: var(--error-color, #e53e3e);
|
||||
}
|
||||
|
||||
.ai-channel-editor-actions {
|
||||
@@ -4827,8 +4884,9 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
color: var(--error-color, #e53e3e);
|
||||
}
|
||||
|
||||
|
||||
.ai-channel-editor-form {
|
||||
max-width: 980px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.ai-channel-editor-form .form-group {
|
||||
@@ -4837,7 +4895,7 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
|
||||
.ai-channel-editor-form .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
@@ -4848,10 +4906,14 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
.ai-channel-editor-form input[type="password"],
|
||||
.ai-channel-editor-form input[type="number"],
|
||||
.ai-channel-editor-form select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 7px;
|
||||
border-color: color-mix(in srgb, var(--border-color, #e2e8f0) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--input-bg, var(--card-bg, #fff)) 92%, transparent);
|
||||
color: var(--text-color, #2d3748);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ai-channel-editor-form input:focus,
|
||||
@@ -4863,9 +4925,175 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
||||
|
||||
.ai-channel-editor-form .form-hint,
|
||||
.ai-channel-editor-form small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text-muted, #718096);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ai-channel-form-section {
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color, #e2e8f0) 64%, transparent);
|
||||
}
|
||||
|
||||
.ai-channel-form-section:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ai-channel-form-section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.ai-channel-form-section-head > div:first-child {
|
||||
max-width: 620px;
|
||||
}
|
||||
|
||||
.ai-channel-form-section-head h6 {
|
||||
margin: 0;
|
||||
color: var(--text-primary, #1f2937);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ai-channel-form-section-head p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-muted, #718096);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ai-channel-section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
.ai-channel-section-actions .form-inline-result {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.ai-channel-section-actions .connection-test-result {
|
||||
display: none;
|
||||
max-width: min(760px, 54vw);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface-color, #ffffff) 86%, var(--bg-secondary, #f7fafc));
|
||||
color: var(--text-muted, #718096);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ai-channel-section-actions .connection-test-result.is-visible {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ai-channel-section-actions .connection-test-result.is-error {
|
||||
border-color: color-mix(in srgb, var(--danger-color, #e53e3e) 42%, var(--border-color, #e2e8f0));
|
||||
background: color-mix(in srgb, var(--danger-color, #e53e3e) 8%, var(--surface-color, #ffffff));
|
||||
color: var(--danger-color, #e53e3e);
|
||||
}
|
||||
|
||||
.ai-channel-section-actions .connection-test-result.is-success {
|
||||
border-color: color-mix(in srgb, var(--success-color, #38a169) 38%, var(--border-color, #e2e8f0));
|
||||
background: color-mix(in srgb, var(--success-color, #38a169) 9%, var(--surface-color, #ffffff));
|
||||
color: var(--success-color, #38a169);
|
||||
}
|
||||
|
||||
.ai-channel-form-grid,
|
||||
.ai-channel-reasoning-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
column-gap: 16px;
|
||||
row-gap: 14px;
|
||||
}
|
||||
|
||||
.ai-channel-reasoning-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.ai-channel-editor-form .span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: var(--error-color, #e53e3e);
|
||||
}
|
||||
|
||||
.form-inline-result {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--text-muted, #718096);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.ai-channel-reasoning-toggle {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ai-channel-advanced-section {
|
||||
padding: 10px 0 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ai-channel-advanced-section summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 0;
|
||||
color: var(--text-primary, #1f2937);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ai-channel-advanced-section summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ai-channel-advanced-section summary::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 auto;
|
||||
border-right: 2px solid var(--text-muted, #718096);
|
||||
border-bottom: 2px solid var(--text-muted, #718096);
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
.ai-channel-advanced-section[open] summary::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.ai-channel-advanced-section summary strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.ai-channel-advanced-section summary small {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ai-channel-advanced-section[open] .ai-channel-reasoning-grid {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-manager {
|
||||
background: #111827;
|
||||
border-color: rgba(71, 85, 105, 0.42);
|
||||
@@ -4876,6 +5104,21 @@ html[data-theme="dark"] .ai-channel-manager-header {
|
||||
border-bottom-color: rgba(71, 85, 105, 0.34);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-switcher {
|
||||
background: #101827;
|
||||
border-bottom-color: rgba(71, 85, 105, 0.34);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-switcher-field label {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-switch-select {
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
border-color: rgba(71, 85, 105, 0.44);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-sidebar {
|
||||
background: #101827;
|
||||
border-right-color: rgba(71, 85, 105, 0.34);
|
||||
@@ -4928,6 +5171,74 @@ html[data-theme="dark"] .ai-channel-editor-head {
|
||||
border-bottom-color: rgba(71, 85, 105, 0.34);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-editor-chip {
|
||||
border-color: rgba(71, 85, 105, 0.46);
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-editor-chip.default,
|
||||
html[data-theme="dark"] .ai-channel-editor-chip.complete {
|
||||
border-color: rgba(96, 165, 250, 0.34);
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-editor-chip.ready {
|
||||
border-color: rgba(52, 211, 153, 0.28);
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-editor-chip.testing {
|
||||
border-color: rgba(251, 191, 36, 0.28);
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-editor-chip.failed,
|
||||
html[data-theme="dark"] .ai-channel-editor-chip.draft {
|
||||
border-color: rgba(248, 113, 113, 0.28);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-form-section,
|
||||
html[data-theme="dark"] .ai-channel-advanced-section {
|
||||
border-color: rgba(71, 85, 105, 0.34);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-form-section-head h6 {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-form-section-head p,
|
||||
html[data-theme="dark"] .form-inline-result {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-section-actions .connection-test-result {
|
||||
border-color: rgba(71, 85, 105, 0.52);
|
||||
background: rgba(15, 23, 42, 0.74);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-section-actions .connection-test-result.is-error {
|
||||
border-color: rgba(248, 113, 113, 0.42);
|
||||
background: rgba(127, 29, 29, 0.22);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-section-actions .connection-test-result.is-success {
|
||||
border-color: rgba(52, 211, 153, 0.38);
|
||||
background: rgba(6, 78, 59, 0.22);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-advanced-section summary {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ai-channel-icon-btn {
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
border-color: rgba(71, 85, 105, 0.44);
|
||||
@@ -5009,23 +5320,13 @@ html[data-theme="dark"] .ai-channel-editor-form select {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ai-channel-manager-body {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
.ai-channel-editor-meta {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.ai-channel-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.ai-channel-list {
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
max-height: 360px;
|
||||
.ai-channel-switcher {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ai-channel-editor {
|
||||
@@ -5035,19 +5336,40 @@ html[data-theme="dark"] .ai-channel-editor-form select {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ai-channel-manager-header,
|
||||
.ai-channel-sidebar,
|
||||
.ai-channel-switcher,
|
||||
.ai-channel-editor {
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.ai-channel-editor-actions {
|
||||
.ai-channel-switcher-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.ai-channel-bulk-actions {
|
||||
.ai-channel-header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.ai-channel-switcher-field {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ai-channel-form-section-head,
|
||||
.ai-channel-form-grid,
|
||||
.ai-channel-reasoning-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ai-channel-form-section-head,
|
||||
.ai-channel-section-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ai-channel-section-actions {
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-reasoning-field-label {
|
||||
@@ -9860,6 +10182,12 @@ html[data-theme="dark"] .robot-binding-service-hint-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.settings-custom-select-menu--floating {
|
||||
display: block;
|
||||
position: fixed;
|
||||
z-index: 10050;
|
||||
}
|
||||
|
||||
.settings-custom-select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -9917,6 +10245,54 @@ html[data-theme="dark"] .robot-binding-service-hint-icon {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-custom-select-option--probe .settings-custom-select-label {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.settings-custom-select-status {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
max-width: 42%;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-secondary, #f5f6f8);
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.settings-custom-select-status-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-custom-select-status-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.settings-custom-select-status.ready {
|
||||
background: color-mix(in srgb, var(--success-color, #38a169) 11%, transparent);
|
||||
color: var(--success-color, #38a169);
|
||||
}
|
||||
|
||||
.settings-custom-select-status.failed {
|
||||
background: color-mix(in srgb, var(--danger-color, #e53e3e) 10%, transparent);
|
||||
color: var(--danger-color, #e53e3e);
|
||||
}
|
||||
|
||||
.settings-custom-select-status.testing {
|
||||
background: color-mix(in srgb, var(--accent-color, #0066ff) 10%, transparent);
|
||||
color: var(--accent-color, #0066ff);
|
||||
}
|
||||
|
||||
.form-group input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
@@ -38395,6 +38771,26 @@ html[data-theme="dark"] #page-settings .audit-custom-select-option.is-selected {
|
||||
color: #60a5fa !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] #page-settings .settings-custom-select-status {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] #page-settings .settings-custom-select-status.ready {
|
||||
background: rgba(52, 211, 153, 0.14);
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] #page-settings .settings-custom-select-status.failed {
|
||||
background: rgba(248, 113, 113, 0.14);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] #page-settings .settings-custom-select-status.testing {
|
||||
background: rgba(96, 165, 250, 0.14);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
/* Chat @ tool mention panel dark theme. */
|
||||
html[data-theme="dark"] .mention-suggestions {
|
||||
background: #111827 !important;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -1106,6 +1106,7 @@
|
||||
"importValidRows": "Import valid rows",
|
||||
"importValidRowsCount": "Import {{count}} valid rows",
|
||||
"importPreviewSummary": "{{total}} rows: {{valid}} valid, {{invalid}} need attention",
|
||||
"importBackendRowError": "Excel row {{row}} (submitted valid asset #{{index}}): {{message}}",
|
||||
"previewLimited": "Showing the first 100 rows; all {{count}} rows will be processed",
|
||||
"fileTypeInvalid": "Only .xlsx and .csv files are supported",
|
||||
"fileTooLarge": "The file must not exceed 100 MB",
|
||||
@@ -2369,6 +2370,7 @@
|
||||
"sourceAll": "All",
|
||||
"sourceUpload": "Chat uploads",
|
||||
"sourceReduction": "Tool outputs",
|
||||
"sourceWorkspace": "Workspace files",
|
||||
"sourceConversationArtifact": "Conversation artifacts",
|
||||
"groupBy": "Group by",
|
||||
"groupNone": "None (flat list)",
|
||||
@@ -2385,6 +2387,7 @@
|
||||
"browseRoot": "Files",
|
||||
"treeUploadsRoot": "Chat uploads",
|
||||
"treeReductionRoot": "Tool outputs",
|
||||
"treeWorkspaceRoot": "Workspace files",
|
||||
"treeArtifactsRoot": "Conversation artifacts",
|
||||
"browseUp": "Up",
|
||||
"enterFolderTitle": "Open folder",
|
||||
@@ -2601,23 +2604,43 @@
|
||||
"aiChannelNew": "New",
|
||||
"aiChannelCopy": "Copy",
|
||||
"aiChannelDelete": "Delete",
|
||||
"aiChannelHint": "Saved channels appear on the left. Saving writes to ai.channels; the default channel is used by new chats and tasks without an explicit channel.",
|
||||
"aiChannelHint": "Use the dropdown to switch saved channels. Saving writes to ai.channels; the default channel is used by new chats and tasks without an explicit channel.",
|
||||
"aiChannelSavedList": "Saved channels",
|
||||
"aiChannelListAria": "AI channel list",
|
||||
"aiChannelEditing": "Editing",
|
||||
"aiChannelFormContext": "Editing the selected channel",
|
||||
"aiChannelFormContextHint": "Saving the form updates this channel configuration.",
|
||||
"aiChannelBulkProbe": "Probe all",
|
||||
"aiChannelBulkProbeTitle": "Check whether all complete channels are available",
|
||||
"aiChannelSelectAria": "Select {name}",
|
||||
"aiChannelDefaultBadge": "Default",
|
||||
"aiChannelReady": "Ready",
|
||||
"aiChannelReadyWithLatency": "Ready{latency}",
|
||||
"aiChannelComplete": "Complete",
|
||||
"aiChannelDraft": "Incomplete",
|
||||
"aiChannelDefaultMeta": "Default channel",
|
||||
"aiChannelCustomMeta": "Custom channel",
|
||||
"aiChannelOpenAICompat": "OpenAI compatible",
|
||||
"aiChannelModelMissing": "Model missing",
|
||||
"aiChannelName": "Channel name",
|
||||
"aiChannelConnectionSection": "Connection",
|
||||
"aiChannelConnectionHint": "Confirm provider, endpoint, key, and model first. Test connection uses these values.",
|
||||
"aiChannelModelSection": "Model and limits",
|
||||
"aiChannelModelHint": "The model name controls routing. Token limits control context and single-response output.",
|
||||
"aiChannelLimitsSection": "Limits",
|
||||
"aiChannelLimitsHint": "Token limits control the context window and single-response output.",
|
||||
"aiChannelUntitled": "New Channel",
|
||||
"aiChannelDeleteConfirm": "Delete AI channel \"{name}\"?",
|
||||
"aiChannelSaved": "Channel saved",
|
||||
"aiChannelDefaultSaved": "Default channel saved",
|
||||
"aiChannelCount": "{count} channel(s) saved",
|
||||
"aiChannelSaving": "Saving channel...",
|
||||
"aiChannelNewUnsaved": "New channel is not saved yet. Fill it in, then click Save changes.",
|
||||
"aiChannelCopyUnsaved": "Copied channel is not saved yet. Review it, then click Save changes.",
|
||||
"aiChannelDeleted": "Channel deleted",
|
||||
"aiChannelProbeNoComplete": "No complete channel to probe. Fill in Base URL, API Key, and Model first.",
|
||||
"aiChannelProbing": "Probing {count} channel(s)...",
|
||||
"aiChannelProbeDone": "Probe complete: {ok}/{total} ready",
|
||||
"apiProvider": "API Provider",
|
||||
"providerOpenAI": "OpenAI / OpenAI-compatible API",
|
||||
"providerClaude": "Claude (Anthropic Messages API)",
|
||||
@@ -2643,8 +2666,8 @@
|
||||
"maxTotalTokensPlaceholder": "120000",
|
||||
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
||||
"maxCompletionTokens": "Max Output Tokens",
|
||||
"maxCompletionTokensPlaceholder": "16384",
|
||||
"maxCompletionTokensHint": "Maximum tokens for a single model response. Default: 16384",
|
||||
"maxCompletionTokensPlaceholder": "32768",
|
||||
"maxCompletionTokensHint": "Maximum tokens for a single model response. Default: 32768",
|
||||
"openaiReasoningTitle": "Reasoning settings",
|
||||
"openaiReasoningHint": "Default reasoning settings for this AI channel; chat Session settings can override them.",
|
||||
"openaiReasoningProfile": "Wire profile",
|
||||
@@ -2793,11 +2816,16 @@
|
||||
"visionTimeout": "Timeout (seconds)",
|
||||
"visionTestFillRequired": "Enter vision model and ensure API Key is available (or reuse OpenAI)",
|
||||
"testConnection": "Test Connection",
|
||||
"testFillRequired": "Please fill in API Key and Model first",
|
||||
"testFillRequired": "Please fill in Base URL, API Key, and Model first",
|
||||
"testing": "Testing connection...",
|
||||
"testSuccess": "Connection successful",
|
||||
"testFailed": "Connection failed",
|
||||
"testError": "Test error"
|
||||
"testError": "Test error",
|
||||
"testErrorInvalidApiKey": "API Key is invalid or unauthorized. Check that the key is correct.",
|
||||
"testErrorUnauthorized": "Authentication failed. Check the API Key.",
|
||||
"testErrorForbidden": "Request denied. Check account permissions or model access.",
|
||||
"testErrorModelUnavailable": "Model is unavailable or the model name is incorrect.",
|
||||
"testErrorBaseUrl": "Base URL is unavailable. Check that the endpoint is correct."
|
||||
},
|
||||
"settingsTerminal": {
|
||||
"title": "Terminal",
|
||||
|
||||
@@ -1094,6 +1094,7 @@
|
||||
"importValidRows": "导入有效数据",
|
||||
"importValidRowsCount": "导入 {{count}} 条有效数据",
|
||||
"importPreviewSummary": "共 {{total}} 行,{{valid}} 行有效,{{invalid}} 行需修正",
|
||||
"importBackendRowError": "Excel 第 {{row}} 行(提交有效数据第 {{index}} 条):{{message}}",
|
||||
"previewLimited": "仅展示前 100 行;提交时将处理全部 {{count}} 行",
|
||||
"fileTypeInvalid": "仅支持 .xlsx 和 .csv 文件",
|
||||
"fileTooLarge": "文件不能超过 100 MB",
|
||||
@@ -2357,6 +2358,7 @@
|
||||
"sourceAll": "全部",
|
||||
"sourceUpload": "对话附件",
|
||||
"sourceReduction": "工具输出",
|
||||
"sourceWorkspace": "工作目录",
|
||||
"sourceConversationArtifact": "会话产物",
|
||||
"groupBy": "分组方式",
|
||||
"groupNone": "不分组(平铺)",
|
||||
@@ -2373,6 +2375,7 @@
|
||||
"browseRoot": "文件",
|
||||
"treeUploadsRoot": "对话附件",
|
||||
"treeReductionRoot": "工具输出",
|
||||
"treeWorkspaceRoot": "工作目录",
|
||||
"treeArtifactsRoot": "会话产物",
|
||||
"browseUp": "上级",
|
||||
"enterFolderTitle": "进入此文件夹",
|
||||
@@ -2589,23 +2592,43 @@
|
||||
"aiChannelNew": "新增",
|
||||
"aiChannelCopy": "复制",
|
||||
"aiChannelDelete": "删除",
|
||||
"aiChannelHint": "已保存的通道会显示在左侧;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。",
|
||||
"aiChannelHint": "通过下拉切换已保存通道;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。",
|
||||
"aiChannelSavedList": "已保存通道",
|
||||
"aiChannelListAria": "AI 通道列表",
|
||||
"aiChannelEditing": "正在编辑",
|
||||
"aiChannelFormContext": "编辑当前选择的通道",
|
||||
"aiChannelFormContextHint": "表单保存后会更新该通道配置。",
|
||||
"aiChannelBulkProbe": "批量探活",
|
||||
"aiChannelBulkProbeTitle": "检测全部完整通道是否可用",
|
||||
"aiChannelSelectAria": "选择 {name}",
|
||||
"aiChannelDefaultBadge": "默认",
|
||||
"aiChannelReady": "可用",
|
||||
"aiChannelReadyWithLatency": "可用{latency}",
|
||||
"aiChannelComplete": "配置完整",
|
||||
"aiChannelDraft": "待完善",
|
||||
"aiChannelDefaultMeta": "默认通道",
|
||||
"aiChannelCustomMeta": "自定义通道",
|
||||
"aiChannelOpenAICompat": "OpenAI 兼容",
|
||||
"aiChannelModelMissing": "未填写模型",
|
||||
"aiChannelName": "通道名称",
|
||||
"aiChannelConnectionSection": "连接信息",
|
||||
"aiChannelConnectionHint": "先确认服务商、地址、密钥和模型,测试连接会使用这些信息。",
|
||||
"aiChannelModelSection": "模型与额度",
|
||||
"aiChannelModelHint": "模型名决定请求路由,Token 上限控制上下文和单次输出。",
|
||||
"aiChannelLimitsSection": "额度设置",
|
||||
"aiChannelLimitsHint": "Token 上限控制上下文窗口和单次输出。",
|
||||
"aiChannelUntitled": "新通道",
|
||||
"aiChannelDeleteConfirm": "确定删除 AI 通道「{name}」吗?",
|
||||
"aiChannelSaved": "通道已保存",
|
||||
"aiChannelDefaultSaved": "已设为默认通道",
|
||||
"aiChannelCount": "已保存 {count} 个通道",
|
||||
"aiChannelSaving": "正在保存通道...",
|
||||
"aiChannelNewUnsaved": "新通道尚未保存,填写后点击「保存更改」。",
|
||||
"aiChannelCopyUnsaved": "复制的通道尚未保存,确认后点击「保存更改」。",
|
||||
"aiChannelDeleted": "通道已删除",
|
||||
"aiChannelProbeNoComplete": "没有可探活的完整通道,请先填写 Base URL、API Key 和模型",
|
||||
"aiChannelProbing": "正在探活 {count} 个通道...",
|
||||
"aiChannelProbeDone": "探活完成:{ok}/{total} 可用",
|
||||
"apiProvider": "API 提供商",
|
||||
"providerOpenAI": "OpenAI / 兼容 OpenAI 协议",
|
||||
"providerClaude": "Claude (Anthropic Messages API)",
|
||||
@@ -2631,8 +2654,8 @@
|
||||
"maxTotalTokensPlaceholder": "120000",
|
||||
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
||||
"maxCompletionTokens": "最大输出 Token 数",
|
||||
"maxCompletionTokensPlaceholder": "16384",
|
||||
"maxCompletionTokensHint": "单次模型回复的输出上限,默认 16384",
|
||||
"maxCompletionTokensPlaceholder": "32768",
|
||||
"maxCompletionTokensHint": "单次模型回复的输出上限,默认 32768",
|
||||
"openaiReasoningTitle": "推理设置",
|
||||
"openaiReasoningHint": "作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。",
|
||||
"openaiReasoningProfile": "线路 profile",
|
||||
@@ -2781,11 +2804,16 @@
|
||||
"visionTimeout": "超时(秒)",
|
||||
"visionTestFillRequired": "请填写视觉模型,并确保 API Key 可用(可复用 OpenAI)",
|
||||
"testConnection": "测试连接",
|
||||
"testFillRequired": "请先填写 API Key 和模型",
|
||||
"testFillRequired": "请先填写 Base URL、API Key 和模型",
|
||||
"testing": "测试中...",
|
||||
"testSuccess": "连接成功",
|
||||
"testFailed": "连接失败",
|
||||
"testError": "测试出错"
|
||||
"testError": "测试出错",
|
||||
"testErrorInvalidApiKey": "API Key 无效或无权限,请检查密钥是否填写正确",
|
||||
"testErrorUnauthorized": "认证失败,请检查 API Key",
|
||||
"testErrorForbidden": "请求被拒绝,请检查账号权限或模型访问权限",
|
||||
"testErrorModelUnavailable": "模型不可用或模型名不正确,请检查模型名称",
|
||||
"testErrorBaseUrl": "Base URL 不可用,请检查地址是否正确"
|
||||
},
|
||||
"settingsTerminal": {
|
||||
"title": "终端",
|
||||
|
||||
+26
-2
@@ -57,6 +57,12 @@ function syncAssetSelect(selectOrId) {
|
||||
if (typeof syncSettingsCustomSelect === 'function') syncSettingsCustomSelect(select);
|
||||
}
|
||||
|
||||
function closeAssetCustomSelects() {
|
||||
if (typeof closeAllSettingsCustomSelects === 'function') {
|
||||
closeAllSettingsCustomSelects();
|
||||
}
|
||||
}
|
||||
|
||||
function assetT(key, fallback, options) {
|
||||
if (window.i18next && typeof window.i18next.t === 'function') {
|
||||
const value = window.i18next.t(key, options || {});
|
||||
@@ -352,7 +358,8 @@ function renderAssetImportPreview() {
|
||||
|
||||
async function submitAssetImport() {
|
||||
if (assetPageState.importBusy) return;
|
||||
const assets = assetPageState.importRows.filter(row => !row.error).map(row => row.asset);
|
||||
const validRows = assetPageState.importRows.filter(row => !row.error);
|
||||
const assets = validRows.map(row => row.asset);
|
||||
if (!assets.length) return;
|
||||
setAssetImportError('');
|
||||
setAssetImportBusy(true);
|
||||
@@ -361,7 +368,7 @@ async function submitAssetImport() {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ assets, source: 'manual-import', source_query: assetPageState.importFileName })
|
||||
});
|
||||
if (!response.ok) throw new Error(await assetEditorResponseError(response));
|
||||
if (!response.ok) throw new Error(formatAssetImportSubmitError(await assetEditorResponseError(response), validRows));
|
||||
const result = await response.json();
|
||||
const invalid = assetPageState.importRows.length - assets.length;
|
||||
closeAssetImport(true);
|
||||
@@ -378,6 +385,20 @@ async function submitAssetImport() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatAssetImportSubmitError(message, validRows) {
|
||||
const text = String(message || '').trim();
|
||||
const match = text.match(/^第\s*(\d+)\s*个资产无效[::]\s*(.+)$/);
|
||||
if (!match) return text;
|
||||
const assetNumber = Number(match[1]);
|
||||
const row = Array.isArray(validRows) ? validRows[assetNumber - 1] : null;
|
||||
if (!row || !row.rowNumber) return text;
|
||||
return assetT('assets.importBackendRowError', `Excel 第 ${row.rowNumber} 行(提交有效数据第 ${assetNumber} 条): ${match[2]}`, {
|
||||
row: row.rowNumber,
|
||||
index: assetNumber,
|
||||
message: match[2]
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAssetOverview() {
|
||||
try {
|
||||
const response = await apiFetch('/api/assets/stats?days=' + assetOverviewDays);
|
||||
@@ -874,6 +895,7 @@ async function openAssetProjectModal() {
|
||||
}
|
||||
|
||||
function closeAssetProjectModal() {
|
||||
closeAssetCustomSelects();
|
||||
if (typeof closeAppModal === 'function') closeAppModal('asset-project-modal');
|
||||
else document.getElementById('asset-project-modal').style.display = 'none';
|
||||
}
|
||||
@@ -922,6 +944,7 @@ function openAssetBulkEdit() {
|
||||
}
|
||||
|
||||
function closeAssetBulkEdit() {
|
||||
closeAssetCustomSelects();
|
||||
if (typeof closeAppModal === 'function') closeAppModal('asset-bulk-edit-modal');
|
||||
else document.getElementById('asset-bulk-edit-modal').style.display = 'none';
|
||||
}
|
||||
@@ -1279,6 +1302,7 @@ async function openAssetEditor(indexOrAsset) {
|
||||
|
||||
function closeAssetEditor(force) {
|
||||
if (!force && assetPageState.editorDirty && !confirm(assetT('assets.discardChanges', '放弃尚未保存的更改吗?'))) return;
|
||||
closeAssetCustomSelects();
|
||||
if (typeof closeAppModal === 'function') closeAppModal('asset-editor-modal');
|
||||
else document.getElementById('asset-editor-modal').style.display = 'none';
|
||||
const returnFocus = assetPageState.editorReturnFocus;
|
||||
|
||||
+134
-28
@@ -3,6 +3,8 @@
|
||||
let chatFilesCache = [];
|
||||
/** 后端 GET /api/chat-uploads 返回的目录相对路径(含空文件夹),与 files 合并成树 */
|
||||
let chatFilesFoldersCache = [];
|
||||
const chatFilesProjectNameById = {};
|
||||
const chatFilesConversationTitleById = {};
|
||||
let chatFilesDisplayed = [];
|
||||
let chatFilesEditRelativePath = '';
|
||||
let chatFilesRenameRelativePath = '';
|
||||
@@ -16,6 +18,7 @@ const CHAT_FILES_BROWSE_PATH_KEY = 'csai_chat_files_browse_path';
|
||||
const CHAT_FILES_PAGE_SIZE_STORAGE_KEY = 'csai_chat_files_page_size';
|
||||
const CHAT_FILES_TREE_UPLOAD_ROOT = 'uploads';
|
||||
const CHAT_FILES_TREE_REDUCTION_ROOT = 'tool_outputs';
|
||||
const CHAT_FILES_TREE_WORKSPACE_ROOT = 'workspace';
|
||||
const CHAT_FILES_TREE_ARTIFACT_ROOT = 'conversation_artifacts';
|
||||
|
||||
/** 按文件夹浏览模式下的当前路径(虚拟根段数组),如 ['uploads','2024-03-21','uuid'] */
|
||||
@@ -39,6 +42,19 @@ function closeAllChatFilesFilterSelects() {
|
||||
});
|
||||
}
|
||||
|
||||
function chatFilesRememberDisplayNames(files) {
|
||||
Object.keys(chatFilesProjectNameById).forEach(function (k) { delete chatFilesProjectNameById[k]; });
|
||||
Object.keys(chatFilesConversationTitleById).forEach(function (k) { delete chatFilesConversationTitleById[k]; });
|
||||
(Array.isArray(files) ? files : []).forEach(function (f) {
|
||||
const pid = String((f && f.projectId) || '').trim();
|
||||
const pname = String((f && f.projectName) || '').trim();
|
||||
if (pid && pname) chatFilesProjectNameById[pid] = pname;
|
||||
const cid = String((f && f.conversationId) || '').trim();
|
||||
const ctitle = String((f && f.conversationTitle) || '').trim();
|
||||
if (cid && ctitle) chatFilesConversationTitleById[cid] = ctitle;
|
||||
});
|
||||
}
|
||||
|
||||
function syncChatFilesFilterSelect(selectId) {
|
||||
const reg = chatFilesFilterSelectMap[selectId];
|
||||
if (!reg) return;
|
||||
@@ -389,6 +405,7 @@ async function loadChatFilesPage() {
|
||||
}
|
||||
const data = await res.json();
|
||||
chatFilesCache = Array.isArray(data.files) ? data.files : [];
|
||||
chatFilesRememberDisplayNames(chatFilesCache);
|
||||
chatFilesFoldersCache = Array.isArray(data.folders) ? data.folders : [];
|
||||
chatFilesTotal = Number.isFinite(Number(data.total)) ? Number(data.total) : chatFilesCache.length;
|
||||
chatFilesPage = Number.isFinite(Number(data.page)) ? Math.max(1, Number(data.page)) : chatFilesPage;
|
||||
@@ -708,6 +725,10 @@ function chatFilesTreePathForFile(f) {
|
||||
rp = rp.replace(/^__reduction__\//, '');
|
||||
return CHAT_FILES_TREE_REDUCTION_ROOT + '/' + rp;
|
||||
}
|
||||
if (source === 'workspace') {
|
||||
rp = rp.replace(/^__workspace__\//, '');
|
||||
return CHAT_FILES_TREE_WORKSPACE_ROOT + '/' + rp;
|
||||
}
|
||||
if (source === 'conversation_artifact') {
|
||||
rp = rp.replace(/^__conversation_artifact__\//, '');
|
||||
return CHAT_FILES_TREE_ARTIFACT_ROOT + '/' + rp;
|
||||
@@ -768,6 +789,7 @@ function chatFilesEnsureSourceRoots(root) {
|
||||
const roots = [];
|
||||
if (source === 'all' || source === 'upload') roots.push(CHAT_FILES_TREE_UPLOAD_ROOT);
|
||||
if (source === 'all' || source === 'reduction') roots.push(CHAT_FILES_TREE_REDUCTION_ROOT);
|
||||
if (source === 'all' || source === 'workspace') roots.push(CHAT_FILES_TREE_WORKSPACE_ROOT);
|
||||
if (source === 'all' || source === 'conversation_artifact') roots.push(CHAT_FILES_TREE_ARTIFACT_ROOT);
|
||||
roots.forEach(function (name) {
|
||||
if (!root.dirs[name]) root.dirs[name] = chatFilesTreeMakeNode();
|
||||
@@ -776,13 +798,16 @@ function chatFilesEnsureSourceRoots(root) {
|
||||
|
||||
function chatFilesIsInternalSource(f) {
|
||||
const source = f && f.source;
|
||||
return source === 'reduction' || source === 'conversation_artifact';
|
||||
return source === 'reduction' || source === 'workspace' || source === 'conversation_artifact';
|
||||
}
|
||||
|
||||
function chatFilesSourceLabel(source) {
|
||||
if (source === 'reduction') {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceReduction') : '工具输出';
|
||||
}
|
||||
if (source === 'workspace') {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceWorkspace') : '工作目录';
|
||||
}
|
||||
if (source === 'conversation_artifact') {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceConversationArtifact') : '会话产物';
|
||||
}
|
||||
@@ -808,12 +833,66 @@ function chatFilesTreeDisplayName(name) {
|
||||
if (name === CHAT_FILES_TREE_REDUCTION_ROOT) {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeReductionRoot') : '工具输出';
|
||||
}
|
||||
if (name === CHAT_FILES_TREE_WORKSPACE_ROOT) {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeWorkspaceRoot') : '工作目录';
|
||||
}
|
||||
if (name === CHAT_FILES_TREE_ARTIFACT_ROOT) {
|
||||
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeArtifactsRoot') : '会话产物';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function chatFilesIDDisplay(id, labelMap, emptyLabel) {
|
||||
const raw = id == null ? '' : String(id);
|
||||
if (!raw || raw === '—') {
|
||||
return { text: emptyLabel || '—', title: '' };
|
||||
}
|
||||
const label = String((labelMap && labelMap[raw]) || '').trim();
|
||||
if (label) {
|
||||
return { text: label, title: label + ' (' + raw + ')' };
|
||||
}
|
||||
if (raw.length > 36) {
|
||||
return { text: raw.slice(0, 8) + '…' + raw.slice(-6), title: raw };
|
||||
}
|
||||
return { text: raw, title: raw };
|
||||
}
|
||||
|
||||
function chatFilesConversationDisplay(id) {
|
||||
const c = id == null ? '' : String(id);
|
||||
if (typeof window.t === 'function') {
|
||||
if (c === '_manual') {
|
||||
return { text: window.t('chatFilesPage.convManual'), title: '_manual' };
|
||||
}
|
||||
if (c === '_new') {
|
||||
return { text: window.t('chatFilesPage.convNew'), title: '_new' };
|
||||
}
|
||||
}
|
||||
return chatFilesIDDisplay(c, chatFilesConversationTitleById);
|
||||
}
|
||||
|
||||
function chatFilesProjectDisplay(id) {
|
||||
const empty = (typeof window.t === 'function') ? window.t('chatFilesPage.projectUnbound') : '未绑定项目';
|
||||
return chatFilesIDDisplay(id, chatFilesProjectNameById, empty);
|
||||
}
|
||||
|
||||
function chatFilesTreePathDisplayName(pathParts) {
|
||||
const parts = Array.isArray(pathParts) ? pathParts : [];
|
||||
const name = parts.length ? parts[parts.length - 1] : '';
|
||||
const root = parts[0] || '';
|
||||
if ((root === CHAT_FILES_TREE_WORKSPACE_ROOT || root === CHAT_FILES_TREE_REDUCTION_ROOT) && parts.length === 3) {
|
||||
if (parts[1] === 'projects') return chatFilesProjectDisplay(name);
|
||||
if (parts[1] === 'conversations') return chatFilesConversationDisplay(name);
|
||||
}
|
||||
if (root === CHAT_FILES_TREE_ARTIFACT_ROOT && parts.length === 2) {
|
||||
return chatFilesConversationDisplay(name);
|
||||
}
|
||||
if (root === CHAT_FILES_TREE_UPLOAD_ROOT && parts.length === 3) {
|
||||
return chatFilesConversationDisplay(name);
|
||||
}
|
||||
const text = chatFilesTreeDisplayName(name);
|
||||
return { text: text, title: String(name || '') };
|
||||
}
|
||||
|
||||
function chatFilesUploadRelativeDirFromBrowsePath(path) {
|
||||
const parts = Array.isArray(path) ? path.slice() : [];
|
||||
if (parts[0] === CHAT_FILES_TREE_UPLOAD_ROOT) {
|
||||
@@ -893,32 +972,15 @@ function chatFilesBuildGroups(files, mode) {
|
||||
|
||||
/** 分组标题:长 ID 缩短展示,完整值放在 title */
|
||||
function chatFilesGroupHeadingID(key, emptyLabel) {
|
||||
const c = key == null ? '' : String(key);
|
||||
if (c === '' || c === '—') {
|
||||
return { text: emptyLabel || '—', title: '' };
|
||||
}
|
||||
if (c.length > 36) {
|
||||
return { text: c.slice(0, 8) + '…' + c.slice(-6), title: c };
|
||||
}
|
||||
return { text: c, title: c };
|
||||
return chatFilesIDDisplay(key, null, emptyLabel);
|
||||
}
|
||||
|
||||
function chatFilesGroupHeadingConversation(key) {
|
||||
const c = key == null ? '' : String(key);
|
||||
if (typeof window.t === 'function') {
|
||||
if (c === '_manual') {
|
||||
return { text: window.t('chatFilesPage.convManual'), title: '_manual' };
|
||||
}
|
||||
if (c === '_new') {
|
||||
return { text: window.t('chatFilesPage.convNew'), title: '_new' };
|
||||
}
|
||||
}
|
||||
return chatFilesGroupHeadingID(key);
|
||||
return chatFilesConversationDisplay(key);
|
||||
}
|
||||
|
||||
function chatFilesGroupHeadingProject(key) {
|
||||
const empty = (typeof window.t === 'function') ? window.t('chatFilesPage.projectUnbound') : '未绑定项目';
|
||||
return chatFilesGroupHeadingID(key, empty);
|
||||
return chatFilesProjectDisplay(key);
|
||||
}
|
||||
|
||||
function renderChatFilesTable() {
|
||||
@@ -965,7 +1027,9 @@ function renderChatFilesTable() {
|
||||
const isInternal = chatFilesIsInternalSource(f);
|
||||
const sourceBadge = isInternal ? '<span class="chat-files-source-badge">' + escapeHtml(chatFilesSourceLabel(f.source)) + '</span>' : '';
|
||||
const conv = f.conversationId || '';
|
||||
const convEsc = escapeHtml(conv);
|
||||
const convDisplay = chatFilesConversationDisplay(conv);
|
||||
const convEsc = escapeHtml(convDisplay.text || conv);
|
||||
const convTitleEsc = escapeHtml(convDisplay.title || conv);
|
||||
const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—';
|
||||
const canOpenChat = conv && conv !== '_manual' && conv !== '_new';
|
||||
|
||||
@@ -1011,7 +1075,7 @@ function renderChatFilesTable() {
|
||||
|
||||
return `<tr>
|
||||
<td>${escapeHtml(f.date || '—')}</td>
|
||||
<td class="chat-files-cell-conv"><code title="${convEsc}">${convEsc}</code></td>
|
||||
<td class="chat-files-cell-conv"><code title="${convTitleEsc}">${convEsc}</code></td>
|
||||
<td class="chat-files-cell-subpath" title="${escapeHtml(subRaw || '')}">${subCellInner}</td>
|
||||
<td class="chat-files-cell-name" title="${escapeHtml(pathForTitle)}">${nameEsc}${sourceBadge}</td>
|
||||
<td>${formatChatFileBytes(f.size || 0)}</td>
|
||||
@@ -1072,11 +1136,14 @@ function renderChatFilesTable() {
|
||||
for (bi = 0; bi < chatFilesBrowsePath.length; bi++) {
|
||||
const seg = chatFilesBrowsePath[bi];
|
||||
const isLast = bi === chatFilesBrowsePath.length - 1;
|
||||
const display = chatFilesTreePathDisplayName(chatFilesBrowsePath.slice(0, bi + 1));
|
||||
const displayText = escapeHtml(display.text);
|
||||
const displayTitle = display.title ? ' title="' + escapeHtml(display.title) + '"' : '';
|
||||
breadcrumbHtml += '<span class="chat-files-breadcrumb-sep">/</span>';
|
||||
if (isLast) {
|
||||
breadcrumbHtml += '<span class="chat-files-breadcrumb-current">' + escapeHtml(chatFilesTreeDisplayName(seg)) + '</span>';
|
||||
breadcrumbHtml += '<span class="chat-files-breadcrumb-current"' + displayTitle + '>' + displayText + '</span>';
|
||||
} else {
|
||||
breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(' + bi + ')">' + escapeHtml(chatFilesTreeDisplayName(seg)) + '</button>';
|
||||
breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(' + bi + ')"' + displayTitle + '>' + displayText + '</button>';
|
||||
}
|
||||
}
|
||||
breadcrumbHtml += '</nav>';
|
||||
@@ -1098,6 +1165,8 @@ function renderChatFilesTable() {
|
||||
function rowHtmlBrowseFolder(name) {
|
||||
const nameAttr = encodeURIComponent(String(name));
|
||||
const folderPath = chatFilesBrowsePath.concat([name]);
|
||||
const folderDisplay = chatFilesTreePathDisplayName(folderPath);
|
||||
const folderTitle = folderDisplay.title || tEnter;
|
||||
const relToFolder = folderPath.join('/');
|
||||
const uploadDirAttr = encodeURIComponent(relToFolder);
|
||||
const canUploadFolder = chatFilesBrowseCanUploadToPath(folderPath);
|
||||
@@ -1109,8 +1178,8 @@ function renderChatFilesTable() {
|
||||
? `<button type="button" class="btn-icon btn-danger" title="${tDeleteFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesDeleteFolderFromBtn(event, this)">${svgTrash}</button>`
|
||||
: '';
|
||||
return `<tr class="chat-files-tr-folder chat-files-tr-folder--nav" role="button" tabindex="0" data-chat-folder-name="${nameAttr}" onclick="chatFilesOnFolderRowClick(event)" onkeydown="chatFilesOnFolderRowKeydown(event)">
|
||||
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${tEnter}">
|
||||
<span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(chatFilesTreeDisplayName(name))}</span></span>
|
||||
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${escapeHtml(folderTitle)}">
|
||||
<span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(folderDisplay.text)}</span></span>
|
||||
</td>
|
||||
<td class="chat-files-tree-muted">—</td>
|
||||
<td class="chat-files-tree-muted">—</td>
|
||||
@@ -1351,7 +1420,25 @@ function chatFilesDeleteFolderFromBtn(ev, btn) {
|
||||
|
||||
async function copyChatFolderPathFromBrowse(folderName) {
|
||||
const segs = chatFilesBrowsePath.concat([folderName]);
|
||||
const text = chatFilesClipboardFolderPath(segs);
|
||||
const relativePath = chatFilesRelativePathFromTreeSegments(segs);
|
||||
let text = '';
|
||||
if (relativePath) {
|
||||
try {
|
||||
const res = await apiFetch('/api/chat-uploads/path?kind=directory&path=' + encodeURIComponent(relativePath));
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
throw new Error(raw || String(res.status));
|
||||
}
|
||||
const data = await res.json();
|
||||
text = String((data && data.absolutePath) || '').trim();
|
||||
} catch (e) {
|
||||
alert((e && e.message) ? e.message : String(e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!text) {
|
||||
text = chatFilesClipboardFolderPath(segs);
|
||||
}
|
||||
const ok = await chatFilesCopyText(text);
|
||||
if (ok) {
|
||||
const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.folderPathCopied') : '目录路径已复制';
|
||||
@@ -1362,6 +1449,25 @@ async function copyChatFolderPathFromBrowse(folderName) {
|
||||
}
|
||||
}
|
||||
|
||||
function chatFilesRelativePathFromTreeSegments(segs) {
|
||||
const parts = Array.isArray(segs) ? segs.slice() : [];
|
||||
if (!parts.length) return '';
|
||||
const root = parts.shift();
|
||||
if (root === CHAT_FILES_TREE_UPLOAD_ROOT) {
|
||||
return parts.length ? parts.join('/') : '.';
|
||||
}
|
||||
if (root === CHAT_FILES_TREE_REDUCTION_ROOT) {
|
||||
return '__reduction__/' + parts.join('/');
|
||||
}
|
||||
if (root === CHAT_FILES_TREE_WORKSPACE_ROOT) {
|
||||
return '__workspace__/' + parts.join('/');
|
||||
}
|
||||
if (root === CHAT_FILES_TREE_ARTIFACT_ROOT) {
|
||||
return '__conversation_artifact__/' + parts.join('/');
|
||||
}
|
||||
return [root].concat(parts).join('/');
|
||||
}
|
||||
|
||||
function chatFilesClipboardFolderPath(segs) {
|
||||
const parts = Array.isArray(segs) ? segs.slice() : [];
|
||||
if (!parts.length) return '';
|
||||
|
||||
+3
-36
@@ -3572,39 +3572,14 @@ function getCachedToolExecutionSummaries(messageElement) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过程摘要中的早期/快速工具结果可能没有 executionId,但消息本身会按调用顺序保存 ID。
|
||||
* 合并两份数据,避免渲染摘要时丢失可用的弹窗详情入口。
|
||||
*/
|
||||
function mergeToolExecutionSummariesWithIds(summaries, executionIds) {
|
||||
const normalizedSummaries = Array.isArray(summaries)
|
||||
? summaries.map(normalizeToolExecutionSummaryForButton)
|
||||
: [];
|
||||
const normalizedIds = normalizeMcpExecutionIds(executionIds);
|
||||
const claimedIds = new Set(
|
||||
normalizedSummaries.map((item) => item.executionId).filter(Boolean)
|
||||
);
|
||||
const fallbackIds = normalizedIds.filter((id) => !claimedIds.has(id));
|
||||
let fallbackIndex = 0;
|
||||
return normalizedSummaries.map((item) => {
|
||||
if (item.executionId || fallbackIndex >= fallbackIds.length) return item;
|
||||
return {
|
||||
...item,
|
||||
executionId: fallbackIds[fallbackIndex++]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function selectToolExecutionSummariesForButtons(summaries, executionIds) {
|
||||
const normalizedSummaries = Array.isArray(summaries)
|
||||
? summaries.map(normalizeToolExecutionSummaryForButton)
|
||||
: [];
|
||||
const normalizedIds = normalizeMcpExecutionIds(executionIds);
|
||||
if (normalizedSummaries.length > 0) return normalizedSummaries;
|
||||
if (normalizedIds.length === 0) return normalizedSummaries;
|
||||
if (normalizedSummaries.length === 0) {
|
||||
return normalizedIds.map((executionId) => normalizeToolExecutionSummaryForButton({ executionId }));
|
||||
}
|
||||
return mergeToolExecutionSummariesWithIds(normalizedSummaries, normalizedIds);
|
||||
return normalizedIds.map((executionId) => normalizeToolExecutionSummaryForButton({ executionId }));
|
||||
}
|
||||
|
||||
function setPendingToolExecutionSummaries(messageElement, summaries) {
|
||||
@@ -3819,10 +3794,6 @@ async function findToolExecutionTimelineItem(messageElement, summary, index) {
|
||||
if (!target && item.toolCallId) {
|
||||
target = timeline.querySelector('[data-tool-call-id="' + cssEscapeValue(item.toolCallId) + '"]');
|
||||
}
|
||||
if (!target) {
|
||||
const toolItems = timeline.querySelectorAll('.timeline-item-tool_call');
|
||||
target = toolItems[index] || null;
|
||||
}
|
||||
if (!target && item.processDetailId && messageElement.dataset && messageElement.dataset.backendMessageId && typeof window.loadProcessDetailsPaginated === 'function') {
|
||||
await window.loadProcessDetailsPaginated(messageElement.id, messageElement.dataset.backendMessageId, {
|
||||
autoLoadAll: false,
|
||||
@@ -3995,11 +3966,7 @@ async function openTaskToolExecutionDetail(messageElement, item, index) {
|
||||
let detailItem = item;
|
||||
if (!detailItem.executionId) {
|
||||
const refreshedItem = await resolveToolExecutionSummaryForFocus(messageElement, '', index);
|
||||
const mergedItems = mergeToolExecutionSummariesWithIds(
|
||||
getCachedToolExecutionSummaries(messageElement),
|
||||
getCachedMcpExecutionIds(messageElement)
|
||||
);
|
||||
detailItem = mergedItems[index] || refreshedItem || detailItem;
|
||||
detailItem = refreshedItem || detailItem;
|
||||
}
|
||||
if (detailItem.executionId) {
|
||||
await showMCPDetail(detailItem.executionId);
|
||||
|
||||
+395
-71
@@ -38,6 +38,16 @@ function closeSettingsCustomSelect(select) {
|
||||
if (reg) {
|
||||
reg.wrapper.classList.remove('open');
|
||||
reg.trigger.setAttribute('aria-expanded', 'false');
|
||||
if (reg.menu.parentNode !== reg.wrapper) {
|
||||
reg.wrapper.appendChild(reg.menu);
|
||||
}
|
||||
reg.menu.classList.remove('settings-custom-select-menu--floating');
|
||||
reg.menu.style.left = '';
|
||||
reg.menu.style.right = '';
|
||||
reg.menu.style.top = '';
|
||||
reg.menu.style.bottom = '';
|
||||
reg.menu.style.width = '';
|
||||
reg.menu.style.maxHeight = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +55,62 @@ function closeAllSettingsCustomSelects() {
|
||||
settingsCustomSelects.forEach((reg) => {
|
||||
reg.wrapper.classList.remove('open');
|
||||
reg.trigger.setAttribute('aria-expanded', 'false');
|
||||
if (reg.menu.parentNode !== reg.wrapper) {
|
||||
reg.wrapper.appendChild(reg.menu);
|
||||
}
|
||||
reg.menu.classList.remove('settings-custom-select-menu--floating');
|
||||
reg.menu.style.left = '';
|
||||
reg.menu.style.right = '';
|
||||
reg.menu.style.top = '';
|
||||
reg.menu.style.bottom = '';
|
||||
reg.menu.style.width = '';
|
||||
reg.menu.style.maxHeight = '';
|
||||
});
|
||||
}
|
||||
|
||||
function positionSettingsCustomSelectMenu(reg) {
|
||||
if (!reg || !reg.wrapper.classList.contains('open')) return;
|
||||
const rect = reg.trigger.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
const gap = 6;
|
||||
const edgePadding = 12;
|
||||
const desiredWidth = Math.max(rect.width, 150);
|
||||
const width = Math.min(desiredWidth, Math.max(160, viewportWidth - edgePadding * 2));
|
||||
const left = Math.min(Math.max(edgePadding, rect.left), Math.max(edgePadding, viewportWidth - width - edgePadding));
|
||||
const spaceBelow = viewportHeight - rect.bottom - gap - edgePadding;
|
||||
const spaceAbove = rect.top - gap - edgePadding;
|
||||
const openAbove = spaceBelow < 180 && spaceAbove > spaceBelow;
|
||||
const maxHeight = Math.max(120, Math.floor((openAbove ? spaceAbove : spaceBelow) || 180));
|
||||
|
||||
reg.menu.style.left = `${Math.round(left)}px`;
|
||||
reg.menu.style.right = 'auto';
|
||||
reg.menu.style.width = `${Math.round(width)}px`;
|
||||
reg.menu.style.maxHeight = `${maxHeight}px`;
|
||||
if (openAbove) {
|
||||
reg.menu.style.top = 'auto';
|
||||
reg.menu.style.bottom = `${Math.round(viewportHeight - rect.top + gap)}px`;
|
||||
} else {
|
||||
reg.menu.style.top = `${Math.round(rect.bottom + gap)}px`;
|
||||
reg.menu.style.bottom = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function openSettingsCustomSelect(select) {
|
||||
const reg = settingsCustomSelects.get(select);
|
||||
if (!reg || select.disabled) return;
|
||||
closeAllSettingsCustomSelects();
|
||||
reg.wrapper.classList.add('open');
|
||||
reg.trigger.setAttribute('aria-expanded', 'true');
|
||||
reg.menu.classList.add('settings-custom-select-menu--floating');
|
||||
document.body.appendChild(reg.menu);
|
||||
positionSettingsCustomSelectMenu(reg);
|
||||
}
|
||||
|
||||
function repositionOpenSettingsCustomSelects() {
|
||||
settingsCustomSelects.forEach((reg) => positionSettingsCustomSelectMenu(reg));
|
||||
}
|
||||
|
||||
function syncSettingsCustomSelect(select) {
|
||||
const reg = settingsCustomSelects.get(select);
|
||||
if (!reg) return;
|
||||
@@ -79,6 +142,19 @@ function syncSettingsCustomSelect(select) {
|
||||
|
||||
item.appendChild(check);
|
||||
item.appendChild(label);
|
||||
|
||||
if (select.id === 'ai-channel-select') {
|
||||
const probeStatus = option.dataset.probeStatus || '';
|
||||
const probeMessage = option.dataset.probeMessage || '';
|
||||
if (probeStatus) {
|
||||
item.classList.add('settings-custom-select-option--probe', `probe-${probeStatus}`);
|
||||
const status = document.createElement('span');
|
||||
status.className = `settings-custom-select-status ${probeStatus}`;
|
||||
status.innerHTML = `<span class="settings-custom-select-status-dot" aria-hidden="true"></span><span class="settings-custom-select-status-text"></span>`;
|
||||
status.querySelector('.settings-custom-select-status-text').textContent = probeMessage || probeStatus;
|
||||
item.appendChild(status);
|
||||
}
|
||||
}
|
||||
reg.menu.appendChild(item);
|
||||
});
|
||||
}
|
||||
@@ -139,9 +215,8 @@ function enhanceSettingsSelect(select) {
|
||||
event.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
const willOpen = !wrapper.classList.contains('open');
|
||||
closeAllSettingsCustomSelects();
|
||||
wrapper.classList.toggle('open', willOpen);
|
||||
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
|
||||
if (willOpen) openSettingsCustomSelect(select);
|
||||
else closeSettingsCustomSelect(select);
|
||||
});
|
||||
|
||||
trigger.addEventListener('keydown', (event) => {
|
||||
@@ -158,8 +233,7 @@ function enhanceSettingsSelect(select) {
|
||||
closeSettingsCustomSelect(select);
|
||||
return;
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
wrapper.classList.add('open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
openSettingsCustomSelect(select);
|
||||
event.preventDefault();
|
||||
return;
|
||||
} else {
|
||||
@@ -200,6 +274,8 @@ function initSettingsCustomSelects(root) {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') closeAllSettingsCustomSelects();
|
||||
});
|
||||
document.addEventListener('scroll', repositionOpenSettingsCustomSelects, true);
|
||||
window.addEventListener('resize', repositionOpenSettingsCustomSelects);
|
||||
settingsCustomSelectsDocBound = true;
|
||||
}
|
||||
refreshSettingsCustomSelects();
|
||||
@@ -2443,7 +2519,7 @@ function ensureAIConfigShape(cfg) {
|
||||
|
||||
function readAIChannelFromMainForm(id) {
|
||||
const prev = currentConfig?.ai?.channels?.[id] || {};
|
||||
const maxCompletionTokens = parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 16384;
|
||||
const maxCompletionTokens = parseInt(document.getElementById('openai-max-completion-tokens')?.value, 10) || 32768;
|
||||
return {
|
||||
...prev,
|
||||
name: (document.getElementById('ai-channel-name')?.value || '').trim() || prev.name || id,
|
||||
@@ -2483,7 +2559,7 @@ function writeAIChannelToMainForm(id) {
|
||||
const maxTokensEl = document.getElementById('openai-max-total-tokens');
|
||||
if (maxTokensEl) maxTokensEl.value = ch.max_total_tokens || 120000;
|
||||
const maxCompletionTokensEl = document.getElementById('openai-max-completion-tokens');
|
||||
if (maxCompletionTokensEl) maxCompletionTokensEl.value = ch.max_completion_tokens || 16384;
|
||||
if (maxCompletionTokensEl) maxCompletionTokensEl.value = ch.max_completion_tokens || 32768;
|
||||
const r = ch.reasoning || {};
|
||||
const modeEl = document.getElementById('openai-reasoning-mode');
|
||||
if (modeEl) modeEl.value = ['auto', 'on', 'off'].includes(String(r.mode || '').toLowerCase()) ? String(r.mode).toLowerCase() : 'auto';
|
||||
@@ -2494,6 +2570,100 @@ function writeAIChannelToMainForm(id) {
|
||||
const allowEl = document.getElementById('openai-reasoning-allow-client');
|
||||
if (allowEl) allowEl.checked = r.allow_client_reasoning !== false;
|
||||
syncModelListFetchButtons();
|
||||
syncAIChannelEditorPreview();
|
||||
syncConnectionTestResultForSelectedAIChannel();
|
||||
}
|
||||
|
||||
function displayAIChannelName(id, ch) {
|
||||
const name = String(ch?.name || '').trim();
|
||||
if ((name === '新通道' || name === 'New Channel') && !String(ch?.model || '').trim()) {
|
||||
return settingsT('settingsBasic.aiChannelUntitled', name || id);
|
||||
}
|
||||
return name || id;
|
||||
}
|
||||
|
||||
function aiChannelSelectLabel(id, ch) {
|
||||
const marker = id === currentConfig?.ai?.default_channel ? ' *' : '';
|
||||
return `${displayAIChannelName(id, ch)}${marker} · ${ch?.model || '-'}`;
|
||||
}
|
||||
|
||||
function aiChannelOptionProbeMeta(id) {
|
||||
const probe = aiChannelProbeResults[id];
|
||||
if (!probe) return null;
|
||||
const status = probe.status || '';
|
||||
if (!['testing', 'ready', 'failed'].includes(status)) return null;
|
||||
return {
|
||||
status,
|
||||
message: probe.message || (status === 'ready'
|
||||
? settingsT('settingsBasic.aiChannelReady', '可用')
|
||||
: status === 'testing'
|
||||
? settingsT('settingsBasic.testing', '测试中...')
|
||||
: settingsT('settingsBasic.testFailed', '连接失败'))
|
||||
};
|
||||
}
|
||||
|
||||
function updateAIChannelSelectOption(id) {
|
||||
const select = document.getElementById('ai-channel-select');
|
||||
if (!select || !currentConfig?.ai?.channels) return;
|
||||
const channelId = normalizeAIChannelId(id || selectedAIChannelId || currentConfig.ai.default_channel || 'default');
|
||||
const ch = currentConfig.ai.channels[channelId];
|
||||
if (!ch) return;
|
||||
const opt = Array.from(select.options).find((option) => option.value === channelId);
|
||||
if (opt) {
|
||||
opt.textContent = aiChannelSelectLabel(channelId, ch);
|
||||
const probeMeta = aiChannelOptionProbeMeta(channelId);
|
||||
if (probeMeta) {
|
||||
opt.dataset.probeStatus = probeMeta.status;
|
||||
opt.dataset.probeMessage = probeMeta.message;
|
||||
} else {
|
||||
delete opt.dataset.probeStatus;
|
||||
delete opt.dataset.probeMessage;
|
||||
}
|
||||
if (channelId === selectedAIChannelId) {
|
||||
select.value = channelId;
|
||||
select.selectedIndex = opt.index;
|
||||
}
|
||||
}
|
||||
if (typeof syncSettingsCustomSelect === 'function') {
|
||||
syncSettingsCustomSelect(select);
|
||||
}
|
||||
}
|
||||
|
||||
function syncSelectedAIChannelUI() {
|
||||
updateAIChannelSelectOption(selectedAIChannelId);
|
||||
updateAIChannelEditorChrome(selectedAIChannelId);
|
||||
renderAIChannelList();
|
||||
syncConnectionTestResultForSelectedAIChannel();
|
||||
}
|
||||
|
||||
function syncAIChannelEditorPreview() {
|
||||
if (!currentConfig?.ai?.channels || !selectedAIChannelId || !currentConfig.ai.channels[selectedAIChannelId]) return;
|
||||
const id = normalizeAIChannelId(selectedAIChannelId);
|
||||
const prev = currentConfig.ai.channels[id] || {};
|
||||
const next = readAIChannelFromMainForm(id);
|
||||
const connectionChanged = ['provider', 'base_url', 'api_key', 'model'].some((key) => String(prev[key] || '') !== String(next[key] || ''));
|
||||
if (connectionChanged) {
|
||||
delete aiChannelProbeResults[id];
|
||||
}
|
||||
currentConfig.ai.channels[id] = next;
|
||||
syncSelectedAIChannelUI();
|
||||
}
|
||||
|
||||
function bindAIChannelEditorPreviewSync() {
|
||||
const ids = [
|
||||
'ai-channel-name',
|
||||
'openai-provider',
|
||||
'openai-api-key',
|
||||
'openai-base-url',
|
||||
'openai-model'
|
||||
];
|
||||
ids.forEach((fieldId) => {
|
||||
const el = document.getElementById(fieldId);
|
||||
if (!el || el.dataset.aiChannelPreviewBound === '1') return;
|
||||
el.dataset.aiChannelPreviewBound = '1';
|
||||
const eventName = el.tagName === 'SELECT' ? 'change' : 'input';
|
||||
el.addEventListener(eventName, syncAIChannelEditorPreview);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAIChannelSelect() {
|
||||
@@ -2507,16 +2677,24 @@ function renderAIChannelSelect() {
|
||||
const ch = currentConfig.ai.channels[id] || {};
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
const marker = id === currentConfig.ai.default_channel ? ' *' : '';
|
||||
opt.textContent = `${ch.name || id}${marker} · ${ch.model || '-'}`;
|
||||
opt.textContent = aiChannelSelectLabel(id, ch);
|
||||
const probeMeta = aiChannelOptionProbeMeta(id);
|
||||
if (probeMeta) {
|
||||
opt.dataset.probeStatus = probeMeta.status;
|
||||
opt.dataset.probeMessage = probeMeta.message;
|
||||
}
|
||||
select.appendChild(opt);
|
||||
});
|
||||
selectedAIChannelId = selectedAIChannelId && currentConfig.ai.channels[selectedAIChannelId]
|
||||
? selectedAIChannelId
|
||||
: currentConfig.ai.default_channel;
|
||||
select.value = selectedAIChannelId;
|
||||
renderAIChannelList(ids);
|
||||
updateAIChannelSelectOption(selectedAIChannelId);
|
||||
if (typeof syncSettingsCustomSelect === 'function') {
|
||||
syncSettingsCustomSelect(select);
|
||||
}
|
||||
updateAIChannelEditorChrome(selectedAIChannelId);
|
||||
renderAIChannelList(ids);
|
||||
const countLabel = typeof window.t === 'function'
|
||||
? window.t('settingsBasic.aiChannelCount').replace('{count}', String(ids.length))
|
||||
: `已保存 ${ids.length} 个通道`;
|
||||
@@ -2558,7 +2736,7 @@ function renderAIChannelList(ids) {
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'ai-channel-bulk-check';
|
||||
checkbox.checked = selectedAIChannelBulkIds.has(id);
|
||||
checkbox.setAttribute('aria-label', `选择 ${ch.name || id}`);
|
||||
checkbox.setAttribute('aria-label', settingsT('settingsBasic.aiChannelSelectAria', '选择 {name}').replace('{name}', displayAIChannelName(id, ch)));
|
||||
checkbox.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
if (checkbox.checked) {
|
||||
@@ -2568,11 +2746,12 @@ function renderAIChannelList(ids) {
|
||||
}
|
||||
item.classList.toggle('checked', checkbox.checked);
|
||||
};
|
||||
const displayName = displayAIChannelName(id, ch);
|
||||
const defaultBadge = isDefault ? `<span class="ai-channel-badge">${escapeAIChannelHtml(settingsT('settingsBasic.aiChannelDefaultBadge', '默认'))}</span>` : '';
|
||||
let statusText = isComplete
|
||||
? settingsT('settingsBasic.aiChannelReady', '可用')
|
||||
? settingsT('settingsBasic.aiChannelComplete', '配置完整')
|
||||
: settingsT('settingsBasic.aiChannelDraft', '待完善');
|
||||
let statusClass = isComplete ? 'ready' : 'draft';
|
||||
let statusClass = isComplete ? 'complete' : 'draft';
|
||||
if (probe) {
|
||||
statusText = probe.message || statusText;
|
||||
statusClass = probe.status || statusClass;
|
||||
@@ -2582,7 +2761,7 @@ function renderAIChannelList(ids) {
|
||||
body.innerHTML = `
|
||||
<div class="ai-channel-list-main">
|
||||
<span class="ai-channel-status-dot ${statusClass}" aria-hidden="true"></span>
|
||||
<strong title="${escapeAIChannelHtml(ch.name || id)}">${escapeAIChannelHtml(ch.name || id)}</strong>
|
||||
<strong title="${escapeAIChannelHtml(displayName)}">${escapeAIChannelHtml(displayName)}</strong>
|
||||
${defaultBadge}
|
||||
</div>
|
||||
<div class="ai-channel-list-meta" title="${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}">${escapeAIChannelHtml(ch.model || '-')} · ${escapeAIChannelHtml(channelHostLabel(ch.base_url))}</div>
|
||||
@@ -2609,19 +2788,36 @@ function updateAIChannelEditorChrome(id) {
|
||||
const ai = ensureAIConfigShape(currentConfig || {});
|
||||
const channelId = normalizeAIChannelId(id || ai.default_channel || 'default');
|
||||
const ch = ai.channels[channelId] || {};
|
||||
const isDefault = channelId === ai.default_channel;
|
||||
const isComplete = !validateSelectedAIChannelPayload(ch);
|
||||
const probe = aiChannelProbeResults[channelId] || null;
|
||||
const title = document.getElementById('ai-channel-editor-title');
|
||||
const meta = document.getElementById('ai-channel-editor-meta');
|
||||
if (title) title.textContent = ch.name || channelId;
|
||||
if (title) {
|
||||
title.textContent = settingsT('settingsBasic.aiChannelFormContextHint', '表单保存后会更新该通道配置。');
|
||||
}
|
||||
if (meta) {
|
||||
const parts = [
|
||||
channelId === ai.default_channel
|
||||
? settingsT('settingsBasic.aiChannelDefaultMeta', '默认通道')
|
||||
: settingsT('settingsBasic.aiChannelCustomMeta', '自定义通道'),
|
||||
ch.provider === 'claude' ? 'Claude' : settingsT('settingsBasic.aiChannelOpenAICompat', 'OpenAI 兼容'),
|
||||
ch.model || settingsT('settingsBasic.aiChannelModelMissing', '未填写模型'),
|
||||
channelHostLabel(ch.base_url)
|
||||
].filter(Boolean);
|
||||
meta.textContent = parts.join(' / ');
|
||||
const provider = ch.provider === 'claude' ? 'Claude' : settingsT('settingsBasic.aiChannelOpenAICompat', 'OpenAI 兼容');
|
||||
const statusText = probe?.message || (isComplete
|
||||
? settingsT('settingsBasic.aiChannelComplete', '配置完整')
|
||||
: settingsT('settingsBasic.aiChannelDraft', '待完善'));
|
||||
const statusClass = probe?.status || (isComplete ? 'complete' : 'draft');
|
||||
const chips = [
|
||||
{
|
||||
label: isDefault
|
||||
? settingsT('settingsBasic.aiChannelDefaultMeta', '默认通道')
|
||||
: settingsT('settingsBasic.aiChannelCustomMeta', '自定义通道'),
|
||||
className: isDefault ? 'default' : ''
|
||||
},
|
||||
{ label: provider },
|
||||
{ label: ch.model || settingsT('settingsBasic.aiChannelModelMissing', '未填写模型') },
|
||||
{ label: channelHostLabel(ch.base_url) },
|
||||
{ label: statusText, className: statusClass }
|
||||
].filter((chip) => chip.label);
|
||||
meta.innerHTML = chips.map((chip) => {
|
||||
const className = chip.className ? ` ${escapeAIChannelHtml(chip.className)}` : '';
|
||||
return `<span class="ai-channel-editor-chip${className}" title="${escapeAIChannelHtml(chip.label)}">${escapeAIChannelHtml(chip.label)}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2636,6 +2832,41 @@ function validateSelectedAIChannelPayload(ch) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveSavedAIChannelId(ai, preferredId, preferredPayload) {
|
||||
const channels = ai?.channels || {};
|
||||
const normalizedPreferred = normalizeAIChannelId(preferredId || '');
|
||||
if (normalizedPreferred && channels[normalizedPreferred]) return normalizedPreferred;
|
||||
|
||||
const payload = preferredPayload || {};
|
||||
const targetName = String(payload.name || '').trim();
|
||||
const targetModel = String(payload.model || '').trim();
|
||||
const targetBaseUrl = String(payload.base_url || '').trim();
|
||||
const targetProvider = String(payload.provider || '').trim();
|
||||
const ids = Object.keys(channels).sort();
|
||||
const matched = ids.find((id) => {
|
||||
const ch = channels[id] || {};
|
||||
return String(ch.name || '').trim() === targetName
|
||||
&& String(ch.model || '').trim() === targetModel
|
||||
&& String(ch.base_url || '').trim() === targetBaseUrl
|
||||
&& String(ch.provider || '').trim() === targetProvider;
|
||||
});
|
||||
return matched || ai?.default_channel || ids[0] || normalizedPreferred || 'default';
|
||||
}
|
||||
|
||||
async function refreshAIChannelsFromServer(preferredId, preferredPayload) {
|
||||
const response = await apiFetch('/api/config');
|
||||
if (!response.ok) return false;
|
||||
currentConfig = await response.json();
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
selectedAIChannelId = resolveSavedAIChannelId(currentConfig.ai, preferredId, preferredPayload);
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(selectedAIChannelId);
|
||||
if (typeof populateChatAIChannelSelect === 'function') {
|
||||
populateChatAIChannelSelect(currentConfig.ai);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function persistAIChannelsToServer(successMessage, options = {}) {
|
||||
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
|
||||
if (!currentConfig) currentConfig = {};
|
||||
@@ -2651,7 +2882,7 @@ async function persistAIChannelsToServer(successMessage, options = {}) {
|
||||
return false;
|
||||
}
|
||||
renderAIChannelSelect();
|
||||
showAIChannelSaveHint('正在保存通道...', true);
|
||||
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelSaving', '正在保存通道...'), true);
|
||||
try {
|
||||
const shouldMergeLatest = options.mergeLatest !== false;
|
||||
const latestResponse = shouldMergeLatest ? await apiFetch('/api/config') : null;
|
||||
@@ -2681,17 +2912,7 @@ async function persistAIChannelsToServer(successMessage, options = {}) {
|
||||
const error = await applyResponse.json().catch(() => ({}));
|
||||
throw new Error(error.error || '应用通道失败');
|
||||
}
|
||||
const response = await apiFetch('/api/config');
|
||||
if (response.ok) {
|
||||
currentConfig = await response.json();
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
selectedAIChannelId = currentConfig.ai.channels[id] ? id : currentConfig.ai.default_channel;
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(selectedAIChannelId);
|
||||
if (typeof populateChatAIChannelSelect === 'function') {
|
||||
populateChatAIChannelSelect(currentConfig.ai);
|
||||
}
|
||||
}
|
||||
await refreshAIChannelsFromServer(id, channelPayload);
|
||||
showAIChannelSaveHint(successMessage || '通道已保存', true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -2705,7 +2926,7 @@ async function persistAIConfigOnlyToServer(successMessage) {
|
||||
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return false;
|
||||
if (!currentConfig) return false;
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
showAIChannelSaveHint('正在保存通道...', true);
|
||||
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelSaving', '正在保存通道...'), true);
|
||||
try {
|
||||
const updateResponse = await apiFetch('/api/config', {
|
||||
method: 'PUT',
|
||||
@@ -2721,9 +2942,7 @@ async function persistAIConfigOnlyToServer(successMessage) {
|
||||
const error = await applyResponse.json().catch(() => ({}));
|
||||
throw new Error(error.error || '应用通道失败');
|
||||
}
|
||||
if (typeof populateChatAIChannelSelect === 'function') {
|
||||
populateChatAIChannelSelect(currentConfig.ai);
|
||||
}
|
||||
await refreshAIChannelsFromServer(selectedAIChannelId);
|
||||
showAIChannelSaveHint(successMessage || '通道已保存', true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -2781,13 +3000,13 @@ function createAIChannelFromForm() {
|
||||
base_url: '',
|
||||
model: '',
|
||||
max_total_tokens: 120000,
|
||||
max_completion_tokens: 16384,
|
||||
max_completion_tokens: 32768,
|
||||
reasoning: { mode: 'auto', effort: '', profile: 'auto', allow_client_reasoning: true }
|
||||
};
|
||||
selectedAIChannelId = id;
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(id);
|
||||
showAIChannelSaveHint('新通道尚未保存,填写后点击「保存更改」。', true);
|
||||
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelNewUnsaved', '新通道尚未保存,填写后点击「保存更改」。'), true);
|
||||
}
|
||||
|
||||
function copyAIChannelFromForm() {
|
||||
@@ -2799,10 +3018,10 @@ function copyAIChannelFromForm() {
|
||||
selectedAIChannelId = id;
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(id);
|
||||
showAIChannelSaveHint('复制的通道尚未保存,确认后点击「保存更改」。', true);
|
||||
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelCopyUnsaved', '复制的通道尚未保存,确认后点击「保存更改」。'), true);
|
||||
}
|
||||
|
||||
function deleteSelectedAIChannel() {
|
||||
async function deleteSelectedAIChannel() {
|
||||
if (!currentConfig) return;
|
||||
currentConfig.ai = ensureAIConfigShape(currentConfig);
|
||||
const ids = Object.keys(currentConfig.ai.channels || {});
|
||||
@@ -2820,10 +3039,21 @@ function deleteSelectedAIChannel() {
|
||||
return;
|
||||
}
|
||||
delete currentConfig.ai.channels[id];
|
||||
currentConfig.ai.default_channel = Object.keys(currentConfig.ai.channels).sort()[0];
|
||||
delete aiChannelProbeResults[id];
|
||||
selectedAIChannelBulkIds.delete(id);
|
||||
|
||||
const remainingIds = Object.keys(currentConfig.ai.channels || {}).sort();
|
||||
if (!currentConfig.ai.channels[currentConfig.ai.default_channel]) {
|
||||
currentConfig.ai.default_channel = remainingIds[0];
|
||||
}
|
||||
selectedAIChannelId = currentConfig.ai.default_channel || remainingIds[0];
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(currentConfig.ai.default_channel);
|
||||
persistAIChannelsToServer('通道已删除', { mergeLatest: false });
|
||||
writeAIChannelToMainForm(selectedAIChannelId);
|
||||
const saved = await persistAIConfigOnlyToServer(settingsT('settingsBasic.aiChannelDeleted', '通道已删除'));
|
||||
if (saved) {
|
||||
renderAIChannelSelect();
|
||||
writeAIChannelToMainForm(selectedAIChannelId);
|
||||
}
|
||||
}
|
||||
|
||||
function selectedOrAllAIChannelIdsForProbe() {
|
||||
@@ -2841,12 +3071,13 @@ async function probeSelectedAIChannels() {
|
||||
if (typeof requirePermission === 'function' && !requirePermission('config:write')) return;
|
||||
const ids = selectedOrAllAIChannelIdsForProbe();
|
||||
if (!ids.length) {
|
||||
alert('没有可探活的完整通道,请先填写 Base URL、API Key 和模型');
|
||||
alert(settingsT('settingsBasic.aiChannelProbeNoComplete', '没有可探活的完整通道,请先填写 Base URL、API Key 和模型'));
|
||||
return;
|
||||
}
|
||||
showAIChannelSaveHint(`正在探活 ${ids.length} 个通道...`, true);
|
||||
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelProbing', '正在探活 {count} 个通道...').replace('{count}', String(ids.length)), true);
|
||||
ids.forEach((id) => {
|
||||
aiChannelProbeResults[id] = { status: 'testing', message: '测试中...' };
|
||||
aiChannelProbeResults[id] = { status: 'testing', message: settingsT('settingsBasic.testing', '测试中...') };
|
||||
updateAIChannelSelectOption(id);
|
||||
});
|
||||
renderAIChannelList();
|
||||
let okCount = 0;
|
||||
@@ -2870,13 +3101,14 @@ async function probeSelectedAIChannels() {
|
||||
if (response.ok && result.success) {
|
||||
okCount += 1;
|
||||
const latency = result.latency_ms ? ` ${result.latency_ms}ms` : '';
|
||||
aiChannelProbeResults[id] = { status: 'ready', message: `可用${latency}` };
|
||||
aiChannelProbeResults[id] = { status: 'ready', message: settingsT('settingsBasic.aiChannelReadyWithLatency', '可用{latency}').replace('{latency}', latency) };
|
||||
} else {
|
||||
aiChannelProbeResults[id] = { status: 'failed', message: (result.error || '连接失败') };
|
||||
aiChannelProbeResults[id] = { status: 'failed', message: formatConnectionTestError(result.error || settingsT('settingsBasic.testFailed', '连接失败')).message };
|
||||
}
|
||||
} catch (error) {
|
||||
aiChannelProbeResults[id] = { status: 'failed', message: error.message || '测试出错' };
|
||||
aiChannelProbeResults[id] = { status: 'failed', message: formatConnectionTestError(error.message || settingsT('settingsBasic.testError', '测试出错')).message };
|
||||
}
|
||||
updateAIChannelSelectOption(id);
|
||||
renderAIChannelList();
|
||||
}
|
||||
const workers = Array.from({ length: Math.min(AI_CHANNEL_PROBE_CONCURRENCY, ids.length) }, async function () {
|
||||
@@ -2885,7 +3117,7 @@ async function probeSelectedAIChannels() {
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
showAIChannelSaveHint(`探活完成:${okCount}/${ids.length} 可用`, okCount === ids.length);
|
||||
showAIChannelSaveHint(settingsT('settingsBasic.aiChannelProbeDone', '探活完成:{ok}/{total} 可用').replace('{ok}', String(okCount)).replace('{total}', String(ids.length)), okCount === ids.length);
|
||||
}
|
||||
|
||||
async function deleteCheckedAIChannels() {
|
||||
@@ -2934,7 +3166,17 @@ if (typeof window !== 'undefined') {
|
||||
window.deleteCheckedAIChannels = deleteCheckedAIChannels;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined' && !document.__aiChannelI18nBound) {
|
||||
document.__aiChannelI18nBound = true;
|
||||
document.addEventListener('languagechange', function () {
|
||||
if (!currentConfig?.ai) return;
|
||||
renderAIChannelSelect();
|
||||
updateAIChannelEditorChrome(selectedAIChannelId || currentConfig.ai.default_channel);
|
||||
});
|
||||
}
|
||||
|
||||
function initModelListControls() {
|
||||
bindAIChannelEditorPreviewSync();
|
||||
const providerEl = document.getElementById('openai-provider');
|
||||
if (providerEl && !providerEl.dataset.modelListBound) {
|
||||
providerEl.dataset.modelListBound = '1';
|
||||
@@ -2985,6 +3227,9 @@ function bindModelSelect(scope) {
|
||||
if (!select.value) return;
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) input.value = select.value;
|
||||
if (scope === 'openai') {
|
||||
syncAIChannelEditorPreview();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3318,10 +3563,10 @@ async function testHitlAuditModelConnection() {
|
||||
const resultEl = document.getElementById('test-hitl-audit-model-result');
|
||||
const cfg = collectHitlAuditModelEffectiveConfig();
|
||||
|
||||
if (!cfg.api_key || !cfg.model) {
|
||||
if (!cfg.base_url || !cfg.api_key || !cfg.model) {
|
||||
if (resultEl) {
|
||||
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
||||
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 API Key 和模型';
|
||||
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 Base URL、API Key 和模型';
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3367,6 +3612,59 @@ async function testHitlAuditModelConnection() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatConnectionTestError(errorText) {
|
||||
const raw = String(errorText || '').trim() || settingsT('settingsBasic.testError', '测试出错');
|
||||
return {
|
||||
message: raw,
|
||||
detail: raw
|
||||
};
|
||||
}
|
||||
|
||||
function setConnectionTestResult(resultEl, state, message, title) {
|
||||
if (!resultEl) return;
|
||||
resultEl.classList.remove('is-error', 'is-success', 'is-muted', 'is-visible');
|
||||
resultEl.style.color = '';
|
||||
resultEl.textContent = message || '';
|
||||
resultEl.title = title || '';
|
||||
if (message) {
|
||||
resultEl.classList.add('is-visible', state || 'is-muted');
|
||||
}
|
||||
}
|
||||
|
||||
function syncConnectionTestResultForSelectedAIChannel() {
|
||||
const resultEl = document.getElementById('test-openai-result');
|
||||
if (!resultEl) return;
|
||||
const channelId = normalizeAIChannelId(selectedAIChannelId || currentConfig?.ai?.default_channel || 'default');
|
||||
const probe = aiChannelProbeResults[channelId];
|
||||
if (!probe) {
|
||||
setConnectionTestResult(resultEl, '', '');
|
||||
return;
|
||||
}
|
||||
const state = probe.status === 'ready'
|
||||
? 'is-success'
|
||||
: probe.status === 'failed'
|
||||
? 'is-error'
|
||||
: 'is-muted';
|
||||
let message = probe.message || '';
|
||||
const fillRequiredMessage = settingsT('settingsBasic.testFillRequired', '请先填写 Base URL、API Key 和模型');
|
||||
const failedPrefix = (typeof window.t === 'function' ? window.t('settingsBasic.testFailed') : '连接失败') + ': ';
|
||||
if (probe.status === 'failed' && message && message !== fillRequiredMessage && !message.startsWith(failedPrefix)) {
|
||||
message = failedPrefix + message;
|
||||
}
|
||||
setConnectionTestResult(resultEl, state, message);
|
||||
}
|
||||
|
||||
function showConnectionTestFailure(resultEl, errorText) {
|
||||
if (!resultEl) return;
|
||||
const formatted = formatConnectionTestError(errorText);
|
||||
setConnectionTestResult(
|
||||
resultEl,
|
||||
'is-error',
|
||||
(typeof window.t === 'function' ? window.t('settingsBasic.testFailed') : '连接失败') + ': ' + formatted.message,
|
||||
formatted.detail || formatted.message
|
||||
);
|
||||
}
|
||||
|
||||
// 测试OpenAI连接
|
||||
async function testOpenAIConnection() {
|
||||
const btn = document.getElementById('test-openai-btn');
|
||||
@@ -3376,17 +3674,29 @@ async function testOpenAIConnection() {
|
||||
const baseUrl = document.getElementById('openai-base-url').value.trim();
|
||||
const apiKey = document.getElementById('openai-api-key').value.trim();
|
||||
const model = document.getElementById('openai-model').value.trim();
|
||||
const channelId = normalizeAIChannelId(selectedAIChannelId || currentConfig?.ai?.default_channel || 'default');
|
||||
const isTestingSameChannel = () => normalizeAIChannelId(selectedAIChannelId || currentConfig?.ai?.default_channel || 'default') === channelId;
|
||||
|
||||
if (!apiKey || !model) {
|
||||
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
||||
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 API Key 和模型';
|
||||
if (!baseUrl || !apiKey || !model) {
|
||||
const message = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 Base URL、API Key 和模型';
|
||||
aiChannelProbeResults[channelId] = { status: 'failed', message };
|
||||
if (isTestingSameChannel()) {
|
||||
setConnectionTestResult(resultEl, 'is-error', message);
|
||||
}
|
||||
syncSelectedAIChannelUI();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.style.pointerEvents = 'none';
|
||||
btn.style.opacity = '0.5';
|
||||
resultEl.style.color = 'var(--text-muted, #888)';
|
||||
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testing') : '测试中...';
|
||||
if (btn) {
|
||||
btn.style.pointerEvents = 'none';
|
||||
btn.style.opacity = '0.5';
|
||||
}
|
||||
const testingMessage = settingsT('settingsBasic.testing', '测试中...');
|
||||
aiChannelProbeResults[channelId] = { status: 'testing', message: testingMessage };
|
||||
if (isTestingSameChannel()) {
|
||||
setConnectionTestResult(resultEl, 'is-muted', testingMessage);
|
||||
}
|
||||
syncSelectedAIChannelUI();
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/config/test-openai', {
|
||||
@@ -3403,20 +3713,33 @@ async function testOpenAIConnection() {
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
resultEl.style.color = 'var(--success-color, #38a169)';
|
||||
const latency = result.latency_ms ? ` (${result.latency_ms}ms)` : '';
|
||||
const modelInfo = result.model ? ` [${result.model}]` : '';
|
||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency;
|
||||
const message = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency;
|
||||
aiChannelProbeResults[channelId] = { status: 'ready', message };
|
||||
if (isTestingSameChannel()) {
|
||||
setConnectionTestResult(resultEl, 'is-success', message);
|
||||
}
|
||||
} else {
|
||||
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testFailed') : '连接失败') + ': ' + (result.error || '未知错误');
|
||||
const message = formatConnectionTestError(result.error || '未知错误').message;
|
||||
aiChannelProbeResults[channelId] = { status: 'failed', message };
|
||||
if (isTestingSameChannel()) {
|
||||
showConnectionTestFailure(resultEl, message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
||||
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testError') : '测试出错') + ': ' + error.message;
|
||||
const message = formatConnectionTestError(error.message || '测试出错').message;
|
||||
aiChannelProbeResults[channelId] = { status: 'failed', message };
|
||||
if (isTestingSameChannel()) {
|
||||
showConnectionTestFailure(resultEl, message);
|
||||
}
|
||||
} finally {
|
||||
btn.style.pointerEvents = '';
|
||||
btn.style.opacity = '';
|
||||
updateAIChannelSelectOption(channelId);
|
||||
syncSelectedAIChannelUI();
|
||||
if (btn) {
|
||||
btn.style.pointerEvents = '';
|
||||
btn.style.opacity = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4325,3 +4648,4 @@ document.addEventListener('languagechange', function () {
|
||||
|
||||
window.initSettingsCustomSelects = initSettingsCustomSelects;
|
||||
window.refreshSettingsCustomSelects = refreshSettingsCustomSelects;
|
||||
window.closeAllSettingsCustomSelects = closeAllSettingsCustomSelects;
|
||||
|
||||
@@ -4831,11 +4831,7 @@ function showAddWebshellModal() {
|
||||
if (osSelEl) osSelEl.value = 'auto';
|
||||
var encSelEl = document.getElementById('webshell-encoding');
|
||||
if (encSelEl) encSelEl.value = 'auto';
|
||||
var defaultProjectId = '';
|
||||
try {
|
||||
defaultProjectId = typeof getActiveProjectId === 'function' ? (getActiveProjectId() || '') : '';
|
||||
} catch (e) {}
|
||||
populateWebshellProjectSelect(defaultProjectId);
|
||||
populateWebshellProjectSelect('');
|
||||
document.getElementById('webshell-remark').value = '';
|
||||
var titleEl = document.getElementById('webshell-modal-title');
|
||||
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
|
||||
|
||||
+133
-101
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CyberStrikeAI</title>
|
||||
<link rel="icon" type="image/png" href="/static/logo.png">
|
||||
<link rel="shortcut icon" type="image/png" href="/static/favicon.ico">
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico?v=20260728">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico?v=20260728">
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
@@ -2727,6 +2727,7 @@
|
||||
<option value="all" data-i18n="chatFilesPage.sourceAll">全部</option>
|
||||
<option value="upload" data-i18n="chatFilesPage.sourceUpload">对话附件</option>
|
||||
<option value="reduction" data-i18n="chatFilesPage.sourceReduction">工具输出</option>
|
||||
<option value="workspace" data-i18n="chatFilesPage.sourceWorkspace">工作目录</option>
|
||||
<option value="conversation_artifact" data-i18n="chatFilesPage.sourceConversationArtifact">会话产物</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -3445,115 +3446,146 @@
|
||||
<div class="ai-channel-manager-header">
|
||||
<div>
|
||||
<h4 data-i18n="settingsBasic.openaiConfig">AI 通道配置</h4>
|
||||
<p id="ai-channel-save-hint" class="ai-channel-manager-hint" data-i18n="settingsBasic.aiChannelHint">已保存的通道会显示在左侧;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。</p>
|
||||
<p id="ai-channel-save-hint" class="ai-channel-manager-hint" data-i18n="settingsBasic.aiChannelHint">通过下拉切换已保存通道;保存后写入 ai.channels,默认通道用于新对话和未指定通道的任务。</p>
|
||||
</div>
|
||||
<div class="ai-channel-header-actions">
|
||||
<button type="button" class="btn-secondary" onclick="createAIChannelFromForm()" data-i18n="settingsBasic.aiChannelNew">新增</button>
|
||||
<button type="button" class="btn-primary ai-channel-save-btn" onclick="saveSelectedAIChannel()" data-i18n="settingsBasic.aiChannelSave">保存更改</button>
|
||||
</div>
|
||||
<button type="button" class="btn-primary ai-channel-save-btn" onclick="saveSelectedAIChannel()" data-i18n="settingsBasic.aiChannelSave">保存更改</button>
|
||||
</div>
|
||||
<div class="ai-channel-manager-body">
|
||||
<aside class="ai-channel-sidebar" aria-label="AI 通道列表" data-i18n="settingsBasic.aiChannelListAria" data-i18n-attr="aria-label">
|
||||
<div class="ai-channel-sidebar-head">
|
||||
<span data-i18n="settingsBasic.aiChannelSavedList">已保存通道</span>
|
||||
<div class="ai-channel-bulk-actions">
|
||||
<button type="button" class="ai-channel-bulk-btn" onclick="probeSelectedAIChannels()" title="检测所选或全部完整通道是否可用">批量探活</button>
|
||||
<button type="button" class="ai-channel-bulk-btn danger" onclick="deleteCheckedAIChannels()" title="删除已勾选的非默认通道">删除所选</button>
|
||||
<button type="button" class="ai-channel-icon-btn" onclick="createAIChannelFromForm()" title="新增通道" aria-label="新增通道">+</button>
|
||||
</div>
|
||||
<div class="ai-channel-switcher" aria-label="AI 通道选择" data-i18n="settingsBasic.aiChannelListAria" data-i18n-attr="aria-label">
|
||||
<div class="ai-channel-switcher-field">
|
||||
<label for="ai-channel-select" data-i18n="settingsBasic.aiChannelCurrent">当前通道</label>
|
||||
<select id="ai-channel-select" class="ai-channel-switch-select" onchange="selectAIChannelForEditing(this.value)"></select>
|
||||
</div>
|
||||
<div class="ai-channel-switcher-actions">
|
||||
<button type="button" class="btn-secondary" onclick="probeSelectedAIChannels()" data-i18n="settingsBasic.aiChannelBulkProbe" data-i18n-title="settingsBasic.aiChannelBulkProbeTitle" data-i18n-attr="title">批量探活</button>
|
||||
<button type="button" class="btn-secondary" onclick="copyAIChannelFromForm()" data-i18n="settingsBasic.aiChannelCopy">复制</button>
|
||||
<button type="button" class="btn-secondary" onclick="setSelectedAIChannelDefault()" data-i18n="settingsBasic.aiChannelSetDefault">设为默认</button>
|
||||
<button type="button" class="btn-secondary danger" onclick="deleteSelectedAIChannel()" data-i18n="settingsBasic.aiChannelDelete">删除</button>
|
||||
</div>
|
||||
<select id="ai-channel-select" class="ai-channel-native-select" onchange="selectAIChannelForEditing(this.value)" aria-hidden="true" tabindex="-1"></select>
|
||||
<div id="ai-channel-list" class="ai-channel-list" aria-live="polite"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="ai-channel-editor">
|
||||
<div class="ai-channel-editor-head">
|
||||
<div>
|
||||
<span class="ai-channel-editor-kicker" data-i18n="settingsBasic.aiChannelEditing">正在编辑</span>
|
||||
<h5 id="ai-channel-editor-title">-</h5>
|
||||
<p id="ai-channel-editor-meta">-</p>
|
||||
</div>
|
||||
<div class="ai-channel-editor-actions">
|
||||
<button type="button" class="btn-secondary" onclick="setSelectedAIChannelDefault()" data-i18n="settingsBasic.aiChannelSetDefault">设为默认</button>
|
||||
<button type="button" class="btn-secondary" onclick="copyAIChannelFromForm()" data-i18n="settingsBasic.aiChannelCopy">复制</button>
|
||||
<button type="button" class="btn-secondary danger" onclick="deleteSelectedAIChannel()" data-i18n="settingsBasic.aiChannelDelete">删除</button>
|
||||
<span class="ai-channel-editor-kicker" data-i18n="settingsBasic.aiChannelFormContext">编辑当前选择的通道</span>
|
||||
<p id="ai-channel-editor-title" class="ai-channel-editor-title">-</p>
|
||||
</div>
|
||||
<div id="ai-channel-editor-meta" class="ai-channel-editor-meta" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="settings-form ai-channel-editor-form">
|
||||
<div class="form-group">
|
||||
<label for="ai-channel-name" data-i18n="settingsBasic.aiChannelName">通道名称</label>
|
||||
<input type="text" id="ai-channel-name" placeholder="Qwen Max" maxlength="24" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label>
|
||||
<select id="openai-provider" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; background: var(--card-bg, #fff); color: var(--text-color, #2d3748); font-size: 0.875rem;">
|
||||
<option value="openai_compatible" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-base-url">Base URL <span style="color: red;">*</span></label>
|
||||
<input type="text" id="openai-base-url" data-i18n="settingsBasic.openaiBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="https://api.openai.com/v1" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-api-key">API Key <span style="color: red;">*</span></label>
|
||||
<input type="password" id="openai-api-key" data-i18n="settingsBasic.openaiApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="输入OpenAI API Key" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
|
||||
<div class="model-pick-row">
|
||||
<input type="text" id="openai-model" class="model-pick-input" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
|
||||
<select id="openai-model-select" class="model-pick-native" style="display: none;" title="" aria-hidden="true" tabindex="-1">
|
||||
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
|
||||
</select>
|
||||
<a href="javascript:void(0)" id="fetch-openai-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('openai')" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
||||
</div>
|
||||
<small id="fetch-openai-models-hint" class="form-hint" style="display: none; font-size: 0.75rem; margin-top: 4px;"></small>
|
||||
<span id="fetch-openai-models-result" style="font-size: 0.75rem; margin-top: 2px; display: block;"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-max-total-tokens"><span data-i18n="settingsBasic.maxTotalTokens">最大上下文 Token 数</span></label>
|
||||
<input type="number" id="openai-max-total-tokens" data-i18n="settingsBasic.maxTotalTokensPlaceholder" data-i18n-attr="placeholder" placeholder="120000" min="1000" step="1000" />
|
||||
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-max-completion-tokens"><span data-i18n="settingsBasic.maxCompletionTokens">最大输出 Token 数</span></label>
|
||||
<input type="number" id="openai-max-completion-tokens" data-i18n="settingsBasic.maxCompletionTokensPlaceholder" data-i18n-attr="placeholder" placeholder="16384" min="1" step="256" />
|
||||
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxCompletionTokensHint">单次模型回复的输出上限,默认 16384</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settingsBasic.openaiReasoningTitle">推理设置</label>
|
||||
<small class="form-hint" data-i18n="settingsBasic.openaiReasoningHint">作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。</small>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; align-items: center;">
|
||||
<label for="openai-reasoning-mode" style="font-size: 0.8125rem;" data-i18n="chat.reasoningModeLabel">模式</label>
|
||||
<select id="openai-reasoning-mode" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
||||
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
||||
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
||||
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
||||
</select>
|
||||
<label for="openai-reasoning-effort" style="font-size: 0.8125rem;" data-i18n="chat.reasoningEffortLabel">强度</label>
|
||||
<select id="openai-reasoning-effort" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
||||
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
|
||||
<option value="low">low</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="high">high</option>
|
||||
<option value="xhigh">xhigh</option>
|
||||
<option value="max">max</option>
|
||||
</select>
|
||||
<label for="openai-reasoning-profile" style="font-size: 0.8125rem;" data-i18n="settingsBasic.openaiReasoningProfile">线路</label>
|
||||
<select id="openai-reasoning-profile" style="min-width: 220px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
|
||||
<option value="auto">auto</option>
|
||||
<option value="deepseek_compat">deepseek_compat</option>
|
||||
<option value="openai_compat">openai_compat</option>
|
||||
<option value="output_config_effort">output_config_effort</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="checkbox-label" style="margin-top: 8px;">
|
||||
<input type="checkbox" id="openai-reasoning-allow-client" class="modern-checkbox" checked />
|
||||
<span class="checkbox-custom"></span>
|
||||
<span class="checkbox-text" data-i18n="settingsBasic.openaiReasoningAllowClient">允许对话页覆盖推理选项</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
|
||||
<a href="javascript:void(0)" id="test-openai-btn" onclick="testOpenAIConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none;" data-i18n="settingsBasic.testConnection">测试连接</a>
|
||||
<span id="test-openai-result" style="font-size: 0.8125rem;"></span>
|
||||
</div>
|
||||
<section class="ai-channel-form-section">
|
||||
<div class="ai-channel-form-section-head">
|
||||
<div>
|
||||
<h6 data-i18n="settingsBasic.aiChannelConnectionSection">连接信息</h6>
|
||||
<p data-i18n="settingsBasic.aiChannelConnectionHint">先确认服务商、地址、密钥和模型,测试连接会使用这些信息。</p>
|
||||
</div>
|
||||
<div class="ai-channel-section-actions">
|
||||
<button type="button" id="test-openai-btn" class="btn-secondary" onclick="testOpenAIConnection()" data-i18n="settingsBasic.testConnection">测试连接</button>
|
||||
<span id="test-openai-result" class="form-inline-result connection-test-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-channel-form-grid">
|
||||
<div class="form-group">
|
||||
<label for="ai-channel-name" data-i18n="settingsBasic.aiChannelName">通道名称</label>
|
||||
<input type="text" id="ai-channel-name" placeholder="Qwen Max" maxlength="24" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-provider" data-i18n="settingsBasic.apiProvider">API 提供商</label>
|
||||
<select id="openai-provider">
|
||||
<option value="openai_compatible" data-i18n="settingsBasic.providerOpenAI">OpenAI / 兼容 OpenAI 协议</option>
|
||||
<option value="claude" data-i18n="settingsBasic.providerClaude">Claude (Anthropic Messages API)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group span-2">
|
||||
<label for="openai-base-url">Base URL <span class="required-mark">*</span></label>
|
||||
<input type="text" id="openai-base-url" data-i18n="settingsBasic.openaiBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="https://api.openai.com/v1" required />
|
||||
</div>
|
||||
<div class="form-group span-2">
|
||||
<label for="openai-api-key">API Key <span class="required-mark">*</span></label>
|
||||
<input type="password" id="openai-api-key" data-i18n="settingsBasic.openaiApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="输入OpenAI API Key" required />
|
||||
</div>
|
||||
<div class="form-group span-2">
|
||||
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span class="required-mark">*</span></label>
|
||||
<div class="model-pick-row">
|
||||
<input type="text" id="openai-model" class="model-pick-input" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
|
||||
<select id="openai-model-select" class="model-pick-native" style="display: none;" title="" aria-hidden="true" tabindex="-1">
|
||||
<option value="" disabled data-i18n="settingsBasic.modelsListSelectPlaceholder">请选择模型</option>
|
||||
</select>
|
||||
<a href="javascript:void(0)" id="fetch-openai-models-btn" class="model-pick-fetch-link" onclick="fetchModelList('openai')" data-i18n="settingsBasic.fetchModels">获取列表</a>
|
||||
</div>
|
||||
<small id="fetch-openai-models-hint" class="form-hint" style="display: none;"></small>
|
||||
<span id="fetch-openai-models-result" class="form-inline-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ai-channel-form-section">
|
||||
<div class="ai-channel-form-section-head">
|
||||
<div>
|
||||
<h6 data-i18n="settingsBasic.aiChannelLimitsSection">额度设置</h6>
|
||||
<p data-i18n="settingsBasic.aiChannelLimitsHint">Token 上限控制上下文窗口和单次输出。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-channel-form-grid">
|
||||
<div class="form-group">
|
||||
<label for="openai-max-total-tokens"><span data-i18n="settingsBasic.maxTotalTokens">最大上下文 Token 数</span></label>
|
||||
<input type="number" id="openai-max-total-tokens" data-i18n="settingsBasic.maxTotalTokensPlaceholder" data-i18n-attr="placeholder" placeholder="120000" min="1000" step="1000" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-max-completion-tokens"><span data-i18n="settingsBasic.maxCompletionTokens">最大输出 Token 数</span></label>
|
||||
<input type="number" id="openai-max-completion-tokens" data-i18n="settingsBasic.maxCompletionTokensPlaceholder" data-i18n-attr="placeholder" placeholder="16384" min="1" step="256" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.maxCompletionTokensHint">单次模型回复的输出上限,默认 16384</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="ai-channel-form-section ai-channel-advanced-section">
|
||||
<summary>
|
||||
<span>
|
||||
<strong data-i18n="settingsBasic.openaiReasoningTitle">推理设置</strong>
|
||||
<small data-i18n="settingsBasic.openaiReasoningHint">作为该 AI 通道的默认推理设置;对话页「会话设置」可覆盖。</small>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="ai-channel-reasoning-grid">
|
||||
<div class="form-group">
|
||||
<label for="openai-reasoning-mode" data-i18n="chat.reasoningModeLabel">模式</label>
|
||||
<select id="openai-reasoning-mode">
|
||||
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
|
||||
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
|
||||
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-reasoning-effort" data-i18n="chat.reasoningEffortLabel">强度</label>
|
||||
<select id="openai-reasoning-effort">
|
||||
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
|
||||
<option value="low">low</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="high">high</option>
|
||||
<option value="xhigh">xhigh</option>
|
||||
<option value="max">max</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="openai-reasoning-profile" data-i18n="settingsBasic.openaiReasoningProfile">线路</label>
|
||||
<select id="openai-reasoning-profile">
|
||||
<option value="auto">auto</option>
|
||||
<option value="deepseek_compat">deepseek_compat</option>
|
||||
<option value="openai_compat">openai_compat</option>
|
||||
<option value="output_config_effort">output_config_effort</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label class="checkbox-label ai-channel-reasoning-toggle">
|
||||
<input type="checkbox" id="openai-reasoning-allow-client" class="modern-checkbox" checked />
|
||||
<span class="checkbox-custom"></span>
|
||||
<span class="checkbox-text" data-i18n="settingsBasic.openaiReasoningAllowClient">允许对话页覆盖推理选项</span>
|
||||
</label>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6644,7 +6676,7 @@
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="/static/js/chat-scroll.js"></script>
|
||||
<script src="/static/js/monitor.js?v=20260723-1"></script>
|
||||
<script src="/static/js/chat.js?v=20260723-1"></script>
|
||||
<script src="/static/js/chat.js?v=20260724-1"></script>
|
||||
<script src="/static/js/hitl.js"></script>
|
||||
<script src="/static/js/settings.js?v=20260717-1"></script>
|
||||
<script src="/static/js/audit-datetime-picker.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user