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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 简体中文(草稿)

> 以下为 README 的中文草稿翻译,供界面中文化与用户文档使用。该翻译为初稿,术语与技术表述可能需要进一步校对。

<div align="center">

<img src="./README/banner.png" alt="Cairn Banner"/>

# Cairn
### 不只是 AI 渗透测试 — 面向一般状态空间搜索的问题求解引擎

Cairn 是一个通用的问题求解引擎。它没有固定角色或工作流。给定一个起点(origin)和目标(goal),它在未知的状态空间中搜索一条到达目标的路径。渗透测试只是第一个验证场景。

Cairn 使用黑板架构和显式的事实-意图图(fact-intent graph)作为核心。主要概念:事实(Fact)、意图(Intent)和提示(Hint)。代理(Agents)通过读取与写入共享的黑板协作探索状态空间。

更多使用与部署说明请参见仓库根 README(英文为主)与 docs/ 目录。

---

> 注意:这是翻译草稿,正式发布前请做术语统一与人工校对。
12 changes: 10 additions & 2 deletions cairn/src/cairn/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from pathlib import Path

from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles

from cairn import __version__
Expand Down Expand Up @@ -34,7 +34,15 @@ async def lifespan(app: FastAPI):

@app.get("/", include_in_schema=False)
def index():
return FileResponse(STATIC_DIR / "index.html")
# Read the static index.html and inject a small i18n loader script tag so translations
# can be loaded at runtime without modifying the shipped static assets heavily.
html_path = STATIC_DIR / "index.html"
html = html_path.read_text(encoding="utf-8")
# Insert the loader script reference just before </body> if it's present.
injector = '<script src="/static/locales/i18n-loader.js"></script>\n</body>'
if "</body>" in html:
html = html.replace("</body>", injector, 1)
return HTMLResponse(html)


app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
28 changes: 28 additions & 0 deletions cairn/src/cairn/server/i18n.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from pathlib import Path
import json
from functools import lru_cache

# Lightweight server-side i18n helper. Reads JSON language packs from the
# static/locales directory and provides a simple t(key, lang) accessor.
# This is intentionally minimal: it is meant for small server-generated
# user-facing messages. API field names, DB columns and machine-readable
# strings MUST NOT be translated.

STATIC_DIR = Path(__file__).parent / "static"


@lru_cache(maxsize=8)
def load_locale(lang: str):
path = STATIC_DIR / "locales" / f"{lang}.json"
if not path.exists():
path = STATIC_DIR / "locales" / "en.json"
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}


def t(key: str, lang: str = "en") -> str:
"""Return translated string for key in given lang, fallback to key."""
loc = load_locale(lang)
return loc.get(key, key)
14 changes: 14 additions & 0 deletions cairn/src/cairn/server/static/locales/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"lang_button": "中文",
"label_all": "All",
"btn_new_project": "New Project",
"btn_snapshot": "Snapshot",
"no_projects": "No projects yet",
"create_project_prompt": "Create a project to start exploring",
"modal_new_project": "New Project",
"ph_project_title": "Project title",
"ph_origin": "Origin — starting point",
"ph_goal": "Goal — what to achieve",
"btn_cancel": "Cancel",
"btn_create": "Create"
}
128 changes: 128 additions & 0 deletions cairn/src/cairn/server/static/locales/i18n-loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
(function () {
// Minimal runtime i18n loader. Strategy:
// 1. Decide lang (localStorage.lang -> navigator.language -> 'en')
// 2. Fetch /static/locales/{lang}.json
// 3. Expose window.__LOCALE and window.t(key)
// 4. Best-effort DOM replacement of exact-match text, placeholders, titles, and common elements
// 5. Inject a small language selector into the header

function detectLang() {
const stored = localStorage.getItem('lang');
if (stored) return stored;
const nav = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
if (nav.startsWith('zh')) return 'zh-CN';
return 'en';
}

async function loadLocale(lang) {
try {
const res = await fetch(`/static/locales/${lang}.json`, {cache: 'no-cache'});
if (!res.ok) throw new Error('Locale not found');
return await res.json();
} catch (e) {
// fallback to en
if (lang !== 'en') return loadLocale('en');
return {};
}
}

function applyTranslations(map) {
if (!map || typeof map !== 'object') return;

// Replace whole-element textContent when it exactly equals a key
const replaceTextNodes = () => {
// Common elements to update
const els = document.querySelectorAll('button, a, span, p, label, option, h1, h2, h3, h4, h5, h6, td, th');
els.forEach(el => {
const txt = el.textContent && el.textContent.trim();
if (txt && map[txt]) {
el.textContent = map[txt];
}
});
};

// Replace attributes: placeholder, title, aria-label, alt, value
const replaceAttrs = () => {
const attrs = ['placeholder', 'title', 'aria-label', 'alt', 'value'];
attrs.forEach(attr => {
const els = document.querySelectorAll('[' + attr + ']');
els.forEach(el => {
const v = el.getAttribute(attr);
if (v && map[v]) el.setAttribute(attr, map[v]);
});
});
};

// Replace simple text nodes that equal keys (best-effort)
const replaceTextNodesExact = () => {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
const toReplace = [];
while (walker.nextNode()) {
const node = walker.currentNode;
const t = node.nodeValue && node.nodeValue.trim();
if (t && map[t] && node.nodeValue.trim() === t) {
toReplace.push({node, val: map[t]});
}
}
toReplace.forEach(item => { item.node.nodeValue = item.val; });
};

replaceTextNodes();
replaceAttrs();
replaceTextNodesExact();
}

function injectSwitcher(currentLang) {
try {
const header = document.querySelector('header');
if (!header) return;
// Avoid duplicate
if (document.getElementById('i18n-select')) return;

const container = document.createElement('div');
container.style.display = 'inline-flex';
container.style.alignItems = 'center';
container.style.gap = '6px';
container.style.marginLeft = '8px';
container.innerHTML = `
<select id="i18n-select" aria-label="Language" class="h-7 rounded-md border px-2 text-xs">
<option value="en">EN</option>
<option value="zh-CN">中文</option>
</select>
`;
header.appendChild(container);
const sel = container.querySelector('#i18n-select');
if (sel) {
sel.value = currentLang;
sel.addEventListener('change', (e) => {
const v = e.target.value;
localStorage.setItem('lang', v);
// Reload to let heavy UI initialize with translations applied
location.reload();
});
}
} catch (e) { /* non-fatal */ }
}

// Public t() helper
window.t = window.t || function (k) { return (window.__LOCALE && window.__LOCALE[k]) || k; };

document.addEventListener('DOMContentLoaded', async function () {
const lang = detectLang();
const locale = await loadLocale(lang);
window.__LOCALE = locale || {};
// Expose t again with loaded locale
window.t = function (k) { return (window.__LOCALE && window.__LOCALE[k]) || k; };

try {
applyTranslations(window.__LOCALE);
injectSwitcher(lang);
// If Alpine is present, provide a helper so developers can use t() in x-text or other bindings
if (window.Alpine) {
window.cairnTranslate = window.t;
}
} catch (e) {
console.error('i18n-loader error', e);
}
});
})();
75 changes: 75 additions & 0 deletions cairn/src/cairn/server/static/locales/zh-CN.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"Cairn": "Cairn",
"All": "全部",
"New Project": "新建项目",
"No projects yet": "暂无项目",
"Create a project to start exploring": "创建项目以开始探索",
"Stop Active": "停止运行",
"Stopping...": "停止中...",
"Snapshot": "快照",
"Stop": "停止",
"Resume": "继续",
"Delete": "删除",
"New Intent": "新建意图",
"Conclude Intent": "完成意图",
"Add Hint": "添加提示",
"Local Preferences": "本地偏好",
"Server Settings": "服务器设置",
"Create": "创建",
"Cancel": "取消",
"Close": "关闭",
"Save": "保存",
"Hints (optional)": "提示(可选)",
"+ Add": "+ 添加",
"Hint content": "提示内容",
"Your name": "你的名字",
"Default layout": "默认布局",
"Stored only in this browser.": "只保存在该浏览器中。",
"Replay": "重放",
"Fast": "快",
"Normal": "正常",
"Slow": "慢",
"Intent": "意图",
"Complete": "完成",
"Hint": "提示",
"Reopen": "重新打开",
"Rename Project": "重命名项目",
"Actor": "角色",
"From facts": "起始事实",
"What will you explore?": "你将要探索什么?",
"What did you find? (new fact)": "你发现了什么?(新的事实)",
"Why is the goal met?": "为什么目标已达成?",
"Declare": "声明",
"Declare & Claim": "声明并认领",
"Conclude": "结论",
"Complete Project": "标记项目为已完成",
"Add": "添加",
"No hints yet": "暂无提示",
"No activity yet": "暂无活动",
"Click a node or edge": "点击一个节点或边",
"Shift+click for multi-select": "Shift+点击以多选",
"Project starting point": "项目起始点",
"Project target fact": "项目目标事实",
"Produced by": "由以下意图产生",
"Origin": "起点",
"Goal": "目标",
"Produced By": "产生自",
"From": "来自",
"Creator": "创建者",
"Worker": "工作器",
"Concluded": "结束时间",
"Sequence": "顺序",
"Time Span": "时间跨度",
"No projects yet": "暂无项目",
"Create a project to start exploring": "创建项目以开始探索",
"All projects": "全部项目",
"Active projects": "运行中项目",
"Stopped projects": "已停止项目",
"Completed projects": "已完成项目",
"Stop all active projects": "停止所有运行中项目",
"Rename project": "重命名项目",
"Snapshot": "快照",
"Delete": "删除",
"Save Server": "保存服务器设置",
"Save Local": "保存本地设置"
}