mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-01 18:25:47 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f58d0a457 | |||
| 5b7f157802 | |||
| 09890db635 | |||
| c0171ef60a | |||
| 4eb73fb638 | |||
| d1b49cb20d | |||
| 930eb47013 | |||
| 9964e13197 | |||
| 4f7b21cb7e | |||
| 9fae9db906 | |||
| 7ecd8c61e8 | |||
| bdb0326e47 | |||
| 8dccc6aa06 |
@@ -175,7 +175,6 @@ multi_agent:
|
|||||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
|
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
|
||||||
plan_execute_loop_max_iterations: 0
|
plan_execute_loop_max_iterations: 0
|
||||||
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中注入用户原文;0=不截断(默认),>0=总字符上限,负数=禁用
|
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中注入用户原文;0=不截断(默认),>0=总字符上限,负数=禁用
|
||||||
user_verbatim_anchor_max_runes: 0 # 主代理 system 中逐轮保留用户原文(压缩后刷新);0=不截断(默认),>0=总字符上限,负数=禁用
|
|
||||||
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
||||||
without_write_todos: false
|
without_write_todos: false
|
||||||
orchestrator_instruction: "" # Deep 主代理:agents/orchestrator.md(或 kind: orchestrator 的单个 .md)正文优先;正文为空时用此处;皆空则 Eino 默认
|
orchestrator_instruction: "" # Deep 主代理:agents/orchestrator.md(或 kind: orchestrator 的单个 .md)正文优先;正文为空时用此处;皆空则 Eino 默认
|
||||||
|
|||||||
@@ -99,9 +99,6 @@ type MultiAgentConfig struct {
|
|||||||
// SubAgentUserContextMaxRunes caps user-context supplement for sub-agent task descriptions.
|
// SubAgentUserContextMaxRunes caps user-context supplement for sub-agent task descriptions.
|
||||||
// 0 (default) preserves all user turns verbatim; >0 caps total runes; negative disables injection.
|
// 0 (default) preserves all user turns verbatim; >0 caps total runes; negative disables injection.
|
||||||
SubAgentUserContextMaxRunes int `yaml:"sub_agent_user_context_max_runes,omitempty" json:"sub_agent_user_context_max_runes,omitempty"`
|
SubAgentUserContextMaxRunes int `yaml:"sub_agent_user_context_max_runes,omitempty" json:"sub_agent_user_context_max_runes,omitempty"`
|
||||||
// UserVerbatimAnchorMaxRunes injects all user turns verbatim into system prompt (survives summarization refresh).
|
|
||||||
// 0 (default) = no cap; >0 = total rune cap; negative disables anchor injection.
|
|
||||||
UserVerbatimAnchorMaxRunes int `yaml:"user_verbatim_anchor_max_runes,omitempty" json:"user_verbatim_anchor_max_runes,omitempty"`
|
|
||||||
// EinoSkills configures CloudWeGo Eino ADK skill middleware + optional local filesystem/execute on DeepAgent.
|
// EinoSkills configures CloudWeGo Eino ADK skill middleware + optional local filesystem/execute on DeepAgent.
|
||||||
EinoSkills MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"`
|
EinoSkills MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"`
|
||||||
// EinoMiddleware wires optional ADK middleware (patchtoolcalls, toolsearch, plantask, reduction) and Deep extras.
|
// EinoMiddleware wires optional ADK middleware (patchtoolcalls, toolsearch, plantask, reduction) and Deep extras.
|
||||||
@@ -110,11 +107,6 @@ type MultiAgentConfig struct {
|
|||||||
EinoCallbacks MultiAgentEinoCallbacksConfig `yaml:"eino_callbacks,omitempty" json:"eino_callbacks,omitempty"`
|
EinoCallbacks MultiAgentEinoCallbacksConfig `yaml:"eino_callbacks,omitempty" json:"eino_callbacks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserVerbatimAnchorMaxRunesEffective returns max runes for user verbatim anchor; 0 = unlimited; negative = disabled.
|
|
||||||
func (c MultiAgentConfig) UserVerbatimAnchorMaxRunesEffective() int {
|
|
||||||
return c.UserVerbatimAnchorMaxRunes
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubAgentUserContextMaxRunesEffective returns max runes for sub-agent task supplement; 0 = unlimited; negative = disabled.
|
// SubAgentUserContextMaxRunesEffective returns max runes for sub-agent task supplement; 0 = unlimited; negative = disabled.
|
||||||
func (c MultiAgentConfig) SubAgentUserContextMaxRunesEffective() int {
|
func (c MultiAgentConfig) SubAgentUserContextMaxRunesEffective() int {
|
||||||
return c.SubAgentUserContextMaxRunes
|
return c.SubAgentUserContextMaxRunes
|
||||||
|
|||||||
@@ -111,19 +111,43 @@ func (db *DB) GetProject(id string) (*Project, error) {
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountProjects 统计项目数量。
|
func projectListSearchPattern(q string) string {
|
||||||
func (db *DB) CountProjects(status, search string) (int, error) {
|
q = strings.TrimSpace(q)
|
||||||
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
|
if q == "" {
|
||||||
args := []interface{}{}
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteByte('%')
|
||||||
|
for _, r := range q {
|
||||||
|
switch r {
|
||||||
|
case '%', '_', '\\':
|
||||||
|
b.WriteByte('\\')
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteByte('%')
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendProjectListFilters(query string, args []interface{}, status, search string) (string, []interface{}) {
|
||||||
if s := strings.TrimSpace(status); s != "" {
|
if s := strings.TrimSpace(status); s != "" {
|
||||||
query += " AND status = ?"
|
query += " AND status = ?"
|
||||||
args = append(args, s)
|
args = append(args, s)
|
||||||
}
|
}
|
||||||
if q := strings.TrimSpace(search); q != "" {
|
if pattern := projectListSearchPattern(search); pattern != "" {
|
||||||
pattern := "%" + q + "%"
|
query += ` AND (LOWER(name) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(description,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(id) LIKE LOWER(?) ESCAPE '\')`
|
||||||
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
|
args = append(args, pattern, pattern, pattern)
|
||||||
args = append(args, pattern, pattern)
|
|
||||||
}
|
}
|
||||||
|
return query, args
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountProjects 统计项目数量。
|
||||||
|
func (db *DB) CountProjects(status, search string) (int, error) {
|
||||||
|
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
|
||||||
|
args := []interface{}{}
|
||||||
|
query, args = appendProjectListFilters(query, args, status, search)
|
||||||
var count int
|
var count int
|
||||||
if err := db.QueryRow(query, args...).Scan(&count); err != nil {
|
if err := db.QueryRow(query, args...).Scan(&count); err != nil {
|
||||||
return 0, fmt.Errorf("统计项目失败: %w", err)
|
return 0, fmt.Errorf("统计项目失败: %w", err)
|
||||||
@@ -139,15 +163,7 @@ func (db *DB) ListProjects(status, search string, limit, offset int) ([]*Project
|
|||||||
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
||||||
FROM projects WHERE 1=1`
|
FROM projects WHERE 1=1`
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
if s := strings.TrimSpace(status); s != "" {
|
query, args = appendProjectListFilters(query, args, status, search)
|
||||||
query += " AND status = ?"
|
|
||||||
args = append(args, s)
|
|
||||||
}
|
|
||||||
if q := strings.TrimSpace(search); q != "" {
|
|
||||||
pattern := "%" + q + "%"
|
|
||||||
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
|
|
||||||
args = append(args, pattern, pattern)
|
|
||||||
}
|
|
||||||
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListProjectsSearchCaseInsensitive(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "projects-search.db")
|
||||||
|
db, err := NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
p1, err := db.CreateProject(&Project{Name: "Alpha Security Review", Status: "active"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p2, err := db.CreateProject(&Project{Name: "beta-scan", Status: "active"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.CreateProject(&Project{Name: "Other", Status: "archived"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
search string
|
||||||
|
status string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "case insensitive name", search: "alpha", status: "active", want: []string{p1.ID}},
|
||||||
|
{name: "upper query", search: "BETA", status: "active", want: []string{p2.ID}},
|
||||||
|
{name: "search by id substring", search: p1.ID[:8], status: "", want: []string{p1.ID}},
|
||||||
|
{name: "status filter", search: "alpha", status: "archived", want: nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
list, err := db.ListProjects(tc.status, tc.search, 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := make([]string, 0, len(list))
|
||||||
|
for _, p := range list {
|
||||||
|
got = append(got, p.ID)
|
||||||
|
}
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("got %v want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Fatalf("got %v want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectListSearchPatternEscapesWildcards(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "projects-like.db")
|
||||||
|
db, err := NewDB(dbPath, zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
p, err := db.CreateProject(&Project{Name: "100% coverage", Status: "active"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := db.ListProjects("active", "100%", 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].ID != p.ID {
|
||||||
|
t.Fatalf("expected exact match for literal %% query, got %#v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// agentSessionContextBlock 注入会话工作目录、项目黑板与用户原文锚点(用于 system prompt 追加块)。
|
// agentSessionContextBlock 注入会话工作目录与项目黑板(用于 system prompt 追加块)。
|
||||||
|
// 用户输入由 message history 承载;压缩后由 summarization 摘要指令保留关键约束。
|
||||||
func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
if ws := h.buildWorkspaceBlock(conversationID); ws != "" {
|
if ws := h.buildWorkspaceBlock(conversationID); ws != "" {
|
||||||
@@ -16,9 +17,6 @@ func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
|||||||
if bb := h.projectBlackboardBlock(conversationID); bb != "" {
|
if bb := h.projectBlackboardBlock(conversationID); bb != "" {
|
||||||
parts = append(parts, bb)
|
parts = append(parts, bb)
|
||||||
}
|
}
|
||||||
if uv := h.userVerbatimAnchorBlock(conversationID); uv != "" {
|
|
||||||
parts = append(parts, uv)
|
|
||||||
}
|
|
||||||
return strings.Join(parts, "\n\n")
|
return strings.Join(parts, "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,29 +68,6 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
|
|||||||
return strings.TrimSpace(block)
|
return strings.TrimSpace(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
// userVerbatimAnchorBlock 从 messages 表构建用户各轮原文锚点(压缩后仍由 summarization Finalize 刷新)。
|
|
||||||
func (h *AgentHandler) userVerbatimAnchorBlock(conversationID string) string {
|
|
||||||
if h == nil || h.db == nil || h.config == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
conversationID = strings.TrimSpace(conversationID)
|
|
||||||
if conversationID == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
maxRunes := h.config.MultiAgent.UserVerbatimAnchorMaxRunesEffective()
|
|
||||||
if maxRunes < 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
msgs, err := h.db.GetMessages(conversationID)
|
|
||||||
if err != nil {
|
|
||||||
if h.logger != nil {
|
|
||||||
h.logger.Warn("构建用户原文锚点失败", zap.String("conversationId", conversationID), zap.Error(err))
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return project.BuildUserVerbatimAnchorBlockFromMessages(msgs, maxRunes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// conversationProjectID 返回对话绑定的项目 ID;未绑定或查询失败时返回空字符串。
|
// conversationProjectID 返回对话绑定的项目 ID;未绑定或查询失败时返回空字符串。
|
||||||
func (h *AgentHandler) conversationProjectID(conversationID string) string {
|
func (h *AgentHandler) conversationProjectID(conversationID string) string {
|
||||||
if h == nil || h.db == nil {
|
if h == nil || h.db == nil {
|
||||||
|
|||||||
@@ -22,15 +22,60 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// einoSummarizeUserInstruction:压缩历史时保留渗透测试关键信息。
|
// einoSummarizeUserInstruction:压缩历史时保留渗透测试与用户约束关键信息。
|
||||||
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史。
|
// 结构对齐 Eino 最佳实践(禁止工具、<analysis>+<summary>、<all_user_messages>),章节为安全测试领域化。
|
||||||
|
const einoSummarizeUserInstruction = `关键:仅以纯文本响应。禁止调用任何工具(read_file、exec、grep、glob、write、edit 等)。
|
||||||
|
上述对话中已包含全部待压缩上下文;不要要求用户粘贴历史,不要输出「请提供待压缩的对话历史」等占位/meta 回复。
|
||||||
|
工具调用将被拒绝并浪费唯一一次摘要机会。
|
||||||
|
|
||||||
必须保留:已确认漏洞与攻击路径、工具输出中的核心发现、凭证与认证细节、架构与薄弱点、当前进度、失败尝试与死路、策略决策。
|
你的任务:在保持所有关键安全测试信息完整的前提下压缩对话历史,使后续代理能无缝继续同一授权测试任务。
|
||||||
保留精确技术细节(URL、路径、参数、Payload、版本号、报错原文可摘要但要点不丢)。
|
|
||||||
将冗长扫描输出概括为结论;重复发现合并表述。
|
|
||||||
已枚举资产须保留**可继承的摘要**:主域、关键子域/主机短表(或数量+代表样例)、高价值目标与已识别服务/端口要点,避免后续子代理因「看不见清单」而重复全量枚举。
|
|
||||||
|
|
||||||
输出须使后续代理能无缝继续同一授权测试任务。`
|
压缩原则:
|
||||||
|
- 必须保留:已确认漏洞与攻击路径、工具输出核心发现、凭证与认证细节、架构与薄弱点、当前进度、失败尝试与死路、策略决策
|
||||||
|
- 保留精确技术细节(URL、路径、参数、Payload、版本号;报错原文可摘要但要点不丢)
|
||||||
|
- 冗长扫描输出概括为结论;重复发现合并表述
|
||||||
|
- 已枚举资产须保留可继承摘要:主域、关键子域/主机短表(或数量+代表样例)、高价值目标、已识别服务/端口要点
|
||||||
|
|
||||||
|
输出格式(严格遵循,仅一轮回复):
|
||||||
|
1. 先输出 <analysis> 块:按时间顺序梳理对话,检查是否涵盖下方各章节要点;analysis 仅供自检,保持简洁(建议 ≤400 字)
|
||||||
|
2. 再输出 <summary> 块:按以下章节写入可继承的压缩报告(无信息处写「无」,禁止留空模板占位符)
|
||||||
|
|
||||||
|
<summary>
|
||||||
|
## 1. 授权范围与约束
|
||||||
|
- 目标/范围/禁止项(域名、路径、IP、环境)
|
||||||
|
- 凭证/认证信息(账号、Token、Cookie;敏感值原文保留)
|
||||||
|
- 用户指定的方法、工具、优先级与待办
|
||||||
|
- 否定约束(不测什么、不用什么手法)
|
||||||
|
|
||||||
|
## 2. 资产与服务枚举摘要
|
||||||
|
- 主域/核心资产、关键子域或主机短表(或数量+代表样例)
|
||||||
|
- 高价值目标、已识别服务/端口要点
|
||||||
|
- 资产状态(存活/可攻/已排除/待验证)
|
||||||
|
|
||||||
|
## 3. 架构与已知薄弱点
|
||||||
|
- 技术栈/部署拓扑/信任边界
|
||||||
|
- 已识别薄弱点列表
|
||||||
|
|
||||||
|
## 4. 已确认漏洞与攻击路径
|
||||||
|
- 漏洞名/CVE、URL/路径、参数/Payload、PoC 要点、影响等级
|
||||||
|
- 攻击链/利用路径(步骤化)
|
||||||
|
|
||||||
|
## 5. 工具核心发现与扫描结论
|
||||||
|
- 各工具结论(概括核心输出,非冗长日志)
|
||||||
|
- 重复发现合并表述
|
||||||
|
|
||||||
|
## 6. 所有用户消息
|
||||||
|
<all_user_messages>
|
||||||
|
- [逐条列出非 tool 结果的用户消息要点;敏感约束与原文措辞尽量保留]
|
||||||
|
</all_user_messages>
|
||||||
|
|
||||||
|
## 7. 当前进度、策略决策与下一步
|
||||||
|
- 当前位置(已完成/进行中/卡点)
|
||||||
|
- 失败尝试与死路(方法、现象/报错摘要、结论)
|
||||||
|
- 策略决策与下一步具体操作(须与最近用户请求及未完成任务一致)
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
提醒:不要调用任何工具;必须基于上文已有对话直接输出 <analysis> 与 <summary>,勿输出 analysis 以外的正文。`
|
||||||
|
|
||||||
// newEinoSummarizationMiddleware 使用 Eino ADK Summarization 中间件(见 https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/eino_adk_chatmodelagentmiddleware/middleware_summarization/)。
|
// newEinoSummarizationMiddleware 使用 Eino ADK Summarization 中间件(见 https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/eino_adk_chatmodelagentmiddleware/middleware_summarization/)。
|
||||||
// 触发阈值:估算 token 超过 openai.max_total_tokens * summarization_trigger_ratio(默认 0.8)时摘要。
|
// 触发阈值:估算 token 超过 openai.max_total_tokens * summarization_trigger_ratio(默认 0.8)时摘要。
|
||||||
@@ -144,13 +189,13 @@ func newEinoSummarizationMiddleware(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
|
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
|
||||||
|
summary = stripAnalysisFromSummarizationMessage(summary)
|
||||||
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
||||||
if ferr != nil {
|
if ferr != nil {
|
||||||
return nil, ferr
|
return nil, ferr
|
||||||
}
|
}
|
||||||
if appCfg != nil {
|
if appCfg != nil {
|
||||||
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
|
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
|
||||||
out = refreshUserVerbatimAnchorInMessages(out, db, conversationID, appCfg.MultiAgent.UserVerbatimAnchorMaxRunesEffective(), logger)
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
},
|
},
|
||||||
@@ -414,36 +459,6 @@ func writeSummarizationTranscript(path string, msgs []adk.Message) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshUserVerbatimAnchorInMessages 压缩后从 messages 表刷新 system 中的用户原文锚点。
|
|
||||||
func refreshUserVerbatimAnchorInMessages(msgs []adk.Message, db *database.DB, conversationID string, maxRunes int, logger *zap.Logger) []adk.Message {
|
|
||||||
if maxRunes < 0 || db == nil {
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
conversationID = strings.TrimSpace(conversationID)
|
|
||||||
if conversationID == "" {
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
rows, err := db.GetMessages(conversationID)
|
|
||||||
if err != nil {
|
|
||||||
if logger != nil {
|
|
||||||
logger.Warn("summarization: 刷新用户原文锚点失败",
|
|
||||||
zap.String("conversationId", conversationID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
block := project.BuildUserVerbatimAnchorBlockFromMessages(rows, maxRunes)
|
|
||||||
if block == "" {
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
out := project.RefreshUserVerbatimAnchorInMessages(msgs, block)
|
|
||||||
if logger != nil {
|
|
||||||
logger.Info("summarization: 已刷新用户原文锚点", zap.String("conversationId", conversationID))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
|
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
|
||||||
tc := agent.NewTikTokenCounter()
|
tc := agent.NewTikTokenCounter()
|
||||||
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
|
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/adk"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
summarizationAnalysisBlockRegex = regexp.MustCompile(`(?is)<analysis>\s*.*?\s*</analysis>`)
|
||||||
|
summarizationSummaryBlockRegex = regexp.MustCompile(`(?is)<summary>\s*(.*?)\s*</summary>`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// stripAnalysisFromSummarizationMessage removes the <analysis> block from a post-processed
|
||||||
|
// Eino summary user message. Analysis helps one-shot generation quality but should not
|
||||||
|
// occupy continuation context after compaction.
|
||||||
|
func stripAnalysisFromSummarizationMessage(msg adk.Message) adk.Message {
|
||||||
|
if msg == nil {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
cloned := *msg
|
||||||
|
if cloned.Content != "" {
|
||||||
|
cloned.Content = stripAnalysisFromSummarizationText(cloned.Content)
|
||||||
|
}
|
||||||
|
if len(cloned.UserInputMultiContent) > 0 {
|
||||||
|
parts := make([]schema.MessageInputPart, len(cloned.UserInputMultiContent))
|
||||||
|
copy(parts, cloned.UserInputMultiContent)
|
||||||
|
// Only the first text part carries model output plus Eino preamble/transcript path.
|
||||||
|
for i := range parts {
|
||||||
|
if parts[i].Type != schema.ChatMessagePartTypeText || parts[i].Text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
parts[i].Text = stripAnalysisFromSummarizationText(parts[i].Text)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cloned.UserInputMultiContent = parts
|
||||||
|
}
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripAnalysisFromSummarizationText(text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
stripped := strings.TrimSpace(summarizationAnalysisBlockRegex.ReplaceAllString(text, ""))
|
||||||
|
if stripped == "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSummarizationSummaryBody returns the inner text of the last <summary> block when present.
|
||||||
|
// Used by tests and optional strict compaction paths.
|
||||||
|
func extractSummarizationSummaryBody(text string) (string, bool) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
all := summarizationSummaryBlockRegex.FindAllStringSubmatch(text, -1)
|
||||||
|
if len(all) == 0 || len(all[len(all)-1]) < 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(all[len(all)-1][1])
|
||||||
|
if body == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return body, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStripAnalysisFromSummarizationText(t *testing.T) {
|
||||||
|
in := "<analysis>internal notes</analysis>\n\n<summary>\n## 1. 授权\n- example.com\n</summary>"
|
||||||
|
got := stripAnalysisFromSummarizationText(in)
|
||||||
|
if strings.Contains(got, "<analysis>") {
|
||||||
|
t.Fatalf("analysis block should be removed: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "## 1. 授权") {
|
||||||
|
t.Fatalf("summary body should remain: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripAnalysisFromSummarizationMessage_UserInputMultiContent(t *testing.T) {
|
||||||
|
msg := &schema.Message{
|
||||||
|
Role: schema.User,
|
||||||
|
UserInputMultiContent: []schema.MessageInputPart{
|
||||||
|
{
|
||||||
|
Type: schema.ChatMessagePartTypeText,
|
||||||
|
Text: "此会话延续自此前一段因上下文耗尽而终止的对话。\n\n<analysis>draft</analysis>\n<summary>body</summary>\n\n完整记录位于:/tmp/transcript.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: schema.ChatMessagePartTypeText,
|
||||||
|
Text: "请从我们中断的地方继续对话,无需向用户提出任何进一步的问题。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := stripAnalysisFromSummarizationMessage(msg)
|
||||||
|
if len(out.UserInputMultiContent) != 2 {
|
||||||
|
t.Fatalf("expected 2 parts, got %d", len(out.UserInputMultiContent))
|
||||||
|
}
|
||||||
|
if strings.Contains(out.UserInputMultiContent[0].Text, "<analysis>") {
|
||||||
|
t.Fatalf("part 0 should drop analysis: %q", out.UserInputMultiContent[0].Text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.UserInputMultiContent[0].Text, "<summary>body</summary>") {
|
||||||
|
t.Fatalf("part 0 should keep summary: %q", out.UserInputMultiContent[0].Text)
|
||||||
|
}
|
||||||
|
if out.UserInputMultiContent[1].Text != "请从我们中断的地方继续对话,无需向用户提出任何进一步的问题。" {
|
||||||
|
t.Fatalf("continue instruction part should be unchanged: %q", out.UserInputMultiContent[1].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractSummarizationSummaryBody(t *testing.T) {
|
||||||
|
body, ok := extractSummarizationSummaryBody("<analysis>x</analysis><summary> kept </summary>")
|
||||||
|
if !ok || body != "kept" {
|
||||||
|
t.Fatalf("extract summary body: ok=%v body=%q", ok, body)
|
||||||
|
}
|
||||||
|
_, ok = extractSummarizationSummaryBody("plain text only")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected false for plain text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripAnalysisFromSummarizationText_NoAnalysisUnchanged(t *testing.T) {
|
||||||
|
in := "<summary>only summary</summary>"
|
||||||
|
got := stripAnalysisFromSummarizationText(in)
|
||||||
|
if got != in {
|
||||||
|
t.Fatalf("expected unchanged text, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
package project
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
|
||||||
"github.com/cloudwego/eino/schema"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// UserVerbatimSectionHeading 用户原文锚点可读标题(块内保留,供 Agent 阅读)。
|
|
||||||
UserVerbatimSectionHeading = "## 用户历史输入(原文保留,勿省略或改写)"
|
|
||||||
|
|
||||||
// UserVerbatimSectionStartMarker / EndMarker:HTML 注释边界,供程序化替换;对模型无指令语义。
|
|
||||||
UserVerbatimSectionStartMarker = "<!-- user-verbatim-start -->"
|
|
||||||
UserVerbatimSectionEndMarker = "<!-- user-verbatim-end -->"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExtractUserContentsFromMessages 按时间顺序提取 user 角色消息的原文(跳过空白)。
|
|
||||||
func ExtractUserContentsFromMessages(msgs []database.Message) []string {
|
|
||||||
out := make([]string, 0, len(msgs))
|
|
||||||
for i := range msgs {
|
|
||||||
if !strings.EqualFold(strings.TrimSpace(msgs[i].Role), "user") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
content := strings.TrimSpace(msgs[i].Content)
|
|
||||||
if content == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, content)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildUserVerbatimAnchorBlockFromMessages 从 messages 表行构建用户原文锚点块。
|
|
||||||
// maxRunes: 0 = 不截断;>0 = 总 rune 上限(仍保留每一轮,仅对超长单条做尾部截断提示)。
|
|
||||||
func BuildUserVerbatimAnchorBlockFromMessages(msgs []database.Message, maxRunes int) string {
|
|
||||||
return BuildUserVerbatimAnchorBlock(ExtractUserContentsFromMessages(msgs), maxRunes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildUserVerbatimAnchorBlock 将各轮用户原文格式化为 system prompt 锚点块。
|
|
||||||
func BuildUserVerbatimAnchorBlock(userContents []string, maxRunes int) string {
|
|
||||||
if len(userContents) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
lines := make([]string, 0, len(userContents))
|
|
||||||
for _, content := range userContents {
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
if content == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, fmt.Sprintf("[第%d轮] %s", len(lines)+1, content))
|
|
||||||
}
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
body := strings.Join(lines, "\n")
|
|
||||||
if maxRunes > 0 {
|
|
||||||
body = capUserVerbatimBody(body, maxRunes)
|
|
||||||
}
|
|
||||||
return wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n" + body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func capUserVerbatimBody(body string, maxRunes int) string {
|
|
||||||
rs := []rune(body)
|
|
||||||
if len(rs) <= maxRunes {
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
suffix := "\n\n...(用户原文锚点已达配置上限,更早轮次可能被截断;完整原文见 messages 表)..."
|
|
||||||
suffixRunes := []rune(suffix)
|
|
||||||
keep := maxRunes - len(suffixRunes)
|
|
||||||
if keep <= 0 {
|
|
||||||
return string(rs[:maxRunes])
|
|
||||||
}
|
|
||||||
return string(rs[:keep]) + suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
func wrapUserVerbatimBlock(content string) string {
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
if content == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return UserVerbatimSectionStartMarker + "\n" + content + "\n" + UserVerbatimSectionEndMarker + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplaceUserVerbatimAnchorSection 用 freshBlock 替换 content 中已有的用户原文锚点段。
|
|
||||||
func ReplaceUserVerbatimAnchorSection(content, freshBlock string) (string, bool) {
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
freshBlock = strings.TrimSpace(freshBlock)
|
|
||||||
if freshBlock == "" {
|
|
||||||
return content, false
|
|
||||||
}
|
|
||||||
start, ok := userVerbatimSectionStart(content)
|
|
||||||
if !ok {
|
|
||||||
return content, false
|
|
||||||
}
|
|
||||||
end, ok := userVerbatimSectionEnd(content, start)
|
|
||||||
if !ok {
|
|
||||||
return content, false
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(content[:start] + freshBlock + content[end:]), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func userVerbatimSectionStart(content string) (int, bool) {
|
|
||||||
idx := strings.Index(content, UserVerbatimSectionStartMarker)
|
|
||||||
if idx < 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return idx, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func userVerbatimSectionEnd(content string, start int) (int, bool) {
|
|
||||||
if start < 0 || start >= len(content) {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
tail := content[start:]
|
|
||||||
idx := strings.LastIndex(tail, UserVerbatimSectionEndMarker)
|
|
||||||
if idx < 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return start + idx + len(UserVerbatimSectionEndMarker), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshUserVerbatimAnchorInMessages 在 summarization 等压缩后,用 freshBlock 刷新 system 中的用户原文锚点。
|
|
||||||
// 若尚无锚点段,则追加到首条 system 消息;若无 system 消息则在开头插入一条。
|
|
||||||
func RefreshUserVerbatimAnchorInMessages(msgs []adk.Message, freshBlock string) []adk.Message {
|
|
||||||
freshBlock = strings.TrimSpace(freshBlock)
|
|
||||||
if freshBlock == "" || len(msgs) == 0 {
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]adk.Message, len(msgs))
|
|
||||||
changed := false
|
|
||||||
for i, msg := range msgs {
|
|
||||||
if msg == nil || msg.Role != schema.System {
|
|
||||||
out[i] = msg
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
newContent, ok := ReplaceUserVerbatimAnchorSection(msg.Content, freshBlock)
|
|
||||||
if !ok {
|
|
||||||
out[i] = msg
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cloned := *msg
|
|
||||||
cloned.Content = newContent
|
|
||||||
out[i] = &cloned
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, msg := range msgs {
|
|
||||||
if msg == nil || msg.Role != schema.System {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cloned := *msg
|
|
||||||
cloned.Content = AppendSystemPromptBlock(cloned.Content, freshBlock)
|
|
||||||
out[i] = &cloned
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := make([]adk.Message, 0, len(msgs)+1)
|
|
||||||
prefix = append(prefix, schema.SystemMessage(freshBlock))
|
|
||||||
return append(prefix, msgs...)
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package project
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
|
||||||
|
|
||||||
"github.com/cloudwego/eino/adk"
|
|
||||||
"github.com/cloudwego/eino/schema"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBuildUserVerbatimAnchorBlock_MultiTurn(t *testing.T) {
|
|
||||||
msgs := []database.Message{
|
|
||||||
{Role: "user", Content: "目标 https://a.com 仅测 /api"},
|
|
||||||
{Role: "assistant", Content: "好的"},
|
|
||||||
{Role: "user", Content: "用 admin:test 登录"},
|
|
||||||
}
|
|
||||||
block := BuildUserVerbatimAnchorBlockFromMessages(msgs, 0)
|
|
||||||
if block == "" {
|
|
||||||
t.Fatal("expected non-empty block")
|
|
||||||
}
|
|
||||||
if !strings.Contains(block, UserVerbatimSectionStartMarker) {
|
|
||||||
t.Error("missing start marker")
|
|
||||||
}
|
|
||||||
if !strings.Contains(block, "[第1轮]") || !strings.Contains(block, "https://a.com") {
|
|
||||||
t.Error("missing first user turn")
|
|
||||||
}
|
|
||||||
if !strings.Contains(block, "[第2轮]") || !strings.Contains(block, "admin:test") {
|
|
||||||
t.Error("missing second user turn")
|
|
||||||
}
|
|
||||||
if strings.Contains(block, "好的") {
|
|
||||||
t.Error("assistant content should not appear")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReplaceUserVerbatimAnchorSection(t *testing.T) {
|
|
||||||
old := "prefix\n\n" + wrapUserVerbatimBlock("## old\n\n[第1轮] a") + "\nsuffix"
|
|
||||||
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] b\n[第2轮] c")
|
|
||||||
out, ok := ReplaceUserVerbatimAnchorSection(old, newBlock)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("expected replace ok")
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "[第2轮] c") {
|
|
||||||
t.Errorf("expected new block, got %q", out)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(strings.TrimSpace(out), "prefix") {
|
|
||||||
t.Error("prefix should remain")
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "suffix") {
|
|
||||||
t.Error("suffix should remain")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshUserVerbatimAnchorInMessages_ReplaceExisting(t *testing.T) {
|
|
||||||
oldBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] old")
|
|
||||||
msgs := []adk.Message{
|
|
||||||
schema.SystemMessage("instr\n\n" + oldBlock),
|
|
||||||
schema.UserMessage("hi"),
|
|
||||||
}
|
|
||||||
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] new")
|
|
||||||
out := RefreshUserVerbatimAnchorInMessages(msgs, newBlock)
|
|
||||||
if len(out) != 2 {
|
|
||||||
t.Fatalf("message count: got %d", len(out))
|
|
||||||
}
|
|
||||||
if !strings.Contains(out[0].Content, "[第1轮] new") {
|
|
||||||
t.Errorf("system content: %q", out[0].Content)
|
|
||||||
}
|
|
||||||
if strings.Contains(out[0].Content, "[第1轮] old") {
|
|
||||||
t.Error("old anchor should be replaced")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshUserVerbatimAnchorInMessages_InsertWhenMissing(t *testing.T) {
|
|
||||||
msgs := []adk.Message{
|
|
||||||
schema.SystemMessage("base instruction"),
|
|
||||||
schema.UserMessage("hi"),
|
|
||||||
}
|
|
||||||
block := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] anchor")
|
|
||||||
out := RefreshUserVerbatimAnchorInMessages(msgs, block)
|
|
||||||
if !strings.Contains(out[0].Content, "[第1轮] anchor") {
|
|
||||||
t.Errorf("expected appended anchor, got %q", out[0].Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildUserVerbatimAnchorBlock_MaxRunes(t *testing.T) {
|
|
||||||
long := strings.Repeat("字", 200)
|
|
||||||
block := BuildUserVerbatimAnchorBlock([]string{long}, 50)
|
|
||||||
body := block
|
|
||||||
if idx := strings.Index(body, UserVerbatimSectionStartMarker); idx >= 0 {
|
|
||||||
body = strings.TrimPrefix(body[idx+len(UserVerbatimSectionStartMarker):], "\n")
|
|
||||||
}
|
|
||||||
if len([]rune(body)) > 120 {
|
|
||||||
t.Errorf("expected capped body, got %d runes", len([]rune(body)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+121
-11
@@ -2257,18 +2257,80 @@ header {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
max-height: 280px;
|
max-height: 320px;
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
padding: 0;
|
||||||
padding: 4px;
|
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-project-filter-ui.open .conversation-project-filter-dropdown {
|
.conversation-project-filter-ui.open .conversation-project-filter-dropdown {
|
||||||
display: block;
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-project-filter-search {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-project-filter-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-project-filter-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-project-filter-search-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-project-filter-options {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-project-filter-empty {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-project-filter-hint,
|
||||||
|
.conversation-project-filter-status {
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-project-filter-status {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-project-filter-option[hidden] {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-project-filter-option {
|
.conversation-project-filter-option {
|
||||||
@@ -24998,7 +25060,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 20px 24px 18px;
|
padding: 16px 24px 14px;
|
||||||
border-bottom: 1px solid #eef2f7;
|
border-bottom: 1px solid #eef2f7;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
@@ -25057,10 +25119,28 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
border-color: #e2e8f0;
|
border-color: #e2e8f0;
|
||||||
}
|
}
|
||||||
.projects-detail-meta {
|
.projects-detail-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.8125rem;
|
padding: 5px 10px;
|
||||||
color: #94a3b8;
|
font-size: 0.75rem;
|
||||||
line-height: 1.4;
|
color: #64748b;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e8edf3;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.projects-detail-meta-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
.projects-detail-meta-time {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
}
|
}
|
||||||
.projects-detail-desc {
|
.projects-detail-desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -25126,8 +25206,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
align-self: flex-start;
|
align-self: center;
|
||||||
margin-top: 2px;
|
|
||||||
}
|
}
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.projects-detail-header {
|
.projects-detail-header {
|
||||||
@@ -26976,6 +27055,37 @@ body.app-modal-open {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.chat-project-panel-search {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 0 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.chat-project-panel-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.chat-project-panel-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
|
||||||
|
}
|
||||||
|
.chat-project-panel-search-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.chat-project-panel-hint {
|
||||||
|
padding: 10px 4px 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
.chat-project-panel .role-selection-list-main {
|
.chat-project-panel .role-selection-list-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -504,6 +504,12 @@
|
|||||||
"filterByProject": "Filter by project",
|
"filterByProject": "Filter by project",
|
||||||
"filterAllProjects": "All projects",
|
"filterAllProjects": "All projects",
|
||||||
"filterUnboundProjects": "Unbound",
|
"filterUnboundProjects": "Unbound",
|
||||||
|
"filterProjectSearch": "Search projects…",
|
||||||
|
"filterProjectSearchEmpty": "No matching projects",
|
||||||
|
"filterProjectSearchHint": "Type to search projects",
|
||||||
|
"filterProjectSearchMore": "Type to find more projects",
|
||||||
|
"filterProjectSearchLoading": "Searching…",
|
||||||
|
"filterProjectSearchFailed": "Failed to load projects. Try again.",
|
||||||
"projectConversationsTitle": "{{name}} · Conversations",
|
"projectConversationsTitle": "{{name}} · Conversations",
|
||||||
"unboundConversationsTitle": "Unbound conversations",
|
"unboundConversationsTitle": "Unbound conversations",
|
||||||
"noProjectConversations": "No conversations in this project",
|
"noProjectConversations": "No conversations in this project",
|
||||||
@@ -2627,6 +2633,7 @@
|
|||||||
"conversationName": "Conversation name",
|
"conversationName": "Conversation name",
|
||||||
"project": "Project",
|
"project": "Project",
|
||||||
"noProject": "No project",
|
"noProject": "No project",
|
||||||
|
"unknownProject": "Unknown project",
|
||||||
"filterByProject": "Filter by project",
|
"filterByProject": "Filter by project",
|
||||||
"lastTime": "Last activity",
|
"lastTime": "Last activity",
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
|
|||||||
@@ -492,6 +492,12 @@
|
|||||||
"filterByProject": "按项目筛选",
|
"filterByProject": "按项目筛选",
|
||||||
"filterAllProjects": "全部项目",
|
"filterAllProjects": "全部项目",
|
||||||
"filterUnboundProjects": "未绑定项目",
|
"filterUnboundProjects": "未绑定项目",
|
||||||
|
"filterProjectSearch": "搜索项目…",
|
||||||
|
"filterProjectSearchEmpty": "没有匹配的项目",
|
||||||
|
"filterProjectSearchHint": "输入关键字搜索项目",
|
||||||
|
"filterProjectSearchMore": "更多项目请输入关键字搜索",
|
||||||
|
"filterProjectSearchLoading": "搜索中…",
|
||||||
|
"filterProjectSearchFailed": "加载项目失败,请重试",
|
||||||
"projectConversationsTitle": "{{name}} · 对话",
|
"projectConversationsTitle": "{{name}} · 对话",
|
||||||
"unboundConversationsTitle": "未绑定项目",
|
"unboundConversationsTitle": "未绑定项目",
|
||||||
"noProjectConversations": "该项目暂无对话",
|
"noProjectConversations": "该项目暂无对话",
|
||||||
@@ -2615,6 +2621,7 @@
|
|||||||
"conversationName": "对话名称",
|
"conversationName": "对话名称",
|
||||||
"project": "项目",
|
"project": "项目",
|
||||||
"noProject": "无项目",
|
"noProject": "无项目",
|
||||||
|
"unknownProject": "未知项目",
|
||||||
"filterByProject": "按项目筛选",
|
"filterByProject": "按项目筛选",
|
||||||
"lastTime": "最近一次对话时间",
|
"lastTime": "最近一次对话时间",
|
||||||
"action": "操作",
|
"action": "操作",
|
||||||
|
|||||||
+221
-90
@@ -6169,32 +6169,72 @@ const BATCH_PROJECT_FILTER_SELECT_ID = 'batch-project-filter';
|
|||||||
const projectFilterCustomSelectRegistry = {};
|
const projectFilterCustomSelectRegistry = {};
|
||||||
let projectFilterCustomSelectDocBound = false;
|
let projectFilterCustomSelectDocBound = false;
|
||||||
|
|
||||||
|
function projectFilterT(key, fallback) {
|
||||||
|
if (typeof window.t === 'function') {
|
||||||
|
const value = window.t(key);
|
||||||
|
if (value && value !== key) return value;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function closeProjectFilterCustomSelect(selectId) {
|
function closeProjectFilterCustomSelect(selectId) {
|
||||||
const reg = projectFilterCustomSelectRegistry[selectId];
|
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||||
if (!reg || !reg.wrapper) return;
|
if (!reg || !reg.wrapper) return;
|
||||||
reg.wrapper.classList.remove('open');
|
reg.wrapper.classList.remove('open');
|
||||||
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
|
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
|
||||||
|
if (reg.filterSearchTimer) {
|
||||||
|
clearTimeout(reg.filterSearchTimer);
|
||||||
|
reg.filterSearchTimer = null;
|
||||||
|
}
|
||||||
|
reg.filterSearchSeq = (reg.filterSearchSeq || 0) + 1;
|
||||||
|
if (reg.searchInput) reg.searchInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAllProjectFilterCustomSelects() {
|
function closeAllProjectFilterCustomSelects() {
|
||||||
Object.keys(projectFilterCustomSelectRegistry).forEach(closeProjectFilterCustomSelect);
|
Object.keys(projectFilterCustomSelectRegistry).forEach(closeProjectFilterCustomSelect);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncProjectFilterCustomSelect(selectId) {
|
function ensureProjectFilterSearchUi(reg) {
|
||||||
const reg = projectFilterCustomSelectRegistry[selectId];
|
if (reg.searchInput && reg.optionsList) return;
|
||||||
if (!reg) return;
|
const { dropdown } = reg;
|
||||||
const { select, dropdown, trigger } = reg;
|
|
||||||
const valueSpan = trigger.querySelector('.conversation-project-filter-value');
|
|
||||||
dropdown.innerHTML = '';
|
dropdown.innerHTML = '';
|
||||||
Array.prototype.forEach.call(select.options, (opt) => {
|
|
||||||
|
const searchWrap = document.createElement('div');
|
||||||
|
searchWrap.className = 'conversation-project-filter-search';
|
||||||
|
const searchInput = document.createElement('input');
|
||||||
|
searchInput.type = 'search';
|
||||||
|
searchInput.className = 'conversation-project-filter-search-input';
|
||||||
|
searchInput.setAttribute('autocomplete', 'off');
|
||||||
|
searchInput.setAttribute('data-i18n', 'chat.filterProjectSearch');
|
||||||
|
searchInput.setAttribute('data-i18n-attr', 'placeholder');
|
||||||
|
searchInput.placeholder = projectFilterT('chat.filterProjectSearch', '搜索项目…');
|
||||||
|
searchWrap.appendChild(searchInput);
|
||||||
|
dropdown.appendChild(searchWrap);
|
||||||
|
reg.searchInput = searchInput;
|
||||||
|
|
||||||
|
const optionsList = document.createElement('div');
|
||||||
|
optionsList.className = 'conversation-project-filter-options';
|
||||||
|
dropdown.appendChild(optionsList);
|
||||||
|
reg.optionsList = optionsList;
|
||||||
|
reg.filterSearchSeq = 0;
|
||||||
|
reg.filterSearchTimer = null;
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', () => loadProjectFilterLocalOptions(reg.select.id));
|
||||||
|
searchInput.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Escape') closeProjectFilterCustomSelect(reg.select.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProjectFilterOptionButton(value, label, selectedValue) {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.type = 'button';
|
item.type = 'button';
|
||||||
item.className = 'conversation-project-filter-option';
|
item.className = 'conversation-project-filter-option';
|
||||||
item.setAttribute('role', 'option');
|
item.setAttribute('role', 'option');
|
||||||
item.setAttribute('data-value', opt.value);
|
item.setAttribute('data-value', value);
|
||||||
const labelText = opt.textContent || '';
|
item.title = label;
|
||||||
item.title = labelText;
|
if (value === selectedValue) {
|
||||||
if (opt.value === select.value) {
|
|
||||||
item.classList.add('is-selected');
|
item.classList.add('is-selected');
|
||||||
item.setAttribute('aria-selected', 'true');
|
item.setAttribute('aria-selected', 'true');
|
||||||
} else {
|
} else {
|
||||||
@@ -6204,14 +6244,108 @@ function syncProjectFilterCustomSelect(selectId) {
|
|||||||
check.className = 'conversation-project-filter-check';
|
check.className = 'conversation-project-filter-check';
|
||||||
check.setAttribute('aria-hidden', 'true');
|
check.setAttribute('aria-hidden', 'true');
|
||||||
check.textContent = '✓';
|
check.textContent = '✓';
|
||||||
const label = document.createElement('span');
|
const labelEl = document.createElement('span');
|
||||||
label.className = 'conversation-project-filter-option-label';
|
labelEl.className = 'conversation-project-filter-option-label';
|
||||||
label.textContent = labelText;
|
labelEl.textContent = label;
|
||||||
label.title = labelText;
|
labelEl.title = label;
|
||||||
item.appendChild(check);
|
item.appendChild(check);
|
||||||
item.appendChild(label);
|
item.appendChild(labelEl);
|
||||||
dropdown.appendChild(item);
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendProjectFilterStatusMessage(optionsList, className, text) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = className;
|
||||||
|
el.textContent = text;
|
||||||
|
optionsList.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectFilterPinnedOptions(reg) {
|
||||||
|
const { select, optionsList } = reg;
|
||||||
|
optionsList.innerHTML = '';
|
||||||
|
Array.prototype.forEach.call(select.options, (opt) => {
|
||||||
|
if (opt.value === '' || opt.value === CONVERSATION_PROJECT_FILTER_NONE) {
|
||||||
|
optionsList.appendChild(createProjectFilterOptionButton(opt.value, opt.textContent || '', select.value));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNativeProjectFilterOption(select, projectId, label) {
|
||||||
|
if (!projectId || projectId === CONVERSATION_PROJECT_FILTER_NONE) return;
|
||||||
|
if (Array.prototype.some.call(select.options, (opt) => opt.value === projectId)) return;
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = projectId;
|
||||||
|
opt.textContent = label || projectId;
|
||||||
|
select.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjectFilterLocalOptions(selectId) {
|
||||||
|
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||||
|
if (!reg || !reg.optionsList) return;
|
||||||
|
const query = (reg.searchInput?.value || '').trim();
|
||||||
|
const seq = ++reg.filterSearchSeq;
|
||||||
|
|
||||||
|
const needsFetch = typeof window.isProjectsCacheReady === 'function' && !window.isProjectsCacheReady();
|
||||||
|
let loadingEl = null;
|
||||||
|
if (needsFetch) {
|
||||||
|
renderProjectFilterPinnedOptions(reg);
|
||||||
|
loadingEl = appendProjectFilterStatusMessage(
|
||||||
|
reg.optionsList,
|
||||||
|
'conversation-project-filter-status',
|
||||||
|
projectFilterT('common.loading', '加载中…')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ensureLoaded = typeof window.ensureProjectsLoaded === 'function'
|
||||||
|
? window.ensureProjectsLoaded
|
||||||
|
: null;
|
||||||
|
const filterLocal = typeof window.filterActiveProjectsLocal === 'function'
|
||||||
|
? window.filterActiveProjectsLocal
|
||||||
|
: null;
|
||||||
|
if (!ensureLoaded || !filterLocal) throw new Error('projects cache unavailable');
|
||||||
|
|
||||||
|
const all = await ensureLoaded();
|
||||||
|
if (seq !== reg.filterSearchSeq) return;
|
||||||
|
|
||||||
|
renderProjectFilterPinnedOptions(reg);
|
||||||
|
const selected = reg.select.value;
|
||||||
|
const pinnedValues = new Set(['', CONVERSATION_PROJECT_FILTER_NONE]);
|
||||||
|
const projects = filterLocal(all, query);
|
||||||
|
projects.forEach((p) => {
|
||||||
|
if (pinnedValues.has(p.id)) return;
|
||||||
|
reg.optionsList.appendChild(
|
||||||
|
createProjectFilterOptionButton(p.id, p.name || p.id, selected)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query && projects.length === 0) {
|
||||||
|
appendProjectFilterStatusMessage(
|
||||||
|
reg.optionsList,
|
||||||
|
'conversation-project-filter-empty',
|
||||||
|
projectFilterT('chat.filterProjectSearchEmpty', '没有匹配的项目')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== reg.filterSearchSeq) return;
|
||||||
|
renderProjectFilterPinnedOptions(reg);
|
||||||
|
appendProjectFilterStatusMessage(
|
||||||
|
reg.optionsList,
|
||||||
|
'conversation-project-filter-empty',
|
||||||
|
projectFilterT('chat.filterProjectSearchFailed', '加载项目失败,请重试')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (loadingEl && loadingEl.parentNode) loadingEl.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncProjectFilterCustomSelect(selectId) {
|
||||||
|
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||||
|
if (!reg) return;
|
||||||
|
ensureProjectFilterSearchUi(reg);
|
||||||
|
const { select, trigger } = reg;
|
||||||
|
const valueSpan = trigger.querySelector('.conversation-project-filter-value');
|
||||||
const selectedOpt = select.options[select.selectedIndex];
|
const selectedOpt = select.options[select.selectedIndex];
|
||||||
const selectedText = selectedOpt ? (selectedOpt.textContent || '') : '';
|
const selectedText = selectedOpt ? (selectedOpt.textContent || '') : '';
|
||||||
if (valueSpan) {
|
if (valueSpan) {
|
||||||
@@ -6264,6 +6398,13 @@ function initProjectFilterCustomSelect(selectId) {
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
wrapper.classList.add('open');
|
wrapper.classList.add('open');
|
||||||
trigger.setAttribute('aria-expanded', 'true');
|
trigger.setAttribute('aria-expanded', 'true');
|
||||||
|
ensureProjectFilterSearchUi(projectFilterCustomSelectRegistry[selectId]);
|
||||||
|
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||||
|
if (reg?.searchInput) {
|
||||||
|
reg.searchInput.value = '';
|
||||||
|
loadProjectFilterLocalOptions(selectId);
|
||||||
|
requestAnimationFrame(() => reg.searchInput.focus());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6273,6 +6414,8 @@ function initProjectFilterCustomSelect(selectId) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const val = opt.getAttribute('data-value');
|
const val = opt.getAttribute('data-value');
|
||||||
if (val === null) return;
|
if (val === null) return;
|
||||||
|
const label = opt.querySelector('.conversation-project-filter-option-label')?.textContent || val;
|
||||||
|
ensureNativeProjectFilterOption(select, val, label);
|
||||||
if (select.value !== val) {
|
if (select.value !== val) {
|
||||||
select.value = val;
|
select.value = val;
|
||||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
@@ -6319,38 +6462,7 @@ function setConversationProjectFilter(projectId) {
|
|||||||
updateConversationSidebarFilterUI();
|
updateConversationSidebarFilterUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidConversationProjectFilter(projectId) {
|
function appendProjectFilterPinnedNativeOptions(sel) {
|
||||||
if (!projectId) return true;
|
|
||||||
if (projectId === CONVERSATION_PROJECT_FILTER_NONE) return true;
|
|
||||||
const map = window.projectNameById;
|
|
||||||
if (!map || typeof map !== 'object') return true;
|
|
||||||
return Object.prototype.hasOwnProperty.call(map, projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshConversationProjectFilter() {
|
|
||||||
const sel = document.getElementById('conversation-project-filter');
|
|
||||||
if (!sel) return;
|
|
||||||
const saved = getConversationProjectFilter();
|
|
||||||
let projects = [];
|
|
||||||
if (typeof window.ensureProjectsLoaded === 'function') {
|
|
||||||
try {
|
|
||||||
const list = await window.ensureProjectsLoaded();
|
|
||||||
projects = (list || []).filter((p) => p && p.id && p.status !== 'archived');
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
if (!projects.length) {
|
|
||||||
try {
|
|
||||||
const res = await apiFetch('/api/projects?status=active&limit=200');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
const items = data.projects || data.items || (Array.isArray(data) ? data : []);
|
|
||||||
projects = items.filter((p) => p && p.id);
|
|
||||||
if (typeof window.rebuildProjectNameMap === 'function') {
|
|
||||||
window.rebuildProjectNameMap(items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
|
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
|
||||||
const allLabel = tFn ? tFn('chat.filterAllProjects') : '全部项目';
|
const allLabel = tFn ? tFn('chat.filterAllProjects') : '全部项目';
|
||||||
const unboundLabel = tFn ? tFn('chat.filterUnboundProjects') : '未绑定项目';
|
const unboundLabel = tFn ? tFn('chat.filterUnboundProjects') : '未绑定项目';
|
||||||
@@ -6365,16 +6477,44 @@ async function refreshConversationProjectFilter() {
|
|||||||
unboundOpt.textContent = unboundLabel;
|
unboundOpt.textContent = unboundLabel;
|
||||||
unboundOpt.setAttribute('data-i18n', 'chat.filterUnboundProjects');
|
unboundOpt.setAttribute('data-i18n', 'chat.filterUnboundProjects');
|
||||||
sel.appendChild(unboundOpt);
|
sel.appendChild(unboundOpt);
|
||||||
projects
|
}
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || '', undefined, { sensitivity: 'base' }))
|
async function resolveProjectFilterSelection(projectId) {
|
||||||
.forEach((p) => {
|
const saved = (projectId || '').trim();
|
||||||
|
if (!saved || saved === CONVERSATION_PROJECT_FILTER_NONE) return saved;
|
||||||
|
const fetchSummary = typeof window.fetchProjectSummary === 'function'
|
||||||
|
? window.fetchProjectSummary
|
||||||
|
: null;
|
||||||
|
if (!fetchSummary) return saved;
|
||||||
|
const project = await fetchSummary(saved);
|
||||||
|
if (!project || !project.id || project.status === 'archived') return '';
|
||||||
|
return project.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendSelectedProjectFilterOption(sel, projectId) {
|
||||||
|
const id = (projectId || '').trim();
|
||||||
|
if (!id || id === CONVERSATION_PROJECT_FILTER_NONE) return;
|
||||||
|
if (Array.prototype.some.call(sel.options, (opt) => opt.value === id)) return;
|
||||||
|
const fetchSummary = typeof window.fetchProjectSummary === 'function'
|
||||||
|
? window.fetchProjectSummary
|
||||||
|
: null;
|
||||||
|
const project = fetchSummary ? await fetchSummary(id) : null;
|
||||||
|
const label = (project && (project.name || project.id)) || (window.projectNameById && window.projectNameById[id]) || id;
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = p.id;
|
opt.value = id;
|
||||||
opt.textContent = p.name || p.id;
|
opt.textContent = label;
|
||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
});
|
}
|
||||||
const normalized = isValidConversationProjectFilter(saved) ? saved : '';
|
|
||||||
|
async function refreshConversationProjectFilter() {
|
||||||
|
const sel = document.getElementById('conversation-project-filter');
|
||||||
|
if (!sel) return;
|
||||||
|
const saved = getConversationProjectFilter();
|
||||||
|
appendProjectFilterPinnedNativeOptions(sel);
|
||||||
|
const normalized = await resolveProjectFilterSelection(saved);
|
||||||
|
if (normalized && normalized !== CONVERSATION_PROJECT_FILTER_NONE) {
|
||||||
|
await appendSelectedProjectFilterOption(sel, normalized);
|
||||||
|
}
|
||||||
if (normalized !== saved) setConversationProjectFilter(normalized);
|
if (normalized !== saved) setConversationProjectFilter(normalized);
|
||||||
sel.value = normalized;
|
sel.value = normalized;
|
||||||
syncConversationProjectCustomSelect();
|
syncConversationProjectCustomSelect();
|
||||||
@@ -8249,47 +8389,37 @@ function getConversationProjectLabel(conv) {
|
|||||||
if (!pid) {
|
if (!pid) {
|
||||||
return typeof window.t === 'function' ? window.t('batchManageModal.noProject') : '无项目';
|
return typeof window.t === 'function' ? window.t('batchManageModal.noProject') : '无项目';
|
||||||
}
|
}
|
||||||
return (window.projectNameById && window.projectNameById[pid]) || pid;
|
const name = window.projectNameById && window.projectNameById[pid];
|
||||||
|
if (name) return name;
|
||||||
|
return typeof window.t === 'function' ? window.t('batchManageModal.unknownProject') : '未知项目';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefetchProjectNamesForConversations(conversations) {
|
||||||
|
const missing = new Set();
|
||||||
|
for (const conv of conversations || []) {
|
||||||
|
const pid = getConversationProjectId(conv);
|
||||||
|
if (pid && !(window.projectNameById && window.projectNameById[pid])) {
|
||||||
|
missing.add(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!missing.size) return;
|
||||||
|
const fetchSummary = typeof window.fetchProjectSummary === 'function'
|
||||||
|
? window.fetchProjectSummary
|
||||||
|
: null;
|
||||||
|
if (!fetchSummary) return;
|
||||||
|
await Promise.all([...missing].map((id) => fetchSummary(id).catch(() => null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshBatchProjectFilter() {
|
async function refreshBatchProjectFilter() {
|
||||||
const sel = document.getElementById('batch-project-filter');
|
const sel = document.getElementById('batch-project-filter');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const saved = sel.value || '';
|
const saved = sel.value || '';
|
||||||
if (typeof window.ensureProjectsLoaded === 'function') {
|
appendProjectFilterPinnedNativeOptions(sel);
|
||||||
try {
|
const normalized = await resolveProjectFilterSelection(saved);
|
||||||
await window.ensureProjectsLoaded();
|
if (normalized && normalized !== CONVERSATION_PROJECT_FILTER_NONE) {
|
||||||
} catch (e) { /* ignore */ }
|
await appendSelectedProjectFilterOption(sel, normalized);
|
||||||
}
|
}
|
||||||
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
|
sel.value = normalized;
|
||||||
const allLabel = tFn ? tFn('chat.filterAllProjects') : '全部项目';
|
|
||||||
const unboundLabel = tFn ? tFn('chat.filterUnboundProjects') : '未绑定项目';
|
|
||||||
sel.innerHTML = '';
|
|
||||||
const allOpt = document.createElement('option');
|
|
||||||
allOpt.value = '';
|
|
||||||
allOpt.textContent = allLabel;
|
|
||||||
allOpt.setAttribute('data-i18n', 'chat.filterAllProjects');
|
|
||||||
sel.appendChild(allOpt);
|
|
||||||
const unboundOpt = document.createElement('option');
|
|
||||||
unboundOpt.value = CONVERSATION_PROJECT_FILTER_NONE;
|
|
||||||
unboundOpt.textContent = unboundLabel;
|
|
||||||
unboundOpt.setAttribute('data-i18n', 'chat.filterUnboundProjects');
|
|
||||||
sel.appendChild(unboundOpt);
|
|
||||||
const source = window.projectNameById ? Object.keys(window.projectNameById) : [];
|
|
||||||
source
|
|
||||||
.sort((a, b) => {
|
|
||||||
const na = (window.projectNameById[a] || a).toLowerCase();
|
|
||||||
const nb = (window.projectNameById[b] || b).toLowerCase();
|
|
||||||
return na.localeCompare(nb);
|
|
||||||
})
|
|
||||||
.forEach((id) => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = id;
|
|
||||||
opt.textContent = window.projectNameById[id] || id;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
});
|
|
||||||
const valid = !saved || saved === CONVERSATION_PROJECT_FILTER_NONE || (window.projectNameById && window.projectNameById[saved]);
|
|
||||||
sel.value = valid ? saved : '';
|
|
||||||
syncProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
|
syncProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8331,6 +8461,7 @@ async function showBatchManageModal() {
|
|||||||
try {
|
try {
|
||||||
initProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
|
initProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
|
||||||
allConversationsForBatch = await fetchAllConversations('');
|
allConversationsForBatch = await fetchAllConversations('');
|
||||||
|
await prefetchProjectNamesForConversations(allConversationsForBatch);
|
||||||
await refreshBatchProjectFilter();
|
await refreshBatchProjectFilter();
|
||||||
const sidebarFilter = getConversationProjectFilter();
|
const sidebarFilter = getConversationProjectFilter();
|
||||||
const batchSel = document.getElementById('batch-project-filter');
|
const batchSel = document.getElementById('batch-project-filter');
|
||||||
|
|||||||
+265
-70
@@ -173,6 +173,65 @@ function rebuildProjectNameMap(list) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rememberProjectsInNameMap(list) {
|
||||||
|
(list || []).forEach((p) => {
|
||||||
|
if (p && p.id) projectNameById[p.id] = p.name || p.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端 projectListSearchPattern 对齐:name / description / id 子串匹配(忽略大小写) */
|
||||||
|
function matchProjectSearchQuery(project, query) {
|
||||||
|
const q = String(query || '').trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
const name = String(project.name || '').toLowerCase();
|
||||||
|
const desc = String(project.description || '').toLowerCase();
|
||||||
|
const id = String(project.id || '').toLowerCase();
|
||||||
|
return name.includes(q) || desc.includes(q) || id.includes(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortProjectsForPicker(projects) {
|
||||||
|
return [...projects].sort((a, b) => {
|
||||||
|
const ap = a.pinned ? 1 : 0;
|
||||||
|
const bp = b.pinned ? 1 : 0;
|
||||||
|
if (bp !== ap) return bp - ap;
|
||||||
|
const au = a.updated_at || a.updatedAt || '';
|
||||||
|
const bu = b.updated_at || b.updatedAt || '';
|
||||||
|
return String(bu).localeCompare(String(au));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从已加载列表中筛选活跃项目(对话选择器 / 项目筛选下拉) */
|
||||||
|
function filterActiveProjectsLocal(projects, query) {
|
||||||
|
const list = (projects || []).filter((p) => p && p.id && p.status !== 'archived');
|
||||||
|
const q = String(query || '').trim();
|
||||||
|
const filtered = q ? list.filter((p) => matchProjectSearchQuery(p, q)) : list;
|
||||||
|
return sortProjectsForPicker(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchActiveProjects(query, opts = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('status', opts.status || 'active');
|
||||||
|
params.set('limit', String(opts.limit ?? (String(query || '').trim() ? PROJECT_PICKER_SEARCH_LIMIT : PROJECT_PICKER_INITIAL_LIMIT)));
|
||||||
|
params.set('offset', String(opts.offset ?? 0));
|
||||||
|
const q = String(query || '').trim();
|
||||||
|
if (q) params.set('search', q);
|
||||||
|
const res = await apiFetch(`/api/projects?${params}`);
|
||||||
|
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
|
||||||
|
const parsed = parseProjectsListResponse(await res.json());
|
||||||
|
rememberProjectsInNameMap(parsed.items);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProjectSummary(projectId) {
|
||||||
|
const id = String(projectId || '').trim();
|
||||||
|
if (!id) return null;
|
||||||
|
const res = await apiFetch(`/api/projects/${encodeURIComponent(id)}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const project = await res.json();
|
||||||
|
if (project && project.id) rememberProjectsInNameMap([project]);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
function getProjectsListPageSize() {
|
function getProjectsListPageSize() {
|
||||||
try {
|
try {
|
||||||
const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10);
|
const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10);
|
||||||
@@ -308,7 +367,15 @@ async function ensureProjectsLoaded(force) {
|
|||||||
return _projectsFetchPromise;
|
return _projectsFetchPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isProjectsCacheReady() {
|
||||||
|
return _projectsListReady;
|
||||||
|
}
|
||||||
|
|
||||||
function prefetchProjectsForChat() {
|
function prefetchProjectsForChat() {
|
||||||
|
const id = (resolveChatProjectSelection() || '').trim();
|
||||||
|
if (id && !projectNameById[id]) {
|
||||||
|
fetchProjectSummary(id).catch(() => {});
|
||||||
|
}
|
||||||
ensureProjectsLoaded().catch(() => {});
|
ensureProjectsLoaded().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,9 +722,12 @@ function updateProjectStatusPill(status) {
|
|||||||
|
|
||||||
function renderProjectDetailMeta(updatedAt) {
|
function renderProjectDetailMeta(updatedAt) {
|
||||||
const metaEl = document.getElementById('projects-detail-meta');
|
const metaEl = document.getElementById('projects-detail-meta');
|
||||||
if (!metaEl) return;
|
const timeEl = document.getElementById('projects-detail-meta-time');
|
||||||
|
if (!metaEl || !timeEl) return;
|
||||||
const time = formatProjectTime(updatedAt);
|
const time = formatProjectTime(updatedAt);
|
||||||
metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
|
const full = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
|
||||||
|
timeEl.textContent = time;
|
||||||
|
metaEl.title = full;
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshProjectDetailMetaI18n() {
|
function refreshProjectDetailMetaI18n() {
|
||||||
@@ -2032,27 +2102,20 @@ function getChatProjectSelection() {
|
|||||||
return getActiveProjectId();
|
return getActiveProjectId();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveChatProjectId(id) {
|
/** 用于 UI:返回当前选中的项目 ID(有效性由 normalizeStaleChatProjectSelection 异步校验) */
|
||||||
if (!id) return false;
|
|
||||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
|
||||||
return source.some((p) => p.id === id && p.status !== 'archived');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用于 UI:无效/已删除/无可用项目时视为未绑定 */
|
|
||||||
function resolveChatProjectSelection() {
|
function resolveChatProjectSelection() {
|
||||||
const raw = getChatProjectSelection();
|
return getChatProjectSelection() || '';
|
||||||
if (!raw) return '';
|
|
||||||
if (!_projectsListReady) return raw;
|
|
||||||
return isActiveChatProjectId(raw) ? raw : '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _normalizingStaleProject = false;
|
let _normalizingStaleProject = false;
|
||||||
|
|
||||||
/** 项目列表加载后,清除 localStorage 或对话上残留的失效项目 ID */
|
/** 清除 localStorage 或对话上残留的失效项目 ID */
|
||||||
async function normalizeStaleChatProjectSelection() {
|
async function normalizeStaleChatProjectSelection() {
|
||||||
if (!_projectsListReady || _normalizingStaleProject) return;
|
if (_normalizingStaleProject) return;
|
||||||
const raw = getChatProjectSelection();
|
const raw = (getChatProjectSelection() || '').trim();
|
||||||
if (!raw || isActiveChatProjectId(raw)) return;
|
if (!raw) return;
|
||||||
|
const project = await fetchProjectSummary(raw);
|
||||||
|
if (project && project.id && project.status !== 'archived') return;
|
||||||
|
|
||||||
_normalizingStaleProject = true;
|
_normalizingStaleProject = true;
|
||||||
try {
|
try {
|
||||||
@@ -2079,6 +2142,171 @@ async function normalizeStaleChatProjectSelection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROJECT_PICKER_DEBOUNCE_MS = 100;
|
||||||
|
const projectPickerPanelState = {
|
||||||
|
chat: { seq: 0, timer: null },
|
||||||
|
webshell: { seq: 0, timer: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
function appendChatProjectPanelItem(list, project, selectedId, onSelect, tFn) {
|
||||||
|
const t = tFn || tp;
|
||||||
|
const isNone = !project.id;
|
||||||
|
const isSelected = isNone ? !selectedId : selectedId === project.id;
|
||||||
|
const desc = isNone
|
||||||
|
? (project.description || '')
|
||||||
|
: (project.description || '').trim().slice(0, 80) || t('projects.sharedFactBoard');
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
||||||
|
btn.setAttribute('role', 'option');
|
||||||
|
btn.onclick = () => onSelect(project.id || '');
|
||||||
|
btn.innerHTML = `
|
||||||
|
<div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div>
|
||||||
|
<div class="role-selection-item-content-main">
|
||||||
|
<div class="role-selection-item-name-main">${escapeHtml(project.name || t('common.untitled'))}</div>
|
||||||
|
<div class="role-selection-item-description-main">${escapeHtml(desc)}</div>
|
||||||
|
</div>
|
||||||
|
${isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : ''}
|
||||||
|
`;
|
||||||
|
list.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendChatProjectPanelMessage(list, className, text) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = className;
|
||||||
|
el.textContent = text;
|
||||||
|
list.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerMessage(t, key, fallback) {
|
||||||
|
const value = t(key);
|
||||||
|
if (!value || value === key) return fallback;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderProjectPickerPanel(panelKey, config) {
|
||||||
|
const state = projectPickerPanelState[panelKey];
|
||||||
|
const list = document.getElementById(config.listId);
|
||||||
|
if (!list || !state) return;
|
||||||
|
const query = (document.getElementById(config.searchInputId)?.value || '').trim();
|
||||||
|
const seq = ++state.seq;
|
||||||
|
const selectedId = config.getSelectedId();
|
||||||
|
const t = config.t || tp;
|
||||||
|
|
||||||
|
const renderPinned = () => {
|
||||||
|
appendChatProjectPanelItem(
|
||||||
|
list,
|
||||||
|
{
|
||||||
|
id: '',
|
||||||
|
name: t('projects.noProject'),
|
||||||
|
description: t('projects.noProjectDescription'),
|
||||||
|
},
|
||||||
|
selectedId,
|
||||||
|
config.onSelect,
|
||||||
|
t
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsFetch = !isProjectsCacheReady();
|
||||||
|
let loadingEl = null;
|
||||||
|
if (needsFetch) {
|
||||||
|
list.innerHTML = '';
|
||||||
|
renderPinned();
|
||||||
|
loadingEl = appendChatProjectPanelMessage(
|
||||||
|
list,
|
||||||
|
'chat-project-panel-loading',
|
||||||
|
pickerMessage(t, 'common.loading', '加载中…')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const all = await ensureProjectsLoaded();
|
||||||
|
if (seq !== state.seq) return;
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
renderPinned();
|
||||||
|
const projects = filterActiveProjectsLocal(all, query);
|
||||||
|
projects.forEach((p) => {
|
||||||
|
appendChatProjectPanelItem(list, p, selectedId, config.onSelect, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query && projects.length === 0) {
|
||||||
|
appendChatProjectPanelMessage(
|
||||||
|
list,
|
||||||
|
'chat-project-panel-empty',
|
||||||
|
pickerMessage(t, 'chat.filterProjectSearchEmpty', '没有匹配的项目')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== state.seq) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
renderPinned();
|
||||||
|
appendChatProjectPanelMessage(
|
||||||
|
list,
|
||||||
|
'chat-project-panel-empty',
|
||||||
|
pickerMessage(t, 'chat.filterProjectSearchFailed', '加载项目失败,请重试')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (loadingEl && loadingEl.parentNode) loadingEl.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initProjectPickerPanelSearch(panelKey, searchInputId, onSearch) {
|
||||||
|
const input = document.getElementById(searchInputId);
|
||||||
|
if (!input || input.dataset.pickerBound === panelKey) return;
|
||||||
|
input.dataset.pickerBound = panelKey;
|
||||||
|
input.addEventListener('input', onSearch);
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (panelKey === 'chat' && typeof closeChatProjectPanel === 'function') {
|
||||||
|
closeChatProjectPanel();
|
||||||
|
} else if (panelKey === 'webshell' && typeof wsCloseProjectPanel === 'function') {
|
||||||
|
wsCloseProjectPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProjectPickerPanelSearch(panelKey, searchInputId) {
|
||||||
|
const state = projectPickerPanelState[panelKey];
|
||||||
|
if (!state) return;
|
||||||
|
state.seq += 1;
|
||||||
|
if (state.timer) {
|
||||||
|
clearTimeout(state.timer);
|
||||||
|
state.timer = null;
|
||||||
|
}
|
||||||
|
const input = document.getElementById(searchInputId);
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleProjectPickerPanelSearch(panelKey, loadFn) {
|
||||||
|
const state = projectPickerPanelState[panelKey];
|
||||||
|
if (!state) return;
|
||||||
|
if (state.timer) clearTimeout(state.timer);
|
||||||
|
state.timer = setTimeout(() => {
|
||||||
|
state.timer = null;
|
||||||
|
loadFn();
|
||||||
|
}, PROJECT_PICKER_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChatProjectPanelList() {
|
||||||
|
await renderProjectPickerPanel('chat', {
|
||||||
|
listId: 'chat-project-list',
|
||||||
|
searchInputId: 'chat-project-search',
|
||||||
|
getSelectedId: resolveChatProjectSelection,
|
||||||
|
onSelect: (projectId) => selectChatProject(projectId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureChatProjectButtonLabel() {
|
||||||
|
const id = (resolveChatProjectSelection() || '').trim();
|
||||||
|
if (id && !projectNameById[id]) {
|
||||||
|
await fetchProjectSummary(id);
|
||||||
|
}
|
||||||
|
updateChatProjectButtonLabel();
|
||||||
|
}
|
||||||
|
|
||||||
function updateChatProjectButtonLabel() {
|
function updateChatProjectButtonLabel() {
|
||||||
const textEl = document.getElementById('chat-project-text');
|
const textEl = document.getElementById('chat-project-text');
|
||||||
if (!textEl) return;
|
if (!textEl) return;
|
||||||
@@ -2086,56 +2314,13 @@ function updateChatProjectButtonLabel() {
|
|||||||
textEl.textContent = id && projectNameById[id] ? projectNameById[id] : tp('projects.noProject');
|
textEl.textContent = id && projectNameById[id] ? projectNameById[id] : tp('projects.noProject');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChatProjectPanelList() {
|
|
||||||
const list = document.getElementById('chat-project-list');
|
|
||||||
if (!list) return;
|
|
||||||
const selected = resolveChatProjectSelection();
|
|
||||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
|
||||||
const activeProjects = source.filter((p) => p.status !== 'archived');
|
|
||||||
const items = [{ id: '', name: tp('projects.noProject'), description: tp('projects.noProjectDescription') }, ...activeProjects];
|
|
||||||
if (!items.length) {
|
|
||||||
list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.noProjectsClickCreate'))}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
list.innerHTML = '';
|
|
||||||
items.forEach((p) => {
|
|
||||||
const isNone = !p.id;
|
|
||||||
const isSelected = isNone ? !selected : selected === p.id;
|
|
||||||
const desc = isNone
|
|
||||||
? (p.description || '')
|
|
||||||
: (p.description || '').trim().slice(0, 80) || tp('projects.sharedFactBoard');
|
|
||||||
const projectId = p.id || '';
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
|
||||||
btn.setAttribute('role', 'option');
|
|
||||||
btn.onclick = () => {
|
|
||||||
selectChatProject(projectId);
|
|
||||||
};
|
|
||||||
btn.innerHTML = `
|
|
||||||
<div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div>
|
|
||||||
<div class="role-selection-item-content-main">
|
|
||||||
<div class="role-selection-item-name-main">${escapeHtml(p.name || tp('common.untitled'))}</div>
|
|
||||||
<div class="role-selection-item-description-main">${escapeHtml(desc)}</div>
|
|
||||||
</div>
|
|
||||||
${isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : ''}
|
|
||||||
`;
|
|
||||||
list.appendChild(btn);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderChatProjectPanel() {
|
async function renderChatProjectPanel() {
|
||||||
const list = document.getElementById('chat-project-list');
|
initProjectPickerPanelSearch('chat', 'chat-project-search', () => {
|
||||||
if (!list) return;
|
scheduleProjectPickerPanelSearch('chat', () => loadChatProjectPanelList());
|
||||||
list.innerHTML = `<div class="chat-project-panel-loading">${escapeHtml(tp('common.loading'))}</div>`;
|
});
|
||||||
try {
|
clearProjectPickerPanelSearch('chat', 'chat-project-search');
|
||||||
await ensureProjectsLoaded();
|
await loadChatProjectPanelList();
|
||||||
} catch (e) {
|
requestAnimationFrame(() => document.getElementById('chat-project-search')?.focus());
|
||||||
console.warn(e);
|
|
||||||
list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.loadFailedRetry'))}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderChatProjectPanelList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeChatProjectPanel() {
|
function closeChatProjectPanel() {
|
||||||
@@ -2146,6 +2331,7 @@ function closeChatProjectPanel() {
|
|||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
btn.setAttribute('aria-expanded', 'false');
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
}
|
}
|
||||||
|
clearProjectPickerPanelSearch('chat', 'chat-project-search');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleChatProjectPanel() {
|
async function toggleChatProjectPanel() {
|
||||||
@@ -2213,15 +2399,14 @@ async function applyChatProjectSelection(projectId) {
|
|||||||
async function refreshChatProjectSelector() {
|
async function refreshChatProjectSelector() {
|
||||||
if (!document.getElementById('chat-project-btn')) return;
|
if (!document.getElementById('chat-project-btn')) return;
|
||||||
try {
|
try {
|
||||||
await ensureProjectsLoaded();
|
|
||||||
await normalizeStaleChatProjectSelection();
|
await normalizeStaleChatProjectSelection();
|
||||||
|
await ensureChatProjectButtonLabel();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
}
|
}
|
||||||
updateChatProjectButtonLabel();
|
|
||||||
const panel = document.getElementById('chat-project-panel');
|
const panel = document.getElementById('chat-project-panel');
|
||||||
if (panel && panel.style.display === 'flex') {
|
if (panel && panel.style.display === 'flex') {
|
||||||
renderChatProjectPanelList();
|
await loadChatProjectPanelList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2240,7 +2425,7 @@ function initChatProjectSelector() {
|
|||||||
renderProjectsPagination();
|
renderProjectsPagination();
|
||||||
updateChatProjectButtonLabel();
|
updateChatProjectButtonLabel();
|
||||||
const panel = document.getElementById('chat-project-panel');
|
const panel = document.getElementById('chat-project-panel');
|
||||||
if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
|
if (panel && panel.style.display === 'flex') loadChatProjectPanelList();
|
||||||
if (currentProjectId) {
|
if (currentProjectId) {
|
||||||
refreshProjectDetailMetaI18n();
|
refreshProjectDetailMetaI18n();
|
||||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||||
@@ -2298,6 +2483,11 @@ window.onChatProjectChange = onChatProjectChange;
|
|||||||
window.toggleChatProjectPanel = toggleChatProjectPanel;
|
window.toggleChatProjectPanel = toggleChatProjectPanel;
|
||||||
window.closeChatProjectPanel = closeChatProjectPanel;
|
window.closeChatProjectPanel = closeChatProjectPanel;
|
||||||
window.selectChatProject = selectChatProject;
|
window.selectChatProject = selectChatProject;
|
||||||
|
window.renderProjectPickerPanel = renderProjectPickerPanel;
|
||||||
|
window.initProjectPickerPanelSearch = initProjectPickerPanelSearch;
|
||||||
|
window.clearProjectPickerPanelSearch = clearProjectPickerPanelSearch;
|
||||||
|
window.scheduleProjectPickerPanelSearch = scheduleProjectPickerPanelSearch;
|
||||||
|
window.loadChatProjectPanelList = loadChatProjectPanelList;
|
||||||
window.prefetchProjectsForChat = prefetchProjectsForChat;
|
window.prefetchProjectsForChat = prefetchProjectsForChat;
|
||||||
window.ensureDefaultActiveProjectForNewChat = ensureDefaultActiveProjectForNewChat;
|
window.ensureDefaultActiveProjectForNewChat = ensureDefaultActiveProjectForNewChat;
|
||||||
window.getActiveProjectId = getActiveProjectId;
|
window.getActiveProjectId = getActiveProjectId;
|
||||||
@@ -2334,5 +2524,10 @@ window.deleteProjectFactEdge = deleteProjectFactEdge;
|
|||||||
window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
|
window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
|
||||||
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
|
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
|
||||||
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
||||||
|
window.rememberProjectsInNameMap = rememberProjectsInNameMap;
|
||||||
|
window.searchActiveProjects = searchActiveProjects;
|
||||||
|
window.filterActiveProjectsLocal = filterActiveProjectsLocal;
|
||||||
|
window.fetchProjectSummary = fetchProjectSummary;
|
||||||
window.projectNameById = projectNameById;
|
window.projectNameById = projectNameById;
|
||||||
window.ensureProjectsLoaded = ensureProjectsLoaded;
|
window.ensureProjectsLoaded = ensureProjectsLoaded;
|
||||||
|
window.isProjectsCacheReady = isProjectsCacheReady;
|
||||||
|
|||||||
+42
-41
@@ -362,6 +362,20 @@ function wsProjectT(key, fallback) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wsProjectPickerT(key) {
|
||||||
|
var fallbacks = {
|
||||||
|
'projects.noProject': '无项目',
|
||||||
|
'projects.noProjectDescription': '不绑定项目黑板',
|
||||||
|
'projects.sharedFactBoard': '共享事实黑板',
|
||||||
|
'common.untitled': '未命名',
|
||||||
|
'common.loading': '加载中…',
|
||||||
|
'chat.filterProjectSearchEmpty': '没有匹配的项目',
|
||||||
|
'chat.filterProjectSearchMore': '更多项目请输入关键字搜索',
|
||||||
|
'chat.filterProjectSearchFailed': '加载项目失败,请重试',
|
||||||
|
};
|
||||||
|
return wsProjectT(key, fallbacks[key]);
|
||||||
|
}
|
||||||
|
|
||||||
function getWebshellAiConvId(conn) {
|
function getWebshellAiConvId(conn) {
|
||||||
if (!conn || !conn.id) return '';
|
if (!conn || !conn.id) return '';
|
||||||
return webshellAiConvMap[conn.id] || '';
|
return webshellAiConvMap[conn.id] || '';
|
||||||
@@ -409,51 +423,32 @@ function wsUpdateProjectButtonLabel() {
|
|||||||
textEl.textContent = id && nameMap[id] ? nameMap[id] : wsProjectT('projects.noProject', '无项目');
|
textEl.textContent = id && nameMap[id] ? nameMap[id] : wsProjectT('projects.noProject', '无项目');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wsRenderProjectPanelList() {
|
async function wsLoadProjectPanelList() {
|
||||||
var list = document.getElementById('ws-project-list');
|
if (typeof window.renderProjectPickerPanel !== 'function') return;
|
||||||
if (!list || !webshellCurrentConn) return;
|
await window.renderProjectPickerPanel('webshell', {
|
||||||
var conn = webshellCurrentConn;
|
listId: 'ws-project-list',
|
||||||
var selected = wsResolveWebshellAiProjectSelection(conn);
|
searchInputId: 'ws-project-search',
|
||||||
var projects = [];
|
getSelectedId: function () {
|
||||||
try {
|
return webshellCurrentConn ? wsResolveWebshellAiProjectSelection(webshellCurrentConn) : '';
|
||||||
if (typeof window.fetchAllProjects === 'function') {
|
},
|
||||||
projects = await window.fetchAllProjects(false);
|
onSelect: function (projectId) { wsSelectProject(projectId); },
|
||||||
}
|
t: wsProjectPickerT,
|
||||||
} catch (e) {
|
|
||||||
list.innerHTML = '<div class="chat-project-panel-empty">' + escapeHtml(wsProjectT('projects.loadFailedRetry', '加载失败,请重试')) + '</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof window.rebuildProjectNameMap === 'function') {
|
|
||||||
window.rebuildProjectNameMap(projects);
|
|
||||||
}
|
|
||||||
var activeProjects = projects.filter(function (p) { return p.status !== 'archived'; });
|
|
||||||
var items = [{ id: '', name: wsProjectT('projects.noProject', '无项目'), description: wsProjectT('projects.noProjectDescription', '不绑定项目') }].concat(activeProjects);
|
|
||||||
list.innerHTML = '';
|
|
||||||
items.forEach(function (p) {
|
|
||||||
var isNone = !p.id;
|
|
||||||
var isSelected = isNone ? !selected : selected === p.id;
|
|
||||||
var desc = isNone
|
|
||||||
? (p.description || '')
|
|
||||||
: ((p.description || '').trim().slice(0, 80) || wsProjectT('projects.sharedFactBoard', '共享事实黑板'));
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
|
||||||
btn.setAttribute('role', 'option');
|
|
||||||
btn.onclick = function () { wsSelectProject(p.id || ''); };
|
|
||||||
btn.innerHTML = '<div class="role-selection-item-icon-main">' + (isNone ? '—' : '📁') + '</div>' +
|
|
||||||
'<div class="role-selection-item-content-main">' +
|
|
||||||
'<div class="role-selection-item-name-main">' + escapeHtml(p.name || '未命名') + '</div>' +
|
|
||||||
'<div class="role-selection-item-description-main">' + escapeHtml(desc) + '</div></div>' +
|
|
||||||
(isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : '');
|
|
||||||
list.appendChild(btn);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wsRenderProjectPanel() {
|
async function wsRenderProjectPanel() {
|
||||||
var list = document.getElementById('ws-project-list');
|
if (typeof window.initProjectPickerPanelSearch === 'function') {
|
||||||
if (!list) return;
|
window.initProjectPickerPanelSearch('webshell', 'ws-project-search', function () {
|
||||||
list.innerHTML = '<div class="chat-project-panel-loading">' + escapeHtml(wsProjectT('common.loading', '加载中...')) + '</div>';
|
if (typeof window.scheduleProjectPickerPanelSearch === 'function') {
|
||||||
await wsRenderProjectPanelList();
|
window.scheduleProjectPickerPanelSearch('webshell', function () { wsLoadProjectPanelList(); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof window.clearProjectPickerPanelSearch === 'function') {
|
||||||
|
window.clearProjectPickerPanelSearch('webshell', 'ws-project-search');
|
||||||
|
}
|
||||||
|
await wsLoadProjectPanelList();
|
||||||
|
requestAnimationFrame(function () { document.getElementById('ws-project-search')?.focus(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function wsCloseProjectPanel() {
|
function wsCloseProjectPanel() {
|
||||||
@@ -464,6 +459,9 @@ function wsCloseProjectPanel() {
|
|||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
btn.setAttribute('aria-expanded', 'false');
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
}
|
}
|
||||||
|
if (typeof window.clearProjectPickerPanelSearch === 'function') {
|
||||||
|
window.clearProjectPickerPanelSearch('webshell', 'ws-project-search');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wsToggleProjectPanel() {
|
async function wsToggleProjectPanel() {
|
||||||
@@ -2230,6 +2228,9 @@ function selectWebshell(id, stateReady) {
|
|||||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
|
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="chat-project-panel-body">' +
|
'<div class="chat-project-panel-body">' +
|
||||||
|
'<div class="chat-project-panel-search">' +
|
||||||
|
'<input type="search" id="ws-project-search" class="chat-project-panel-search-input" autocomplete="off" placeholder="' + escapeHtml(wsProjectT('projects.searchProjectsPlaceholder', '搜索项目…')) + '">' +
|
||||||
|
'</div>' +
|
||||||
'<div id="ws-project-list" class="role-selection-list-main"></div>' +
|
'<div id="ws-project-list" class="role-selection-list-main"></div>' +
|
||||||
'<div class="chat-project-panel-footer">' +
|
'<div class="chat-project-panel-footer">' +
|
||||||
'<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromWebshellAi()">' +
|
'<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromWebshellAi()">' +
|
||||||
|
|||||||
@@ -1052,6 +1052,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-project-panel-body">
|
<div class="chat-project-panel-body">
|
||||||
|
<div class="chat-project-panel-search">
|
||||||
|
<input type="search" id="chat-project-search" class="chat-project-panel-search-input" autocomplete="off" data-i18n="projects.searchProjectsPlaceholder" data-i18n-attr="placeholder" placeholder="搜索项目…">
|
||||||
|
</div>
|
||||||
<div id="chat-project-list" class="role-selection-list-main"></div>
|
<div id="chat-project-list" class="role-selection-list-main"></div>
|
||||||
<div class="chat-project-panel-footer">
|
<div class="chat-project-panel-footer">
|
||||||
<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromChat()">
|
<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromChat()">
|
||||||
@@ -1710,10 +1713,13 @@
|
|||||||
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
|
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="projects-detail-meta" class="projects-detail-meta"></p>
|
|
||||||
<p id="projects-detail-desc" class="projects-detail-desc" hidden></p>
|
<p id="projects-detail-desc" class="projects-detail-desc" hidden></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="projects-detail-header-actions">
|
<div class="projects-detail-header-actions">
|
||||||
|
<span id="projects-detail-meta" class="projects-detail-meta" aria-live="polite">
|
||||||
|
<svg class="projects-detail-meta-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
<span id="projects-detail-meta-time" class="projects-detail-meta-time"></span>
|
||||||
|
</span>
|
||||||
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.vulnerabilityManagement">漏洞管理</button>
|
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()" data-i18n="projects.vulnerabilityManagement">漏洞管理</button>
|
||||||
<button type="button" class="btn-primary btn-small" onclick="showAddFactModal()" data-i18n="projects.addFactCta">+ 添加事实</button>
|
<button type="button" class="btn-primary btn-small" onclick="showAddFactModal()" data-i18n="projects.addFactCta">+ 添加事实</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user