mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
fix(desktop): support web_password auth and fix password settings
This commit is contained in:
@@ -79,11 +79,42 @@ def _verify_auth_token(token):
|
|||||||
return hmac.compare_digest(sig, expected)
|
return hmac.compare_digest(sig, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bearer_token():
|
||||||
|
"""Extract the token from an `Authorization: Bearer <token>` header.
|
||||||
|
|
||||||
|
The desktop client renders from a file:// origin, so cross-origin cookies
|
||||||
|
to http://127.0.0.1 are unreliable (SameSite=Lax cookies aren't sent). It
|
||||||
|
therefore authenticates via this header instead; browsers keep using the
|
||||||
|
cookie set by /auth/login.
|
||||||
|
"""
|
||||||
|
auth = web.ctx.env.get("HTTP_AUTHORIZATION", "") or ""
|
||||||
|
if auth.startswith("Bearer "):
|
||||||
|
return auth[7:].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_query_token():
|
||||||
|
"""Extract a token from the `token` query param.
|
||||||
|
|
||||||
|
Needed for SSE endpoints: EventSource can't set an Authorization header,
|
||||||
|
and file:// cookies are unreliable, so the desktop client passes the token
|
||||||
|
in the query string for /stream and /api/logs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return web.input(token="").token or ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _check_auth():
|
def _check_auth():
|
||||||
"""Return True if request is authenticated or password not enabled."""
|
"""Return True if request is authenticated or password not enabled."""
|
||||||
if not _is_password_enabled():
|
if not _is_password_enabled():
|
||||||
return True
|
return True
|
||||||
return _verify_auth_token(web.cookies().get("cow_auth_token", ""))
|
if _verify_auth_token(web.cookies().get("cow_auth_token", "")):
|
||||||
|
return True
|
||||||
|
if _verify_auth_token(_get_bearer_token()):
|
||||||
|
return True
|
||||||
|
return _verify_auth_token(_get_query_token())
|
||||||
|
|
||||||
|
|
||||||
def _require_auth():
|
def _require_auth():
|
||||||
@@ -1249,6 +1280,7 @@ class WebChannel(ChatChannel):
|
|||||||
|
|
||||||
urls = (
|
urls = (
|
||||||
'/', 'RootHandler',
|
'/', 'RootHandler',
|
||||||
|
'/api/health', 'HealthHandler',
|
||||||
'/auth/login', 'AuthLoginHandler',
|
'/auth/login', 'AuthLoginHandler',
|
||||||
'/auth/check', 'AuthCheckHandler',
|
'/auth/check', 'AuthCheckHandler',
|
||||||
'/auth/logout', 'AuthLogoutHandler',
|
'/auth/logout', 'AuthLogoutHandler',
|
||||||
@@ -1341,6 +1373,16 @@ class RootHandler:
|
|||||||
raise web.seeother('/chat')
|
raise web.seeother('/chat')
|
||||||
|
|
||||||
|
|
||||||
|
class HealthHandler:
|
||||||
|
# Unauthenticated liveness probe. The desktop shell polls this to know the
|
||||||
|
# backend is up; it must never require auth (a set web_password would
|
||||||
|
# otherwise make startup hang). Returns no sensitive data.
|
||||||
|
def GET(self):
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
web.header('Cache-Control', 'no-store')
|
||||||
|
return json.dumps({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
class AuthCheckHandler:
|
class AuthCheckHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
@@ -1368,7 +1410,9 @@ class AuthLoginHandler:
|
|||||||
token = _create_auth_token()
|
token = _create_auth_token()
|
||||||
web.setcookie("cow_auth_token", token, expires=_session_expire_seconds(),
|
web.setcookie("cow_auth_token", token, expires=_session_expire_seconds(),
|
||||||
path="/", httponly=True, samesite="Lax")
|
path="/", httponly=True, samesite="Lax")
|
||||||
return json.dumps({"status": "success"})
|
# Also return the token in the body: the desktop client (file:// origin)
|
||||||
|
# can't rely on the cookie and sends it back via an Authorization header.
|
||||||
|
return json.dumps({"status": "success", "token": token})
|
||||||
|
|
||||||
|
|
||||||
class AuthLogoutHandler:
|
class AuthLogoutHandler:
|
||||||
@@ -1807,7 +1851,7 @@ class ConfigHandler:
|
|||||||
raw_pwd = str(local_config.get("web_password", "") or "")
|
raw_pwd = str(local_config.get("web_password", "") or "")
|
||||||
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
||||||
|
|
||||||
return json.dumps({
|
result = {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"use_agent": use_agent,
|
"use_agent": use_agent,
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -1824,7 +1868,13 @@ class ConfigHandler:
|
|||||||
"api_keys": api_keys_masked,
|
"api_keys": api_keys_masked,
|
||||||
"providers": providers,
|
"providers": providers,
|
||||||
"web_password_masked": masked_pwd,
|
"web_password_masked": masked_pwd,
|
||||||
}, ensure_ascii=False)
|
}
|
||||||
|
# The desktop app runs on the local trusted machine, so it can edit
|
||||||
|
# the real password in place (cursor at the end, delete to clear).
|
||||||
|
# Browser access only ever sees the masked value.
|
||||||
|
if os.environ.get("COW_DESKTOP") == "1":
|
||||||
|
result["web_password"] = raw_pwd
|
||||||
|
return json.dumps(result, ensure_ascii=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting config: {e}")
|
logger.error(f"Error getting config: {e}")
|
||||||
return json.dumps({"status": "error", "message": str(e)})
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|||||||
@@ -315,7 +315,10 @@ export class PythonBackend extends EventEmitter {
|
|||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
// Probe the unauthenticated health endpoint, NOT /config: /config
|
||||||
|
// requires auth once a web_password is set, which would make this poll
|
||||||
|
// 401 forever and hang startup.
|
||||||
|
const req = http.get(`http://127.0.0.1:${this.port}/api/health`, (res) => {
|
||||||
if (res.statusCode === 200) {
|
if (res.statusCode === 200) {
|
||||||
this.status = 'ready'
|
this.status = 'ready'
|
||||||
this.emit('log', `Backend ready on port ${this.port}`)
|
this.emit('log', `Backend ready on port ${this.port}`)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import NavRail from './layout/NavRail'
|
|||||||
import SessionList from './layout/SessionList'
|
import SessionList from './layout/SessionList'
|
||||||
import WindowControls from './layout/WindowControls'
|
import WindowControls from './layout/WindowControls'
|
||||||
import StatusScreen from './components/StatusScreen'
|
import StatusScreen from './components/StatusScreen'
|
||||||
|
import LoginGate from './components/LoginGate'
|
||||||
import { useBackend } from './hooks/useBackend'
|
import { useBackend } from './hooks/useBackend'
|
||||||
import { usePlatform } from './hooks/usePlatform'
|
import { usePlatform } from './hooks/usePlatform'
|
||||||
import { useUIStore } from './store/uiStore'
|
import { useUIStore } from './store/uiStore'
|
||||||
@@ -32,16 +33,45 @@ const App: React.FC = () => {
|
|||||||
const onboardingOpen = useOnboardingStore((s) => s.open)
|
const onboardingOpen = useOnboardingStore((s) => s.open)
|
||||||
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
||||||
const [, forceUpdate] = useState(0)
|
const [, forceUpdate] = useState(0)
|
||||||
|
// Auth gate for web_password-protected backends. 'checking' until we know
|
||||||
|
// whether login is needed; 'need_login' shows the password screen; 'ok' lets
|
||||||
|
// the main UI render.
|
||||||
|
const [authState, setAuthState] = useState<'checking' | 'need_login' | 'ok'>('checking')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
||||||
}, [backend.status, backend.baseUrl])
|
}, [backend.status, backend.baseUrl])
|
||||||
|
|
||||||
|
// Once the backend is ready, check whether a web_password is set. If so and
|
||||||
|
// this session isn't authenticated, show the login gate before the app.
|
||||||
|
useEffect(() => {
|
||||||
|
if (backend.status !== 'ready') {
|
||||||
|
setAuthState('checking')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
apiClient
|
||||||
|
.authCheck()
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const needLogin = res.auth_required && !res.authenticated
|
||||||
|
setAuthState(needLogin ? 'need_login' : 'ok')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// If the check itself fails, don't hard-block the user — assume no auth
|
||||||
|
// is required (backends without web_password never return errors here).
|
||||||
|
if (!cancelled) setAuthState('ok')
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [backend.status, backend.baseUrl])
|
||||||
|
|
||||||
// First-run check: once the backend is ready, decide whether to show the
|
// First-run check: once the backend is ready, decide whether to show the
|
||||||
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
|
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
|
||||||
// configured (and not dismissed earlier this session); no persisted flag.
|
// configured (and not dismissed earlier this session); no persisted flag.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (backend.status !== 'ready') return
|
if (backend.status !== 'ready' || authState !== 'ok') return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
apiClient
|
apiClient
|
||||||
.getModels()
|
.getModels()
|
||||||
@@ -65,7 +95,7 @@ const App: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [backend.status, maybeOpenOnboarding])
|
}, [backend.status, authState, maybeOpenOnboarding])
|
||||||
|
|
||||||
// Subscribe to auto-update status from the main process (no-op in dev).
|
// Subscribe to auto-update status from the main process (no-op in dev).
|
||||||
useEffect(() => initUpdateListener(), [])
|
useEffect(() => initUpdateListener(), [])
|
||||||
@@ -91,6 +121,15 @@ const App: React.FC = () => {
|
|||||||
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
|
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backend is up but we're still resolving auth — keep the loading screen.
|
||||||
|
if (authState === 'checking') {
|
||||||
|
return <StatusScreen status="connecting" onRetry={backend.restart} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authState === 'need_login') {
|
||||||
|
return <LoginGate onAuthenticated={() => setAuthState('ok')} />
|
||||||
|
}
|
||||||
|
|
||||||
const isChat = location.pathname === '/'
|
const isChat = location.pathname === '/'
|
||||||
const showSessions = isChat && !sessionsCollapsed
|
const showSessions = isChat && !sessionsCollapsed
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,16 @@ interface ApiResult {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTH_TOKEN_KEY = 'cow_auth_token'
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private baseUrl = 'http://127.0.0.1:9876'
|
private baseUrl = 'http://127.0.0.1:9876'
|
||||||
|
// Bearer token for web_password-protected backends. The desktop renderer
|
||||||
|
// runs from a file:// origin, where cross-origin cookies to http://127.0.0.1
|
||||||
|
// aren't sent reliably, so we authenticate via an Authorization header
|
||||||
|
// instead. Persisted in localStorage so it survives reloads.
|
||||||
|
private authToken: string | null =
|
||||||
|
typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null
|
||||||
|
|
||||||
setBaseUrl(url: string) {
|
setBaseUrl(url: string) {
|
||||||
this.baseUrl = url
|
this.baseUrl = url
|
||||||
@@ -35,13 +43,25 @@ class ApiClient {
|
|||||||
return this.baseUrl
|
return this.baseUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAuthToken(token: string | null) {
|
||||||
|
this.authToken = token
|
||||||
|
try {
|
||||||
|
if (token) localStorage.setItem(AUTH_TOKEN_KEY, token)
|
||||||
|
else localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable; in-memory token still works this session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
// Send cookies for future web_password auth support
|
// Cookies still work for browser access; the desktop app relies on the
|
||||||
|
// Authorization header below.
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -93,8 +113,16 @@ class ApiClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EventSource can't set an Authorization header, so append the auth token as
|
||||||
|
// a query param for SSE endpoints (the backend accepts it there).
|
||||||
|
private withToken(url: string): string {
|
||||||
|
if (!this.authToken) return url
|
||||||
|
const sep = url.includes('?') ? '&' : '?'
|
||||||
|
return `${url}${sep}token=${encodeURIComponent(this.authToken)}`
|
||||||
|
}
|
||||||
|
|
||||||
createSSEStream(requestId: string): EventSource {
|
createSSEStream(requestId: string): EventSource {
|
||||||
return new EventSource(`${this.baseUrl}/stream?request_id=${requestId}`)
|
return new EventSource(this.withToken(`${this.baseUrl}/stream?request_id=${requestId}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteMessage(opts: {
|
async deleteMessage(opts: {
|
||||||
@@ -138,11 +166,13 @@ class ApiClient {
|
|||||||
|
|
||||||
getFileUrl(previewUrl: string): string {
|
getFileUrl(previewUrl: string): string {
|
||||||
if (/^https?:\/\//.test(previewUrl)) return previewUrl
|
if (/^https?:\/\//.test(previewUrl)) return previewUrl
|
||||||
return `${this.baseUrl}${previewUrl}`
|
// Served via <img src>, which can't set headers — carry the token in the
|
||||||
|
// query so protected file endpoints load under web_password.
|
||||||
|
return this.withToken(`${this.baseUrl}${previewUrl}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
getServeFileUrl(absPath: string): string {
|
getServeFileUrl(absPath: string): string {
|
||||||
return `${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`
|
return this.withToken(`${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
@@ -390,7 +420,7 @@ class ApiClient {
|
|||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|
||||||
createLogStream(): EventSource {
|
createLogStream(): EventSource {
|
||||||
return new EventSource(`${this.baseUrl}/api/logs`)
|
return new EventSource(this.withToken(`${this.baseUrl}/api/logs`))
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVersion(): Promise<string> {
|
async getVersion(): Promise<string> {
|
||||||
@@ -406,14 +436,19 @@ class ApiClient {
|
|||||||
return this.request('/auth/check')
|
return this.request('/auth/check')
|
||||||
}
|
}
|
||||||
|
|
||||||
async authLogin(password: string): Promise<ApiResult> {
|
async authLogin(password: string): Promise<ApiResult & { token?: string }> {
|
||||||
return this.request('/auth/login', {
|
const res = await this.request<ApiResult & { token?: string }>('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
})
|
})
|
||||||
|
if (res.status === 'success' && res.token) {
|
||||||
|
this.setAuthToken(res.token)
|
||||||
|
}
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
async authLogout(): Promise<ApiResult> {
|
async authLogout(): Promise<ApiResult> {
|
||||||
|
this.setAuthToken(null)
|
||||||
return this.request('/auth/logout', { method: 'POST' })
|
return this.request('/auth/logout', { method: 'POST' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
desktop/src/renderer/src/components/LoginGate.tsx
Normal file
72
desktop/src/renderer/src/components/LoginGate.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
|
||||||
|
interface LoginGateProps {
|
||||||
|
// Called once the password is accepted (auth cookie set), so the app can
|
||||||
|
// proceed to the main UI.
|
||||||
|
onAuthenticated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when the backend has a web_password set and the current session isn't
|
||||||
|
* authenticated yet. Submitting the correct password sets an auth cookie
|
||||||
|
* (handled by the backend), after which the app reloads its data.
|
||||||
|
*/
|
||||||
|
const LoginGate: React.FC<LoginGateProps> = ({ onAuthenticated }) => {
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!password || submitting) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await apiClient.authLogin(password)
|
||||||
|
if (res.status === 'success') {
|
||||||
|
onAuthenticated()
|
||||||
|
} else {
|
||||||
|
setError(t('login_error'))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t('login_error'))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-[#111111]">
|
||||||
|
<form onSubmit={submit} className="text-center space-y-6 max-w-md px-8 w-full">
|
||||||
|
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mx-auto shadow-lg shadow-primary-500/20" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('login_title')}</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">{t('login_desc')}</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value)
|
||||||
|
if (error) setError('')
|
||||||
|
}}
|
||||||
|
placeholder={t('login_placeholder')}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-[#1a1a1a] text-slate-800 dark:text-slate-100 text-sm outline-none focus:border-primary-500 transition-colors"
|
||||||
|
/>
|
||||||
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || !password}
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
{submitting ? t('login_checking') : t('login_submit')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginGate
|
||||||
@@ -22,7 +22,10 @@ export function useBackend() {
|
|||||||
|
|
||||||
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
|
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/config`, {
|
// Probe the unauthenticated health endpoint, NOT /config: once a
|
||||||
|
// web_password is set, /config returns 401 and we'd wrongly treat the
|
||||||
|
// (healthy) backend as unreachable, hanging on "connecting".
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||||
signal: AbortSignal.timeout(3000),
|
signal: AbortSignal.timeout(3000),
|
||||||
})
|
})
|
||||||
return res.ok
|
return res.ok
|
||||||
|
|||||||
@@ -356,6 +356,13 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
status_error: '初始化失败',
|
status_error: '初始化失败',
|
||||||
status_error_desc: '客户端初始化失败,请重试',
|
status_error_desc: '客户端初始化失败,请重试',
|
||||||
status_retry: '重试',
|
status_retry: '重试',
|
||||||
|
// login (web_password)
|
||||||
|
login_title: '请输入访问密码',
|
||||||
|
login_desc: '此客户端已设置访问密码,请输入以继续',
|
||||||
|
login_placeholder: '访问密码',
|
||||||
|
login_submit: '进入',
|
||||||
|
login_error: '密码错误,请重试',
|
||||||
|
login_checking: '验证中...',
|
||||||
// slash command descriptions
|
// slash command descriptions
|
||||||
slash_menu_title: '命令',
|
slash_menu_title: '命令',
|
||||||
slash_new: '新建对话',
|
slash_new: '新建对话',
|
||||||
@@ -731,6 +738,13 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
status_error: 'Initialization Failed',
|
status_error: 'Initialization Failed',
|
||||||
status_error_desc: 'Failed to initialize the client, please retry',
|
status_error_desc: 'Failed to initialize the client, please retry',
|
||||||
status_retry: 'Retry',
|
status_retry: 'Retry',
|
||||||
|
// login (web_password)
|
||||||
|
login_title: 'Enter access password',
|
||||||
|
login_desc: 'This client is password-protected. Enter the password to continue.',
|
||||||
|
login_placeholder: 'Access password',
|
||||||
|
login_submit: 'Enter',
|
||||||
|
login_error: 'Wrong password, please try again',
|
||||||
|
login_checking: 'Verifying...',
|
||||||
// slash command descriptions
|
// slash command descriptions
|
||||||
slash_menu_title: 'Commands',
|
slash_menu_title: 'Commands',
|
||||||
slash_new: 'New chat',
|
slash_new: 'New chat',
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
|||||||
setMaxSteps(data.agent_max_steps ?? 20)
|
setMaxSteps(data.agent_max_steps ?? 20)
|
||||||
setThinking(!!data.enable_thinking)
|
setThinking(!!data.enable_thinking)
|
||||||
setEvolution(!!data.self_evolution_enabled)
|
setEvolution(!!data.self_evolution_enabled)
|
||||||
setPassword(data.web_password_masked || '')
|
// Prefer the real password (desktop only) so it can be edited in place;
|
||||||
|
// fall back to the masked value for browser access.
|
||||||
|
setPassword(data.web_password ?? data.web_password_masked ?? '')
|
||||||
setPwDirty(false)
|
setPwDirty(false)
|
||||||
|
|
||||||
const ids = data.providers ? Object.keys(data.providers) : []
|
const ids = data.providers ? Object.keys(data.providers) : []
|
||||||
@@ -130,8 +132,14 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
|||||||
setTimeout(() => setAgentStatus(''), 2000)
|
setTimeout(() => setAgentStatus(''), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Desktop returns the real password, so the field holds plaintext and can be
|
||||||
|
// saved (including cleared) directly. Browser access only has the masked
|
||||||
|
// value, where a masked string must never be saved as the real password.
|
||||||
|
const hasRealPassword = config?.web_password !== undefined
|
||||||
|
|
||||||
const savePassword = async () => {
|
const savePassword = async () => {
|
||||||
if (!pwDirty || MASK_RE.test(password)) return
|
if (!pwDirty) return
|
||||||
|
if (!hasRealPassword && MASK_RE.test(password)) return
|
||||||
try {
|
try {
|
||||||
await apiClient.updateConfig({ web_password: password })
|
await apiClient.updateConfig({ web_password: password })
|
||||||
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
|
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
|
||||||
@@ -292,10 +300,13 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
|||||||
value={password}
|
value={password}
|
||||||
placeholder={t('config_password_placeholder')}
|
placeholder={t('config_password_placeholder')}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!pwDirty && MASK_RE.test(password)) setPassword('')
|
// Browser access shows a mask; clear it on focus so the user
|
||||||
|
// types a fresh password. Desktop holds the real password and
|
||||||
|
// must stay editable in place (cursor at the end).
|
||||||
|
if (!hasRealPassword && !pwDirty && MASK_RE.test(password)) setPassword('')
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (!pwDirty) setPassword(config?.web_password_masked || '')
|
if (!hasRealPassword && !pwDirty) setPassword(config?.web_password_masked || '')
|
||||||
}}
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setPassword(e.target.value)
|
setPassword(e.target.value)
|
||||||
|
|||||||
@@ -230,6 +230,9 @@ export interface ConfigData {
|
|||||||
api_keys: Record<string, string>
|
api_keys: Record<string, string>
|
||||||
providers: Record<string, ProviderMeta>
|
providers: Record<string, ProviderMeta>
|
||||||
web_password_masked?: string
|
web_password_masked?: string
|
||||||
|
// Real password, only returned to the desktop app (trusted local machine) so
|
||||||
|
// it can be edited in place. Undefined for browser access.
|
||||||
|
web_password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -36,3 +36,16 @@ The desktop client has built-in auto-update. When a new version is available it
|
|||||||
|
|
||||||
- **Desktop client**: best for personal use on your own computer — works out of the box, GUI-based, auto-updating.
|
- **Desktop client**: best for personal use on your own computer — works out of the box, GUI-based, auto-updating.
|
||||||
- **Command-line deployment**: best for developers or long-running servers with more customization. See [Quick Start](/guide/quick-start).
|
- **Command-line deployment**: best for developers or long-running servers with more customization. See [Quick Start](/guide/quick-start).
|
||||||
|
|
||||||
|
## Access from a Browser
|
||||||
|
|
||||||
|
Once the desktop client is running, it listens on port `9876` locally, and its backend is exactly the same Web console. So while the app is open you can also just point your browser at `http://localhost:9876` for the same full experience as the client UI.
|
||||||
|
|
||||||
|
## Local Data Storage
|
||||||
|
|
||||||
|
All data of the desktop client is stored on your machine:
|
||||||
|
|
||||||
|
- **Config directory**: `~/.cow` in your home folder, holding `config.json` (model keys, channels, etc.) along with logs, cache and other runtime data.
|
||||||
|
- **Workspace**: `~/cow` by default, holding chat history, knowledge base, memory, skills, scheduled tasks and other files produced by the Agent.
|
||||||
|
|
||||||
|
Uninstalling the client does not delete these two directories, so your data is preserved across reinstalls. To fully clean up or migrate to another device, just back up or remove the corresponding folders manually.
|
||||||
|
|||||||
@@ -36,3 +36,16 @@ CowAgent は、Agent の実行環境を内蔵したすぐに使えるデスク
|
|||||||
|
|
||||||
- **デスクトップクライアント**:自分の PC での個人利用に最適。すぐに使え、GUI 操作で自動アップデート対応。
|
- **デスクトップクライアント**:自分の PC での個人利用に最適。すぐに使え、GUI 操作で自動アップデート対応。
|
||||||
- **コマンドラインデプロイ**:開発者やサーバーでの長期運用に最適で、カスタマイズ性が高い。詳しくは [クイックスタート](/ja/guide/quick-start) を参照。
|
- **コマンドラインデプロイ**:開発者やサーバーでの長期運用に最適で、カスタマイズ性が高い。詳しくは [クイックスタート](/ja/guide/quick-start) を参照。
|
||||||
|
|
||||||
|
## ブラウザからのアクセス
|
||||||
|
|
||||||
|
デスクトップクライアントは起動するとローカルでポート `9876` をリッスンし、そのバックエンドは Web コンソールとまったく同じです。そのため、アプリを開いている間はブラウザで `http://localhost:9876` にアクセスすれば、クライアント UI と同じ完全な体験が得られます。
|
||||||
|
|
||||||
|
## ローカルデータの保存
|
||||||
|
|
||||||
|
デスクトップクライアントのすべてのデータはお使いのマシンに保存されます:
|
||||||
|
|
||||||
|
- **設定ディレクトリ**:ホームフォルダの `~/.cow`。`config.json`(モデルキーやチャネルなどの設定)のほか、ログ・キャッシュなどの実行データが含まれます。
|
||||||
|
- **ワークスペース**:デフォルトは `~/cow`。会話履歴・ナレッジベース・記憶・スキル・定期タスクなど、Agent が生成したファイルが保存されます。
|
||||||
|
|
||||||
|
クライアントをアンインストールしてもこの 2 つのディレクトリは自動削除されないため、再インストール後もデータは保持されます。完全に削除したい場合や別のデバイスへ移行する場合は、該当するフォルダを手動でバックアップまたは削除してください。
|
||||||
|
|||||||
@@ -34,5 +34,18 @@ CowAgent 提供开箱即用的桌面客户端,内置 Agent 运行环境,**
|
|||||||
|
|
||||||
## 与命令行部署的区别
|
## 与命令行部署的区别
|
||||||
|
|
||||||
- **桌面客户端**:适合个人在本地电脑使用,开箱即用,图形界面操作,自动更新。
|
- **桌面客户端**:适合个人用户开箱即用,无需安装任何依赖,图形界面操作,支持自动更新。
|
||||||
- **命令行部署**:适合开发者或服务器长期运行,可定制性更强,详见 [一键安装](/zh/guide/quick-start)。
|
- **命令行部署**:源码运行方式,适合开发者在本地或服务器部署,方便扩展功能,可定制性更强,详见 [一键安装](/zh/guide/quick-start)。
|
||||||
|
|
||||||
|
## 通过浏览器访问
|
||||||
|
|
||||||
|
桌面客户端启动后会在本机监听 `9876` 端口,其后端与 Web 控制台完全一致。因此在打开客户端的同时,也可以直接用浏览器访问 `http://localhost:9876`,获得与客户端界面一致的完整体验。
|
||||||
|
|
||||||
|
## 本地数据存储
|
||||||
|
|
||||||
|
桌面客户端的所有数据都保存在本机:
|
||||||
|
|
||||||
|
- **配置文件**:位于用户目录下的 `~/.cow`,包含 `config.json`(模型密钥、通道等配置)以及日志、缓存等运行数据。
|
||||||
|
- **工作空间**:默认位于 `~/cow`,用于存放对话记录、知识库、记忆、技能、定时任务等 Agent 产生的文件。
|
||||||
|
|
||||||
|
卸载客户端不会自动删除这两个目录,重装后数据依然保留。如需彻底清理或迁移到其他设备,手动备份或删除对应目录即可。
|
||||||
|
|||||||
Reference in New Issue
Block a user